import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Io import qs.Commons import qs.Ui // Bar icon + popup panel for Screencast: the receivers found on the network, // what is being cast right now, and the handful of knobs that decide what goes // over the wire. All the state lives in the screencast-server daemon (see // Service.qml); this file renders it and forwards what the user does. Panel { id: root moduleName: "io.github.alanfortlink.screencast" ipcTarget: "io.github.alanfortlink.screencast" // manageIpc: false so this panel can own the single IpcHandler the target // permits — needed for the cast/stop methods below. manageIpc: false readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("io.github.alanfortlink.screencast") : null readonly property bool connected: !!svc && svc.connected readonly property bool installed: !!svc && svc.installed readonly property var devices: svc ? svc.devices : [] readonly property var session: svc ? svc.session : ({}) readonly property var s: svc ? svc.settings : ({}) readonly property bool casting: !!svc && svc.casting readonly property bool starting: !!svc && svc.sessionState === "starting" // The click that has not been answered yet, so the row can react to it now. readonly property string pendingId: svc ? svc.pendingId : "" readonly property var castDevice: svc ? svc.castDevice : null readonly property bool alwaysShow: setting("alwaysShow", true) // Anything the daemon is currently doing on our behalf, so every control that // triggers it can show that it was heard. readonly property bool workingBusy: !!svc && svc.acting // "Casting but nothing fetched yet" counts as work in progress: that wait is // where a blocked port shows up, and silence there is what looks broken. readonly property bool working: starting || workingBusy || (!!svc && svc.setupBusy) || (casting && !!svc && !svc.live) // Only cast work belongs on the bar icon: a routine rescan every time the // panel opens must not make the bar look like something is happening. readonly property bool castWorking: starting || (!!svc && svc.pendingId !== "") || (casting && !!svc && !svc.live) // A start goes through the portal, the encoder, the receiver, and then the // wait for that receiver to actually fetch the stream. Say which one it is. function phaseText() { if (!svc) return "" var to = svc.castingTo || "the receiver" if (!starting && !casting && svc.activity !== "") return svc.activity switch (svc.sessionPhase) { case "screen": return "Choose the screen to share…" case "encoder": return "Starting the encoder…" case "connect": return "Handing the stream to " + to + "…" case "waiting": return "Waiting for " + to + " to start playing…" } if (starting) return "Connecting to " + to + "…" if (casting && !svc.live) return "Waiting for " + to + " to start playing…" return svc.activity } readonly property color fg: bar ? bar.foreground : Color.foreground readonly property color dim: Qt.darker(fg, 1.45) readonly property color urgent: bar ? bar.urgent : Color.urgent readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family // md-cast / md-cast_connected: the bar icon says at a glance whether the // screen is going somewhere. readonly property string castGlyph: "󰄘" readonly property string castConnectedGlyph: "󰄙" readonly property string tvGlyph: "󰔂" readonly property string speakerGlyph: "󱀞" readonly property string monitorGlyph: "󰍹" // Frame-based rather than a rotating glyph: it stays legible at caption size // and does not depend on the icon font having a spinner. component Spinner: Text { property bool spinning: true property int frame: 0 readonly property string frames: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" text: frames.charAt(frame) color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall opacity: spinning ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 120 } } Timer { interval: 90 repeat: true running: parent.spinning && root.opened onTriggered: parent.frame = (parent.frame + 1) % parent.frames.length } } // The preview file is overwritten in place, so the Image needs a changing // source to reload it. Ticks only while the panel is actually on screen. property int previewTick: 0 Timer { interval: 450 repeat: true running: root.opened && root.casting onTriggered: root.previewTick++ } // Ticks only while something is in flight; drives the "(12s)" counter. property int elapsedTick: 0 Timer { interval: 1000 repeat: true running: root.working && root.opened onTriggered: root.elapsedTick++ } function elapsedSuffix() { if (!svc || !svc.sessionSince || !(starting || (casting && !svc.live))) return "" var tick = elapsedTick // referenced so this binding re-runs every second var secs = Math.floor(Date.now() / 1000 - svc.sessionSince) + 0 * tick return secs >= 3 ? " " + secs + "s" : "" } function pairingThis(dev) { if (!svc || !dev) return false return svc.activity.indexOf("Asking " + dev.name) === 0 || svc.pairing === dev.id } function deviceGlyph(dev) { if (!dev) return tvGlyph return dev.audioOnly ? speakerGlyph : tvGlyph } function statusLine() { if (!svc) return "starting…" if (!installed) return "not installed yet" if (!connected) return svc.daemonError !== "" ? svc.daemonError : "starting the caster…" if (svc.sessionState === "error") return svc.sessionError || "cast failed" if (starting) return "setting up…" if (svc.activity !== "") return svc.activity if (casting) { var res = session.width > 0 ? session.width + "×" + session.height : "" var bits = [res, String(session.encoder || "").toUpperCase(), (s.fps || 30) + " fps"] if (svc.paused) bits.push("paused") return bits.filter(function(b) { return b !== "" }).join(" · ") } var n = devices.length return n === 0 ? "looking for displays…" : n + (n === 1 ? " display found" : " displays found") } function deviceSubtitle(dev) { if (dev.id === pendingId && !casting) return svc.activity !== "" ? svc.activity : "connecting…" if (dev.id === (session.deviceId || "") && casting) return starting ? "connecting…" : (svc.paused ? "paused" : "casting" + (session.clients > 0 ? "" : " · waiting for the receiver")) if (dev.kind === "airplay" && svc && !svc.airplayAvailable) return "AirPlay · needs pyatv" var bits = [dev.kindLabel] if (dev.app !== "") bits.push(dev.app) else if (dev.status === "busy") bits.push("in use") else if (dev.status === "away") bits.push("not answering") if (dev.error !== "") bits.push(dev.error) return bits.join(" · ") } // ---- actions ---- function castTo(dev) { if (!svc || !dev) return if (dev.id === svc.pendingId && !casting) return // already asked, still waiting if (dev.id === (session.deviceId || "") && casting) { svc.stop(); return } svc.cast(dev.id) } function quality() { var h = s.maxHeight || 1080, f = s.fps || 30 return h + "p" + f } function setQuality(value) { var m = String(value).match(/^(\d+)p(\d+)$/) if (!m || !svc) return svc.send({ cmd: "set", settings: { maxHeight: parseInt(m[1]), fps: parseInt(m[2]) } }) } Connections { target: root.svc // A cast killed by the firewall leaves exactly one thing to do. Put the // panel (and its button) on screen rather than leave a notification that // explains the problem but cannot act on it. function onFirewallBlockedChanged() { if (root.svc && root.svc.firewallBlocked && !root.opened) root.open() } } IpcHandler { target: root.ipcTarget function open() { root.open() } function close() { root.close() } function toggle() { root.toggle() } // omarchy-shell io.github.alanfortlink.screencast cast "TV name" function cast(device: string): string { if (!root.svc) return "no service" if (device === "") { var last = root.s.lastDevice || "" if (last === "") return "no device given" root.svc.cast(last) return "ok" } root.svc.cast(device) return "ok" } function stop(): string { if (root.svc) root.svc.stop(); return "ok" } } // ---- keyboard cursor over the device list ---- property bool cursorActive: false property int selectedIndex: 0 function moveCursor(dx, dy) { cursorActive = true if (dy === 0 || devices.length === 0) return selectedIndex = Math.max(0, Math.min(devices.length - 1, selectedIndex + dy)) } function activateCursor() { if (!cursorActive || selectedIndex >= devices.length) return castTo(devices[selectedIndex]) } visible: alwaysShow || casting implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight BarIconButton { id: button anchors.fill: parent bar: root.bar text: root.casting ? root.castConnectedGlyph : root.castGlyph active: root.casting // Setting up a cast takes seconds (portal, encoder, receiver): pulse so the // bar shows the click landed, even with the panel closed. opacity: root.castWorking ? barPulse.value : 1 QtObject { id: barPulse property real value: 1 NumberAnimation on value { running: root.castWorking from: 1; to: 0.35; duration: 700 loops: Animation.Infinite easing.type: Easing.InOutSine onRunningChanged: if (!running) barPulse.value = 1 } } useActiveColor: true activeColor: root.svc && root.svc.sessionState === "error" ? Color.urgent : Color.accent tooltipText: root.casting ? ("Casting to " + root.svc.castingTo + " · right-click: stop") : "Cast this screen" onPressed: function(b) { if (b === Qt.RightButton && root.casting && root.svc) root.svc.stop() else root.toggle() } } onOpenedChanged: if (opened && svc) { svc.refresh(); svc.rescan() } KeyboardPanel { id: panel anchorItem: button owner: root bar: root.bar open: root.opened focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(400)) contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(620)) PanelKeyCatcher { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { if (!root.cursorActive) { root.cursorActive = true; return } root.moveCursor(dx, dy) } onActivateRequested: root.activateCursor() onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { if (t === "r" || t === "R") { if (root.svc) root.svc.rescan() } else if (t === "s" || t === "S") { if (root.svc) root.svc.stop() } else if (t === "p" || t === "P") { if (root.svc && root.casting) root.svc.transport(root.svc.paused ? "play" : "pause") } } Flickable { id: panelFlick anchors.fill: parent contentWidth: width contentHeight: column.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds flickableDirection: Flickable.VerticalFlick interactive: contentHeight > height ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { id: column width: panelFlick.width spacing: Style.space(12) PanelHero { id: hero width: parent.width title: root.casting ? root.svc.castingTo : "Screencast" meta: root.statusLine() foreground: root.fg fontFamily: root.fontFamily iconComponent: Component { Text { textFormat: Text.PlainText text: root.casting ? root.castConnectedGlyph : root.castGlyph color: root.casting ? Color.accent : root.fg font.family: root.fontFamily font.pixelSize: Style.font.display } } } // ---------- what the daemon is doing right now ---------- Row { width: parent.width spacing: Style.space(8) visible: root.working && root.phaseText() !== "" Spinner { anchors.verticalCenter: parent.verticalCenter spinning: root.working color: Color.accent } Text { textFormat: Text.PlainText width: parent.width - Style.space(30) anchors.verticalCenter: parent.verticalCenter text: root.phaseText() + root.elapsedSuffix() color: root.fg wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } } // ---------- the port receivers fetch from ---------- // Opening it needs root, which installing deliberately does not, so it // is asked for here: a warning once a cast has proved it necessary, a // quiet note before that. Column { width: parent.width spacing: Style.space(6) visible: !!root.svc && root.svc.firewallTool !== "" && (root.svc.firewallBlocked || !root.svc.firewallVerified) Row { width: parent.width spacing: Style.space(8) Text { textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: "󰞀" color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.icon } Text { textFormat: Text.PlainText width: parent.width - Style.space(34) anchors.verticalCenter: parent.verticalCenter text: !root.svc ? "" : root.svc.firewallBlocked ? ("Nothing reached us on port " + root.svc.firewallPort + ". " + root.svc.firewallTool + " blocks incoming connections, and receivers fetch the stream from this machine.") : ("Receivers fetch the stream from this machine, and " + root.svc.firewallTool + " blocks incoming connections. Port " + root.svc.firewallPort + " has to be open for a cast to play.") color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.caption } } Row { spacing: Style.space(8) Button { text: root.svc ? "Open port " + root.svc.firewallPort : "Open the port" iconText: "󰌾" foreground: root.fg fontFamily: root.fontFamily bordered: true enabled: !!root.svc && root.svc.activity.indexOf("Opening port") !== 0 onClicked: if (root.svc) root.svc.openFirewall() } Spinner { anchors.verticalCenter: parent.verticalCenter visible: !!root.svc && root.svc.activity.indexOf("Opening port") === 0 spinning: visible color: Color.accent } } Text { textFormat: Text.PlainText width: parent.width text: "Asks for your password once, allows only this network, and stays." color: root.dim wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.caption } } // ---------- not installed / install failed ---------- Column { width: parent.width spacing: Style.space(6) visible: !!root.svc && (!root.installed || root.svc.setupOutput !== "" || root.svc.busyText !== "") Button { visible: !!root.svc && !root.installed && !root.svc.setupBusy text: "Install the caster" foreground: root.fg fontFamily: root.fontFamily bordered: true onClicked: if (root.svc) root.svc.install() } Text { textFormat: Text.PlainText width: parent.width visible: !!root.svc && (root.svc.busyText !== "" || root.svc.setupOutput !== "") text: root.svc ? (root.svc.busyText !== "" ? root.svc.busyText : root.svc.setupOutput) : "" color: root.svc && root.svc.setupOutput !== "" ? root.urgent : root.dim wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.caption } } // ---------- what is being cast ---------- Column { width: parent.width spacing: Style.space(10) visible: root.casting || (!!root.svc && root.svc.sessionState === "error") PanelSectionHeader { text: root.casting ? "CASTING" : "LAST CAST" foreground: root.fg fontFamily: root.fontFamily } // ---------- what the receiver is being sent ---------- // Tapped off the encoder itself rather than re-grabbed, so it shows // the screen that is really going out, at the size it goes out. Rectangle { id: preview width: parent.width height: Math.round(width * (root.svc && root.svc.sessionHeight > 0 ? root.svc.sessionHeight / root.svc.sessionWidth : 9 / 16)) visible: root.casting radius: Style.cornerRadius clip: true color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.05) border.width: 1 border.color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.14) Image { id: previewImage anchors.fill: parent anchors.margins: 1 fillMode: Image.PreserveAspectFit cache: false asynchronous: true smooth: true source: root.svc && root.svc.sessionPreview !== "" ? "file://" + root.svc.sessionPreview + "?t=" + root.previewTick : "" // Never blank between reloads: hold the last good frame. opacity: status === Image.Ready ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 150 } } } // Before the first frame lands: a quiet breathing placeholder // rather than an empty hole. Column { anchors.centerIn: parent spacing: Style.space(6) visible: previewImage.status !== Image.Ready opacity: waitPulse.value Text { textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.monitorGlyph color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.display } Text { textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.starting ? "getting the picture…" : "no picture yet" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption } QtObject { id: waitPulse property real value: 1 NumberAnimation on value { running: previewImage.status !== Image.Ready && root.opened from: 1; to: 0.4; duration: 1100 loops: Animation.Infinite easing.type: Easing.InOutSine } } } // A live badge in the corner: red dot while the receiver is // actually pulling, hollow while it is only being offered. Row { anchors.left: parent.left anchors.top: parent.top anchors.margins: Style.space(8) spacing: Style.space(5) visible: previewImage.status === Image.Ready Rectangle { anchors.verticalCenter: parent.verticalCenter width: Style.space(7); height: width; radius: width / 2 color: root.svc && root.svc.live ? root.urgent : "transparent" border.width: root.svc && root.svc.live ? 0 : 1 border.color: root.fg opacity: root.svc && root.svc.live ? livePulse2.value : 0.7 QtObject { id: livePulse2 property real value: 1 NumberAnimation on value { running: !!root.svc && root.svc.live && root.opened from: 1; to: 0.35; duration: 1200 loops: Animation.Infinite easing.type: Easing.InOutSine } } } Text { textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.svc && root.svc.live ? "LIVE" : "OFFERED" color: root.fg font.family: root.fontFamily font.pixelSize: Style.font.caption font.bold: true } } // Which screen, bottom-left, over a scrim so it stays readable // whatever is on the picture. Rectangle { anchors.left: parent.left anchors.bottom: parent.bottom anchors.margins: Style.space(6) visible: previewImage.status === Image.Ready && sourceLabel.text !== "" width: sourceLabel.width + Style.space(12) height: sourceLabel.height + Style.space(6) radius: Style.space(4) color: Qt.rgba(0, 0, 0, 0.45) Text { textFormat: Text.PlainText id: sourceLabel anchors.centerIn: parent text: root.svc ? root.svc.sessionSource : "" color: "#ffffff" font.family: root.fontFamily font.pixelSize: Style.font.caption } } } Text { textFormat: Text.PlainText width: parent.width visible: !!root.svc && root.svc.sessionState === "error" text: root.svc ? root.svc.sessionError : "" color: root.urgent wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Row { width: parent.width spacing: Style.space(8) visible: root.casting Button { readonly property bool stopping: !!root.svc && root.svc.stopping text: stopping ? "Stopping…" : "Stop" iconText: stopping ? "󰔟" : "󰖱" iconSpinning: stopping enabled: !stopping foreground: root.fg fontFamily: root.fontFamily bordered: true onClicked: if (root.svc) root.svc.stop() } Button { text: root.svc && root.svc.paused ? "Resume" : "Pause" iconText: root.svc && root.svc.paused ? "󰐊" : "󰏤" // play / pause foreground: root.fg fontFamily: root.fontFamily bordered: true onClicked: if (root.svc) root.svc.transport(root.svc.paused ? "play" : "pause") } Button { readonly property bool repicking: !!root.svc && root.svc.activity.indexOf("Asking which screen") === 0 text: repicking ? "Choose a screen…" : "Change screen" iconText: root.monitorGlyph iconSpinning: repicking enabled: !repicking foreground: root.fg fontFamily: root.fontFamily bordered: true onClicked: if (root.svc) root.svc.repickSource() } } // Receiver volume, when the protocol has one. Row { width: parent.width spacing: Style.space(8) visible: root.casting && !!root.castDevice && root.castDevice.volume >= 0 Text { textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.castDevice && root.castDevice.muted ? "󰖁" : "󰕾" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.icon MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: if (root.svc && root.castDevice) root.svc.setMuted(!root.castDevice.muted) } } PanelSlider { width: parent.width - Style.space(40) anchors.verticalCenter: parent.verticalCenter bar: root.bar minimum: 0 maximum: 1 step: 0.02 value: root.castDevice ? root.castDevice.volume : 0 onMoved: function(v) { if (root.svc) root.svc.setVolume(v) } } } Text { textFormat: Text.PlainText width: parent.width visible: root.casting text: { var src = session.source || "" var bits = [] if (src !== "") bits.push("Sharing " + src) bits.push((s.audio ? "with" : "without") + " desktop audio") if (session.clients > 0) bits.push(session.clients + " receiver" + (session.clients > 1 ? "s" : "") + " connected") return bits.join(" · ") } color: root.dim wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.caption } } PanelSeparator { visible: root.casting; foreground: root.fg } // ---------- the network ---------- Column { width: parent.width spacing: Style.space(8) Item { width: parent.width implicitHeight: sectionLabel.implicitHeight PanelSectionHeader { id: sectionLabel text: "DISPLAYS ON THE NETWORK" foreground: root.fg fontFamily: root.fontFamily } Text { textFormat: Text.PlainText id: rescanIcon readonly property bool scanning: !!root.svc && root.svc.activity.indexOf("Looking for") === 0 anchors.right: parent.right anchors.verticalCenter: sectionLabel.verticalCenter text: "󰑐" // md-refresh color: rescanIcon.scanning ? Color.accent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption RotationAnimation on rotation { running: rescanIcon.scanning loops: Animation.Infinite from: 0; to: 360; duration: 900 } MouseArea { anchors.fill: parent anchors.margins: -Style.space(6) cursorShape: Qt.PointingHandCursor onClicked: if (root.svc) root.svc.rescan() ToolTip.visible: containsMouse } } } Text { textFormat: Text.PlainText width: parent.width visible: root.devices.length === 0 text: root.connected ? "Nothing found yet. Cast devices announce themselves over mDNS; make sure this machine is on the same network (and not only on a VPN)." : "Waiting for the caster to start." color: root.dim wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Column { id: deviceColumn width: parent.width spacing: Style.space(4) Repeater { model: root.devices DeviceRow { required property var modelData required property int index width: deviceColumn.width dev: modelData rowIndex: index } } } } PanelSeparator { foreground: root.fg } // ---------- what gets sent ---------- Column { width: parent.width spacing: Style.space(8) PanelSectionHeader { text: "QUALITY" foreground: root.fg fontFamily: root.fontFamily } Dropdown { width: parent.width label: "Resolution" fontFamily: root.fontFamily value: root.quality() options: [ { value: "720p30", label: "720p · 30 fps" }, { value: "1080p30", label: "1080p · 30 fps" }, { value: "1080p60", label: "1080p · 60 fps" }, { value: "1440p30", label: "1440p · 30 fps" }, { value: "2160p30", label: "4K · 30 fps" } ] onChanged: function(v) { root.setQuality(v) } } Row { width: parent.width spacing: Style.space(8) Text { textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter width: Style.space(70) text: "Bitrate" color: root.fg font.family: root.fontFamily font.pixelSize: Style.font.body } PanelSlider { id: bitrateSlider width: parent.width - Style.space(130) anchors.verticalCenter: parent.verticalCenter bar: root.bar minimum: 1 maximum: 25 step: 1 integer: true value: (root.s.bitrate || 8000) / 1000 onReleased: function(v) { if (root.svc) root.svc.setSetting("bitrate", Math.round(v) * 1000) } } Text { textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Math.round(bitrateSlider.liveValue) + " Mb/s" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption } } Toggle { width: parent.width label: "Desktop audio" description: "Send what the speakers are playing along with the picture" checked: !!root.s.audio foreground: root.fg fontFamily: root.fontFamily onClicked: if (root.svc) root.svc.setSetting("audio", !root.s.audio) } Toggle { width: parent.width label: "Show the pointer" checked: !!root.s.cursor foreground: root.fg fontFamily: root.fontFamily onClicked: if (root.svc) root.svc.setSetting("cursor", !root.s.cursor) } } Text { textFormat: Text.PlainText width: parent.width visible: !!root.svc && root.svc.error !== "" && root.svc.sessionState !== "error" text: root.svc ? root.svc.error : "" color: root.urgent wrapMode: Text.WordWrap font.family: root.fontFamily font.pixelSize: Style.font.caption } } } } } // One receiver: click to cast, or to stop when it is the one being cast to. component DeviceRow: CursorSurface { id: row property var dev: null property int rowIndex: 0 readonly property bool isCurrent: !!dev && ((dev.id === (root.session.deviceId || "") && root.casting) || dev.id === root.pendingId) // Waiting on us, not yet on the receiver: the row spins from the click on. readonly property bool isPending: !!dev && (dev.id === root.pendingId || (isCurrent && root.starting)) readonly property bool disabled: !!dev && dev.kind === "airplay" && !!root.svc && !root.svc.airplayAvailable hasCursor: root.cursorActive && root.selectedIndex === rowIndex current: isCurrent foreground: root.fg implicitHeight: rowLayout.implicitHeight + Style.spacing.rowPaddingX opacity: disabled ? 0.55 : 1.0 MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: row.disabled ? Qt.ArrowCursor : Qt.PointingHandCursor onEntered: { root.cursorActive = true; root.selectedIndex = row.rowIndex } onClicked: { if (row.disabled) return root.castTo(row.dev) } } RowLayout { id: rowLayout anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(10) anchors.rightMargin: Style.space(10) spacing: Style.space(10) Text { textFormat: Text.PlainText text: root.deviceGlyph(row.dev) color: row.isCurrent ? Color.accent : root.fg font.family: root.fontFamily font.pixelSize: Style.font.heading Layout.alignment: Qt.AlignVCenter } ColumnLayout { Layout.fillWidth: true spacing: 0 Text { textFormat: Text.PlainText Layout.fillWidth: true text: row.dev ? row.dev.name : "" color: root.fg elide: Text.ElideRight font.family: root.fontFamily font.pixelSize: Style.font.body } Text { textFormat: Text.PlainText Layout.fillWidth: true text: row.dev ? root.deviceSubtitle(row.dev) : "" color: root.dim elide: Text.ElideRight font.family: root.fontFamily font.pixelSize: Style.font.caption } } // AirPlay receivers usually want to be paired once before they accept // anything; the button is only in the way until then. Button { visible: !!row.dev && row.dev.kind === "airplay" && !row.disabled && !row.isCurrent && (row.hasCursor || (!!root.svc && root.svc.pairing === row.dev.id)) text: root.pairingThis(row.dev) ? "Asking…" : "Pair" iconText: root.pairingThis(row.dev) ? "󰔟" : "" iconSpinning: root.pairingThis(row.dev) enabled: !root.pairingThis(row.dev) foreground: root.dim fontFamily: root.fontFamily fontSize: Style.font.caption onClicked: if (root.svc) root.svc.pair(row.dev.id) Layout.alignment: Qt.AlignVCenter } // Starting: a spinner. Live: a breathing dot. Casting but nothing being // fetched yet: a dim dot. Otherwise nothing — the whole row is the button. Spinner { Layout.alignment: Qt.AlignVCenter visible: row.isPending spinning: visible color: Color.accent font.pixelSize: Style.font.icon } Text { textFormat: Text.PlainText id: liveDot Layout.alignment: Qt.AlignVCenter visible: row.isCurrent && !row.isPending text: root.svc && root.svc.live ? "󰄙" : "󰔟" // cast_connected / timer-outline color: root.svc && root.svc.live ? Color.accent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.icon opacity: root.svc && root.svc.live ? 1 : livePulse.value QtObject { id: livePulse property real value: 1 NumberAnimation on value { running: liveDot.visible && !(root.svc && root.svc.live) from: 1; to: 0.4; duration: 900 loops: Animation.Infinite easing.type: Easing.InOutSine } } } Text { textFormat: Text.PlainText Layout.alignment: Qt.AlignVCenter visible: row.isCurrent && !row.isPending text: "󰖱" // md-stop color: root.fg font.family: root.fontFamily font.pixelSize: Style.font.icon } } // AirPlay receivers that want a PIN: the daemon asks, the code is typed here. Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.rightMargin: Style.space(10) spacing: Style.space(6) visible: !!root.svc && root.svc.pairing !== "" && !!row.dev && root.svc.pairing === row.dev.id TextField { id: pinField width: Style.space(90) placeholderText: "PIN" foreground: root.fg onAccepted: if (root.svc) { root.svc.pairPin(text); text = "" } } Button { readonly property bool checking: !!root.svc && root.svc.activity.indexOf("Pairing with") === 0 text: checking ? "Checking…" : "Pair" iconText: checking ? "󰔟" : "" iconSpinning: checking enabled: !checking foreground: root.fg fontFamily: root.fontFamily bordered: true onClicked: if (root.svc) { root.svc.pairPin(pinField.text); pinField.text = "" } } } } }