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:
@@ -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 = "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user