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.
This commit is contained in:
2026-08-29 19:57:54 +01:00
commit 1ed57291f9
24 changed files with 5086 additions and 0 deletions
+999
View File
@@ -0,0 +1,999 @@
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 = "" }
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
import QtQuick
import Quickshell
import Quickshell.Io
// Headless service: owns the screencast-server daemon (starts it, restarts it
// if it dies) and keeps a control connection to it. Everything the panel shows
// comes from the daemon's state pushes; everything the panel does goes through
// send().
Item {
id: root
property var shell: null
property var manifest: null
// ---- daemon state (mirrors the JSON pushed by screencast-server) ----
property var state: ({})
readonly property bool connected: sockConnected
readonly property var devices: state.devices || []
readonly property var session: state.session || ({})
readonly property var settings: state.settings || ({})
readonly property var capabilities: state.capabilities || ({})
readonly property string error: state.error || ""
readonly property string busy: state.busy || ""
readonly property string pairing: state.pairing || ""
// Receivers pull the stream from us, so a default-deny firewall silently
// stops every cast. The daemon sets `blocked` once a cast fails that way.
readonly property var firewall: state.firewall || ({})
readonly property bool firewallBlocked: !!firewall.blocked
readonly property string firewallCommand: firewall.command || ""
readonly property string firewallTool: firewall.tool || ""
readonly property bool firewallVerified: firewall.verified !== false
readonly property int firewallPort: firewall.port || 8011
readonly property int streamPort: capabilities.port || 8011
readonly property bool airplayAvailable: capabilities.airplay !== false
// "idle" | "starting" | "streaming" | "stopping" | "error"
readonly property string sessionState: session.state || "idle"
readonly property bool casting: sessionState === "starting" || sessionState === "streaming"
readonly property bool live: sessionState === "streaming" && (session.clients || 0) > 0
readonly property string castingTo: session.deviceName || ""
readonly property string castingId: session.deviceId || ""
readonly property string sessionError: session.error || ""
// What a "starting" session is waiting on, so the panel can say so.
readonly property string sessionPhase: session.phase || ""
// A JPEG of the screen actually being encoded, refreshed a few times a second
// by the daemon. Empty until the first frame has been written.
readonly property string sessionPreview: session.preview || ""
readonly property real sessionPreviewAt: session.previewAt || 0
readonly property string sessionSource: session.source || ""
readonly property int sessionWidth: session.width || 0
readonly property int sessionHeight: session.height || 0
readonly property real sessionSince: session.since || 0
readonly property string playerState: session.playerState || ""
readonly property bool paused: playerState === "paused"
readonly property bool stopping: sessionState === "stopping"
function deviceById(id) {
var list = devices
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]
return null
}
readonly property var castDevice: deviceById(castingId)
// ---- what we are optimistically claiming, before the daemon answers ----
// Every round trip, however short, is a gap where a click looks ignored. The
// panel says what it just asked for straight away and the daemon's own `busy`
// takes over as soon as the first state arrives.
property string pendingLabel: ""
property string pendingId: "" // the device the pending action is about
property real pendingAt: 0
readonly property string activity: pendingLabel !== "" ? pendingLabel : busy
readonly property bool acting: activity !== ""
function expect(label, id) {
pendingLabel = label
pendingId = id || ""
pendingAt = Date.now()
pendingTimer.restart()
}
function clearPending() {
pendingLabel = ""
pendingId = ""
pendingTimer.stop()
}
// The daemon confirms by putting something in `busy` (it pushes that before
// it starts the work). This only has to fire when it never answers at all.
Timer { id: pendingTimer; interval: 8000; repeat: false; onTriggered: root.clearPending() }
onBusyChanged: if (busy !== "") clearPending()
// ---- commands ----
function send(obj) {
if (!sockConnected) return false
sock.write(JSON.stringify(obj) + "\n")
sock.flush()
return true
}
function refresh() { return send({ cmd: "get" }) }
function rescan() {
expect("Looking for displays…", "")
return send({ cmd: "rescan" })
}
function cast(deviceId) {
var dev = deviceById(deviceId)
expect("Connecting to " + (dev ? dev.name : "the display") + "…", deviceId)
return send({ cmd: "cast", device: deviceId })
}
function stop() {
expect("Stopping…", castingId)
return send({ cmd: "stop" })
}
function setSetting(key, value) { var p = {}; p[key] = value; return send({ cmd: "set", settings: p }) }
function setVolume(v) { return send({ cmd: "volume", value: v }) }
function setMuted(v) { return send({ cmd: "mute", value: !!v }) }
function transport(action) { return send({ cmd: "transport", action: action }) }
// Restart the cast so the portal asks which screen to share again.
function repickSource() {
expect("Asking which screen to share…", castingId)
return send({ cmd: "repick" })
}
function pair(deviceId) {
var dev = deviceById(deviceId)
expect("Asking " + (dev ? dev.name : "the display") + " for a code…", deviceId)
return send({ cmd: "pair", device: deviceId })
}
function pairPin(pin) {
expect("Checking the code…", pairing)
return send({ cmd: "pairPin", pin: String(pin) })
}
function pairCancel() { clearPending(); return send({ cmd: "pairCancel" }) }
function openFirewall() {
expect("Opening port " + firewallPort + "…", "")
return send({ cmd: "openFirewall" })
}
// Notification bodies are parsed as markup by most daemons, and both of these
// carry device names and receiver error text off the network.
function plain(text) {
return String(text).replace(/[<>&]/g, " ").slice(0, 200)
}
function notify(title, body) {
if (notifyProc.running) return
title = plain(title); body = plain(body)
notifyProc.command = ["sh", "-c",
'if command -v omarchy-notification-send >/dev/null 2>&1; then exec omarchy-notification-send "$1" "$2"; fi; exec notify-send "$1" "$2"',
"screencast-notify", title, body]
notifyProc.running = true
}
Process { id: notifyProc }
// ---- paths ----
readonly property string runtimeDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screencast"
readonly property string socketPath: runtimeDir + "/ctl.sock"
readonly property string homeDir: Quickshell.env("HOME") || ""
readonly property string libDir: homeDir + "/.local/lib/screencast"
readonly property string daemonBinary: libDir + "/screencast-server"
readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (homeDir + "/.cache")) + "/screencast"
readonly property string installLog: cacheDir + "/install.log"
// The plugin checkout (this file lives in <repo>/plugin/).
readonly property string repoDir: decodeURIComponent(String(Qt.resolvedUrl("..")).replace(/^file:\/\//, "").replace(/\/$/, ""))
property bool installed: false
property string setupOutput: "" // only what the user must act on
property string busyText: "" // transient progress, disappears on its own
property string daemonLog: ""
property string daemonError: ""
property int restarts: 0
readonly property bool setupBusy: installProc.running
// ---- install / update ----
// First run after `omarchy plugin add` (and "Reinstall" later): build the
// virtualenv under ~/.local/lib and install the daemon into it. No password:
// nothing here touches the system.
function install() {
if (installProc.running) return
setupOutput = ""
if (busyText === "") busyText = "Installing the caster…"
daemonError = ""
installProc.command = ["sh", "-c",
'mkdir -p "$(dirname "$2")"; cd "$1" && ./install.sh --no-root >"$2" 2>&1; rc=$?; tail -n 4 "$2"; exit $rc',
"screencast-install", repoDir, installLog]
installProc.running = true
}
Process {
id: installProc
property string tailText: ""
stdout: StdioCollector { onStreamFinished: installProc.tailText = String(text).trim() }
onExited: function(code) {
root.busyText = ""
if (code !== 0) {
root.setupOutput = "Install failed (" + code + "). Log: " + root.installLog + "\n" + tailText
} else {
// Everything the installer can do without a password is done; system
// packages are not one of them, so pass that one line straight through.
const missing = /^MISSING: (.*)$/m.exec(tailText)
root.setupOutput = missing
? "Some system packages are missing, so capture will fail. Install them:\n" + missing[1]
: ""
restartTimer.restart()
}
tailText = ""
probeProc.running = true
}
}
// After `omarchy plugin update` the shell reloads this plugin: rebuild
// silently when the checkout has moved past what install.sh last installed.
Process {
id: updateCheckProc
command: ["sh", "-c",
'a=$(git -C "$1" rev-parse HEAD 2>/dev/null) || exit 0; b=$(cat "$2/installed-commit" 2>/dev/null); ' +
'[ -x "$2/screencast-server" ] || exit 0; [ -n "$a" ] && [ "$a" != "$b" ] && exit 3; exit 0',
"screencast-updatecheck", root.repoDir, root.libDir]
onExited: function(code) { if (code === 3) { root.busyText = "Updating…"; root.install() } }
}
Process {
id: probeProc
command: ["sh", "-c", 'test -x "$1"', "probe", root.daemonBinary]
onExited: function(code) {
root.installed = code === 0
if (root.installed && !daemon.running) restartTimer.restart()
}
}
// While not installed, keep looking: an install interrupted by a shell reload
// finishes on its own and we pick the daemon up.
Timer {
interval: 5000
repeat: true
running: !root.installed && !installProc.running && !probeProc.running
onTriggered: probeProc.running = true
}
// ---- daemon lifecycle ----
Process {
id: daemon
command: [root.daemonBinary, "run"]
running: false
stderr: SplitParser {
onRead: function(line) {
var l = root.daemonLog + line + "\n"
if (l.length > 4000) l = l.slice(l.length - 4000)
root.daemonLog = l
}
}
onExited: function(code, status) {
root.state = ({})
if (code === 127) { root.installed = false; probeProc.running = true; return }
root.restarts += 1
if (code !== 3 && root.restarts >= 3)
root.daemonError = "the caster keeps exiting (code " + code + ") — reinstall it or check the log"
restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock
restartTimer.restart()
}
}
// Set while we wait for an orphaned daemon (left by a previous shell) to quit.
property bool orphanQuit: false
Timer {
id: restartTimer
interval: 1000
repeat: false
onTriggered: {
if (!root.installed) { probeProc.running = true; return }
if (daemon.running) { daemon.signal(15); return }
if (root.sockConnected) { root.send({ cmd: "quit" }); root.orphanQuit = true; interval = 5000; restart(); return }
daemon.running = true
}
}
Timer { interval: 60000; running: daemon.running; repeat: false; onTriggered: root.restarts = 0 }
// ---- control connection ----
// Quickshell's Socket cannot recover from a refused connection, so recreate
// the object for every attempt.
property var sock: null
readonly property bool sockConnected: sock ? sock.connected === true : false
property string lastSessionState: "idle"
Component {
id: sockComp
Socket {
path: root.socketPath
connected: true
parser: SplitParser {
onRead: function(line) {
try {
var msg = JSON.parse(line)
if (msg && msg.type === "state") {
root.state = msg
if (root.daemonError !== "") root.daemonError = ""
// Any state the daemon produced after the click is its answer to
// it; the optimistic label has done its job.
if (root.pendingLabel !== "" && Date.now() - root.pendingAt > 250)
root.clearPending()
root.noteSession()
}
} catch (e) { /* a reply we do not care about */ }
}
}
onConnectionStateChanged: {
root.sockConnectedChanged()
if (!connected) {
root.state = ({})
if (root.orphanQuit) { root.orphanQuit = false; restartTimer.interval = 1000; restartTimer.restart() }
reconnectTimer.restart()
}
}
onError: function(err) { reconnectTimer.restart() }
}
}
function connectSocket() {
if (sock) { sock.destroy(); sock = null }
sock = sockComp.createObject(root)
sockConnectedChanged()
}
Timer { id: reconnectTimer; interval: 800; repeat: false; onTriggered: if (!root.sockConnected) root.connectSocket() }
Timer { interval: 3000; running: !root.sockConnected; repeat: true; onTriggered: if (!root.sockConnected) root.connectSocket() }
// A cast that fails while the panel is closed would otherwise be invisible.
function noteSession() {
var now = sessionState
if (now === lastSessionState) return
var was = lastSessionState
lastSessionState = now
if (now === "error" && sessionError !== "")
notify("Screencast", firewallBlocked
? "Port " + firewallPort + " is closed — press “Open port " + firewallPort + "” in the panel"
: sessionError)
else if (now === "streaming" && was !== "streaming") notify("Screencast", "Casting to " + castingTo)
}
Component.onCompleted: {
probeProc.running = true
connectSocket()
updateCheckProc.running = true
}
Component.onDestruction: {
if (daemon.running) daemon.signal(15)
}
}