Files
alan 1ed57291f9 Screencast: cast the desktop to Cast, DLNA and AirPlay receivers
An Omarchy shell plugin: a Python/asyncio daemon that discovers network
displays and serves an encoded screen capture for them to pull, plus a
Quickshell bar widget and panel to drive it.

The screen comes from the xdg-desktop-portal ScreenCast interface (asked
for every time, never remembered), goes through GStreamer, and is served
from a local HTTP port. Desktop audio is mixed in from the default
output's monitor. The container follows the receiver: WebM/VP8 for Cast,
MPEG-TS/H.264 for DLNA, HLS for AirPlay video.

The stream port has to be reachable from the LAN, and none of these
protocols can carry a credential, so the capability is the URL: a fresh
random path per session, refused to anything off the LAN, capped at four
concurrent readers.
2026-08-29 19:57:54 +01:00

1000 lines
38 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "" }
}
}
}
}