diff --git a/.github/workflows/cast-helper.yml b/.github/workflows/cast-helper.yml deleted file mode 100644 index cb01fb8..0000000 --- a/.github/workflows/cast-helper.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: cast-helper - -on: - push: - branches: [master] - tags: ["cast-v*"] - paths: ["helpers/cast/**", ".github/workflows/cast-helper.yml"] - pull_request: - paths: ["helpers/cast/**", ".github/workflows/cast-helper.yml"] - -permissions: - contents: read - -defaults: - run: - working-directory: helpers/cast - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: helpers/cast/go.mod - cache-dependency-path: helpers/cast/go.sum - - run: go vet ./... - - run: go test ./... - - # A tag cast-vX.Y.Z publishes the helper for plugin version X.Y.Z. - # Asset names follow `uname -m`, which the plugin's download command uses. - release: - if: startsWith(github.ref, 'refs/tags/cast-v') - needs: test - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: helpers/cast/go.mod - cache-dependency-path: helpers/cast/go.sum - - name: Check the tag matches plugin.json - run: | - version=$(jq -r .version ../../Cast/plugin.json) - [ "$GITHUB_REF_NAME" = "cast-v$version" ] || { echo "tag $GITHUB_REF_NAME, plugin.json $version"; exit 1; } - - name: Build - run: | - mkdir -p dist - for arch in amd64:x86_64 arm64:aarch64; do - CGO_ENABLED=0 GOOS=linux GOARCH=${arch%%:*} go build -trimpath -ldflags "-s -w" -o "dist/cast-helper-${arch##*:}" . - done - (cd dist && sha256sum cast-helper-* > SHA256SUMS) - - name: Publish - env: - GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" dist/* --title "Cast $GITHUB_REF_NAME" --notes "Helper binaries for the Cast plugin, built from this tag." diff --git a/.gitignore b/.gitignore index 1eac6d9..d55d43f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ CLAUDE.local.md .claude/settings.local.json drafts/ spike/ +docs/ diff --git a/Cast/CastService.qml b/Cast/CastService.qml index 0d1fb47..f56c461 100644 --- a/Cast/CastService.qml +++ b/Cast/CastService.qml @@ -4,6 +4,7 @@ import QtQuick import Qt.labs.folderlistmodel import Quickshell import Quickshell.Io +import Quickshell.Services.Mpris import qs.Common import qs.DCommon.Widgets import qs.Modals.FileBrowser @@ -26,16 +27,26 @@ PluginComponent { readonly property string bundledHelper: Paths.strip(Paths.data) + "/cast/bin/" + helperName property string helperPath: "" property bool helperResolved: false - readonly property string installGuide: "https://github.com/Conava/dux-plugins/blob/master/Cast/README.md#3-install-cast-helper" + // The lowest protocol this plugin works with (cast-helper's protocolVersion). + readonly property int helperProtocol: 2 + // How the helper names this plugin to other apps using it. + readonly property string clientId: "dms" + property bool helperOutdated: false + property string helperVersion: "" + readonly property string customHelper: (pluginData.helperPath ?? "").trim().replace(/^~\//, Quickshell.env("HOME") + "/") + readonly property string installGuide: "https://github.com/Conava/dux-plugins/blob/master/Cast/README.md#1-install-cast-helper" // Arch gets the helper from the AUR, which also installs the packages it needs. property bool onArch: false - // Downloads the helper built for this plugin version from the repository's releases. + property string aurHelper: "yay" + // Downloads the newest helper release and installs it only if it matches + // the release's SHA256SUMS. readonly property string helperDownloadCommand: { - const version = pluginService?.availablePlugins?.[pluginId]?.version ?? ""; - if (!version) - return ""; - const url = "https://github.com/Conava/dux-plugins/releases/download/cast-v" + version + "/" + helperName + "-$(uname -m)"; - return "dms dl " + url + " --timeout 300 -o " + bundledHelper + " && chmod +x " + bundledHelper; + const base = "https://github.com/Conava/dux-cast/releases/latest/download"; + const dir = bundledHelper.substring(0, bundledHelper.lastIndexOf("/")); + const file = helperName + "-$(uname -m)"; + return "(mkdir -p " + dir + " && cd " + dir + " && dms dl " + base + "/" + file + " --timeout 300 -o " + file + + " && dms dl " + base + "/SHA256SUMS | grep \" " + file + "$\" | sha256sum -c && mv " + file + " " + helperName + + " && chmod +x " + helperName + ")"; } property var devices: [] @@ -56,7 +67,10 @@ PluginComponent { // The TV loads local files from this port, so a firewall rule can allow it. readonly property int mediaPort: parseInt(pluginData.mediaPort ?? "47830") || 47830 readonly property bool casting: session !== null - readonly property bool wanted: discoveryWanted || casting || starting + // The helper is on MPRIS while it casts, also for another app connected to + // it; the plugin attaches then to show and control that cast. + readonly property bool helperCasting: Mpris.players.values.some(p => p.dbusName === "org.mpris.MediaPlayer2.cast") + readonly property bool wanted: discoveryWanted || casting || starting || helperCasting readonly property string favorite: pluginData.favoriteDevice ?? "" readonly property var favoriteDevice: devices.find(d => d.id === favorite) ?? null @@ -82,6 +96,8 @@ PluginComponent { property string queueFolder: "" property bool queueFromLibrary: false + // Another app connected to the same helper started the cast. + readonly property string ownerName: session?.owner && session.owner.id !== clientId ? (session.owner.name || session.owner.id) : "" readonly property bool isMedia: session?.kind === "media" readonly property var sessionQueue: isMedia ? (session.queue ?? []) : [] readonly property int sessionIndex: session?.index ?? 0 @@ -222,6 +238,15 @@ PluginComponent { return device?.protocols ?? [device?.protocol ?? "chromecast"]; } + // Dropdowns pick by label, so a second device with the same name gets a number. + function deviceLabels(list) { + const seen = {}; + return list.map(d => { + seen[d.name] = (seen[d.name] ?? 0) + 1; + return seen[d.name] > 1 ? d.name + " (" + seen[d.name] + ")" : d.name; + }); + } + function canMedia(device) { return protocolsOf(device).includes("chromecast"); } @@ -937,7 +962,7 @@ PluginComponent { "commands": [ { "label": "", - "command": "doubletake-bin" + "command": root.aurHelper + " -S doubletake-bin" } ] }; @@ -1037,7 +1062,7 @@ PluginComponent { case "unsupportedLink": return I18n.trFor("cast", "Only http and https links can be played"); case "busy": - return I18n.trFor("cast", "Already casting"); + return ownerName ? I18n.trFor("cast", "%1 is already casting").arg(ownerName) : I18n.trFor("cast", "Already casting"); case "helperCrashed": return I18n.trFor("cast", "The cast helper stopped unexpectedly"); case "avahi": @@ -1080,8 +1105,14 @@ PluginComponent { function ensureRunning() { if (helper.running) return; - if (!helperResolved || !helperPath) { - console.info("cast: helper not started, path", helperResolved ? "not found" : "not resolved yet"); + if (!helperResolved) { + console.info("cast: helper not started, path not resolved yet"); + return; + } + if (!helperPath || helperOutdated) { + // The setup view takes over; nothing queued should run once the helper is installed. + _queue = []; + starting = false; return; } helper.stdinEnabled = true; @@ -1119,6 +1150,12 @@ PluginComponent { followIndex(session?.index ?? -1, next.index ?? 0); if (!next && session && queueFromLibrary && !starting) endShow(); + // What this plugin sent doesn't describe another app's queue. + if (next?.owner && next.owner.id !== clientId && queueItems.length > 0) { + queueItems = []; + queueFolder = ""; + queueFromLibrary = false; + } devices = msg.devices ?? []; rememberDevices(devices); session = next; @@ -1134,6 +1171,16 @@ PluginComponent { console.warn("cast:", msg.code, msg.detail ?? ""); return; case "check": + // Helpers up to 0.0.2 don't report it and speak protocol 1. + if ((msg.protocol ?? 1) < helperProtocol) { + console.warn("cast: helper speaks protocol", msg.protocol, "but the plugin needs", helperProtocol); + helperOutdated = true; + starting = false; + _queue = []; + helper.stdinEnabled = false; + return; + } + helperVersion = msg.version ?? ""; missing = msg.missing ?? []; deps = msg.deps ?? []; distro = msg.distro ?? "arch"; @@ -1197,25 +1244,44 @@ PluginComponent { knownDevices = pluginService?.loadPluginState(pluginId, "knownDevices", {}) ?? {}; findHelper(); Proc.runCommand("cast.findPacman", ["test", "-x", "/usr/bin/pacman"], (out, code) => root.onArch = code === 0); + Proc.runCommand("cast.findAurHelper", ["sh", "-c", "command -v paru || command -v yay || true"], out => { + const found = out.trim().split("/").pop(); + if (found) + root.aurHelper = found; + }); } + // A helper path set in Settings wins (another install location, or a + // development build); then PATH, then the plugin's own folder. function findHelper() { - Proc.runCommand("cast.findHelper", ["sh", "-c", "command -v cast-helper || true"], (stdout, exitCode) => { + helperOutdated = false; + if (customHelper) { + Proc.runCommand("cast.findCustom", ["test", "-x", customHelper], (out, code) => root.useHelper(code === 0 ? root.customHelper : "")); + return; + } + Proc.runCommand("cast.findHelper", ["sh", "-c", "command -v cast-helper || true"], stdout => { const onPath = stdout.trim(); if (onPath) { - root.helperPath = onPath; - root.helperResolved = true; - root.ensureRunningIfWanted(); + root.useHelper(onPath); return; } - Proc.runCommand("cast.findBundled", ["test", "-x", root.bundledHelper], (out, code) => { - root.helperPath = code === 0 ? root.bundledHelper : ""; - root.helperResolved = true; - root.ensureRunningIfWanted(); - }); + Proc.runCommand("cast.findBundled", ["test", "-x", root.bundledHelper], (out, code) => root.useHelper(code === 0 ? root.bundledHelper : "")); }); } + function useHelper(path) { + helperPath = path; + helperResolved = true; + ensureRunningIfWanted(); + } + + // A running helper finishes and the next one starts from the new path. + onCustomHelperChanged: { + if (helper.running) + helper.stdinEnabled = false; + findHelper(); + } + function ensureRunningIfWanted() { if (wanted) ensureRunning(); @@ -1239,12 +1305,17 @@ PluginComponent { Process { id: helper - command: [root.helperPath] + command: [root.helperPath, "attach"] stdinEnabled: true onStarted: { console.info("cast: helper started:", root.helperPath); root._thumbPending = {}; + root.send({ + "cmd": "hello", + "client": root.clientId, + "name": "Dux Shell" + }); root.send({ "cmd": "check" }); @@ -1258,12 +1329,14 @@ PluginComponent { onExited: (exitCode, exitStatus) => { console.info("cast: helper exited with", exitCode); - const wasCasting = root.casting || root.starting; + const wasWanted = root.wanted; root.session = null; root.starting = false; root.devices = []; root._queue = []; - if (exitCode !== 0 && wasCasting) + if (!root.wanted) + root.thumbs = {}; + if (exitCode !== 0 && wasWanted) root.lastError = { "code": "helperCrashed" }; @@ -1307,6 +1380,8 @@ PluginComponent { property string command: "" // A short label in front, such as which hardware the command is for. property string label: "" + // A web page instead of a command: it opens rather than being copied. + readonly property bool isLink: /^https?:\/\//.test(command) visible: command !== "" height: visible ? implicitHeight : 0 @@ -1335,11 +1410,16 @@ PluginComponent { DActionButton { id: copyButton anchors.verticalCenter: parent.verticalCenter - iconName: "content_copy" + iconName: commandRow.isLink ? "open_in_new" : "content_copy" iconSize: Theme.iconSizeSmall - tooltipText: I18n.trFor("cast", "Copy") + tooltipText: commandRow.isLink ? I18n.trFor("cast", "Open") : I18n.trFor("cast", "Copy") Accessible.name: tooltipText - onClicked: Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]) + onClicked: { + if (commandRow.isLink) + Qt.openUrlExternally(commandRow.command); + else + Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]); + } } } @@ -1474,7 +1554,10 @@ PluginComponent { title: I18n.trFor("cast", "Pair with %1").arg(pairWin.service.sessionDevice?.name ?? "") subtitle: pairWin.password ? I18n.trFor("cast", "Enter the AirPlay password") : I18n.trFor("cast", "Enter the code shown on the screen") titleAlignment: Text.AlignLeft - onCloseRequested: pairWin.close() + onCloseRequested: { + pairWin.answered = true; + pairWin.service.stopCast(); + } } DTextField { @@ -1702,10 +1785,11 @@ PluginComponent { dropdownWidth: CcMetrics.rowDropdownWidth alignPopupRight: true emptyText: I18n.trFor("cast", "No cast devices") - options: win.service.mediaDevices.map(d => d.name) - currentValue: win.device?.name ?? "" + readonly property var labels: win.service.deviceLabels(win.service.mediaDevices) + options: labels + currentValue: labels[win.service.mediaDevices.findIndex(d => d.id === win.device?.id)] ?? "" onValueChanged: value => { - const target = win.service.mediaDevices.find(d => d.name === value); + const target = win.service.mediaDevices[labels.indexOf(value)]; if (!target) return; win.service.playerDeviceId = target.id; diff --git a/Cast/CastWidget.qml b/Cast/CastWidget.qml index 571b27e..6cd6d79 100644 --- a/Cast/CastWidget.qml +++ b/Cast/CastWidget.qml @@ -133,6 +133,8 @@ PluginComponent { property string command: "" // A short label in front, such as which hardware the command is for. property string label: "" + // A web page instead of a command: it opens rather than being copied. + readonly property bool isLink: /^https?:\/\//.test(command) visible: command !== "" height: visible ? implicitHeight : 0 @@ -161,11 +163,16 @@ PluginComponent { DActionButton { id: copyButton anchors.verticalCenter: parent.verticalCenter - iconName: "content_copy" + iconName: commandRow.isLink ? "open_in_new" : "content_copy" iconSize: Theme.iconSizeSmall - tooltipText: I18n.trFor("cast", "Copy") + tooltipText: commandRow.isLink ? I18n.trFor("cast", "Open") : I18n.trFor("cast", "Copy") Accessible.name: tooltipText - onClicked: Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]) + onClicked: { + if (commandRow.isLink) + Qt.openUrlExternally(commandRow.command); + else + Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]); + } } } @@ -221,8 +228,9 @@ PluginComponent { required property var service property string path: "" property string kind: "" + property string art: "" // a cover link from the app that sent the item - readonly property string source: kind === "image" ? (service?.fileUrl(path) ?? "") : (service?.thumbs[path] ?? "") + readonly property string source: art || (kind === "image" ? (service?.fileUrl(path) ?? "") : (service?.thumbs[path] ?? "")) function fetch() { if ((kind === "video" || kind === "audio") && path) @@ -319,6 +327,7 @@ PluginComponent { service: root.service path: root.service?.currentItem?.path ?? "" kind: root.service?.currentKind ?? "" + art: root.service?.currentEntry?.art ?? "" width: root.iconSize height: width radius: Theme.cornerRadiusS @@ -459,8 +468,6 @@ PluginComponent { } } - popoutWidth: 400 - popoutContent: Component { PopoutComponent { id: popout @@ -596,7 +603,8 @@ PluginComponent { readonly property string demandKey: "panel" + Math.random() readonly property bool helperMissing: service && service.helperResolved && !service.helperPath - readonly property bool needsSetup: helperMissing + readonly property bool helperOutdated: !!service?.helperOutdated + readonly property bool needsSetup: helperMissing || helperOutdated readonly property var session: service?.session ?? null readonly property bool isMedia: service?.isMedia ?? false readonly property bool connecting: !!session && (session.phase === "starting" || session.phase === "authorizing" || session.phase === "pairing") @@ -671,11 +679,12 @@ PluginComponent { case "stopping": return I18n.trFor("cast", "Stopping..."); } - return I18n.trFor("cast", "Mirroring"); + return service.ownerName ? I18n.trFor("cast", "Mirroring from %1").arg(service.ownerName) : I18n.trFor("cast", "Mirroring"); } function mediaSubtitle() { - const name = service.sessionDevice?.name ?? ""; + const device = service.sessionDevice?.name ?? ""; + const name = service.ownerName ? device + " · " + I18n.trFor("cast", "Playing from %1").arg(service.ownerName) : device; if (service.currentEntry?.status === "failed") return service.itemErrorText(service.currentEntry); if (service.preparing) @@ -719,14 +728,14 @@ PluginComponent { // Setup required CcEmptyState { width: parent.width - visible: panel.helperMissing + visible: panel.needsSetup iconName: "build" - title: I18n.trFor("cast", "Cast helper not installed") + title: panel.helperOutdated ? I18n.trFor("cast", "Cast helper is outdated") : I18n.trFor("cast", "Cast helper not installed") subtitle: panel.service?.onArch ? I18n.trFor("cast", "Install it from the AUR, which also installs the packages it needs") : I18n.trFor("cast", "Download it with this command, or build it from source") CommandRow { width: parent.width - command: panel.service?.onArch ? "yay -S cast-helper" : "" + command: panel.service?.onArch ? panel.service.aurHelper + " -S cast-helper" : "" } CommandRow { @@ -758,7 +767,7 @@ PluginComponent { } CcGroup { - visible: !panel.helperMissing && (panel.service?.missing.length ?? 0) > 0 + visible: !panel.needsSetup && (panel.service?.missing.length ?? 0) > 0 CcEmptyState { iconName: "build" @@ -773,7 +782,7 @@ PluginComponent { CcGroup { id: missingGroup - visible: !panel.helperMissing && (panel.service?.missing.length ?? 0) > 0 + visible: !panel.needsSetup && (panel.service?.missing.length ?? 0) > 0 Repeater { model: panel.service?.missing ?? [] @@ -960,6 +969,7 @@ PluginComponent { service: panel.service path: panel.service?.currentItem?.path ?? "" kind: panel.service?.currentKind ?? "" + art: panel.service?.currentEntry?.art ?? "" width: Theme.listItemTwoLineHeight * 4 / 3 height: width * 9 / 16 radius: Theme.cornerRadiusM @@ -1247,9 +1257,10 @@ PluginComponent { compactMode: true dropdownWidth: CcMetrics.headerDropdownWidth alignPopupRight: true - options: panel.mediaDevices.map(d => d.name) - currentValue: panel.sendTarget?.name ?? "" - onValueChanged: value => panel.sendToId = panel.mediaDevices.find(d => d.name === value)?.id ?? "" + readonly property var labels: panel.service?.deviceLabels(panel.mediaDevices) ?? [] + options: labels + currentValue: labels[panel.mediaDevices.findIndex(d => d.id === panel.sendTarget?.id)] ?? "" + onValueChanged: value => panel.sendToId = panel.mediaDevices[labels.indexOf(value)]?.id ?? "" } } diff --git a/Cast/README.md b/Cast/README.md index f33b919..ae72ed4 100644 --- a/Cast/README.md +++ b/Cast/README.md @@ -21,49 +21,24 @@ is picked for that device. Files, music and links always use Chromecast. ## Installation -Cast needs the plugin, `cast-helper` (does the casting) and a few system packages. AirPlay -additionally needs doubletake. +Cast needs the plugin and `cast-helper`, which does the casting. The helper lives in its own +repository, [dux-cast](https://github.com/Conava/dux-cast), so other apps can share it. -### 1. Install the packages +### 1. Install cast-helper -Arch: +On Arch it's in the AUR, along with the packages it needs: ```bash -sudo pacman -S --needed avahi gstreamer gst-plugins-base gst-plugins-bad gst-plugin-pipewire gst-plugin-va \ - gst-plugins-good gst-libav libheif -``` - -Debian / Ubuntu: - -```bash -sudo apt install avahi-daemon gstreamer1.0-plugins-base gstreamer1.0-plugins-bad gstreamer1.0-pipewire \ - gstreamer1.0-plugins-good gstreamer1.0-libav pipewire-bin libheif-examples -``` - -Fedora: - -```bash -sudo dnf install avahi gstreamer1-plugins-base gstreamer1-plugins-bad-free pipewire-gstreamer \ - gstreamer1-plugins-good gstreamer1-plugin-libav pipewire-utils libheif-tools +yay -S cast-helper ``` -openSUSE: - -```bash -sudo zypper install avahi gstreamer-plugins-base gstreamer-plugins-bad gstreamer-plugin-pipewire \ - gstreamer-plugins-good gstreamer-plugins-libav pipewire-tools heif-examples -``` +Everywhere else, follow the [dux-cast installation guide](https://github.com/Conava/dux-cast#install): +a few GStreamer and Avahi packages, then a release build or a build from source. AirPlay also needs +doubletake, which is explained there too. -The first line of each is required, the second recommended (more formats, sound on the cast device -only, HEIC photos). Then start Avahi: - -```bash -sudo systemctl enable --now avahi-daemon -``` - -Mirroring needs VA-API H.264 or HEVC encoding. Fedora and openSUSE ship drivers without them; get -the drivers from RPM Fusion or Packman. Settings > Plugins > Cast lists every package, marks what's -missing and shows the install command for your distribution. +The plugin looks for `cast-helper` on `PATH`, then in `~/.local/share/dms/cast/bin/`. A helper +installed somewhere else can be set in Settings > Helper path. Until it's found, the popout shows +the install command for your system. It does the same when a plugin update needs a newer helper. ### 2. Install the plugin @@ -74,69 +49,10 @@ git clone https://github.com/Conava/dux-plugins.git ~/.local/src/dux-plugins ln -s ~/.local/src/dux-plugins/Cast ~/.config/dms/plugins/cast ``` -Enable it in Settings > Plugins and add it to a bar or the Control Center. - -### 3. Install cast-helper - -The plugin looks for `cast-helper` on `PATH`, then in `~/.local/share/dms/cast/bin/`. Source: -[`helpers/cast/`](../helpers/cast). Use one of these: - -Arch: install it from the AUR. This also installs the required packages from step 1. +Enable it in Settings > Plugins and add it to a bar or the Control Center. Settings > Plugins > Cast +lists every package the helper can use, marks what's missing and shows the install command. -```bash -yay -S cast-helper -``` - -Download the release build (x86_64 or aarch64, built by GitHub Actions from the tagged source): - -```bash -dms dl https://github.com/Conava/dux-plugins/releases/download/cast-v0.0.2/cast-helper-$(uname -m) \ - --timeout 300 -o ~/.local/share/dms/cast/bin/cast-helper \ - && chmod +x ~/.local/share/dms/cast/bin/cast-helper -``` - -Optionally check it against the release's checksums: - -```bash -cd ~/.local/share/dms/cast/bin -dms dl https://github.com/Conava/dux-plugins/releases/download/cast-v0.0.2/SHA256SUMS \ - | sed "s/-$(uname -m)\$//" | grep "cast-helper$" | sha256sum -c -``` - -Or build it from source (Go 1.25 or newer, no C compiler): - -```bash -cd ~/.local/src/dux-plugins/helpers/cast -CGO_ENABLED=0 go build -o ~/.local/share/dms/cast/bin/cast-helper . -``` - -The popout shows these commands (the AUR one on Arch). Click "Check again" there once the helper -is in place. - -### 4. Install doubletake (AirPlay only) - -[doubletake](https://github.com/omarroth/doubletake) is an independent AirPlay sender, not part of -this project. Review it before installing. Cast is tested with 0.5.0. - -Arch (AUR): - -```bash -yay -S doubletake-bin -``` - -Other distributions, from its releases (x86_64 shown, use `doubletake-arm64` on aarch64): - -```bash -dms dl https://github.com/omarroth/doubletake/releases/download/v0.5.0/doubletake \ - --timeout 300 -o ~/.local/share/dms/cast/bin/doubletake \ - && chmod +x ~/.local/share/dms/cast/bin/doubletake -``` - -doubletake also runs `gst-launch-1.0` and `pactl`: install `gstreamer1.0-tools` and -`pulseaudio-utils` on Debian / Ubuntu, `pulseaudio-utils` on Fedora, `gstreamer-utils` and -`pulseaudio-utils` on openSUSE, `libpulse` on Arch. - -### 5. Open the firewall +### 3. Open the firewall Only needed with a firewall. The ports can be changed in Settings, which also shows the `ufw` commands. @@ -153,17 +69,15 @@ commands. - Settings above 1080p at 30 fps are marked experimental: they can lag on slow networks or devices. - Mirroring asks for the screen through the desktop portal. The choice is remembered. - Discovery is IPv4 only. +- Other apps can use the same helper. Their casts show in the bar and the Control Center too, with + the app they play from. ## Network and files -Apart from the cast devices on the local network, Cast only contacts youtube.com, when a YouTube -link is played. It writes to: - -| Path | Contents | -|---|---| -| `~/.cache/dms/cast` | Converted files while they play, thumbnails | -| `~/.local/share/dms/cast` | The helpers, AirPlay pairings | -| `$XDG_RUNTIME_DIR/dms-cast` | doubletake's control socket during an AirPlay cast | +The plugin itself only loads the covers of songs another app casts, from where that app says. What +cast-helper contacts and writes is listed in +[its README](https://github.com/Conava/dux-cast#network-and-files): the cast devices on the local +network, youtube.com for YouTube links, and the server of a direct link to ask for its media type. ## License diff --git a/Cast/Settings.qml b/Cast/Settings.qml index 71c550c..c413e1b 100644 --- a/Cast/Settings.qml +++ b/Cast/Settings.qml @@ -38,6 +38,22 @@ PluginSettings { } readonly property string port: value("mediaPort", "47830") readonly property string airplayPorts: value("airplayPorts", "47840-47849") + // The local network of the default route, for the firewall commands. + property string lanSubnet: "192.168.0.0/16" + + Component.onCompleted: { + Proc.runCommand("cast.lanSubnet", ["ip", "-4", "-j", "route", "show"], (out, code) => { + if (code !== 0) + return; + try { + const routes = JSON.parse(out); + const dev = routes.find(r => r.dst === "default")?.dev; + const lan = routes.find(r => r.dev === dev && r.scope === "link" && r.dst.includes("/")); + if (lan) + root.lanSubnet = lan.dst; + } catch (e) {} + }); + } readonly property var deps: service?.deps ?? [] readonly property var missingIds: deps.filter(d => !d.present).map(d => d.id) @@ -157,6 +173,8 @@ PluginSettings { property string command: "" // A short label in front, such as which hardware the command is for. property string label: "" + // A web page instead of a command: it opens rather than being copied. + readonly property bool isLink: /^https?:\/\//.test(command) visible: command !== "" height: visible ? implicitHeight : 0 @@ -185,11 +203,16 @@ PluginSettings { DActionButton { id: copyButton anchors.verticalCenter: parent.verticalCenter - iconName: "content_copy" + iconName: commandRow.isLink ? "open_in_new" : "content_copy" iconSize: Theme.iconSizeSmall - tooltipText: I18n.trFor("cast", "Copy") + tooltipText: commandRow.isLink ? I18n.trFor("cast", "Open") : I18n.trFor("cast", "Copy") Accessible.name: tooltipText - onClicked: Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]) + onClicked: { + if (commandRow.isLink) + Qt.openUrlExternally(commandRow.command); + else + Quickshell.execDetached(["dms", "cl", "copy", commandRow.command]); + } } } @@ -282,18 +305,20 @@ PluginSettings { }; for (const d of root.devices) byId[d.id] = d; - return Object.values(byId).sort((a, b) => a.name.localeCompare(b.name)); + return Object.values(byId).sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); } + readonly property var labels: root.service?.deviceLabels(deviceChoices) ?? [] + readonly property string favoriteLabel: labels[deviceChoices.findIndex(d => d.id === root.value("favoriteDevice", ""))] ?? favoriteName options: { - const names = deviceChoices.map(d => d.name); - if (favoriteName && !names.includes(favoriteName)) - names.unshift(favoriteName); + const names = labels.slice(); + if (favoriteLabel && !names.includes(favoriteLabel)) + names.unshift(favoriteLabel); return [noneLabel].concat(names); } - currentValue: favoriteName || noneLabel + currentValue: favoriteLabel || noneLabel onValueChanged: value => { - const device = deviceChoices.find(d => d.name === value); + const device = deviceChoices[labels.indexOf(value)]; if (value === noneLabel || device) { root.saveValue("favoriteDevice", device?.id ?? ""); root.save("favoriteName", device?.name ?? ""); @@ -454,7 +479,7 @@ PluginSettings { SettingsRow { body: CommandRow { width: parent.width - command: "sudo ufw allow from 192.168.0.0/16 to any port " + root.port + " proto tcp" + command: "sudo ufw allow from " + root.lanSubnet + " to any port " + root.port + " proto tcp" } } } @@ -480,7 +505,7 @@ PluginSettings { SettingsRow { body: CommandRow { width: parent.width - command: "sudo ufw allow from 192.168.0.0/16 to any port " + root.airplayPorts.replace("-", ":") + " proto udp" + command: "sudo ufw allow from " + root.lanSubnet + " to any port " + root.airplayPorts.replace("-", ":") + " proto udp" } } } @@ -520,18 +545,22 @@ PluginSettings { subtitle: { if (!root.service?.helperResolved) return ""; + if (root.service.helperPath && root.service.helperOutdated) + return I18n.trFor("cast", "Outdated, update it with one of these commands"); + if (root.service.helperPath && root.service.helperVersion) + return root.service.helperPath + " · " + root.service.helperVersion; return root.service.helperPath || I18n.trFor("cast", "Not installed"); } body: Column { width: parent.width spacing: Theme.spacingS - visible: !!root.service?.helperResolved && !root.service.helperPath + visible: !!root.service?.helperResolved && (!root.service.helperPath || root.service.helperOutdated) height: visible ? implicitHeight : 0 CommandRow { width: parent.width - command: root.service?.onArch ? "yay -S cast-helper" : "" + command: root.service?.onArch ? root.service.aurHelper + " -S cast-helper" : "" } CommandRow { @@ -542,6 +571,18 @@ PluginSettings { } } + SettingsTextFieldRow { + iconName: "folder_open" + text: I18n.trFor("cast", "Helper path") + description: I18n.trFor("cast", "Only needed for a helper outside PATH and the plugin's folder") + placeholderText: "cast-helper" + value: root.value("helperPath", "") + onEditingFinished: value => { + if (value.trim() !== root.value("helperPath", "")) + root.save("helperPath", value.trim()); + } + } + SettingsRow { DButton { anchors.verticalCenter: parent.verticalCenter diff --git a/Cast/plugin.json b/Cast/plugin.json index 79d044a..22a28d4 100644 --- a/Cast/plugin.json +++ b/Cast/plugin.json @@ -2,7 +2,7 @@ "id": "cast", "name": "Cast", "description": "Mirror your screen to Chromecast, Google TV and AirPlay devices, and play files, music, playlists, slideshows and links on Chromecast", - "version": "0.0.2", + "version": "0.0.3", "author": "Marlon Kranz", "type": "composite", "capabilities": ["daemon", "dankbar-widget", "control-center"], diff --git a/Cast/screenshot.png b/Cast/screenshot.png index 4a1a4a9..802d70a 100644 Binary files a/Cast/screenshot.png and b/Cast/screenshot.png differ diff --git a/dev b/dev index 1e43478..7b6e4ba 100755 --- a/dev +++ b/dev @@ -6,8 +6,10 @@ # ./dev check [PLUGIN...] validate manifests (all plugins when none given) # ./dev link PLUGIN symlink PLUGIN into the running shell's plugin dir and rescan # ./dev reload PLUGIN reload PLUGIN in the running shell and print its status -# (link and reload also build helpers// if the plugin has one) -# ./dev unlink PLUGIN remove the symlink again +# (link and reload also build the plugin's helper from a checkout +# next to this repo, ../dux-, and point the plugin's +# helperPath setting at that build) +# ./dev unlink PLUGIN remove the symlink and the helperPath setting again # ./dev strings PLUGIN [LOCALE] sync translations/*.json (or start LOCALE.json) with the code # # PLUGIN is a plugin directory in this repo, e.g. MyPlugin. @@ -16,6 +18,7 @@ set -euo pipefail root=$(cd "$(dirname "$0")" && pwd) shell_dir="$root/.dux-shell" plugins_dir="${XDG_CONFIG_HOME:-$HOME/.config}/dms/plugins" +plugin_settings="${XDG_CONFIG_HOME:-$HOME/.config}/dms/plugin_settings.json" upstream=https://github.com/duxlabs-dev/dux-shell.git die() { echo "dev: $*" >&2; exit 1; } @@ -102,14 +105,35 @@ sys.exit(failed) EOF } -# A plugin with a Go helper keeps it in helpers//. Build it into the -# path the plugin looks in when the helper isn't on PATH. +# A plugin's Go helper lives in its own repo, checked out next to this one as +# dux-. Build it next to the plugin's data and point its helperPath setting +# there, so the running shell uses this build over an installed helper; the +# shell picks the change up live. A shared helper still running the previous +# build is stopped, and the plugin starts the new one. +helper_src() { (cd "$root/../dux-$1" 2>/dev/null && pwd) || echo "$root/../dux-$1"; } + build_helper() { - local id=$1 src="$root/helpers/$1" + local id=$1 src + src=$(helper_src "$id") [ -f "$src/go.mod" ] || return 0 - local out="${XDG_DATA_HOME:-$HOME/.local/share}/dms/$id/bin/$id-helper" + local out="${XDG_DATA_HOME:-$HOME/.local/share}/dms/$id/dev/$id-helper" (cd "$src" && CGO_ENABLED=0 go build -o "$out" .) || die "building $id-helper failed" - echo "built $out" + set_helper_path "$id" "$out" + pkill -f -x "$out serve" || true + echo "built $out from $src" +} + +# Sets (or with no path, removes) a plugin's helperPath setting. +set_helper_path() { + local id=$1 path=${2:-} tmp + [ -f "$plugin_settings" ] || echo '{}' >"$plugin_settings" + tmp=$(mktemp "$plugin_settings.XXXXXX") + if [ -n "$path" ]; then + jq --arg id "$id" --arg p "$path" '.[$id].helperPath = $p' "$plugin_settings" >"$tmp" + else + jq --arg id "$id" 'if .[$id] then .[$id] |= del(.helperPath) else . end' "$plugin_settings" >"$tmp" + fi + mv "$tmp" "$plugin_settings" } cmd_link() { @@ -143,6 +167,7 @@ cmd_unlink() { dir=$(plugin_dir "${1:-}"); id=$(plugin_id "$dir"); link="$plugins_dir/$id" [ -L "$link" ] && [ "$(readlink -f "$link")" = "$dir" ] || die "$link is not a link to this repo" rm "$link" + [ -f "$(helper_src "$id")/go.mod" ] && set_helper_path "$id" dms ipc plugin-scan scan >/dev/null echo "unlinked $link" } diff --git a/docs/cast.md b/docs/cast.md deleted file mode 100644 index d4ee25f..0000000 --- a/docs/cast.md +++ /dev/null @@ -1,360 +0,0 @@ -# Cast: design - -Mirror the screen, with sound, to a Chromecast or an AirPlay receiver, from the bar, the Control -Center or the island. Successor to the closed shell PR -[duxlabs-dev/dux-shell#2697](https://github.com/duxlabs-dev/dux-shell/pull/2697), rebuilt as a -plugin. - -## Scope of v1 - -- **Play a file on the TV** (Chromecast only; doubletake can't): the TV fetches and decodes the - original itself, so a 4K movie plays at full quality with no re-encoding. Play, pause, seek, - volume and subtitles from the plugin. -- **Play a link:** YouTube links open in the TV's YouTube app, direct media links (MP4, WebM, HLS, - DASH, audio) in its media player. Other pages need the site's own cast button or mirroring. -- Screen mirroring, video and audio, to **Chromecast** (real time) and **AirPlay 2**. -- One monitor at a time, picked in the portal dialog. -- A favorite device to start casting to in one click. -- Not in v1: single-window casting, several targets at once. - -Chromecast is tested by the maintainer. AirPlay is built on doubletake, checked against a local -UxPlay receiver, and tested by community volunteers once Chromecast works. - -## Architecture - -``` -Cast plugin (QML) ──JSON lines over stdin/stdout──► cast-helper (Go, no CGO) - bar pill + popout │ - Control Center tile + detail panel ├─ avahi (D-Bus) discovery - settings, setup page ├─ xdg-desktop-portal screen access - ▲ ├─ libgstreamer (dlopen) capture + encode - │ MPRIS ├─ Cast v2 + Cast Streaming Chromecast - island, media widgets ◄──────── helper's MPRIS player ───────┤ - └─ doubletake (daemon) AirPlay -``` - -**Plugin:** one `composite` plugin with two surfaces. -- **`daemon` (`CastService.qml`):** owns the helper process and holds the state the UI binds - to. The shell creates it once and destroys it when the plugin is disabled or reloaded, which stops - the helper. A `qmldir` singleton would survive both. -- **`widget` (`CastWidget.qml`):** the bar pill, the popout and the Control Center tile and - detail panel. Every instance reads the daemon through `pluginService.pluginDaemonInstances`. -- **One file per surface:** reload only recompiles a plugin's entry files, so shared sibling files - would go stale. The device panel is an inline component of the widget, and Settings keeps its own - copy of the option lists. - -**Helper:** a single Go binary built without CGO, `cast-helper`, in `cast-helper/` at the repo root. -- **Lifetime:** runs only while the popout or detail panel is open, or a cast is running. Exits on - stdin EOF, so it can't outlive the shell. -- **Owns:** discovery, sessions, the portal request, the capture pipeline, and the MPRIS player. - -**External requirements:** avahi-daemon running, GStreamer with the plugins listed under Setup, -PipeWire with `pw-metadata`, and a portal backend with ScreenCast (every DMS compositor has one). -AirPlay also needs doubletake, which itself needs `gst-launch-1.0`, `gst-inspect-1.0` and `pactl`. - -## Helper protocol - -One JSON object per line. The plugin sends commands, the helper sends events. The full state is -pushed on every change, so the plugin never polls. - -```jsonc -// plugin → helper -{"cmd": "check"} -{"cmd": "discover", "on": true} -{"cmd": "start", "device": "", "audio": "tv|both|local", "width": 1920, "height": 1080, "fps": 30, - "kbps": 0 /* auto */, "codec": "auto|h264|hevc", "delay": 200, "restoreToken": "…", - "protocol": "chromecast|airplay" /* where a device has both */, "airplayPorts": "47840-47849"} -{"cmd": "pair", "pin": "1234"} // AirPlay PIN or password -{"cmd": "play", "device": "", "path": "/abs/movie.mkv" | "url": "https://…", "port": 47830} -{"cmd": "queue", "device": "", "items": [{"path": "…"} | {"url": "…"}], "index": 0, "interval": 8, - "loop": false, "shrinkPhotos": true, "convertVideos": true, "port": 47830} -{"cmd": "pause"} {"cmd": "resume"} {"cmd": "seek", "position": 30} {"cmd": "subtitles", "on": false} -{"cmd": "next"} {"cmd": "previous"} {"cmd": "jump", "index": 3} {"cmd": "slideshow", "interval": 0} -{"cmd": "move", "device": ""} // same cast, other device -{"cmd": "thumbnail", "path": "/abs/video.mp4", "size": 256} // a song's thumbnail is its cover -{"cmd": "playlist", "path": "/abs/list.m3u8"} // .m3u, .m3u8, .pls -{"cmd": "stop"} -{"cmd": "volume", "level": 0.4} // receiver volume, where supported - -// helper → plugin -{"event": "state", "devices": [{"id", "name", "model", "protocol": "chromecast|airplay", "protocols": [...]}], - "session": null | {"kind": "mirror|media", "device", "name", - "phase": "starting|authorizing|pairing|converting|casting", "since", "volume", "canVolume", - "protocol"?: "airplay", "credential"?: "pin|password" /* while pairing */, - // media only: - "title", "progress", "playerState", "position", "positionAt", "duration", "rate", - "subtitles", "subtitlesOn", "index", "interval", "loop", - "queue": [{"title", "kind": "image|video|audio|link|youtube", "status": "waiting|preparing|ready|failed", - "progress", "artist", "error"?, "feature"?, "missing"?}]}} -{"event": "restoreToken", "token": "…"} // the plugin stores it in its state -{"event": "thumbnail", "path": "…", "file": "~/.cache/dms/cast/thumbs/.jpg" | ""} -{"event": "playlist", "path": "…", "items": [{"path"?, "url"?, "title"?}]} // entries in order -{"event": "error", "code": "missingDependency|portalDenied|deviceLost|receiverStopped|…", "detail": "…", - "feature"?: "mirroring", "missing"?: ["gst-plugin-va"]} -{"event": "warning", "code": "soundOnBoth", "feature": "tvOnlySound", "missing": ["pipewire"]} -{"event": "warning", "code": "airplaySound"} // AirPlay sound stays on this computer -{"event": "check", "distro": "arch|fedora|debian|opensuse|void|nixos", "missing": [required ids], - "deps": [{"id", "package", "required", "present", "features": ["mirroring", …]}]} -``` - -- **Dependencies are optional at start.** The helper runs with nothing installed; each feature checks - its packages when used and fails with `missingDependency` naming them. Features: `discovery`, - `mirroring`, `sound`, `tvOnlySound`, `files`, `mkv`, `convert`, `photos`, `heic`, `thumbnails`. - Required packages are those for discovery and mirroring; the rest are recommended. Without - `pw-metadata`, "only the cast device" falls back to both and says so; without the photo - elements, photos go out unshrunk. -- **Queue:** a single `play` is a queue of one. The current item and the next video or next two - photos are prepared ahead (served, remuxed, converted, shrunk), one at a time; prepared copies more - than two behind are deleted. Photos advance on the interval, videos when they finish. A YouTube - link plays alone, in the YouTube app. -- **Music:** songs go out with their title, artist, album and cover (embedded, else `cover.jpg` / - `folder.jpg` in the folder), so the device shows a music screen. A playlist file inside a `queue` - expands in place; relative entries are relative to the playlist's folder. -- **Mixed shows:** songs, photos, videos and links can share a queue, played one after another: - photos for the interval (or until next in Manual mode), the rest to their end. The device plays - one thing at a time, so music can't run under photos; rendering that on the PC was ruled out. -- **Devices:** a TV that speaks Chromecast and AirPlay is one device, merged by address, keeping - its Chromecast id. Mirroring uses Chromecast unless `protocol` says AirPlay; files, links and - shows always use Chromecast, and an AirPlay-only device mirrors only (`airplayNoMedia`). -- **Position:** the receiver reports it only on changes. The plugin counts on from `positionAt` - while playing, with a timer that runs only while the panel is on screen. -- **MPRIS:** file playback exposes play/pause, next/previous and seek; mirroring stays display-only. -- **Debugging:** `CAST_DEBUG=1` in the shell's environment logs every Cast message and HTTP request. -- **Error codes** map to translated strings in QML. The helper never sends user-facing text. - -## Chromecast: Cast Streaming - -This is the mirroring protocol Chrome uses for tab and desktop casting. The receiver is built into -every Chromecast and Google TV, so nothing gets installed on the TV. A sender built on Google's Open -Screen library has been measured at about 120 ms latency at 1080p. - -1. **Control:** a TLS connection on port 8009 (Cast v2: length-prefixed protobuf with a JSON - payload, about 200 lines without any library). Launch the mirroring receiver (`0F5096E8`), then - exchange OFFER/ANSWER on the `urn:x-cast:com.google.cast.webrtc` namespace to agree on codecs, - SSRCs, ports, AES keys and the target playout delay (200 ms works). - The first cast from a new computer makes the TV ask the viewer for permission: LAUNCH answers - `LAUNCH_STATUS` `USER_PENDING_AUTHORIZATION`, so the UI says "Accept on your TV" and waits up to - 90 s. -2. **Media:** RTP over UDP in Cast's profile. Frames are AES-128-CTR encrypted. RTCP sender reports - go out; receiver reports, ACK/NACK and picture-loss requests come back. Retransmit on NACK, force - a keyframe on picture loss. -3. **Codecs:** do it the way Chrome does. First ask the receiver with `GET_CAPABILITIES`; its - `CAPABILITIES_RESPONSE` lists `mediaCaps` (`h264`, `vp8`, `vp9`, `hevc`, `av1`, `4k`). Then offer - one video stream per codec this machine can encode, in Chrome's priority order (HEVC, AV1, VP9, - H.264, VP8), each with its `codecParameter` (e.g. `hev1.1.6.L123.B0`, H.264 Main at level 4.2). - The receiver's ANSWER picks a stream through `sendIndexes`. Offering a single codec is a trap: - the spike's LG accepted a lone HEVC offer and then showed only a black screen. - **Mirroring tops out at 1080p.** Chrome caps it at 1920×1080 and 30 fps (60 only behind a flag), - and its codec levels stop at 1080p60. The spike's LG OLED C3 (2023) lists - `aac, opus, vp9, h264, hevc, vp8, audio` with no `4k`, picks H.264 when offered HEVC and H.264, - quits or stalls on anything above 1080p, and falls behind at 1080p60. 4K is the job of file - playback, not mirroring. -4. **Encoding:** the helper runs the GStreamer pipeline in its own process, loading - `libgstreamer-1.0` at run time through purego, so it needs no CGO. The pipeline ends in an - `appsink`; the helper splits frames and hands them to the sender. That gives the sender a key - frame on demand (upstream force-key-unit, about 40 ms) and bitrate changes on the running - encoder. The cost: the binary is dynamically linked against glibc's loader, so musl systems - and NixOS without nix-ld need a build from source or a distro package. What the spike needed: - - `aud=true` on the encoder, so access units split cleanly at their delimiters; `b-frames=0`; - Main profile, as the `codecParameter` declares. - - `vapostproc disable-passthrough=true` right after `pipewiresrc`. Without it, a capture that - already has the output size passes the portal's own buffer to the encoder, which keeps it as a - reference frame until the portal's small pool runs dry and capture stops. - - `videorate` after that copy for a steady frame rate on an idle desktop, then a one-frame leaky - `queue` before the encoder to drop stale frames. - - The VA H.264 encoder writes no colour description, and receivers may then assume BT.601 while - vapostproc produces BT.709 (measured: within 2/255 when read as BT.709, off by up to 23 as - BT.601). The helper rewrites each SPS to declare BT.709 limited range. - - Defaults that held up: 1080p30, 12 Mbit/s CBR, a key frame every 2 s, 200 ms playout delay. -5. **Screen access:** the portal ScreenCast session (ported from #2697). The PipeWire fd is passed - to `pipewiresrc` as its `fd`. Request `persist_mode` 2 and store the restore token so the dialog only - appears on the first cast. On Hyprland the picker issues a token only when its "Allow a restore - token" box is ticked, so the UI should say so the first time. - -Reference implementations to read, not copy: Open Screen's `cast/streaming` (BSD-3) and omacast -(Rust, MIT), which follows the same design. Ported code keeps its license notice. - -## AirPlay: doubletake - -[doubletake](https://github.com/omarroth/doubletake) (Go, LGPL-3.0-or-later, active). The spike -paired and streamed to its test receiver and to a local UxPlay. -- **Lifetime:** the helper starts `doubletake -daemonize -socket … -creds /…` per AirPlay - session and stops it with SIGTERM. Its daemon always runs its own mDNS browsing, so it must not - stay up. `-creds` keeps its pairings out of a standalone install's `~/.config/doubletake`. -- **Control:** one JSON request per connection on the socket, no events: `connect {target, port, - pin?}`, `disconnect`, `status`, `mute`/`unmute`. The helper polls `status` about once a second - during a session. -- **Pairing:** `pin_required` with `credential_kind` `pin` → `needPin`. A `password` kind exists for - receivers set to require one. doubletake stores pairings in its creds file. -- **Capture:** doubletake runs `gst-launch-1.0` and opens **its own portal dialog**, storing that - restore token in its creds file per receiver. -- **Audio:** off (`-no-audio`), sound stays on this computer. With sound, doubletake 0.5.0 sets the - receiver to 0 dB, full volume (it turned an LG C3 up to 100), and has no option to keep the - current level. Worth reporting upstream: sending the receiver's `initialVolume` from `/info` - instead would fix it. -- **Firewall:** random ports unless `-port-range` is given: three UDP ports per receiver, inbound. -- **Limits:** doubletake reads the receiver's display size itself; frame rate is ours (`-fps`). -- **No version flag:** the helper tracks it by the pinned checksum. -- **Encoder:** the helper passes `-hwaccel vaapi` when VA-API is there. doubletake 0.5.0's automatic - choice, the Vulkan encoder, sends no frames from a real screen at 60 fps (its test pattern works); - worth reporting upstream. -- **60 fps** decodes on the LG C3 over AirPlay in H.264 and HEVC (HEVC even at 4K), but lags like - 60 fps over Chromecast, so it stays experimental. - -## Audio - -Setting **Play sound on**: *TV only* (default) or *TV and this computer*. - -- **TV only:** the helper's capture stream is itself a sink (`media.class=Audio/Sink`), owned by the - helper's own PipeWire connection, so PipeWire removes it however the helper ends. It becomes the - default only through the runtime key `default.audio.sink`, which WirePlumber never saves. The - user's chosen default is never changed, so the speakers come back by themselves. - - While casting, the helper watches the metadata and claims the default again after a device is - plugged in, unless the user picked another output. - - Apps the user once moved to a device keep playing there. - - Volume keys control what the TV receives. -- **TV and this computer:** capture the current default sink's monitor. The TV runs behind the - speakers, which is why this isn't the default. - -## Island and media widgets - -The helper registers an MPRIS player (`org.mpris.MediaPlayer2.cast`) while mirroring: title -"Casting to ", playback status Playing, CanPlay true (playerctl skips players without it), -no artist (so no lyrics or artwork lookups). The island, the media widget, the dash and `playerctl` -show it. The shell's buttons only call play/pause and never Stop, so the player is display-only and -the cast is ended from the pill or tile. Whichever player was playing first stays the active one. - -## Surfaces - -**Bar pill:** -- **Idle:** cast icon. Hidden when the "show only while casting" setting is on. -- **Casting:** a live indicator (pulsing dot in the error color, like a recording light), the device - name, and a stop button. The pulse runs only while casting. -- **Vertical bar:** live indicator and stop button, no name. -- Clicking anywhere but the stop button opens the popout. - -**Popout and Control Center detail panel (one shared component):** -- **Header:** state ("Casting to Living Room TV" / "Not casting"). -- **Device list:** found devices, with icons by protocol, a pin for the favorite, a button to play - a file on that device (the shell's file browser), and a spinner while scanning. -- **While casting:** Stop button and receiver volume slider where the receiver supports it. For a - file also play/pause, a seek bar with times, and a subtitles toggle. -- **Options:** Play sound on and Quality, the same settings as the settings page. -- **Setup required:** when something is missing, the list is replaced by the setup view (below). - -**Control Center tile:** -- Toggle: starts casting to the favorite device, or stops. -- Secondary text: device name or "Off". -- With no favorite set, the toggle shows a toast; the plugin API can't open the detail panel. - -Every user-facing string goes through `I18n.trFor("cast", …)`. Plugin id: `cast`, -directory `Cast/`, helper in `helpers/cast/`. - -## Settings - -- **Favorite device**, also settable from the star in the list. -- **Play sound on:** TV only / TV and this computer. -- **Quality presets:** Balanced (1080p30), Smooth (1080p60), Low bandwidth (720p30), or Custom, - which shows the three values below. -- **Resolution:** 720p or 1080p for Chromecast mirroring (the protocol's ceiling); AirPlay may - allow more, which its testers will tell. File playback always plays the original. -- **Frame rate:** 30 (default) or 60, where the receiver keeps up. -- **Bitrate:** Auto (from resolution and frame rate) or a fixed value in Mbit/s. -- **Codec:** Auto (best one the TV accepts), or a fixed one for troubleshooting. -- **Delay:** how far the TV plays behind (100–1000 ms, default 200). -- **Show bar pill only while casting**, and **show the device name** in the pill. -- **Helper:** installed version, location, and Update / Reinstall buttons. - -## Setup and installation - -The helper's `check` reports what's missing. The plugin shows it as a setup view and, the first -time, as a notification (`dms notify --action`) with a button that opens the setup view. - -| Missing | What the plugin offers | -|---|---| -| helper / doubletake | **Install** (default): download the pinned release into `~/.local/share/dms/cast/bin`, verify its SHA-256, done. **Install system-wide**: the same files into `/usr/local/bin` via `pkexec install`, hidden on NixOS. **Build from source**: `go build` the helper from the plugin's own checkout and doubletake from its pinned tag; shown only when `go` is on PATH. | -| avahi not running | The command for the distro (`sudo systemctl enable --now avahi-daemon`; on NixOS `services.avahi.enable = true;`) with a copy button | -| GStreamer elements | The package names for the distro (Arch, Fedora, Debian/Ubuntu, openSUSE, Void, NixOS), detected from `/etc/os-release`, with a copy button | - -- **Pinning:** versions and checksums live in the plugin source. A plugin update that pins a newer - helper shows "Update helper". -- **Lookup:** a helper or doubletake on `PATH` is used before the downloaded copy. -- **Releases:** built by GitHub Actions for amd64 and arm64 on a `cast-helper-v*` tag, - `CGO_ENABLED=0`, with a checksums file. - -## Safety and cost - -- **Screen access** always goes through the portal: the user picks the monitor once, and the restore - token is kept in the plugin's state (AirPlay: in doubletake's creds file). -- **Network:** traffic goes only to the chosen device on the LAN, with three exceptions, each after - an explicit action: playing a YouTube link calls youtube.com's lounge API (lounge token, bind, - setPlaylist) like a phone's YouTube app does; a direct link without a known extension gets one - HEAD request for its type; and a release download from GitHub. -- **Idle cost is zero.** No helper process, no timers. Discovery runs only while a device list is on - screen. -- **Cleanup:** the user's default output is never changed, so nothing needs restoring. Stopping - closes the portal session and the receiver app; remuxed copies in `~/.cache/dms/cast` are - deleted after playback and on the next start after a crash. - -## Milestones - -0. **Spike:** desktop to the maintainer's Chromecast in real time from a throwaway Go program. - *Done for mirroring (2026-10-21), see "Spike results". Audio routing, discovery, MPRIS, file - playback and doubletake spiked on 2026-10-22 without the TV.* -1. **Helper:** discovery, Chromecast mirroring with audio, the protocol, MPRIS, and `check`. -2. **Plugin:** service, bar pill, popout, CC tile and detail panel, settings. -3. **Install:** releases, download, system-wide and source installs, the setup view. -4. **AirPlay:** doubletake integration and the PIN flow, then community testing. -5. **Registry submission.** - -## Spike results - -Tested from a Hyprland desktop (AMD RX 7900 XTX, VA-API) to an LG OLED65C3 over Ethernet to Wi-Fi: - -- **Works:** discovery through avahi, the permission prompt on the TV, the Cast Streaming session, - encrypted video and Opus audio, retransmission on loss. A test pattern's clock matched a phone to - the second, and the real desktop with sound mirrored at 1080p30. -- **Stalls:** at 1080p30 the TV still occasionally stops acknowledging video for a few seconds - without reporting any loss. The receiver seems to stop consuming frames, so the sender has to - back off: answer picture-loss requests with a key frame at once and lower the bitrate while - feedback lags, as Chrome's sender does. -- **Colours** are close but not right yet, even after the BT.709 rewrite. The next step is an A/B - test of BT.709 vs BT.601 colour bars on the TV, with the TV in an accurate picture mode. -- **Latency:** 1–2 s at 1080p60 came from the TV falling behind. Still to measure at 1080p30. - -## File playback - -- **Default Media Receiver** (`CC1AD845`): LOAD with the file's URL; play, pause, seek and - `EDIT_TRACKS_INFO` need the receiver's `mediaSessionId`. Volume goes to the device, not the media. -- **Serving:** a small HTTP server on the address that routes to the TV, one unguessable path, - Range support and CORS, answering only the TV's IP. It listens on a fixed TCP port (setting, - default 47830), because the TV connects in and a firewall has to allow it, e.g. - `sudo ufw allow from 192.168.0.0/16 to any port 47830 proto tcp`. Mirroring and YouTube need no - rule. When the TV hasn't fetched anything 10 s after LOAD, the plugin says the firewall blocks it. -- **Formats:** MP4, WebM and audio files are sent as they are. MKV isn't documented for Cast, so it - is remuxed to a temporary MP4 (`moov` first, so the TV can seek) without re-encoding video. - Matroska has no decode timestamps, so H.264/HEVC need `h26Xtimestamper` before `mp4mux`. Audio the - TV can't take in MP4 (DTS, TrueHD, FLAC) is re-encoded to AAC. A 2.4 GB movie took about 5 s on - NVMe; it needs free space equal to the file. -- **Too much for the TV:** each video is probed first (GStreamer's discoverer). Above 4K, or in a - codec the TV lacks (AV1, MPEG-2, …), it is converted on the GPU to 4K HEVC at 30 Mbit/s with AAC - audio, with progress in the panel. A 68 s 8K phone clip took 20 s on an RX 7900 XTX. `decodebin3` - stalls without a GLib main loop, so the pipeline uses `decodebin`. -- **Bandwidth:** files are sent untouched, so a 60 Mbit/s 4K120 phone video can rebuffer over the - TV's Wi-Fi. -- **Subtitles:** a `movie.srt` or `movie.vtt` next to `movie.mkv` is converted to WebVTT and attached. -- **Direct links** go into LOAD as they are; the TV fetches them, so they need to work without the - browser's cookies, and HLS/DASH servers must send CORS headers. -- **YouTube links** (watch, youtu.be, shorts, live, music, with `list` and `t`) follow pychromecast - and catt (casttube): launch `233637DE`, `getMdxSessionStatus` on `urn:x-cast:com.google.youtube.mdx` - for the screen id, then youtube.com's lounge API: `get_lounge_token_batch`, `bc/bind`, and - `setPlaylist`. Play, pause, seek and volume then go through the media namespace. The API is - undocumented and can break; the error says so and mirroring still works. -- Untested until the TV: whether it plays remuxed HEVC and AC-3, and whether HDR metadata survives. - -## Open questions - -- **Install:** downloading a binary and `pkexec install` conflict with this repo's rules (no - downloading and running code, no sudo or pkexec). Settle with the registry before milestone 3; - source builds and distro or AUR packages avoid it. -- Drag and drop a file onto the bar pill to play it (a `DropArea`, like the clipboard button). diff --git a/helpers/cast/airplay.go b/helpers/cast/airplay.go deleted file mode 100644 index a34d075..0000000 --- a/helpers/cast/airplay.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "os" - "os/exec" - "path/filepath" - "strconv" - "sync" - "syscall" - "time" -) - -// AirPlay mirroring goes through doubletake, an AirPlay 2 sender. Its daemon -// runs only for the length of a cast, because it keeps browsing the network -// while it lives, and is driven over its control socket: one JSON request per -// connection, polled for state since it pushes nothing. doubletake captures -// the screen itself, so it opens its own portal dialog. Sound stays on this -// computer: doubletake sets the receiver to full volume whenever it sends -// sound (0.5.0), and has no option to keep the current level. -type airplaySession struct { - h *helper - dev device - opts castOptions - - ctx context.Context - cancel context.CancelFunc - done chan struct{} - pins chan string - - mu sync.Mutex - phase string // starting, pairing, casting - since time.Time - credential string // pin or password, while pairing -} - -func newAirplaySession(h *helper, d device, opts castOptions) *airplaySession { - opts.defaults() - ctx, cancel := context.WithCancel(context.Background()) - return &airplaySession{h: h, dev: d, opts: opts, ctx: ctx, cancel: cancel, done: make(chan struct{}), - pins: make(chan string, 1), phase: "starting", since: time.Now()} -} - -func (a *airplaySession) deviceID() string { return a.dev.ID } -func (a *airplaySession) Stop() { a.cancel() } -func (a *airplaySession) SetVolume(float64) {} - -func (a *airplaySession) Wait(d time.Duration) { - select { - case <-a.done: - case <-time.After(d): - } -} - -func (a *airplaySession) status() map[string]any { - a.mu.Lock() - defer a.mu.Unlock() - st := map[string]any{"kind": "mirror", "protocol": "airplay", "device": a.dev.ID, "name": a.dev.Name, - "phase": a.phase, "since": a.since.Unix(), "volume": 0, "canVolume": false} - if a.phase == "pairing" { - st["credential"] = a.credential - } - return st -} - -// pair hands the PIN or password the user typed to the waiting connection. -func (a *airplaySession) pair(pin string) { - select { - case a.pins <- pin: - default: - } -} - -func (a *airplaySession) setPhase(p, credential string) { - a.mu.Lock() - changed := a.phase != p || a.credential != credential - if p == "casting" && a.phase != "casting" { - a.since = time.Now() - } - a.phase, a.credential = p, credential - a.mu.Unlock() - if changed { - a.h.emitState() - } -} - -type dtStatus struct { - OK bool `json:"ok"` - Error string `json:"error"` - Streams []struct { - DeviceIP string `json:"device_ip"` - State string `json:"state"` - CredentialKind string `json:"credential_kind"` - } `json:"streams"` -} - -func (a *airplaySession) run() (code string, err error) { - defer close(a.done) - var undo cleanups - defer func() { undo.run() }() - stopped := func(c string, e error) (string, error) { - if a.ctx.Err() != nil { - return "", nil - } - return c, e - } - if err := need("airplay"); err != nil { - return "missingDependency", err - } - if a.opts.Audio != "local" { - a.h.emit(map[string]any{"event": "warning", "code": "airplaySound"}) - } - - runtime := os.Getenv("XDG_RUNTIME_DIR") - if runtime == "" { - runtime = os.TempDir() - } - dir := filepath.Join(runtime, "dms-cast") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "airplayFailed", err - } - sock := filepath.Join(dir, "doubletake.sock") - data, err := dataDir() - if err != nil { - return "airplayFailed", err - } - args := []string{"-daemonize", "-socket", sock, "-creds", filepath.Join(data, "doubletake-credentials.json"), - "-no-audio", "-fps", strconv.Itoa(a.opts.FPS), "-target-latency-ms", strconv.Itoa(a.opts.Delay), "-port", strconv.Itoa(a.dev.AirplayPort)} - if a.opts.Codec == "h264" || a.opts.Codec == "hevc" { - args = append(args, "-video-codec", a.opts.Codec) - } - if a.opts.Kbps > 0 { - args = append(args, "-bitrate", strconv.Itoa(a.opts.Kbps)) - } - // doubletake's automatic choice is the Vulkan encoder, which sends no - // frames from a real screen at 60 fps (doubletake 0.5.0, AMD); VA-API, - // which Chromecast mirroring uses too, works. - if hasElement("vah264enc") { - args = append(args, "-hwaccel", "vaapi") - } - if a.opts.AirplayPorts != "" { - args = append(args, "-port-range", a.opts.AirplayPorts) - } - cmd := exec.Command(doubletakePath(), append(args, doubletakeTestArgs...)...) - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} - if stderr, err := cmd.StderrPipe(); err == nil { - go func() { - sc := bufio.NewScanner(stderr) - for sc.Scan() { - debugf("doubletake: %s", sc.Text()) - } - }() - } - if err := cmd.Start(); err != nil { - return "airplayFailed", err - } - exited := make(chan struct{}) - go func() { cmd.Wait(); close(exited) }() - undo.add(func() { - dtRequest(sock, map[string]any{"cmd": "disconnect", "target": a.dev.Host}) - cmd.Process.Signal(syscall.SIGTERM) - select { - case <-exited: - case <-time.After(3 * time.Second): - cmd.Process.Kill() - <-exited - } - }) - - // The socket appears once the daemon is up. - for i := 0; ; i++ { - if _, err := os.Stat(sock); err == nil { - break - } - select { - case <-exited: - return stopped("airplayFailed", errors.New("doubletake exited at startup")) - case <-a.ctx.Done(): - return "", nil - case <-time.After(50 * time.Millisecond): - } - if i > 100 { - return stopped("airplayFailed", errors.New("doubletake didn't open its control socket")) - } - } - connect := map[string]any{"cmd": "connect", "target": a.dev.Host, "port": a.dev.AirplayPort} - if st, err := dtRequest(sock, connect); err != nil || !st.OK { - return stopped("airplayFailed", fmt.Errorf("connect: %v %s", err, st.Error)) - } - - poll := time.NewTicker(time.Second) - defer poll.Stop() - seen := false - for { - select { - case <-a.ctx.Done(): - return "", nil - case <-exited: - return stopped("airplayFailed", errors.New("doubletake exited")) - case pin := <-a.pins: - pair := map[string]any{"cmd": "connect", "target": a.dev.Host, "port": a.dev.AirplayPort, "pin": pin} - if st, err := dtRequest(sock, pair); err != nil || !st.OK { - log.Printf("airplay pairing: %v %s", err, st.Error) - } - a.setPhase("starting", "") - case <-poll.C: - st, err := dtRequest(sock, map[string]any{"cmd": "status"}) - if err != nil { - return stopped("airplayFailed", err) - } - found := false - for _, s := range st.Streams { - if s.DeviceIP != a.dev.Host { - continue - } - found, seen = true, true - switch s.State { - case "pin_required": - a.setPhase("pairing", s.CredentialKind) - case "streaming": - a.setPhase("casting", "") - default: - a.setPhase("starting", "") - } - } - // A stream that ended or failed drops out of the list. - if !found && seen { - if st.Error != "" { - return stopped("airplayFailed", errors.New(st.Error)) - } - return stopped("deviceLost", errors.New("the AirPlay stream ended")) - } - } - } -} - -// doubletakeTestArgs lets tests use doubletake's synthetic video instead of -// the screen, so no portal dialog opens. -var doubletakeTestArgs []string - -// dtRequest sends one request to doubletake's control socket. -func dtRequest(sock string, req map[string]any) (dtStatus, error) { - var st dtStatus - conn, err := net.DialTimeout("unix", sock, 2*time.Second) - if err != nil { - return st, err - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(30 * time.Second)) - if err := json.NewEncoder(conn).Encode(req); err != nil { - return st, err - } - err = json.NewDecoder(conn).Decode(&st) - return st, err -} - -// doubletakePath finds doubletake on PATH, else where the plugin or a -// source build put it. -func doubletakePath() string { - if p, err := exec.LookPath("doubletake"); err == nil { - return p - } - var candidates []string - if d, err := dataDir(); err == nil { - candidates = append(candidates, filepath.Join(d, "bin", "doubletake")) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, "go", "bin", "doubletake")) - } - for _, c := range candidates { - if st, err := os.Stat(c); err == nil && st.Mode()&0o111 != 0 { - return c - } - } - return "" -} - -// dataDir is the plugin's data folder, ~/.local/share/dms/cast. -func dataDir() (string, error) { - base := os.Getenv("XDG_DATA_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, ".local", "share") - } - dir := filepath.Join(base, "dms", "cast") - return dir, os.MkdirAll(dir, 0o700) -} diff --git a/helpers/cast/airplay_test.go b/helpers/cast/airplay_test.go deleted file mode 100644 index 1d143ba..0000000 --- a/helpers/cast/airplay_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "strings" - "testing" - "time" -) - -func TestMergeDevices(t *testing.T) { - got := mergeDevices(map[string]Device{ - "chromecast:abc": {ID: "chromecast:abc", Name: "Living Room", Protocol: "chromecast", Addr: "192.168.2.63", Port: 8009}, - "airplay:aa:bb": {ID: "airplay:aa:bb", Name: "[LG] webOS TV", Protocol: "airplay", Addr: "192.168.2.63", Port: 7000}, - "airplay:cc:dd": {ID: "airplay:cc:dd", Name: "Apple TV", Protocol: "airplay", Addr: "192.168.2.70", Port: 7000}, - }) - byID := map[string]device{} - for _, d := range got { - byID[d.ID] = d - } - tv, apple := byID["chromecast:abc"], byID["airplay:cc:dd"] - if len(got) != 2 || tv.Name != "Living Room" || !tv.has("airplay") || tv.AirplayPort != 7000 || tv.Port != 8009 { - t.Errorf("both protocols: %+v", got) - } - if apple.Protocol != "airplay" || apple.has("chromecast") { - t.Errorf("AirPlay only: %+v", apple) - } -} - -// Set AIRPLAY_TEST_PORT to a running doubletake-test-receiver -// (-auth pin -code 4321) to check the session end to end. -func TestAirplayReceiver(t *testing.T) { - port := os.Getenv("AIRPLAY_TEST_PORT") - if port == "" || doubletakePath() == "" { - t.Skip("set AIRPLAY_TEST_PORT and put doubletake on PATH") - } - doubletakeTestArgs = []string{"-test"} - var events []string - out := writerFunc(func(p []byte) { events = append(events, strings.TrimSpace(string(p))) }) - h := &helper{out: json.NewEncoder(out), devices: map[string]device{}} - var ap int - json.Unmarshal([]byte(port), &ap) - a := newAirplaySession(h, device{ID: "airplay:test", Name: "Test", Protocols: []string{"airplay"}, Host: "127.0.0.1", AirplayPort: ap}, - castOptions{Audio: "local"}) - h.sess = a - go func() { - for range 30 { - time.Sleep(time.Second) - st := a.status() - if st["phase"] == "pairing" { - a.pair("4321") - } - if st["phase"] == "casting" { - time.Sleep(2 * time.Second) - break - } - } - a.Stop() - }() - code, err := a.run() - phases := []string{} - for _, e := range events { - if i := strings.Index(e, `"phase":"`); i >= 0 { - p := e[i+9:] - p = p[:strings.Index(p, `"`)] - if len(phases) == 0 || phases[len(phases)-1] != p { - phases = append(phases, p) - } - } - } - t.Logf("phases %v, ended %q %v", phases, code, err) - if !strings.Contains(strings.Join(phases, " "), "pairing starting casting") { - t.Errorf("expected pairing then casting, got %v", phases) - } -} - -type writerFunc func([]byte) - -func (f writerFunc) Write(p []byte) (int, error) { f(p); return len(p), nil } diff --git a/helpers/cast/audio.go b/helpers/cast/audio.go deleted file mode 100644 index 3a744ed..0000000 --- a/helpers/cast/audio.go +++ /dev/null @@ -1,100 +0,0 @@ -package main - -import ( - "bufio" - "log" - "os/exec" - "regexp" - "strings" - "syscall" -) - -// "TV only" sound: the helper's capture stream is itself a sink, so PipeWire -// removes it with the helper's connection however the helper ends. It becomes -// the default only through the runtime key default.audio.sink, which -// WirePlumber never saves and recomputes from the user's configured default -// once the sink is gone. Nothing needs restoring. -const sinkName = "dux_cast" - -func audioPipelineFor(target string) string { - src := "pipewiresrc stream-properties=props,stream.capture.sink=true" - if target != "both" { - // String-typed values: GstStructure writes a plain false as FALSE, which - // WirePlumber ignores. always-process starts the node before anything - // plays; without it pipewiresrc waits and fails after 30 s. - src = `pipewiresrc stream-properties="props,media.class=Audio/Sink,node.name=` + sinkName + - `,node.description=Cast,state.restore-props=(string)false,node.always-process=(string)true"` - } - return src + " ! audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! " + - "opusenc name=enc bitrate=128000 frame-size=10 ! appsink name=out sync=false" -} - -// holdDefault keeps the cast sink the default while the user's configured -// default stays what it was. Plugging in a device makes WirePlumber recompute -// the runtime default; a changed configured default means the user picked an -// output, and then the speakers win. -func holdDefault() (stop func()) { - cmd := exec.Command("pw-metadata", "-m", "-n", "default", "0") - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} - stdout, err := cmd.StdoutPipe() - if err == nil { - err = cmd.Start() - } - if err != nil { - log.Printf("audio: %v, sound stays on this computer", err) - return func() {} - } - go func() { - var h holder - sc := bufio.NewScanner(stdout) - for sc.Scan() { - key, value, ok := parseUpdate(sc.Text()) - if !ok || !h.update(key, value) { - continue - } - exec.Command("pw-metadata", "-n", "default", "0", "default.audio.sink", - `{"name":"`+sinkName+`"}`, "Spa:String:JSON").Run() - } - }() - return func() { - cmd.Process.Kill() - cmd.Wait() - } -} - -type holder struct { - configured, initial string - seenConfigured bool -} - -// update records one metadata change and says whether to claim the default. -func (h *holder) update(key, value string) bool { - switch key { - case "default.configured.audio.sink": - if !h.seenConfigured { - h.initial, h.seenConfigured = value, true - } - h.configured = value - return false - case "default.audio.sink": - // ponytail: no brake; WirePlumber only recomputes on device or - // configured-default changes, so nothing ping-pongs with it. - return value != sinkName && h.configured == h.initial - } - return false -} - -var updateRe = regexp.MustCompile(`^update: id:0 key:'([^']*)' value:'(.*)' type:'[^']*'$`) -var nameRe = regexp.MustCompile(`"name"\s*:\s*"([^"]*)"`) - -// parseUpdate reads a pw-metadata line; value is the node name, "" if unset. -func parseUpdate(line string) (key, value string, ok bool) { - m := updateRe.FindStringSubmatch(strings.TrimSpace(line)) - if m == nil { - return "", "", false - } - if n := nameRe.FindStringSubmatch(m[2]); n != nil { - value = n[1] - } - return m[1], value, true -} diff --git a/helpers/cast/audio_test.go b/helpers/cast/audio_test.go deleted file mode 100644 index 83ce600..0000000 --- a/helpers/cast/audio_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import "testing" - -func TestHolder(t *testing.T) { - line := func(k, name string) (string, string) { - key, v, ok := parseUpdate(`update: id:0 key:'` + k + `' value:'{"name":"` + name + `"}' type:'Spa:String:JSON'`) - if !ok || key != k || v != name { - t.Fatalf("parse %s: %q %q %v", k, key, v, ok) - } - return key, v - } - if _, _, ok := parseUpdate(`Found "default" metadata 40`); ok { - t.Fatal("parsed a non-update line") - } - var h holder - steps := []struct { - key, name string - claim bool - }{ - {"default.configured.audio.sink", "speakers", false}, - {"default.audio.sink", "speakers", true}, // initial claim - {"default.audio.sink", sinkName, false}, // ours now - {"default.audio.sink", "speakers", true}, // hotplug rescan: reclaim - {"default.configured.audio.sink", "headphones", false}, - {"default.audio.sink", "headphones", false}, // user picked an output: let go - } - for i, s := range steps { - if got := h.update(line(s.key, s.name)); got != s.claim { - t.Fatalf("step %d: claim %v, want %v", i, got, s.claim) - } - } -} diff --git a/helpers/cast/castv2.go b/helpers/cast/castv2.go deleted file mode 100644 index 7e3bb87..0000000 --- a/helpers/cast/castv2.go +++ /dev/null @@ -1,209 +0,0 @@ -package main - -import ( - "bufio" - "context" - "crypto/tls" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net" - "sync" - "time" -) - -const ( - nsConnection = "urn:x-cast:com.google.cast.tp.connection" - nsHeartbeat = "urn:x-cast:com.google.cast.tp.heartbeat" - nsReceiver = "urn:x-cast:com.google.cast.receiver" - nsWebrtc = "urn:x-cast:com.google.cast.webrtc" - senderID = "sender-0" - platformID = "receiver-0" -) - -type castMsg struct { - src, dst, ns string - payload []byte -} - -// castConn is a Cast v2 control channel: TLS to port 8009, each message a -// length-prefixed CastMessage protobuf carrying a JSON payload. -type castConn struct { - conn net.Conn - mu sync.Mutex - msgs chan castMsg - once sync.Once - done chan struct{} -} - -func (c *castConn) Close() { - c.once.Do(func() { - close(c.done) - c.conn.Close() - }) -} - -func dialCast(addr string) (*castConn, error) { - // Receivers present self-signed device certs; mirroring doesn't need device auth. - conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, - &tls.Config{InsecureSkipVerify: true}) - if err != nil { - return nil, err - } - c := &castConn{conn: conn, msgs: make(chan castMsg, 64), done: make(chan struct{})} - go c.readLoop() - go func() { - t := time.NewTicker(5 * time.Second) - defer t.Stop() - for { - select { - case <-c.done: - return - case <-t.C: - if c.send(platformID, nsHeartbeat, map[string]string{"type": "PING"}) != nil { - return - } - } - } - }() - return c, nil -} - -func appendField(b []byte, field int, wire int) []byte { - return binary.AppendUvarint(b, uint64(field<<3|wire)) -} - -func appendString(b []byte, field int, s string) []byte { - b = appendField(b, field, 2) - b = binary.AppendUvarint(b, uint64(len(s))) - return append(b, s...) -} - -func (c *castConn) send(dst, ns string, v any) error { - payload, err := json.Marshal(v) - if err != nil { - return err - } - // CastMessage is proto2 with required fields, so protocol_version (1) and - // payload_type (5) go on the wire even though both are 0. - var b []byte - b = appendField(b, 1, 0) - b = append(b, 0) - b = appendString(b, 2, senderID) - b = appendString(b, 3, dst) - b = appendString(b, 4, ns) - b = appendField(b, 5, 0) - b = append(b, 0) - b = appendString(b, 6, string(payload)) - - frame := binary.BigEndian.AppendUint32(make([]byte, 0, 4+len(b)), uint32(len(b))) - frame = append(frame, b...) - if ns != nsHeartbeat { - debugf("cast → %s %s %s", dst, ns, payload) - } - c.mu.Lock() - defer c.mu.Unlock() - _, err = c.conn.Write(frame) - return err -} - -func (c *castConn) readLoop() { - defer close(c.msgs) - r := bufio.NewReader(c.conn) - for { - var size uint32 - if err := binary.Read(r, binary.BigEndian, &size); err != nil { - log.Printf("cast channel closed: %v", err) - return - } - if size > 64<<10 { - log.Printf("cast message too large: %d", size) - return - } - body := make([]byte, size) - if _, err := io.ReadFull(r, body); err != nil { - log.Printf("cast channel closed: %v", err) - return - } - m, err := parseCastMessage(body) - if err != nil { - log.Printf("bad cast message: %v", err) - continue - } - if m.ns == nsHeartbeat { - var h struct{ Type string } - if json.Unmarshal(m.payload, &h) == nil && h.Type == "PING" { - c.send(m.src, nsHeartbeat, map[string]string{"type": "PONG"}) - } - continue - } - select { - case c.msgs <- m: - case <-c.done: - return - } - } -} - -func parseCastMessage(b []byte) (castMsg, error) { - var m castMsg - for len(b) > 0 { - tag, n := binary.Uvarint(b) - if n <= 0 { - return m, errors.New("bad tag") - } - b = b[n:] - field, wire := int(tag>>3), int(tag&7) - switch wire { - case 0: - _, n = binary.Uvarint(b) - if n <= 0 { - return m, errors.New("bad varint") - } - b = b[n:] - case 2: - l, n := binary.Uvarint(b) - if n <= 0 || uint64(len(b)-n) < l { - return m, errors.New("bad length") - } - v := b[n : n+int(l)] - b = b[n+int(l):] - switch field { - case 2: - m.src = string(v) - case 3: - m.dst = string(v) - case 4: - m.ns = string(v) - case 6, 7: - m.payload = v - } - default: - return m, fmt.Errorf("unsupported wire type %d", wire) - } - } - return m, nil -} - -// await returns the first message on ns whose payload satisfies match. -func (c *castConn) await(ctx context.Context, ns string, timeout time.Duration, match func([]byte) bool) ([]byte, error) { - deadline := time.After(timeout) - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case m, ok := <-c.msgs: - if !ok { - return nil, errors.New("cast channel closed") - } - if m.ns == ns && match(m.payload) { - return m.payload, nil - } - case <-deadline: - return nil, fmt.Errorf("timed out waiting on %s", ns) - } - } -} diff --git a/helpers/cast/convert.go b/helpers/cast/convert.go deleted file mode 100644 index 5e68943..0000000 --- a/helpers/cast/convert.go +++ /dev/null @@ -1,189 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/url" - "os" - "path/filepath" - "strings" - "time" - "unsafe" - - "github.com/ebitengine/purego" -) - -// The TV decodes up to 4K in H.264, HEVC, VP9 and VP8. Anything beyond that -// (an 8K phone video, AV1, MPEG-2) is converted on the GPU to 4K HEVC first; -// everything else is sent untouched. -const maxPixels = 3840 * 2160 - -var tvCodecs = []string{"video/x-h264", "video/x-h265", "video/x-vp9", "video/x-vp8"} - -type videoInfo struct { - width, height int - codec string // caps media type, e.g. video/x-h265 - audio bool -} - -func (v videoInfo) needsConvert() bool { - known := false - for _, c := range tvCodecs { - known = known || v.codec == c - } - return v.width*v.height > maxPixels || !known -} - -// target fits the video into 4K, keeping its aspect ratio and even sizes. -func (v videoInfo) target() (w, h int) { - w, h = v.width, v.height - if w*h > maxPixels { - scale := min(3840/float64(max(w, h)), 2160/float64(min(w, h))) - w, h = int(float64(w)*scale), int(float64(h)*scale) - } - return w &^ 1, h &^ 1 -} - -var ( - discovererNew func(timeout uint64, gerr unsafe.Pointer) uintptr - discovererDiscoverURI func(d uintptr, uri string, gerr unsafe.Pointer) uintptr - discovererVideoStreams func(info uintptr) *gList - discovererAudioStreams func(info uintptr) *gList - discovererVideoWidth func(vinfo uintptr) uint32 - discovererVideoHeight func(vinfo uintptr) uint32 - discovererStreamCaps func(sinfo uintptr) uintptr - discovererListFree func(list *gList) - gstCapsGetStructure func(caps uintptr, index uint32) uintptr - gstStructureGetName func(s uintptr) *byte - gstElementQueryPosition func(el uintptr, format int32, cur *int64) bool - gstElementQueryDuration func(el uintptr, format int32, dur *int64) bool - gObjectUnref func(obj uintptr) -) - -func loadDiscoverer() error { - if err := loadGst(); err != nil { - return err - } - if discovererNew != nil { - return nil - } - for name, fns := range map[string]map[string]any{ - "libgstpbutils-1.0.so.0": { - "gst_discoverer_new": &discovererNew, "gst_discoverer_discover_uri": &discovererDiscoverURI, - "gst_discoverer_info_get_video_streams": &discovererVideoStreams, - "gst_discoverer_info_get_audio_streams": &discovererAudioStreams, - "gst_discoverer_video_info_get_width": &discovererVideoWidth, - "gst_discoverer_video_info_get_height": &discovererVideoHeight, - "gst_discoverer_stream_info_get_caps": &discovererStreamCaps, - "gst_discoverer_stream_info_list_free": &discovererListFree, - }, - "libgstreamer-1.0.so.0": { - "gst_caps_get_structure": &gstCapsGetStructure, "gst_structure_get_name": &gstStructureGetName, - "gst_element_query_position": &gstElementQueryPosition, - "gst_element_query_duration": &gstElementQueryDuration, - }, - "libgobject-2.0.so.0": {"g_object_unref": &gObjectUnref}, - } { - lib, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) - if err != nil { - return err - } - for sym, fn := range fns { - purego.RegisterLibFunc(fn, lib, sym) - } - } - return nil -} - -// probeVideo reads the first video stream's size and codec. -func probeVideo(file string) (videoInfo, error) { - if err := loadDiscoverer(); err != nil { - return videoInfo{}, err - } - var gerr *gError - d := discovererNew(uint64(10*time.Second), unsafe.Pointer(&gerr)) - if d == 0 { - return videoInfo{}, fmt.Errorf("discoverer: %s", gerrorMessage(gerr)) - } - defer gObjectUnref(d) - info := discovererDiscoverURI(d, (&url.URL{Scheme: "file", Path: file}).String(), unsafe.Pointer(&gerr)) - if info == 0 { - return videoInfo{}, fmt.Errorf("probe: %s", gerrorMessage(gerr)) - } - defer gObjectUnref(info) - - var v videoInfo - videos := discovererVideoStreams(info) - if videos == nil { - return v, fmt.Errorf("no video stream") - } - defer discovererListFree(videos) - first := videos.data - v.width, v.height = int(discovererVideoWidth(first)), int(discovererVideoHeight(first)) - if caps := discovererStreamCaps(first); caps != 0 { - v.codec = cString(gstStructureGetName(gstCapsGetStructure(caps, 0))) - gstMiniObjectUnref(caps) - } - if audios := discovererAudioStreams(info); audios != nil { - v.audio = true - discovererListFree(audios) - } - return v, nil -} - -// convertVideo re-encodes file to 4K HEVC in an MP4 on the GPU, reporting -// progress from 0 to 1. -func convertVideo(ctx context.Context, file string, v videoInfo, progress func(float64)) (string, error) { - w, h := v.target() - desc := fmt.Sprintf("filesrc name=src ! decodebin name=d "+ - "d. ! queue ! vapostproc ! video/x-raw(memory:VAMemory),width=%d,height=%d ! "+ - "vah265enc rate-control=cbr bitrate=30000 key-int-max=60 b-frames=0 ! h265parse ! video/x-h265,stream-format=hvc1 ! mux.video_0 ", w, h) - if v.audio { - desc += "d. ! queue ! audioconvert ! audioresample ! avenc_aac bitrate=256000 ! aacparse ! mux.audio_0 " - } - desc += "mp4mux name=mux faststart=true ! filesink name=sink" - out, err := cacheFile("conv.mp4") - if err != nil { - return "", err - } - start := time.Now() - log.Printf("converting %dx%d %s to %dx%d HEVC: %s", v.width, v.height, v.codec, w, h, desc) - err = runGst(ctx, desc, map[string]map[string]string{ - "src": {"location": file}, - "sink": {"location": out}, - "mux": {"faststart-file": out + ".part"}, - }, progress) - if err != nil { - return out, err - } - log.Printf("converted in %s", time.Since(start).Round(time.Millisecond)) - return out, nil -} - -// cacheFile names a temporary file in the helper's cache directory; stale -// ones are removed at startup. -func cacheFile(suffix string) (string, error) { - cache, err := os.UserCacheDir() - if err != nil { - return "", err - } - dir := filepath.Join(cache, "dms", "cast") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", err - } - return filepath.Join(dir, fmt.Sprintf("%d-%s", os.Getpid(), suffix)), nil -} - -type gList struct{ data, next, prev uintptr } - -func cString(p *byte) string { - if p == nil { - return "" - } - n := 0 - for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 { - n++ - } - return strings.Clone(string(unsafe.Slice(p, n))) -} diff --git a/helpers/cast/convert_test.go b/helpers/cast/convert_test.go deleted file mode 100644 index 98303c5..0000000 --- a/helpers/cast/convert_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "context" - "os" - "testing" -) - -func TestNeedsConvert(t *testing.T) { - for _, c := range []struct { - v videoInfo - want bool - tw, th int - }{ - {videoInfo{width: 7680, height: 4320, codec: "video/x-h265"}, true, 3840, 2160}, - {videoInfo{width: 4320, height: 7680, codec: "video/x-h265"}, true, 2160, 3840}, - {videoInfo{width: 3840, height: 2160, codec: "video/x-h265"}, false, 3840, 2160}, - {videoInfo{width: 1920, height: 1080, codec: "video/x-av1"}, true, 1920, 1080}, - {videoInfo{width: 5760, height: 2880, codec: "video/x-h264"}, true, 3840, 1920}, - } { - w, h := c.v.target() - if c.v.needsConvert() != c.want || w != c.tw || h != c.th { - t.Errorf("%+v: convert %v to %dx%d", c.v, c.v.needsConvert(), w, h) - } - } -} - -// Set CONVERT_FILE to a real video to check probing and GPU conversion. -func TestConvertFile(t *testing.T) { - file := os.Getenv("CONVERT_FILE") - if file == "" { - t.Skip("set CONVERT_FILE") - } - v, err := probeVideo(file) - if err != nil { - t.Fatal(err) - } - t.Logf("probe: %+v needsConvert=%v", v, v.needsConvert()) - last := 0.0 - out, err := convertVideo(context.Background(), file, v, func(p float64) { last = p }) - defer os.Remove(out) - if err != nil { - t.Fatal(err) - } - got, err := probeVideo(out) - t.Logf("output: %+v %v, last progress %.2f", got, err, last) - if got.width*got.height > maxPixels || got.codec != "video/x-h265" || !got.audio { - t.Errorf("bad output %+v", got) - } -} diff --git a/helpers/cast/deps.go b/helpers/cast/deps.go deleted file mode 100644 index 1d6c3dc..0000000 --- a/helpers/cast/deps.go +++ /dev/null @@ -1,202 +0,0 @@ -package main - -import ( - "bufio" - "io" - "os" - "os/exec" - "slices" - "strings" - - "github.com/godbus/dbus/v5" -) - -// Nothing is needed to start the helper. Each feature checks the packages it -// uses when it's used and reports what's missing, so a system without, say, -// gst-libav still mirrors and only misses converting DTS audio. -// -// Features: discovery, mirroring, sound (sending sound), tvOnlySound, files -// (probing and serving), mkv, convert, photos (shrinking), heic, thumbnails. - -type dependency struct { - ID string // the package's name on Arch, used as a stable id - Required bool // needed for discovery and mirroring, the core of the plugin - Features []string - present func() bool - Packages map[string]string // distro family → package; the ID is Arch's name and the upstream one - // Hints name an extra step a distro needs beyond its package, such as a - // third-party repository; the plugin turns the code into text. - Hints map[string]string - Manual bool // installed by hand, never part of the install command -} - -func elements(names ...string) func() bool { - return func() bool { - for _, n := range names { - if !hasElement(n) { - return false - } - } - return true - } -} - -func onPath(name string) func() bool { - return func() bool { - _, err := exec.LookPath(name) - return err == nil - } -} - -// Package names checked against the Arch, Debian trixie / Ubuntu noble, -// Fedora 43 and openSUSE Tumbleweed indexes (2026-10). -var dependencies = []dependency{ - {ID: "avahi", Required: true, Features: []string{"discovery"}, present: avahiRunning, - Packages: map[string]string{"debian": "avahi-daemon", "fedora": "avahi", "opensuse": "avahi"}}, - {ID: "gstreamer", Required: true, Features: []string{"mirroring", "sound", "files", "mkv", "convert", "photos", "thumbnails"}, - present: func() bool { return loadGst() == nil }, - Packages: map[string]string{"debian": "libgstreamer1.0-0", "fedora": "gstreamer1", "opensuse": "gstreamer"}}, - {ID: "gst-plugins-base-libs", Required: true, Features: []string{"mirroring", "files", "convert", "photos", "thumbnails"}, - present: func() bool { - return loadDiscoverer() == nil && elements("videorate", "videoconvert", "videoscale", "audioconvert", "audioresample", "appsink", "decodebin")() - }, - Packages: map[string]string{"debian": "gstreamer1.0-plugins-base", "fedora": "gstreamer1-plugins-base", "opensuse": "gstreamer-plugins-base"}}, - {ID: "gst-plugins-base", Required: true, Features: []string{"sound"}, present: elements("opusenc"), - Packages: map[string]string{"debian": "gstreamer1.0-plugins-base", "fedora": "gstreamer1-plugins-base", "opensuse": "gstreamer-plugins-base"}}, - {ID: "gst-plugin-pipewire", Required: true, Features: []string{"mirroring", "sound"}, present: elements("pipewiresrc"), - Packages: map[string]string{"debian": "gstreamer1.0-pipewire", "fedora": "pipewire-gstreamer", "opensuse": "gstreamer-plugin-pipewire"}}, - {ID: "gst-plugins-bad-libs", Required: true, Features: []string{"mirroring", "mkv"}, - present: elements("h264parse", "h265parse", "h264timestamper", "h265timestamper"), - Packages: map[string]string{"debian": "gstreamer1.0-plugins-bad", "fedora": "gstreamer1-plugins-bad-free", "opensuse": "gstreamer-plugins-bad"}}, - // The va elements only register when the VA-API driver can encode, so a - // missing one can also mean a missing driver, or one built without - // H.264/HEVC for patent reasons (Fedora's and openSUSE's Mesa). - {ID: "gst-plugin-va", Required: true, Features: []string{"mirroring", "convert"}, - present: func() bool { return hasElement("vapostproc") && (hasElement("vah264enc") || hasElement("vah265enc")) }, - Packages: map[string]string{"debian": "gstreamer1.0-plugins-bad", "fedora": "gstreamer1-plugins-bad-free", "opensuse": "gstreamer-plugins-bad"}, - Hints: map[string]string{"fedora": "rpmfusionVA", "opensuse": "packmanVA"}}, - {ID: "gst-plugins-good", Features: []string{"mkv", "convert", "photos"}, - present: elements("matroskademux", "mp4mux", "qtdemux", "aacparse", "ac3parse", "jpegdec", "jpegenc", "pngdec", "videoflip"), - Packages: map[string]string{"debian": "gstreamer1.0-plugins-good", "fedora": "gstreamer1-plugins-good", "opensuse": "gstreamer-plugins-good"}}, - {ID: "gst-libav", Features: []string{"convert"}, present: elements("avenc_aac"), - Packages: map[string]string{"debian": "gstreamer1.0-libav", "fedora": "gstreamer1-plugin-libav", "opensuse": "gstreamer-plugins-libav"}}, - {ID: "pipewire", Features: []string{"tvOnlySound"}, present: onPath("pw-metadata"), - Packages: map[string]string{"debian": "pipewire-bin", "fedora": "pipewire-utils", "opensuse": "pipewire-tools"}}, - // doubletake runs gst-launch-1.0 and pactl itself. - {ID: "gstreamer-tools", Features: []string{"airplay"}, present: func() bool { return onPath("gst-launch-1.0")() && onPath("gst-inspect-1.0")() }, - Packages: map[string]string{"arch": "gstreamer", "debian": "gstreamer1.0-tools", "fedora": "gstreamer1", "opensuse": "gstreamer-utils"}}, - {ID: "pactl", Features: []string{"airplay"}, present: onPath("pactl"), - Packages: map[string]string{"arch": "libpulse", "debian": "pulseaudio-utils", "fedora": "pulseaudio-utils", "opensuse": "pulseaudio-utils"}}, - // doubletake isn't in the distributions' main repositories: the AUR on - // Arch, a release binary or a source build elsewhere. Manual keeps it - // out of the install command. - {ID: "doubletake", Features: []string{"airplay"}, present: func() bool { return doubletakePath() != "" }, - Manual: true, Packages: map[string]string{"arch": "doubletake-bin"}, - Hints: map[string]string{"arch": "doubletakeAUR", "debian": "doubletakeRelease", "fedora": "doubletakeRelease", - "opensuse": "doubletakeRelease", "other": "doubletakeRelease"}}, - // Ubuntu 24.04 ships only the older heif-convert. - {ID: "libheif", Features: []string{"heic"}, present: func() bool { return heifTool() != "" }, - Packages: map[string]string{"debian": "libheif-examples", "fedora": "libheif-tools", "opensuse": "heif-examples"}, - Hints: map[string]string{"fedora": "rpmfusionHeif"}}, -} - -// heifTool is the HEIC decoder on PATH, if any. -func heifTool() string { - for _, t := range []string{"heif-dec", "heif-convert"} { - if _, err := exec.LookPath(t); err == nil { - return t - } - } - return "" -} - -// missingFor lists the packages a feature needs that aren't there. -func missingFor(features ...string) []string { - var missing []string - for _, d := range dependencies { - needed := false - for _, f := range features { - needed = needed || slices.Contains(d.Features, f) - } - if needed && !d.present() { - missing = append(missing, d.ID) - } - } - return missing -} - -// dependencyReport is the answer to "check": every package with what it's -// for, whether it's there, and its name on this distro. -func dependencyReport() map[string]any { - distro := distroFamily() - deps := make([]map[string]any, 0, len(dependencies)) - missing := []string{} - for _, d := range dependencies { - pkg := d.ID - if p, ok := d.Packages[distro]; ok { - pkg = p - } - present := d.present() - if !present && d.Required { - missing = append(missing, d.ID) - } - dep := map[string]any{"id": d.ID, "package": pkg, "required": d.Required, - "features": d.Features, "present": present} - if d.Manual { - dep["manual"] = true - } - if hint, ok := d.Hints[distro]; ok { - dep["hint"] = hint - } - deps = append(deps, dep) - } - // missing (required only) keeps the setup view working as before. - return map[string]any{"event": "check", "distro": distro, "deps": deps, "missing": missing} -} - -// distroFamily maps os-release to a package naming the table knows: arch, -// debian, fedora or opensuse, which their derivatives follow. Anything else -// is "other" and gets the upstream names without a command. -func distroFamily() string { - f, err := os.Open("/etc/os-release") - if err != nil { - return "other" - } - defer f.Close() - return familyOf(f) -} - -func familyOf(r io.Reader) string { - var ids []string - sc := bufio.NewScanner(r) - for sc.Scan() { - k, v, _ := strings.Cut(sc.Text(), "=") - if k == "ID" || k == "ID_LIKE" { - ids = append(ids, strings.Fields(strings.Trim(v, `"'`))...) - } - } - for _, id := range ids { - switch { - case id == "arch", id == "fedora", id == "debian": - return id - case id == "ubuntu": - return "debian" - case strings.HasPrefix(id, "opensuse"), id == "suse": - return "opensuse" - case id == "rhel", id == "centos": - return "fedora" - } - } - return "other" -} - -func avahiRunning() bool { - conn, err := dbus.ConnectSystemBus() - if err != nil { - return false - } - defer conn.Close() - var has bool - conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, avahiName).Store(&has) - return has -} diff --git a/helpers/cast/deps_test.go b/helpers/cast/deps_test.go deleted file mode 100644 index 31b7c8c..0000000 --- a/helpers/cast/deps_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestFamilyOf(t *testing.T) { - for release, want := range map[string]string{ - "ID=cachyos\nID_LIKE=arch": "arch", - "ID=manjaro\nID_LIKE=arch": "arch", - "ID=ubuntu\nID_LIKE=debian": "debian", - "ID=linuxmint\nID_LIKE=\"ubuntu debian\"": "debian", - "ID=pop\nID_LIKE=\"ubuntu debian\"": "debian", - "ID=fedora": "fedora", - "ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"": "fedora", - "ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"": "opensuse", - "ID=gentoo": "other", - "ID=nixos": "other", - "ID=void": "other", - "": "other", - } { - if got := familyOf(strings.NewReader(release)); got != want { - t.Errorf("%q: got %s, want %s", release, got, want) - } - } -} diff --git a/helpers/cast/discovery.go b/helpers/cast/discovery.go deleted file mode 100644 index aed979c..0000000 --- a/helpers/cast/discovery.go +++ /dev/null @@ -1,263 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "strings" - - "github.com/godbus/dbus/v5" -) - -const ( - avahiName = "org.freedesktop.Avahi" - avahiServer = "org.freedesktop.Avahi.Server2" - avahiBrowserIf = "org.freedesktop.Avahi.ServiceBrowser" - - ifaceUnspec = int32(-1) - protoInet = int32(0) // IPv4 only: some Cast firmware refuses the control connection over IPv6 -) - -var serviceTypes = map[string]string{ - "_googlecast._tcp": "chromecast", - "_airplay._tcp": "airplay", -} - -// ErrAvahiUnavailable maps to "avahi" in the helper's check result. -var ErrAvahiUnavailable = errors.New("avahi not available") - -type Device struct { - ID string // ":", so a TV doing both shows twice - Name string - Model string - Protocol string - Host string - Addr string - Port uint16 - Features string // AirPlay feature bits, raw -} - -type DeviceEvent struct { - Added bool - Device Device -} - -// parseTXT turns a resolved service into a Device. name is the mDNS instance name. -func parseTXT(protocol, name string, txt [][]byte) Device { - kv := map[string]string{} - for _, rec := range txt { - k, v, _ := strings.Cut(string(rec), "=") - kv[strings.ToLower(k)] = v - } - d := Device{Protocol: protocol, Name: name} - id := "" - switch protocol { - case "chromecast": - id, d.Model = kv["id"], kv["md"] - if kv["fn"] != "" { - d.Name = kv["fn"] - } - case "airplay": - id, d.Model, d.Features = strings.ToLower(kv["deviceid"]), kv["model"], kv["features"] - } - if id == "" { - id = name - } - d.ID = protocol + ":" + id - return d -} - -// instance is one mDNS service as avahi reports it; the same device shows once per interface. -type instance struct { - iface int32 - name, stype, domain string -} - -// registry dedupes instances into devices. Not goroutine-safe: Discovery's loop owns it. -type registry struct { - inst map[instance]string // instance -> device id ("" while resolving) - devs map[string]Device - refs map[string]int -} - -func newRegistry() *registry { - return ®istry{inst: map[instance]string{}, devs: map[string]Device{}, refs: map[string]int{}} -} - -func (r *registry) pending(in instance) { r.inst[in] = "" } - -// resolved returns true when this is the device's first instance. -func (r *registry) resolved(in instance, d Device) bool { - if id, ok := r.inst[in]; !ok || id != "" { - return false // removed while resolving, or already counted - } - r.inst[in] = d.ID - r.refs[d.ID]++ - if r.refs[d.ID] > 1 { - return false - } - r.devs[d.ID] = d // first interface's address wins - return true -} - -// removed returns the device when its last instance is gone. -func (r *registry) removed(in instance) (Device, bool) { - id, ok := r.inst[in] - delete(r.inst, in) - if !ok || id == "" { - return Device{}, false - } - if r.refs[id]--; r.refs[id] > 0 { - return Device{}, false - } - d := r.devs[id] - delete(r.devs, id) - delete(r.refs, id) - return d, true -} - -func (r *registry) clear() []Device { - var gone []Device - for _, d := range r.devs { - gone = append(gone, d) - } - *r = *newRegistry() - return gone -} - -type Discovery struct { - Events chan DeviceEvent - Errors chan error - - conn *dbus.Conn - sig chan *dbus.Signal - resolves chan resolveResult - browsers map[dbus.ObjectPath]string // path -> protocol - reg *registry -} - -type resolveResult struct { - in instance - dev Device - err error -} - -// StartDiscovery browses Cast and AirPlay receivers until the connection closes. -// Returns ErrAvahiUnavailable when avahi isn't running and can't be activated. -func StartDiscovery(conn *dbus.Conn) (*Discovery, error) { - d := &Discovery{ - Events: make(chan DeviceEvent, 16), - Errors: make(chan error, 4), - conn: conn, - sig: make(chan *dbus.Signal, 64), - resolves: make(chan resolveResult, 16), - reg: newRegistry(), - } - conn.Signal(d.sig) - for _, m := range [][]dbus.MatchOption{ - {dbus.WithMatchSender(avahiName), dbus.WithMatchInterface(avahiBrowserIf)}, - {dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, avahiName)}, - } { - if err := conn.AddMatchSignal(m...); err != nil { - return nil, err - } - } - if err := d.browse(); err != nil { - return nil, err - } - go d.loop() - return d, nil -} - -// browse uses Prepare + Start (avahi >= 0.8) so no ItemNew can fire before we know the browser's path. -func (d *Discovery) browse() error { - d.browsers = map[dbus.ObjectPath]string{} - srv := d.conn.Object(avahiName, "/") - for stype, protocol := range serviceTypes { - var path dbus.ObjectPath - err := srv.Call(avahiServer+".ServiceBrowserPrepare", 0, ifaceUnspec, protoInet, stype, "", uint32(0)).Store(&path) - if err != nil { - return fmt.Errorf("%w: %v", ErrAvahiUnavailable, err) - } - d.browsers[path] = protocol - if err := d.conn.Object(avahiName, path).Call(avahiBrowserIf+".Start", 0).Err; err != nil { - return fmt.Errorf("%w: %v", ErrAvahiUnavailable, err) - } - } - return nil -} - -func (d *Discovery) loop() { - for { - select { - case s, ok := <-d.sig: - if !ok { - return - } - d.handle(s) - case r := <-d.resolves: - if r.err != nil { - d.reg.removed(r.in) // device advertises but doesn't answer; drop it until it re-announces - continue - } - if d.reg.resolved(r.in, r.dev) { - d.Events <- DeviceEvent{Added: true, Device: r.dev} - } - } - } -} - -func (d *Discovery) handle(s *dbus.Signal) { - if s.Name == "org.freedesktop.DBus.NameOwnerChanged" { - d.ownerChanged(s.Body[2].(string)) - return - } - protocol, ours := d.browsers[s.Path] - if !ours { - return // a stale browser from before an avahi restart, or someone else's - } - switch s.Name { - case avahiBrowserIf + ".ItemNew", avahiBrowserIf + ".ItemRemove": - in := instance{iface: s.Body[0].(int32), name: s.Body[2].(string), stype: s.Body[3].(string), domain: s.Body[4].(string)} - if s.Name == avahiBrowserIf+".ItemRemove" { - if dev, gone := d.reg.removed(in); gone { - d.Events <- DeviceEvent{Added: false, Device: dev} - } - return - } - d.reg.pending(in) - go d.resolve(in, protocol) - case avahiBrowserIf + ".Failure": - d.Errors <- fmt.Errorf("browser failed: %v", s.Body) - } -} - -// ownerChanged handles avahi stopping (all devices gone) and coming back (browse again). -func (d *Discovery) ownerChanged(newOwner string) { - for _, dev := range d.reg.clear() { - d.Events <- DeviceEvent{Added: false, Device: dev} - } - d.browsers = nil - if newOwner == "" { - d.Errors <- ErrAvahiUnavailable - return - } - if err := d.browse(); err != nil { - d.Errors <- err - } -} - -func (d *Discovery) resolve(in instance, protocol string) { - var ( - iface, proto, aproto int32 - name, stype, domain, host, addr string - port uint16 - txt [][]byte - flags uint32 - ) - err := d.conn.Object(avahiName, "/").Call(avahiServer+".ResolveService", 0, - in.iface, protoInet, in.name, in.stype, in.domain, protoInet, uint32(0)). - Store(&iface, &proto, &name, &stype, &domain, &host, &aproto, &addr, &port, &txt, &flags) - dev := parseTXT(protocol, in.name, txt) - dev.Host, dev.Addr, dev.Port = host, addr, port - d.resolves <- resolveResult{in: in, dev: dev, err: err} -} diff --git a/helpers/cast/discovery_test.go b/helpers/cast/discovery_test.go deleted file mode 100644 index c7d0e73..0000000 --- a/helpers/cast/discovery_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import "testing" - -func TestParseTXTAndDedupe(t *testing.T) { - cast := parseTXT("chromecast", "Chromecast-abc", [][]byte{[]byte("id=abc123"), []byte("md=Chromecast Ultra"), []byte("fn=Living Room"), []byte("rs=")}) - if cast.ID != "chromecast:abc123" || cast.Name != "Living Room" || cast.Model != "Chromecast Ultra" { - t.Fatalf("cast: %+v", cast) - } - air := parseTXT("airplay", "Bedroom TV", [][]byte{[]byte("deviceid=AA:BB:CC:DD:EE:FF"), []byte("features=0x5A7FFFF7,0x1E"), []byte("model=AppleTV5,3")}) - if air.ID != "airplay:aa:bb:cc:dd:ee:ff" || air.Name != "Bedroom TV" || air.Model != "AppleTV5,3" || air.Features != "0x5A7FFFF7,0x1E" { - t.Fatalf("airplay: %+v", air) - } - if noID := parseTXT("chromecast", "Foo", nil); noID.ID != "chromecast:Foo" { - t.Fatalf("fallback id: %+v", noID) - } - - r := newRegistry() - eth := instance{iface: 2, name: "Chromecast-abc", stype: "_googlecast._tcp", domain: "local"} - wifi := eth - wifi.iface = 3 - r.pending(eth) - r.pending(wifi) - if !r.resolved(eth, cast) || r.resolved(wifi, cast) { - t.Fatal("second interface must not add the device again") - } - if _, gone := r.removed(eth); gone { - t.Fatal("device still on wifi") - } - if d, gone := r.removed(wifi); !gone || d.ID != cast.ID { - t.Fatal("last instance gone must remove the device") - } - - // removed while resolving: the late resolve result is dropped - r.pending(eth) - r.removed(eth) - if r.resolved(eth, cast) { - t.Fatal("stale resolve must not add") - } -} diff --git a/helpers/cast/glue.go b/helpers/cast/glue.go deleted file mode 100644 index fea2cfa..0000000 --- a/helpers/cast/glue.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "slices" - - "github.com/godbus/dbus/v5" -) - -// discover reports the full device list on every change until ctx ends. -func discover(ctx context.Context, onChange func([]device)) error { - conn, err := dbus.ConnectSystemBus() - if err != nil { - return err - } - defer conn.Close() - d, err := StartDiscovery(conn) - if err != nil { - return err - } - found := map[string]Device{} - for { - select { - case <-ctx.Done(): - return nil - case err := <-d.Errors: - log.Printf("discovery: %v", err) - case e := <-d.Events: - if e.Added { - found[e.Device.ID] = e.Device - } else { - delete(found, e.Device.ID) - } - onChange(mergeDevices(found)) - } - } -} - -// mergeDevices joins a device's Chromecast and AirPlay entries, which avahi -// reports separately, by address. -func mergeDevices(found map[string]Device) []device { - byAddr := map[string]*device{} - var order []string - for _, f := range found { - d := byAddr[f.Addr] - if d == nil { - d = &device{Host: f.Addr} - byAddr[f.Addr] = d - order = append(order, f.Addr) - } - d.Protocols = append(d.Protocols, f.Protocol) - switch f.Protocol { - case "chromecast": - d.ID, d.Name, d.Model, d.Protocol, d.Port = f.ID, f.Name, f.Model, "chromecast", int(f.Port) - case "airplay": - d.AirplayPort = int(f.Port) - if d.Protocol == "" { - d.ID, d.Name, d.Model, d.Protocol = f.ID, f.Name, f.Model, "airplay" - } - } - } - list := make([]device, 0, len(order)) - for _, a := range order { - d := byAddr[a] - slices.Sort(d.Protocols) - list = append(list, *d) - } - return list -} - -// debug turns on per-message and per-request logging, for troubleshooting: -// CAST_DEBUG=1 in the shell's environment. -var debug = os.Getenv("CAST_DEBUG") != "" - -func debugf(format string, args ...any) { - if debug { - log.Printf(format, args...) - } -} diff --git a/helpers/cast/go.mod b/helpers/cast/go.mod deleted file mode 100644 index 063defd..0000000 --- a/helpers/cast/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -module cast-helper - -go 1.25.0 - -require ( - github.com/ebitengine/purego v0.11.2 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - golang.org/x/sys v0.27.0 // indirect -) diff --git a/helpers/cast/go.sum b/helpers/cast/go.sum deleted file mode 100644 index 21a5ec4..0000000 --- a/helpers/cast/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= -github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/helpers/cast/gst.go b/helpers/cast/gst.go deleted file mode 100644 index 74d34cd..0000000 --- a/helpers/cast/gst.go +++ /dev/null @@ -1,209 +0,0 @@ -package main - -import ( - "fmt" - "io" - "log" - "sync" - "sync/atomic" - "unsafe" - - "github.com/ebitengine/purego" -) - -// GStreamer is loaded at run time through purego, so the binary stays static -// and CGO-free while still controlling the encoder of a running pipeline. -var ( - gstInit func(argc, argv unsafe.Pointer) - gstParseLaunch func(desc string, err unsafe.Pointer) uintptr - gstBinGetByName func(bin uintptr, name string) uintptr - gstElementSetState func(el uintptr, state int32) int32 - gstElementGetStaticPad func(el uintptr, name string) uintptr - gstElementGetBus func(el uintptr) uintptr - gstPadSendEvent func(pad, event uintptr) bool - gstBusTimedPopFiltered func(bus uintptr, timeout uint64, types uint32) uintptr - gstMessageGetStructure func(msg uintptr) uintptr - gstMessageParseError func(msg uintptr, gerr, debug unsafe.Pointer) - gstSampleGetBuffer func(sample uintptr) uintptr - gstBufferGetSize func(buf uintptr) uint64 - gstBufferExtract func(buf uintptr, offset uint64, dest unsafe.Pointer, size uint64) uint64 - gstMiniObjectUnref func(obj uintptr) - gstObjectUnref func(obj uintptr) - gstUtilSetObjectArg func(obj uintptr, name, value string) - gstElementFactoryFind func(name string) uintptr - gstAppSinkTryPullSample func(sink uintptr, timeout uint64) uintptr - gstAppSinkIsEOS func(sink uintptr) bool - gstForceKeyUnit func(runningTime uint64, allHeaders bool, count uint32) uintptr -) - -const ( - gstStateNull = 1 - gstStatePlaying = 4 - gstClockNone = ^uint64(0) - gstMsgEOS = 1 << 0 - gstMsgError = 1 << 1 -) - -var loadGst = sync.OnceValue(func() error { - libs := map[string]map[string]any{ - "libgstreamer-1.0.so.0": { - "gst_init": &gstInit, "gst_parse_launch": &gstParseLaunch, "gst_bin_get_by_name": &gstBinGetByName, - "gst_element_set_state": &gstElementSetState, "gst_element_get_static_pad": &gstElementGetStaticPad, - "gst_element_get_bus": &gstElementGetBus, "gst_pad_send_event": &gstPadSendEvent, - "gst_bus_timed_pop_filtered": &gstBusTimedPopFiltered, "gst_message_get_structure": &gstMessageGetStructure, - "gst_message_parse_error": &gstMessageParseError, "gst_sample_get_buffer": &gstSampleGetBuffer, - "gst_buffer_get_size": &gstBufferGetSize, "gst_buffer_extract": &gstBufferExtract, - "gst_mini_object_unref": &gstMiniObjectUnref, "gst_object_unref": &gstObjectUnref, - "gst_util_set_object_arg": &gstUtilSetObjectArg, - "gst_element_factory_find": &gstElementFactoryFind, - }, - "libgstapp-1.0.so.0": { - "gst_app_sink_try_pull_sample": &gstAppSinkTryPullSample, "gst_app_sink_is_eos": &gstAppSinkIsEOS, - }, - "libgstvideo-1.0.so.0": { - "gst_video_event_new_upstream_force_key_unit": &gstForceKeyUnit, - }, - } - for name, fns := range libs { - lib, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) - if err != nil { - return err - } - for sym, fn := range fns { - purego.RegisterLibFunc(fn, lib, sym) - } - } - gstInit(nil, nil) - return nil -}) - -// gstPipeline is a running pipeline with an encoder named "enc" and an appsink -// named "out". -type gstPipeline struct { - pipe, enc, sink uintptr - pending []byte - once sync.Once - stopped atomic.Bool - failed chan string // receives the error message if the pipeline fails -} - -func startGstPipeline(desc string) (*gstPipeline, error) { - if err := loadGst(); err != nil { - return nil, fmt.Errorf("loading GStreamer: %w", err) - } - debugf("pipeline: %s", desc) - var gerr *gError - pipe := gstParseLaunch(desc, unsafe.Pointer(&gerr)) - if pipe == 0 || gerr != nil { - return nil, fmt.Errorf("parse: %s", gerrorMessage(gerr)) - } - p := &gstPipeline{pipe: pipe, enc: gstBinGetByName(pipe, "enc"), sink: gstBinGetByName(pipe, "out"), failed: make(chan string, 1)} - if p.enc == 0 || p.sink == 0 { - return nil, fmt.Errorf("pipeline needs elements named enc and out") - } - if gstElementSetState(pipe, gstStatePlaying) == 0 { - return nil, fmt.Errorf("pipeline refused to start") - } - go p.watchBus() - return p, nil -} - -func (p *gstPipeline) watchBus() { - bus := gstElementGetBus(p.pipe) - defer gstObjectUnref(bus) - msg := gstBusTimedPopFiltered(bus, gstClockNone, gstMsgEOS|gstMsgError) - if msg == 0 { - return - } - defer gstMiniObjectUnref(msg) - if gstMessageGetStructure(msg) == 0 { - return // EOS carries no structure - } - var gerr *gError - var debug uintptr - gstMessageParseError(msg, unsafe.Pointer(&gerr), unsafe.Pointer(&debug)) - log.Printf("pipeline error: %s", gerrorMessage(gerr)) - p.failed <- gerrorMessage(gerr) -} - -// Pull returns the next buffer from the appsink, one encoded frame or packet. -func (p *gstPipeline) Pull() ([]byte, error) { - for { - sample := gstAppSinkTryPullSample(p.sink, 100_000_000) // 100 ms - if sample == 0 { - if gstAppSinkIsEOS(p.sink) || p.stopped.Load() { - return nil, io.EOF - } - continue - } - buf := gstSampleGetBuffer(sample) - b := make([]byte, gstBufferGetSize(buf)) - if len(b) > 0 { - gstBufferExtract(buf, 0, unsafe.Pointer(&b[0]), uint64(len(b))) - } - gstMiniObjectUnref(sample) - return b, nil - } -} - -// Read hands out the appsink's buffers as one byte stream. -func (p *gstPipeline) Read(b []byte) (int, error) { - for len(p.pending) == 0 { - var err error - if p.pending, err = p.Pull(); err != nil { - return 0, err - } - } - n := copy(b, p.pending) - p.pending = p.pending[n:] - return n, nil -} - -// KeyFrame asks the encoder for a key frame with fresh SPS/PPS. The upstream -// event skips the encoder's stream lock, so it can't block on a full appsink. -func (p *gstPipeline) KeyFrame() { - pad := gstElementGetStaticPad(p.enc, "src") - defer gstObjectUnref(pad) - gstPadSendEvent(pad, gstForceKeyUnit(gstClockNone, true, 0)) -} - -func (p *gstPipeline) SetBitrate(kbps int) { - gstUtilSetObjectArg(p.enc, "bitrate", fmt.Sprint(kbps)) -} - -func (p *gstPipeline) Stop() { - p.once.Do(func() { - p.stopped.Store(true) - gstElementSetState(p.pipe, gstStateNull) - }) -} - -type gError struct { - domain uint32 - code int32 - message *byte -} - -func gerrorMessage(gerr *gError) string { - if gerr == nil || gerr.message == nil { - return "unknown error" - } - n := 0 - for *(*byte)(unsafe.Add(unsafe.Pointer(gerr.message), n)) != 0 { - n++ - } - return string(unsafe.Slice(gerr.message, n)) -} - -// hasElement reports whether a GStreamer element is installed. -func hasElement(name string) bool { - if loadGst() != nil { - return false - } - f := gstElementFactoryFind(name) - if f == 0 { - return false - } - gstObjectUnref(f) - return true -} diff --git a/helpers/cast/gst_test.go b/helpers/cast/gst_test.go deleted file mode 100644 index e525a3c..0000000 --- a/helpers/cast/gst_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -// TestEncoderControl needs GStreamer and a VA encoder; it checks that a key -// frame can be forced and the bitrate changed while the pipeline runs. -func TestEncoderControl(t *testing.T) { - p, err := startGstPipeline("videotestsrc is-live=true pattern=ball ! video/x-raw,width=640,height=360,framerate=30/1 ! " + - "vapostproc ! vah264enc name=enc aud=true b-frames=0 key-int-max=300 rate-control=cbr bitrate=4000 ! " + - "h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false") - if err != nil { - t.Skip(err) - } - defer p.Stop() - - type au struct { - at time.Time - size int - key bool - } - aus := make(chan au, 1000) - go readAccessUnits(p, nalCodec{}, func(b []byte, key bool) { aus <- au{time.Now(), len(b), key} }) - collect := func(d time.Duration) (keys, bytes int) { - end := time.After(d) - for { - select { - case a := <-aus: - if a.key { - keys++ - } - bytes += a.size - case <-end: - return - } - } - } - - collect(time.Second) // skip the first key frame and the encoder warming up - asked := time.Now() - p.KeyFrame() - for a := range aus { - if a.key { - t.Logf("forced key frame after %v", a.at.Sub(asked).Round(time.Millisecond)) - break - } - if a.at.Sub(asked) > time.Second { - t.Fatal("no key frame within 1 s of asking (GOP is 10 s)") - } - } - - _, high := collect(2 * time.Second) - p.SetBitrate(500) - collect(500 * time.Millisecond) - _, low := collect(2 * time.Second) - t.Logf("2 s at 4000 kbit/s: %d bytes, at 500 kbit/s: %d bytes", high, low) - if low*3 > high { - t.Fatal("output did not shrink after lowering the bitrate") - } -} diff --git a/helpers/cast/main.go b/helpers/cast/main.go deleted file mode 100644 index 816fa3b..0000000 --- a/helpers/cast/main.go +++ /dev/null @@ -1,507 +0,0 @@ -// Command cast-helper does the casting for the Cast plugin. It -// reads one JSON command per line on stdin and writes one JSON event per line -// on stdout, and exits when stdin closes. -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "log" - "os" - "os/signal" - "path/filepath" - "slices" - "strings" - "sync" - "syscall" - "time" -) - -// device is one TV or speaker. One that speaks Chromecast and AirPlay is a -// single device: it keeps its Chromecast id and lists both protocols. -type device struct { - ID string `json:"id"` - Name string `json:"name"` - Model string `json:"model"` - Protocol string `json:"protocol"` // the main one: chromecast when there is one - Protocols []string `json:"protocols"` - Host string `json:"-"` - Port int `json:"-"` // Cast control port - AirplayPort int `json:"-"` -} - -func (d device) has(protocol string) bool { return slices.Contains(d.Protocols, protocol) } - -type command struct { - Cmd string `json:"cmd"` - On bool `json:"on"` - Device string `json:"device"` - Level float64 `json:"level"` - Path string `json:"path"` - URL string `json:"url"` - Port int `json:"port"` // file server port, 0 for any - Pin string `json:"pin"` // AirPlay pairing PIN or password - Position float64 `json:"position"` - Size int `json:"size"` // thumbnail width - // queue - Items []struct{ Path, URL string } `json:"items"` - Index int `json:"index"` - Interval int `json:"interval"` - Loop bool `json:"loop"` - ShrinkPhotos *bool `json:"shrinkPhotos"` - ConvertVideos *bool `json:"convertVideos"` - castOptions -} - -// source is the path or link of a play command or queue item. -func source(path, link string) (string, error) { - if link != "" { - link = strings.TrimSpace(link) - if !isLink(link) { - return "", fmt.Errorf("not an http(s) link: %q", link) - } - return link, nil - } - if !filepath.IsAbs(path) { - return "", fmt.Errorf("not an absolute path: %q", path) - } - return path, nil -} - -func orTrue(b *bool) bool { return b == nil || *b } - -// activeSession is a mirroring cast or a file playing on the TV. -type activeSession interface { - deviceID() string - status() map[string]any - run() (code string, err error) - Stop() - Wait(time.Duration) - SetVolume(level float64) -} - -// pendingSession holds the slot while the device is looked up. -type pendingSession struct { - h *helper - id string - since time.Time -} - -func (p *pendingSession) deviceID() string { return p.id } -func (p *pendingSession) status() map[string]any { - return map[string]any{"device": p.id, "phase": "starting", "since": p.since.Unix()} -} -func (p *pendingSession) run() (string, error) { return "", nil } -func (p *pendingSession) Wait(time.Duration) {} -func (p *pendingSession) SetVolume(float64) {} -func (p *pendingSession) Stop() { - p.h.mu.Lock() - if p.h.sess == p { - p.h.sess = nil - } - p.h.mu.Unlock() - p.h.emitState() -} - -type helper struct { - outMu sync.Mutex - out *json.Encoder - - mu sync.Mutex - devices map[string]device - stopDiscover context.CancelFunc - discoverers int // the plugin's request plus a start waiting for its device - sess activeSession - // The portal's restore tokens are single use: the newest one is kept for - // moving a cast, which opens a new portal session. - restoreToken string -} - -func main() { - log.SetFlags(log.Ltime | log.Lmicroseconds) - log.SetPrefix("") - h := &helper{out: json.NewEncoder(os.Stdout), devices: map[string]device{}} - removeStaleRemuxes() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - lines := make(chan string) - go func() { - sc := bufio.NewScanner(os.Stdin) - sc.Buffer(make([]byte, 64<<10), 1<<20) - for sc.Scan() { - lines <- sc.Text() - } - close(lines) - }() - - for { - select { - case <-sig: - h.shutdown() - return - case line, ok := <-lines: - if !ok { - h.shutdown() - return - } - var c command - if err := json.Unmarshal([]byte(line), &c); err != nil { - log.Printf("bad command %q: %v", line, err) - continue - } - h.handle(c) - } - } -} - -func (h *helper) handle(c command) { - switch c.Cmd { - case "check": - h.emit(dependencyReport()) - case "discover": - h.setDiscovery(c.On) - case "start": - // Chromecast unless the device only speaks AirPlay, or the user chose - // AirPlay for mirroring it. - h.start(c.Device, func(d device) activeSession { - if d.has("airplay") && (c.Protocol == "airplay" || !d.has("chromecast")) { - return newAirplaySession(h, d, c.castOptions) - } - return newSession(h, d, c.castOptions) - }) - case "pair": - h.mu.Lock() - a, _ := h.sess.(*airplaySession) - h.mu.Unlock() - if a != nil { - a.pair(c.Pin) - } - case "play": - src, err := source(c.Path, c.URL) - if err != nil { - h.emitError("unsupportedLink", err) - return - } - opts := queueOptions{ShrinkPhotos: c.ShrinkPhotos != nil && *c.ShrinkPhotos, ConvertVideos: orTrue(c.ConvertVideos), Port: c.Port} - h.start(c.Device, func(d device) activeSession { return newMediaSession(h, d, []string{src}, 0, opts) }) - case "playlist": - go h.expandPlaylist(c.Path) - case "queue": - var srcs []string - add := func(path, link string) { - if src, err := source(path, link); err == nil { - srcs = append(srcs, src) - } - } - for _, it := range c.Items { - if it.URL == "" && isPlaylist(it.Path) { - entries, _ := readPlaylist(it.Path) - for _, e := range entries { - add(e.Path, e.URL) - } - continue - } - add(it.Path, it.URL) - } - if len(srcs) == 0 { - h.emitError("unsupportedLink", errors.New("empty queue")) - return - } - opts := queueOptions{Interval: c.Interval, Loop: c.Loop, ShrinkPhotos: c.ShrinkPhotos != nil && *c.ShrinkPhotos, - ConvertVideos: orTrue(c.ConvertVideos), Port: c.Port} - h.start(c.Device, func(d device) activeSession { return newMediaSession(h, d, srcs, c.Index, opts) }) - case "move": - go h.move(c.Device) - case "thumbnail": - go h.thumbnail(c.Path, c.Size) - case "pause", "resume", "seek", "subtitles", "next", "previous", "jump", "slideshow": - h.mu.Lock() - m, _ := h.sess.(*mediaSession) - h.mu.Unlock() - if m != nil { - m.control(c) - } - case "stop": - h.mu.Lock() - s := h.sess - h.mu.Unlock() - if s != nil { - s.Stop() - } - case "volume": - h.mu.Lock() - s := h.sess - h.mu.Unlock() - if s != nil { - s.SetVolume(c.Level) - } - default: - log.Printf("unknown command %q", c.Cmd) - } -} - -func (h *helper) emit(v any) { - h.outMu.Lock() - defer h.outMu.Unlock() - if err := h.out.Encode(v); err != nil { - log.Printf("stdout: %v", err) - } -} - -func (h *helper) emitError(code string, err error) { - log.Printf("error %s: %v", code, err) - msg := map[string]any{"event": "error", "code": code} - var m *missingError - if errors.As(err, &m) { - msg["feature"], msg["missing"] = m.feature, m.missing - } - if err != nil { - msg["detail"] = err.Error() - } - h.emit(msg) -} - -// missingError says which packages a feature needs; the plugin names them. -type missingError struct { - feature string - missing []string -} - -func (e *missingError) Error() string { - return e.feature + " needs " + strings.Join(e.missing, ", ") -} - -// need returns a missingError when a feature's packages aren't installed. -func need(feature string) error { - if missing := missingFor(feature); len(missing) > 0 { - return &missingError{feature, missing} - } - return nil -} - -// emitState pushes the full state; the plugin keeps no state of its own. -func (h *helper) emitState() { - h.mu.Lock() - devs := make([]device, 0, len(h.devices)) - for _, d := range h.devices { - devs = append(devs, d) - } - var sess any - if h.sess != nil { - sess = h.sess.status() - } - h.mu.Unlock() - slices.SortFunc(devs, func(a, b device) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) - h.emit(map[string]any{"event": "state", "devices": devs, "session": sess}) -} - -// setDiscovery follows the plugin's request to browse while a device list is -// on screen. -func (h *helper) setDiscovery(on bool) { - h.mu.Lock() - defer h.mu.Unlock() - if on == (h.discoverers&1 == 1) { - return - } - if on { - h.discoverers |= 1 - } else { - h.discoverers &^= 1 - } - h.updateDiscoveryLocked() -} - -func (h *helper) updateDiscoveryLocked() { - if h.discoverers != 0 && h.stopDiscover == nil { - ctx, cancel := context.WithCancel(context.Background()) - h.stopDiscover = cancel - go func() { - err := discover(ctx, func(devs []device) { - h.mu.Lock() - h.devices = map[string]device{} - for _, d := range devs { - h.devices[d.ID] = d - } - h.mu.Unlock() - h.emitState() - }) - if err != nil && ctx.Err() == nil { - if missing := need("discovery"); missing != nil { - h.emitError("missingDependency", missing) - } else { - h.emitError("avahi", err) - } - } - }() - } - if h.discoverers == 0 && h.stopDiscover != nil { - h.stopDiscover() - h.stopDiscover = nil - // Keep the device being cast to; forget the rest. - for id := range h.devices { - if h.sess == nil || h.sess.deviceID() != id { - delete(h.devices, id) - } - } - go h.emitState() - } -} - -// lookup finds a device, browsing for a few seconds if it isn't known yet -// (the Control Center tile starts a cast without showing the list). -func (h *helper) lookup(id string) (device, bool) { - h.mu.Lock() - d, ok := h.devices[id] - if ok { - h.mu.Unlock() - return d, true - } - h.discoverers |= 2 - h.updateDiscoveryLocked() - h.mu.Unlock() - defer func() { - h.mu.Lock() - h.discoverers &^= 2 - h.updateDiscoveryLocked() - h.mu.Unlock() - }() - for range 30 { - time.Sleep(200 * time.Millisecond) - h.mu.Lock() - d, ok = h.devices[id] - h.mu.Unlock() - if ok { - return d, true - } - } - return device{}, false -} - -func (h *helper) start(id string, create func(device) activeSession) { - h.mu.Lock() - if h.sess != nil { - h.mu.Unlock() - h.emitError("busy", fmt.Errorf("already casting to %s", h.sess.deviceID())) - return - } - // Reserve the slot so a second start can't race this one. - placeholder := &pendingSession{h: h, id: id, since: time.Now()} - h.sess = placeholder - h.mu.Unlock() - h.emitState() - - go func() { - d, ok := h.lookup(id) - h.mu.Lock() - cancelled := h.sess != placeholder - h.mu.Unlock() - if cancelled { - return - } - if !ok { - h.endSession(placeholder, "deviceNotFound", fmt.Errorf("%s did not show up on the network", id)) - return - } - s := create(d) - h.mu.Lock() - if h.sess != placeholder { // stopped while looking up - h.mu.Unlock() - return - } - h.sess = s - h.mu.Unlock() - code, err := s.run() - h.endSession(s, code, err) - }() -} - -func (h *helper) endSession(s activeSession, code string, err error) { - h.mu.Lock() - if h.sess == s { - h.sess = nil - } - h.mu.Unlock() - if code != "" { - h.emitError(code, err) - } - h.emitState() -} - -// move ends what is casting and starts the same on another device: a -// mirroring cast with the same options, playback at the same item and time. -func (h *helper) move(id string) { - h.mu.Lock() - s := h.sess - token := h.restoreToken - h.mu.Unlock() - if s == nil || s.deviceID() == id { - return - } - var create func(device) activeSession - switch s := s.(type) { - case *session: - opts := s.opts - if token != "" { - opts.RestoreToken = token - } - create = func(d device) activeSession { return newSession(h, d, opts) } - case *airplaySession: - opts := s.opts - create = func(d device) activeSession { - if d.has("airplay") && !d.has("chromecast") { - return newAirplaySession(h, d, opts) - } - return newSession(h, d, opts) - } - case *mediaSession: - index, at := s.position() - opts := s.opts - opts.StartAt = at - srcs := s.sources() - create = func(d device) activeSession { return newMediaSession(h, d, srcs, index, opts) } - default: - return - } - s.Stop() - s.Wait(5 * time.Second) - for range 50 { // until run has handed the slot back - h.mu.Lock() - free := h.sess != s - h.mu.Unlock() - if free { - break - } - time.Sleep(20 * time.Millisecond) - } - h.start(id, create) -} - -func (h *helper) shutdown() { - h.mu.Lock() - s := h.sess - if h.stopDiscover != nil { - h.stopDiscover() - } - h.mu.Unlock() - if s != nil { - s.Stop() - s.Wait(5 * time.Second) - } -} - -// removeStaleRemuxes deletes MP4 copies a crashed helper left in the cache. -// Only one helper runs at a time, so none of them is in use. -func removeStaleRemuxes() { - cache, err := os.UserCacheDir() - if err != nil { - return - } - stale, _ := filepath.Glob(filepath.Join(cache, "dms", "cast", "[0-9]*-*")) - for _, f := range stale { - os.Remove(f) - } -} diff --git a/helpers/cast/media.go b/helpers/cast/media.go deleted file mode 100644 index a96cd34..0000000 --- a/helpers/cast/media.go +++ /dev/null @@ -1,300 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "time" -) - -const ( - defaultMediaAppID = "CC1AD845" - nsMedia = "urn:x-cast:com.google.cast.media" -) - -func isLink(s string) bool { - return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") -} - -// remoteType names a link's media type from its extension, else from the -// server's answer; the receiver needs it to pick a player. -func remoteType(ctx context.Context, link string) string { - if u, err := url.Parse(link); err == nil { - if t := typeByExt(u.Path); t != "" { - return t - } - } - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if req, err := http.NewRequestWithContext(ctx, http.MethodHead, link, nil); err == nil { - if resp, err := http.DefaultClient.Do(req); err == nil { - resp.Body.Close() - if t, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";"); t != "" && t != "application/octet-stream" { - return t - } - } - } - return "video/mp4" -} - -// sidecarSubtitle finds "movie.srt" or "movie.vtt" next to "movie.mkv". -func sidecarSubtitle(file string) string { - base := strings.TrimSuffix(file, filepath.Ext(file)) - for _, ext := range []string{".vtt", ".srt", ".VTT", ".SRT"} { - if _, err := os.Stat(base + ext); err == nil { - return base + ext - } - } - return "" -} - -type mediaInfo struct { - url, contentType, title string - subURL, subLang string - artist, album, coverURL string // songs -} - -func loadRequest(m mediaInfo, id int64, start float64) map[string]any { - mi := map[string]any{ - "contentId": m.url, - "contentUrl": m.url, - "contentType": m.contentType, - "streamType": "BUFFERED", - "metadata": map[string]any{"metadataType": 0, "title": m.title}, - } - if m.artist != "" || m.album != "" || m.coverURL != "" { - md := map[string]any{"metadataType": 3, "title": m.title, "artist": m.artist, "albumName": m.album} - if m.coverURL != "" { - md["images"] = []map[string]any{{"url": m.coverURL}} - } - mi["metadata"] = md - } - load := map[string]any{"type": "LOAD", "requestId": id, "media": mi, "autoplay": true, "currentTime": start} - if m.subURL != "" { - mi["tracks"] = []map[string]any{{ - "trackId": 1, "type": "TEXT", "subtype": "SUBTITLES", - "trackContentId": m.subURL, "trackContentType": "text/vtt", - "language": m.subLang, "name": m.subLang, - }} - mi["textTrackStyle"] = map[string]any{"backgroundColor": "#00000000", "edgeType": "OUTLINE", "edgeColor": "#000000FF"} - load["activeTrackIds"] = []int{1} - } - return load -} - -// playerStatus is the last MEDIA_STATUS for the loaded media. -type playerStatus struct { - SessionID int64 `json:"mediaSessionId"` - PlayerState string `json:"playerState"` - IdleReason string `json:"idleReason"` - CurrentTime float64 `json:"currentTime"` - Rate float64 `json:"playbackRate"` - Duration float64 - Title string - at time.Time - follow bool // take every new media session (the YouTube app starts one per video) -} - -// update applies one media-namespace message. idle is the idle reason once -// the media stopped (FINISHED, CANCELLED, INTERRUPTED, ERROR). -func (s *playerStatus) update(p []byte) (idle string, err error) { - var msg struct { - Type string - Status []struct { - playerStatus - Media *struct { - Duration float64 - Metadata struct{ Title string } - } - } - DetailedErrorCode int `json:"detailedErrorCode"` - } - if err := json.Unmarshal(p, &msg); err != nil { - return "", nil - } - switch msg.Type { - case "LOAD_FAILED": - return "ERROR", fmt.Errorf("the receiver could not load it (detailedErrorCode %d)", msg.DetailedErrorCode) - case "MEDIA_STATUS": - default: - if msg.Type != "" { - log.Printf("receiver: %s", p) - } - return "", nil - } - for _, ns := range msg.Status { - if s.SessionID != 0 && ns.SessionID != s.SessionID && !s.follow { - continue - } - dur, title := s.Duration, s.Title - if ns.SessionID != s.SessionID { - dur, title = 0, "" - } - if ns.Media != nil { - if ns.Media.Duration > 0 { - dur = ns.Media.Duration - } - if ns.Media.Metadata.Title != "" { - title = ns.Media.Metadata.Title - } - } - follow := s.follow - *s = ns.playerStatus - s.Duration, s.Title, s.at, s.follow = dur, title, time.Now(), follow - if s.PlayerState == "IDLE" && s.IdleReason != "" { - if s.IdleReason == "ERROR" { - return "ERROR", fmt.Errorf("the receiver stopped playing it") - } - return s.IdleReason, nil - } - } - return "", nil -} - -// sourceIP is the local address the kernel would use to reach ip; a UDP -// "connect" sends nothing. -func sourceIP(ip string) (string, error) { - c, err := net.Dial("udp", net.JoinHostPort(ip, "9")) - if err != nil { - return "", err - } - defer c.Close() - return c.LocalAddr().(*net.UDPAddr).IP.String(), nil -} - -func typeByExt(file string) string { - switch strings.ToLower(filepath.Ext(file)) { - case ".mp4", ".m4v", ".mov": - return "video/mp4" - case ".webm": - return "video/webm" - case ".mkv": - return "video/x-matroska" - case ".ts", ".m2ts": - return "video/mp2t" - case ".m4a", ".aac": - return "audio/mp4" - case ".mp3": - return "audio/mpeg" - case ".flac": - return "audio/flac" - case ".ogg", ".oga", ".opus": - return "audio/ogg" - case ".wav": - return "audio/wav" - case ".m3u8": - return "application/x-mpegurl" - case ".mpd": - return "application/dash+xml" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - case ".webp": - return "image/webp" - case ".gif": - return "image/gif" - } - return "" -} - -// server serves only the files added to it, under a random path prefix, and -// only to the TV. -type server struct { - fetched atomic.Bool // the TV has asked for something - ln net.Listener - prefix string - tvIP string - mu sync.Mutex - routes map[string]http.HandlerFunc -} - -func newServer(addr, tvIP string) (*server, error) { - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - b := make([]byte, 16) - rand.Read(b) - s := &server{ln: ln, prefix: "/" + hex.EncodeToString(b) + "/", tvIP: tvIP, routes: map[string]http.HandlerFunc{}} - go http.Serve(ln, s) - return s, nil -} - -func (s *server) close() { s.ln.Close() } - -func (s *server) url(name string) string { - return "http://" + s.ln.Addr().String() + s.prefix + name -} - -func (s *server) add(name, file, contentType string) string { - s.mu.Lock() - defer s.mu.Unlock() - s.routes[s.prefix+name] = func(w http.ResponseWriter, r *http.Request) { - f, err := os.Open(file) - if err != nil { - http.Error(w, "gone", http.StatusNotFound) - return - } - defer f.Close() - st, _ := f.Stat() - w.Header().Set("Content-Type", contentType) - http.ServeContent(w, r, name, st.ModTime(), f) - } - return s.url(name) -} - -func (s *server) addBytes(name string, data []byte, contentType string) string { - s.mu.Lock() - defer s.mu.Unlock() - mod := time.Now() - s.routes[s.prefix+name] = func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", contentType) - http.ServeContent(w, r, name, mod, strings.NewReader(string(data))) - } - return s.url(name) -} - -func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - host, _, _ := net.SplitHostPort(r.RemoteAddr) - debugf("http %s %s %s range=%q", host, r.Method, path.Base(r.URL.Path), r.Header.Get("Range")) - if s.tvIP != "" && host != s.tvIP { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - s.fetched.Store(true) - s.mu.Lock() - h := s.routes[r.URL.Path] - s.mu.Unlock() - if h == nil { - http.NotFound(w, r) - return - } - // The receiver is a web page on another origin: text tracks, and media - // played through its Shaka-based player, are fetched with CORS. - hd := w.Header() - hd.Set("Access-Control-Allow-Origin", "*") - hd.Set("Access-Control-Allow-Headers", "Range, Content-Type") - hd.Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges") - switch r.Method { - case http.MethodOptions: - hd.Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") - w.WriteHeader(http.StatusNoContent) - case http.MethodGet, http.MethodHead: - h(w, r) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} diff --git a/helpers/cast/mpris.go b/helpers/cast/mpris.go deleted file mode 100644 index af0868e..0000000 --- a/helpers/cast/mpris.go +++ /dev/null @@ -1,238 +0,0 @@ -package main - -import ( - "errors" - "log" - - "github.com/godbus/dbus/v5" - "github.com/godbus/dbus/v5/introspect" - "github.com/godbus/dbus/v5/prop" -) - -const ( - mprisName = "org.mpris.MediaPlayer2.cast" - mprisPath = dbus.ObjectPath("/org/mpris/MediaPlayer2") - mprisRoot = "org.mpris.MediaPlayer2" - mprisPlayer = "org.mpris.MediaPlayer2.Player" -) - -// playerControls are what the MPRIS buttons do. A nil control is shown as -// unavailable; mirroring has only stop, so the shell's pause button ends it. -type playerControls struct { - stop, playPause, next, previous func() - seek func(seconds float64) -} - -// Player is the MPRIS face of a running cast, shown by the island and the -// media widgets. -type Player struct { - props *prop.Properties - c playerControls -} - -type mprisRootMethods struct{} - -func (mprisRootMethods) Raise() *dbus.Error { return nil } -func (mprisRootMethods) Quit() *dbus.Error { return nil } - -type mprisPlayerMethods struct{ p *Player } - -func (m mprisPlayerMethods) Stop() *dbus.Error { m.p.c.stop(); return nil } -func (m mprisPlayerMethods) Pause() *dbus.Error { - m.p.playPauseOr(m.p.c.stop, "Playing") - return nil -} -func (m mprisPlayerMethods) PlayPause() *dbus.Error { m.p.playPauseOr(m.p.c.stop, ""); return nil } -func (m mprisPlayerMethods) Play() *dbus.Error { m.p.playPauseOr(func() {}, "Paused"); return nil } -func (m mprisPlayerMethods) Next() *dbus.Error { call(m.p.c.next); return nil } -func (m mprisPlayerMethods) Previous() *dbus.Error { call(m.p.c.previous); return nil } - -// MPRIS Seek, not io.Seeker (go vet -stdmethods=false). -func (m mprisPlayerMethods) SetPosition(_ dbus.ObjectPath, us int64) *dbus.Error { - if m.p.c.seek != nil { - m.p.c.seek(float64(us) / 1e6) - } - return nil -} -func (mprisPlayerMethods) OpenUri(string) *dbus.Error { return nil } - -func call(f func()) { - if f != nil { - f() - } -} - -// playPauseOr toggles when the player can, only from the given status when -// one is named, and otherwise runs fallback. -func (p *Player) playPauseOr(fallback func(), only string) { - if p.c.playPause == nil { - fallback() - return - } - if only != "" { - if v, err := p.props.Get(mprisPlayer, "PlaybackStatus"); err != nil || v.Value() != only { - return - } - } - p.c.playPause() -} - -// metadata has no xesam:artist on purpose: the shell then shows the -// identity as the subtitle and skips its lyrics and artwork lookups. -func metadata(title string, length float64) map[string]dbus.Variant { - md := map[string]dbus.Variant{ - "mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/org/mpris/MediaPlayer2/cast/item")), - "xesam:title": dbus.MakeVariant(title), - } - if length > 0 { - md["mpris:length"] = dbus.MakeVariant(int64(length * 1e6)) - } - return md -} - -// StartPlayer exports the player and claims the bus name. -func StartPlayer(conn *dbus.Conn, title string, c playerControls) (*Player, error) { - p := &Player{c: c} - ro := func(v any) *prop.Prop { return &prop.Prop{Value: v, Emit: prop.EmitTrue} } - props, err := prop.Export(conn, mprisPath, prop.Map{ - mprisRoot: { - "Identity": ro("Cast"), - "CanQuit": ro(false), - "CanRaise": ro(false), - "HasTrackList": ro(false), - "SupportedUriSchemes": ro([]string{}), - "SupportedMimeTypes": ro([]string{}), - }, - mprisPlayer: { - "PlaybackStatus": ro("Playing"), - "Metadata": ro(metadata(title, 0)), - "Position": &prop.Prop{Value: int64(0), Emit: prop.EmitFalse}, // clients poll it - "Rate": ro(1.0), - "MinimumRate": ro(1.0), - "MaximumRate": ro(1.0), - "CanGoNext": ro(c.next != nil), - "CanGoPrevious": ro(c.previous != nil), - "CanPlay": ro(true), // playerctl skips players with CanPlay false for metadata - "CanPause": ro(c.playPause != nil), - "CanSeek": ro(false), - "CanControl": ro(true), - }, - }) - if err != nil { - return nil, err - } - p.props = props - if err := conn.Export(mprisRootMethods{}, mprisPath, mprisRoot); err != nil { - return nil, err - } - if err := conn.Export(mprisPlayerMethods{p}, mprisPath, mprisPlayer); err != nil { - return nil, err - } - node := &introspect.Node{Name: string(mprisPath), Interfaces: []introspect.Interface{ - introspect.IntrospectData, prop.IntrospectData, - {Name: mprisRoot, Methods: introspect.Methods(mprisRootMethods{}), Properties: props.Introspection(mprisRoot)}, - {Name: mprisPlayer, Methods: introspect.Methods(mprisPlayerMethods{}), Properties: props.Introspection(mprisPlayer)}, - }} - if err := conn.Export(introspect.NewIntrospectable(node), mprisPath, "org.freedesktop.DBus.Introspectable"); err != nil { - return nil, err - } - // DoNotQueue: a second helper must fail loudly rather than wait for the name. - reply, err := conn.RequestName(mprisName, dbus.NameFlagDoNotQueue) - if err != nil { - return nil, err - } - if reply != dbus.RequestNameReplyPrimaryOwner { - return nil, errors.New("another cast player owns " + mprisName) - } - return p, nil -} - -// set changes a property only when it differs, so clients see few signals. -func (p *Player) set(name string, v any) { - if cur, err := p.props.Get(mprisPlayer, name); err == nil && equalVariant(cur.Value(), v) { - return - } - p.props.SetMust(mprisPlayer, name, v) -} - -func equalVariant(a, b any) bool { - am, aok := a.(map[string]dbus.Variant) - bm, bok := b.(map[string]dbus.Variant) - if aok && bok { - if len(am) != len(bm) { - return false - } - for k, v := range am { - if bv, ok := bm[k]; !ok || bv.String() != v.String() { - return false - } - } - return true - } - return a == b -} - -// startMirrorMpris shows a mirroring cast until the returned function runs. -// Failing to register only costs that display. -func startMirrorMpris(device string, stop func()) func() { - conn, err := dbus.ConnectSessionBus() - if err != nil { - log.Printf("mpris: %v", err) - return func() {} - } - if _, err := StartPlayer(conn, "Casting to "+device, playerControls{stop: stop}); err != nil { - log.Printf("mpris: %v", err) - } - return func() { conn.Close() } -} - -// startMpris shows file playback with real controls. -func (m *mediaSession) startMpris() func() { - conn, err := dbus.ConnectSessionBus() - if err != nil { - log.Printf("mpris: %v", err) - return func() {} - } - c := playerControls{ - stop: m.Stop, - playPause: func() { - m.mu.Lock() - paused := m.paused || m.st.PlayerState == "PAUSED" - m.mu.Unlock() - if paused { - m.control(command{Cmd: "resume"}) - } else { - m.control(command{Cmd: "pause"}) - } - }, - seek: func(s float64) { m.control(command{Cmd: "seek", Position: s}) }, - } - if len(m.items) > 1 { - c.next = func() { m.control(command{Cmd: "next"}) } - c.previous = func() { m.control(command{Cmd: "previous"}) } - } - p, err := StartPlayer(conn, m.items[m.index].title, c) - if err != nil { - log.Printf("mpris: %v", err) - } - m.mu.Lock() - m.player = p - m.mu.Unlock() - return func() { conn.Close() } -} - -// update copies the session's state to the MPRIS properties. -func (p *Player) update(m *mediaSession) { - if p == nil { - return - } - st := m.status() - status := "Playing" - if st["playerState"] == "PAUSED" || st["interval"] == 0 && st["playerState"] != "PLAYING" && len(m.items) > 1 { - status = "Paused" - } - p.set("PlaybackStatus", status) - p.set("Metadata", metadata(st["title"].(string), st["duration"].(float64))) - p.set("CanSeek", st["duration"].(float64) > 0) - p.props.SetMust(mprisPlayer, "Position", int64(st["position"].(float64)*1e6)) -} diff --git a/helpers/cast/music.go b/helpers/cast/music.go deleted file mode 100644 index a23b9cb..0000000 --- a/helpers/cast/music.go +++ /dev/null @@ -1,233 +0,0 @@ -package main - -import ( - "bufio" - "io" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "time" - "unsafe" - - "github.com/ebitengine/purego" -) - -// Songs go to the device with their title, artist, album and cover, so it -// shows a music screen instead of a file name. Playlists (.m3u, .m3u8, .pls) -// expand into their entries for the player. - -type songInfo struct { - title, artist, album string - cover []byte // embedded picture, any image type - coverType string -} - -var ( - discovererInfoTags func(info uintptr) uintptr - tagListGetString func(list uintptr, tag string, value **byte) bool - tagListGetSample func(list uintptr, tag string, sample *uintptr) bool - gstSampleGetCaps func(sample uintptr) uintptr - gFree func(p unsafe.Pointer) -) - -func loadTags() error { - if err := loadDiscoverer(); err != nil { - return err - } - if discovererInfoTags != nil { - return nil - } - for name, fns := range map[string]map[string]any{ - "libgstpbutils-1.0.so.0": {"gst_discoverer_info_get_tags": &discovererInfoTags}, - "libgstreamer-1.0.so.0": { - "gst_tag_list_get_string": &tagListGetString, "gst_tag_list_get_sample": &tagListGetSample, - "gst_sample_get_caps": &gstSampleGetCaps, - }, - "libglib-2.0.so.0": {"g_free": &gFree}, - } { - lib, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) - if err != nil { - return err - } - for sym, fn := range fns { - purego.RegisterLibFunc(fn, lib, sym) - } - } - return nil -} - -// probeSong reads a song's tags and cover; a missing tag is just empty. -func probeSong(file string) songInfo { - var s songInfo - if loadTags() != nil { - return s - } - var gerr *gError - d := discovererNew(uint64(10*time.Second), unsafe.Pointer(&gerr)) - if d == 0 { - return s - } - defer gObjectUnref(d) - info := discovererDiscoverURI(d, (&url.URL{Scheme: "file", Path: file}).String(), unsafe.Pointer(&gerr)) - if info == 0 { - return s - } - defer gObjectUnref(info) - tags := discovererInfoTags(info) - if tags == 0 { - return s - } - str := func(tag string) string { - var p *byte - if !tagListGetString(tags, tag, &p) || p == nil { - return "" - } - defer gFree(unsafe.Pointer(p)) - return cString(p) - } - s.title, s.artist, s.album = str("title"), str("artist"), str("album") - for _, tag := range []string{"image", "preview-image"} { - var sample uintptr - if !tagListGetSample(tags, tag, &sample) || sample == 0 { - continue - } - buf := gstSampleGetBuffer(sample) - if n := gstBufferGetSize(buf); n > 0 && n < 16<<20 { - s.cover = make([]byte, n) - gstBufferExtract(buf, 0, unsafe.Pointer(&s.cover[0]), n) - s.coverType = "image/jpeg" - if caps := gstSampleGetCaps(sample); caps != 0 { - if t := cString(gstStructureGetName(gstCapsGetStructure(caps, 0))); strings.HasPrefix(t, "image/") { - s.coverType = t - } - } - } - gstMiniObjectUnref(sample) - break - } - return s -} - -// folderCover finds the cover image an album folder usually has. -func folderCover(file string) string { - dir := filepath.Dir(file) - entries, err := os.ReadDir(dir) - if err != nil { - return "" - } - for _, want := range []string{"cover", "folder", "front", "album"} { - for _, e := range entries { - name := strings.ToLower(e.Name()) - base := strings.TrimSuffix(name, filepath.Ext(name)) - if base == want && strings.HasPrefix(typeByExt(name), "image/") { - return filepath.Join(dir, e.Name()) - } - } - } - return "" -} - -func isPlaylist(file string) bool { - switch strings.ToLower(filepath.Ext(file)) { - case ".m3u", ".m3u8", ".pls": - return true - } - return false -} - -type playlistEntry struct { - Path string `json:"path,omitempty"` - URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` -} - -// readPlaylist lists a playlist's entries in order. Relative paths are -// relative to the playlist's folder. -func readPlaylist(file string) ([]playlistEntry, error) { - f, err := os.Open(file) - if err != nil { - return nil, err - } - defer f.Close() - if strings.EqualFold(filepath.Ext(file), ".pls") { - return parsePLS(f, filepath.Dir(file)), nil - } - return parseM3U(f, filepath.Dir(file)), nil -} - -func parseM3U(r io.Reader, dir string) []playlistEntry { - var out []playlistEntry - title := "" - sc := bufio.NewScanner(r) - sc.Buffer(make([]byte, 64<<10), 1<<20) - for sc.Scan() { - line := strings.TrimSpace(strings.TrimPrefix(sc.Text(), "\ufeff")) - switch { - case line == "": - case strings.HasPrefix(line, "#EXTINF:"): - if _, t, ok := strings.Cut(line, ","); ok { - title = strings.TrimSpace(t) - } - case strings.HasPrefix(line, "#"): - default: - out = append(out, playlistEntryFor(line, dir, title)) - title = "" - } - } - return out -} - -func parsePLS(r io.Reader, dir string) []playlistEntry { - files, titles := map[int]string{}, map[int]string{} - maxN := 0 - sc := bufio.NewScanner(r) - for sc.Scan() { - k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=") - if !ok { - continue - } - lk := strings.ToLower(k) - for prefix, into := range map[string]map[int]string{"file": files, "title": titles} { - if n, err := strconv.Atoi(strings.TrimPrefix(lk, prefix)); err == nil && strings.HasPrefix(lk, prefix) { - into[n] = strings.TrimSpace(v) - maxN = max(maxN, n) - } - } - } - var out []playlistEntry - for n := 1; n <= maxN; n++ { - if f, ok := files[n]; ok { - out = append(out, playlistEntryFor(f, dir, titles[n])) - } - } - return out -} - -func playlistEntryFor(ref, dir, title string) playlistEntry { - if isLink(ref) { - return playlistEntry{URL: ref, Title: title} - } - if u, err := url.Parse(ref); err == nil && u.Scheme == "file" { - ref = u.Path - } - ref = filepath.FromSlash(strings.ReplaceAll(ref, `\`, "/")) // Windows-made playlists - if !filepath.IsAbs(ref) { - ref = filepath.Join(dir, ref) - } - return playlistEntry{Path: filepath.Clean(ref), Title: title} -} - -// expandPlaylist answers {"cmd":"playlist"} with the entries, or an empty -// list when the file can't be read. -func (h *helper) expandPlaylist(file string) { - entries, err := readPlaylist(file) - if err != nil { - h.emitError("unsupportedFile", err) - } - if entries == nil { - entries = []playlistEntry{} - } - h.emit(map[string]any{"event": "playlist", "path": file, "items": entries}) -} diff --git a/helpers/cast/music_test.go b/helpers/cast/music_test.go deleted file mode 100644 index b53490d..0000000 --- a/helpers/cast/music_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" -) - -func TestPlaylists(t *testing.T) { - m3u := "\ufeff#EXTM3U\n#EXTINF:123,Artist - Song\nsongs/a.mp3\n\n/music/b.flac\nfile:///music/c%20d.ogg\nAlbum\\e.mp3\nhttps://radio.example/stream.mp3\n" - got := parseM3U(strings.NewReader(m3u), "/lists") - want := []playlistEntry{ - {Path: "/lists/songs/a.mp3", Title: "Artist - Song"}, - {Path: "/music/b.flac"}, - {Path: "/music/c d.ogg"}, - {Path: "/lists/Album/e.mp3"}, - {URL: "https://radio.example/stream.mp3"}, - } - if len(got) != len(want) { - t.Fatalf("m3u: %+v", got) - } - for i := range want { - if got[i] != want[i] { - t.Errorf("m3u %d: %+v, want %+v", i, got[i], want[i]) - } - } - pls := "[playlist]\nFile2=two.mp3\nTitle2=Two\nFile1=/abs/one.mp3\nNumberOfEntries=2\n" - gotPLS := parsePLS(strings.NewReader(pls), "/lists") - if len(gotPLS) != 2 || gotPLS[0].Path != "/abs/one.mp3" || gotPLS[1] != (playlistEntry{Path: "/lists/two.mp3", Title: "Two"}) { - t.Errorf("pls: %+v", gotPLS) - } -} - -// Set SONG_FILE to a tagged song to check tags and cover reading. -func TestProbeSong(t *testing.T) { - file := os.Getenv("SONG_FILE") - if file == "" { - t.Skip("set SONG_FILE") - } - s := probeSong(file) - t.Logf("title %q artist %q album %q cover %d bytes %s", s.title, s.artist, s.album, len(s.cover), s.coverType) - if s.title == "" || len(s.cover) == 0 { - t.Error("missing title or cover") - } -} diff --git a/helpers/cast/portal.go b/helpers/cast/portal.go deleted file mode 100644 index 06c1186..0000000 --- a/helpers/cast/portal.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "sync/atomic" - - "github.com/godbus/dbus/v5" -) - -const ( - portalBus = "org.freedesktop.portal.Desktop" - portalPath = "/org/freedesktop/portal/desktop" - scIface = "org.freedesktop.portal.ScreenCast" - reqIface = "org.freedesktop.portal.Request" -) - -var tokenSeq atomic.Uint64 - -type portalSession struct { - conn *dbus.Conn - session dbus.ObjectPath - node uint32 - fd *os.File -} - -func (p *portalSession) Close() { - if p.fd != nil { - p.fd.Close() - } - if p.session != "" { - p.conn.Object(portalBus, p.session).Call("org.freedesktop.portal.Session.Close", 0) - } - p.conn.Close() -} - -// requestScreencast opens a ScreenCast portal session for one monitor with an -// embedded cursor and returns it with the token that skips the share dialog -// next time. Shows the dialog unless restoreToken is still valid. -func requestScreencast(ctx context.Context, restoreToken string) (p *portalSession, newToken string, err error) { - p, newToken, err = openScreencast(ctx, restoreToken) - return -} - -func openScreencast(ctx context.Context, restoreToken string) (*portalSession, string, error) { - conn, err := dbus.ConnectSessionBus() - if err != nil { - return nil, "", err - } - p := &portalSession{conn: conn} - sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_") - portal := conn.Object(portalBus, portalPath) - - res, err := portalRequest(ctx, conn, sender, portal, scIface+".CreateSession", map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(token("req")), - "session_handle_token": dbus.MakeVariant(token("ses")), - }) - if err != nil { - p.Close() - return nil, "", fmt.Errorf("CreateSession: %w", err) - } - handle, _ := res["session_handle"].Value().(string) - if handle == "" { - p.Close() - return nil, "", errors.New("CreateSession: no session_handle") - } - p.session = dbus.ObjectPath(handle) - - opts := map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(token("req")), - "types": dbus.MakeVariant(uint32(1)), // monitor - "multiple": dbus.MakeVariant(false), - "cursor_mode": dbus.MakeVariant(uint32(2)), // embedded - "persist_mode": dbus.MakeVariant(uint32(2)), // until revoked - } - if restoreToken != "" { - opts["restore_token"] = dbus.MakeVariant(restoreToken) - } - if _, err := portalRequest(ctx, conn, sender, portal, scIface+".SelectSources", p.session, opts); err != nil { - p.Close() - return nil, "", fmt.Errorf("SelectSources: %w", err) - } - - res, err = portalRequest(ctx, conn, sender, portal, scIface+".Start", p.session, "", map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(token("req")), - }) - if err != nil { - p.Close() - return nil, "", fmt.Errorf("Start: %w", err) - } - newToken, _ := res["restore_token"].Value().(string) - // streams: a(ua{sv}) - streams, ok := res["streams"].Value().([][]any) - if !ok || len(streams) == 0 || len(streams[0]) == 0 { - p.Close() - return nil, "", errors.New("Start: no streams") - } - if p.node, ok = streams[0][0].(uint32); !ok { - p.Close() - return nil, "", errors.New("Start: bad stream node") - } - - var fd dbus.UnixFD - if err := portal.CallWithContext(ctx, scIface+".OpenPipeWireRemote", 0, p.session, - map[string]dbus.Variant{}).Store(&fd); err != nil { - p.Close() - return nil, "", fmt.Errorf("OpenPipeWireRemote: %w", err) - } - p.fd = os.NewFile(uintptr(fd), "pipewire-remote") - return p, newToken, nil -} - -func token(prefix string) string { - return fmt.Sprintf("cast_%s_%d", prefix, tokenSeq.Add(1)) -} - -// portalRequest calls a portal method returning a Request and waits for its -// Response. The options map, carrying handle_token, must be the last argument. -func portalRequest(ctx context.Context, conn *dbus.Conn, sender string, portal dbus.BusObject, method string, args ...any) (map[string]dbus.Variant, error) { - opts := args[len(args)-1].(map[string]dbus.Variant) - tok := opts["handle_token"].Value().(string) - path := dbus.ObjectPath(fmt.Sprintf("%s/request/%s/%s", portalPath, sender, tok)) - match := []dbus.MatchOption{dbus.WithMatchObjectPath(path), dbus.WithMatchInterface(reqIface), dbus.WithMatchMember("Response")} - // Subscribe before calling so a fast Response isn't missed. - if err := conn.AddMatchSignal(match...); err != nil { - return nil, err - } - defer conn.RemoveMatchSignal(match...) - ch := make(chan *dbus.Signal, 4) - conn.Signal(ch) - defer conn.RemoveSignal(ch) - - if call := portal.CallWithContext(ctx, method, 0, args...); call.Err != nil { - return nil, call.Err - } - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case sig := <-ch: - if sig.Path != path || len(sig.Body) < 2 { - continue - } - if code, _ := sig.Body[0].(uint32); code != 0 { - return nil, fmt.Errorf("denied or cancelled (response %d)", code) - } - results, _ := sig.Body[1].(map[string]dbus.Variant) - return results, nil - } - } -} diff --git a/helpers/cast/queue.go b/helpers/cast/queue.go deleted file mode 100644 index b706bf7..0000000 --- a/helpers/cast/queue.go +++ /dev/null @@ -1,819 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "log" - "net" - "net/url" - "os" - "os/exec" - "path" - "path/filepath" - "strconv" - "strings" - "sync" - "time" -) - -// mediaSession plays a queue of files and links on the cast device: a single -// file is a queue of one, a slideshow a queue with an interval. The device -// fetches and decodes each item itself; the helper only prepares what the -// device can't take as it is (MKV, too large or unsupported video, huge -// photos, HEIC), one or two items ahead so the next one shows at once. -// A YouTube link plays alone, in the device's YouTube app. -type mediaSession struct { - h *helper - dev device - opts queueOptions - - ctx context.Context - cancel context.CancelFunc - done chan struct{} - cmds chan command - ready chan int // an item finished preparing - - mu sync.Mutex - items []*queueItem - index int - paused bool // a slideshow's auto-advance is held - phase string - st playerStatus - subsOn bool - volume float64 - canVolume bool - cast *castConn - transport string - requestID int64 - loadReq int64 // the LOAD whose answer names the media session - working bool // the preparing goroutine runs - srv *server - stopMpris func() - player *Player -} - -type queueOptions struct { - Interval int // seconds per photo; 0 = advance by hand - Loop bool // start over after the last item - ShrinkPhotos bool // compress photos above 4K to 4K JPEG; off sends originals - ConvertVideos bool // convert to formats the device plays: video above 4K or in other codecs, HEIC - Port int // file server port - StartAt float64 // position in the first item, for a moved cast -} - -type queueItem struct { - source string // absolute path or http(s) link - title string - artist string // songs - kind string // image, video, audio, link, youtube - status string // waiting, preparing, ready, failed - progress float64 - media mediaInfo - files []string // temporary files to delete - err error - code string -} - -func newQueueItem(source string) *queueItem { - it := &queueItem{source: source, title: filepath.Base(source), status: "waiting"} - if u, err := url.Parse(source); err == nil && u.Host != "" { - it.title = u.Host - if base := path.Base(u.Path); base != "/" && base != "." { - it.title = base - } - } - ct := typeByExt(source) - switch { - case isLink(source): - it.kind = "link" - if _, ok := parseYouTube(source); ok { - it.kind = "youtube" - } - case isHEIC(source), strings.HasPrefix(ct, "image/"): - it.kind = "image" - case strings.HasPrefix(ct, "audio/"): - it.kind = "audio" - default: - it.kind = "video" - } - return it -} - -func newMediaSession(h *helper, d device, sources []string, index int, opts queueOptions) *mediaSession { - ctx, cancel := context.WithCancel(context.Background()) - m := &mediaSession{h: h, dev: d, opts: opts, ctx: ctx, cancel: cancel, done: make(chan struct{}), - cmds: make(chan command, 8), ready: make(chan int, 8), phase: "starting", index: index} - for _, s := range sources { - m.items = append(m.items, newQueueItem(s)) - } - if index < 0 || index >= len(m.items) { - m.index = 0 - } - return m -} - -func (m *mediaSession) deviceID() string { return m.dev.ID } - -func (m *mediaSession) status() map[string]any { - m.mu.Lock() - defer m.mu.Unlock() - cur := m.items[m.index] - title := cur.title - if m.st.Title != "" && (cur.kind == "youtube" || cur.kind == "link") { - title = m.st.Title - } - at := int64(0) - if !m.st.at.IsZero() { - at = m.st.at.UnixMilli() - } - queue := make([]map[string]any, len(m.items)) - for i, it := range m.items { - q := map[string]any{"title": it.title, "artist": it.artist, "kind": it.kind, "status": it.status, "progress": it.progress} - if it.status == "failed" { - q["error"] = it.code - var miss *missingError - if errors.As(it.err, &miss) { - q["feature"], q["missing"] = miss.feature, miss.missing - } - } - queue[i] = q - } - interval := m.opts.Interval - if m.paused { - interval = 0 - } - return map[string]any{"kind": "media", "device": m.dev.ID, "name": m.dev.Name, "phase": m.phase, - "title": title, "progress": cur.progress, "playerState": m.st.PlayerState, "position": m.st.CurrentTime, - "positionAt": at, "duration": m.st.Duration, "rate": max(m.st.Rate, 1), - "subtitles": cur.media.subURL != "", "subtitlesOn": m.subsOn, "volume": m.volume, "canVolume": m.canVolume, - "queue": queue, "index": m.index, "interval": interval, "paused": m.paused, "loop": m.opts.Loop} -} - -// position is where the current item is now, for moving the cast. -func (m *mediaSession) position() (index int, at float64) { - m.mu.Lock() - defer m.mu.Unlock() - at = m.st.CurrentTime - if m.st.PlayerState == "PLAYING" && !m.st.at.IsZero() { - at += time.Since(m.st.at).Seconds() * max(m.st.Rate, 1) - } - return m.index, at -} - -func (m *mediaSession) sources() []string { - m.mu.Lock() - defer m.mu.Unlock() - s := make([]string, len(m.items)) - for i, it := range m.items { - s[i] = it.source - } - return s -} - -func (m *mediaSession) setPhase(p string) { - m.mu.Lock() - m.phase = p - m.mu.Unlock() - m.h.emitState() -} - -// Stop ends playback; run closes the media app on the device and cleans up. -func (m *mediaSession) Stop() { m.cancel() } - -func (m *mediaSession) Wait(d time.Duration) { - select { - case <-m.done: - case <-time.After(d): - } -} - -func (m *mediaSession) SetVolume(level float64) { - m.mu.Lock() - c, can := m.cast, m.canVolume - m.requestID++ - id := m.requestID - m.mu.Unlock() - if c != nil && can { - c.send(platformID, nsReceiver, map[string]any{"type": "SET_VOLUME", "volume": map[string]any{"level": level}, "requestId": id}) - } -} - -// control hands a command to the run loop, which owns the queue. -func (m *mediaSession) control(c command) { - select { - case m.cmds <- c: - case <-m.ctx.Done(): - } -} - -// send issues a media command for the loaded media. -func (m *mediaSession) send(typ string, extra map[string]any) { - m.mu.Lock() - c, tr, sid := m.cast, m.transport, m.st.SessionID - m.requestID++ - id := m.requestID - m.mu.Unlock() - if c == nil || sid == 0 { - return - } - req := map[string]any{"type": typ, "mediaSessionId": sid, "requestId": id} - for k, v := range extra { - req[k] = v - } - c.send(tr, nsMedia, req) -} - -func (m *mediaSession) run() (code string, err error) { - defer close(m.done) - var undo cleanups - defer func() { undo.run() }() - defer func() { - m.mu.Lock() - for _, it := range m.items { - for _, f := range it.files { - os.Remove(f) - } - } - m.mu.Unlock() - }() - stopped := func(c string, e error) (string, error) { - if m.ctx.Err() != nil { - return "", nil - } - return c, e - } - - if !m.dev.has("chromecast") { - return "airplayNoMedia", errors.New("files and links play over Chromecast only") - } - youtube := len(m.items) == 1 && m.items[0].kind == "youtube" - if !youtube { - for _, it := range m.items { - if it.kind == "youtube" { - it.status, it.code, it.err = "failed", "youtubeInQueue", errors.New("YouTube links play alone") - } - } - needsServer := false - for _, it := range m.items { - needsServer = needsServer || (it.kind != "link" && it.kind != "youtube") - } - if needsServer { - ip, err := sourceIP(m.dev.Host) - if err != nil { - return "deviceLost", err - } - srv, err := newServer(net.JoinHostPort(ip, strconv.Itoa(m.opts.Port)), m.dev.Host) - if err != nil { - return "portInUse", err - } - undo.add(srv.close) - m.srv = srv - } - // Start preparing while the device app launches. - m.prepareAhead() - } - - cast, err := dialCast(net.JoinHostPort(m.dev.Host, strconv.Itoa(m.dev.Port))) - if err != nil { - return stopped("deviceLost", err) - } - undo.add(cast.Close) - m.mu.Lock() - m.cast = cast - m.mu.Unlock() - app := defaultMediaAppID - if youtube { - app = youtubeAppID - } - transport, sessionID, st, code, err := launchApp(m.ctx, cast, app, func() { m.setPhase("authorizing") }) - if err != nil { - return stopped(code, err) - } - m.updateVolume(st) - undo.add(func() { - cast.send(platformID, nsReceiver, map[string]any{"type": "STOP", "sessionId": sessionID, "requestId": 2}) - time.Sleep(200 * time.Millisecond) - }) - m.mu.Lock() - m.transport = transport - m.requestID = 10 - m.mu.Unlock() - m.stopMpris = m.startMpris() - undo.add(func() { m.stopMpris() }) - - if youtube { - m.items[0].status = "ready" - m.mu.Lock() - m.st.follow = true - m.mu.Unlock() - yt, _ := parseYouTube(m.items[0].source) - yt.start = max(yt.start, int(m.opts.StartAt)) - if err := playOnYouTube(m.ctx, cast, transport, yt); err != nil { - return stopped("youtubeFailed", err) - } - cast.send(transport, nsMedia, map[string]any{"type": "GET_STATUS", "requestId": 9}) - } - - pending := -1 // an item waiting to be shown once it's prepared - var advance <-chan time.Time - var unreachable <-chan time.Time - show := func(i int) { - advance = nil - m.mu.Lock() - m.index = i - it := m.items[i] - status := it.status - m.mu.Unlock() - m.prune() - switch status { - case "ready": - pending = -1 - m.load(it) - if m.srv != nil && it.kind != "link" && unreachable == nil && !m.srv.fetched.Load() { - unreachable = time.After(10 * time.Second) - } - if it.kind == "image" && m.autoAdvance() { - advance = time.After(time.Duration(m.opts.Interval) * time.Second) - } - case "failed": - pending = -1 - m.h.emitError(it.code, it.err) - if len(m.items) > 1 { - advance = time.After(2 * time.Second) - } - default: - pending = i - m.setPhase("converting") - } - m.prepareAhead() - m.h.emitState() - } - // step moves by delta, wrapping when looping; it reports false at an end. - step := func(delta int) bool { - m.mu.Lock() - i, n := m.index+delta, len(m.items) - m.mu.Unlock() - if i < 0 || i >= n { - if !m.opts.Loop { - return false - } - i = (i + n) % n - } - show(i) - return true - } - if !youtube { - show(m.index) - if len(m.items) == 1 && m.items[0].status == "failed" { - return m.items[0].code, m.items[0].err - } - } - - for { - select { - case <-m.ctx.Done(): - return "", nil - case <-unreachable: - unreachable = nil - if !m.srv.fetched.Load() { - return stopped("serverUnreachable", fmt.Errorf("the device did not fetch anything from %s", m.srv.ln.Addr())) - } - case i := <-m.ready: - if i != pending { - continue - } - if len(m.items) == 1 && m.items[0].status == "failed" { - return stopped(m.items[0].code, m.items[0].err) - } - show(i) - case <-advance: - if !step(1) { - advance = nil - } - case c := <-m.cmds: - switch c.Cmd { - case "next": - step(1) - case "previous": - step(-1) - case "jump": - if c.Index >= 0 && c.Index < len(m.items) { - show(c.Index) - } - case "slideshow": - m.mu.Lock() - m.opts.Interval, m.paused = c.Interval, false - cur := m.items[m.index] - m.mu.Unlock() - advance = nil - if cur.kind == "image" && m.autoAdvance() { - advance = time.After(time.Duration(c.Interval) * time.Second) - } - m.h.emitState() - case "pause": - m.mu.Lock() - m.paused = true - m.mu.Unlock() - advance = nil - m.send("PAUSE", nil) - m.h.emitState() - case "resume": - m.mu.Lock() - m.paused = false - cur := m.items[m.index] - m.mu.Unlock() - m.send("PLAY", nil) - if cur.kind == "image" && m.autoAdvance() { - advance = time.After(time.Duration(m.opts.Interval) * time.Second) - } - m.h.emitState() - case "seek": - m.send("SEEK", map[string]any{"currentTime": max(c.Position, 0)}) - case "subtitles": - ids := []int{} - if c.On { - ids = []int{1} - } - m.mu.Lock() - m.subsOn = c.On - m.mu.Unlock() - m.send("EDIT_TRACKS_INFO", map[string]any{"activeTrackIds": ids}) - m.h.emitState() - } - case msg, ok := <-cast.msgs: - if !ok { - return stopped("deviceLost", fmt.Errorf("cast channel closed")) - } - switch { - case msg.ns == nsMedia: - m.mu.Lock() - // Until the LOAD is answered, other statuses belong to the - // media it replaced. - if m.st.SessionID == 0 && !m.st.follow && requestIDOf(msg.payload) != m.loadReq { - m.mu.Unlock() - continue - } - idle, err := m.st.update(msg.payload) - cur := m.items[m.index] - m.mu.Unlock() - m.player.update(m) - if err != nil { - if len(m.items) == 1 { - return stopped("loadFailed", err) - } - m.h.emitError("loadFailed", fmt.Errorf("%s: %w", cur.title, err)) - advance = time.After(2 * time.Second) - continue - } - switch { - case idle == "CANCELLED": - return "", nil // stopped on the device - case idle == "FINISHED" && youtube: - // The YouTube app idles between playlist videos. - case idle == "FINISHED" && cur.kind != "image": - if !step(1) { - if len(m.items) == 1 { - return "", nil - } - m.setPhase("casting") - } - continue - } - m.setPhase("casting") - case msg.ns == nsReceiver && typeOf(msg.payload) == "RECEIVER_STATUS": - st := parseReceiverStatus(msg.payload) - m.updateVolume(st) - running := false - for _, a := range st.Status.Applications { - running = running || a.SessionID == sessionID - } - if !running { - return stopped("receiverStopped", fmt.Errorf("the media app was closed on the device")) - } - case msg.ns == nsConnection && msg.src == transport && typeOf(msg.payload) == "CLOSE": - return stopped("receiverStopped", fmt.Errorf("the device closed the media session")) - } - } - } -} - -func (m *mediaSession) autoAdvance() bool { - m.mu.Lock() - defer m.mu.Unlock() - return m.opts.Interval > 0 && !m.paused && len(m.items) > 1 -} - -// load sends a prepared item to the device. -func (m *mediaSession) load(it *queueItem) { - m.mu.Lock() - m.requestID++ - id := m.requestID - m.loadReq = id - m.st = playerStatus{} - m.subsOn = it.media.subURL != "" - start := m.opts.StartAt - m.opts.StartAt = 0 - c, tr := m.cast, m.transport - m.mu.Unlock() - c.send(tr, nsMedia, loadRequest(it.media, id, start)) -} - -func requestIDOf(p []byte) int64 { - var m struct { - RequestID int64 `json:"requestId"` - } - json.Unmarshal(p, &m) - return m.RequestID -} - -// prepareAhead keeps the current item and the next ones prepared, one at a -// time: the current one first, then the next video or the next two photos. -func (m *mediaSession) prepareAhead() { - m.mu.Lock() - if m.working || m.srv == nil && !m.hasLinks() { - m.mu.Unlock() - return - } - m.working = true - m.mu.Unlock() - go func() { - for { - m.mu.Lock() - i := m.nextToPrepare() - if i < 0 || m.ctx.Err() != nil { - m.working = false - m.mu.Unlock() - return - } - it := m.items[i] - it.status = "preparing" - m.mu.Unlock() - m.h.emitState() - - err := m.prepare(i, it) - m.mu.Lock() - if err != nil { - it.status, it.err = "failed", err - var miss *missingError - switch { - case errors.As(err, &miss): - it.code = "missingDependency" - case it.code == "": - it.code = "unsupportedFile" - } - } else { - it.status = "ready" - } - m.mu.Unlock() - if err != nil { - log.Printf("preparing %s: %v", it.source, err) - } - m.h.emitState() - select { - case m.ready <- i: - case <-m.ctx.Done(): - return - } - } - }() -} - -func (m *mediaSession) hasLinks() bool { - for _, it := range m.items { - if it.kind == "link" { - return true - } - } - return false -} - -// nextToPrepare picks the first waiting item in the look-ahead window. -// Called with mu held. -func (m *mediaSession) nextToPrepare() int { - n := len(m.items) - for ahead := 0; ahead <= 2 && ahead < n; ahead++ { - i := m.index + ahead - if i >= n { - if !m.opts.Loop { - break - } - i %= n - } - it := m.items[i] - if ahead == 2 && it.kind != "image" { - break // converting a video two ahead would compete with the current one - } - if it.status == "waiting" { - return i - } - } - return -1 -} - -// prune frees the prepared copies of items left well behind, so a long -// slideshow doesn't fill the cache. -func (m *mediaSession) prune() { - m.mu.Lock() - defer m.mu.Unlock() - n := len(m.items) - for i, it := range m.items { - // When looping, the items at the start are also the ones coming up. - far := m.index-i > 2 - if m.opts.Loop { - behind := (m.index - i + n) % n - far = behind > 2 && behind < n-2 - } - if far && it.status == "ready" && len(it.files) > 0 { - for _, f := range it.files { - os.Remove(f) - } - it.files, it.status = nil, "waiting" - } - } -} - -func (m *mediaSession) setItemProgress(it *queueItem) func(float64) { - return func(p float64) { - m.mu.Lock() - it.progress = p - m.mu.Unlock() - m.h.emitState() - } -} - -// prepare makes one item ready to load: its URL on the file server, after -// any conversion the device needs. -func (m *mediaSession) prepare(i int, it *queueItem) error { - if it.kind == "link" { - it.media = mediaInfo{url: it.source, contentType: remoteType(m.ctx, it.source), title: it.title} - return nil - } - if _, err := os.Stat(it.source); err != nil { - return err - } - var file, ct string - var err error - if it.kind == "image" { - file, ct, err = m.prepareImage(it) - } else { - file, ct, err = m.prepareVideo(it) - } - if err != nil { - return err - } - it.media = mediaInfo{url: m.srv.add(fmt.Sprintf("%d%s", i, path.Ext(file)), file, ct), contentType: ct, title: it.title} - if it.kind == "audio" { - song := probeSong(it.source) - if song.title != "" { - it.title = song.title - } - it.artist = song.artist - it.media.title, it.media.artist, it.media.album = it.title, song.artist, song.album - switch cover := folderCover(it.source); { - case song.cover != nil: - it.media.coverURL = m.srv.addBytes(fmt.Sprintf("%d-cover", i), song.cover, song.coverType) - case cover != "": - it.media.coverURL = m.srv.add(fmt.Sprintf("%d-cover%s", i, path.Ext(cover)), cover, typeByExt(cover)) - } - } - if it.kind == "video" { - if sub := sidecarSubtitle(it.source); sub != "" { - if vtt, err := loadSubtitle(sub); err == nil { - it.media.subURL, it.media.subLang = m.srv.addBytes(fmt.Sprintf("%d.vtt", i), vtt, "text/vtt"), "und" - } - } - } - return nil -} - -// prepareVideo converts what the device can't play, remuxes MKV so it can -// seek, and passes everything else through untouched. -func (m *mediaSession) prepareVideo(it *queueItem) (string, string, error) { - file, ct := it.source, typeByExt(it.source) - if it.kind == "video" { - v, err := probeVideo(file) - switch { - case err != nil: - log.Printf("probe %s: %v", file, err) - case v.needsConvert() && !m.opts.ConvertVideos: - return "", "", fmt.Errorf("%dx%d %s needs converting, which is off", v.width, v.height, v.codec) - case v.needsConvert(): - if err := need("convert"); err != nil { - return "", "", err - } - out, err := convertVideo(m.ctx, file, v, m.setItemProgress(it)) - if out != "" { - it.files = append(it.files, out, out+".part") - } - return out, "video/mp4", err - } - } - if isMatroska(file) { - if err := need("mkv"); err != nil { - return "", "", err - } - out, err := remuxMKV(m.ctx, file, "auto", -1) - if out != "" { - it.files = append(it.files, out, out+".part") - } - return out, "video/mp4", err - } - if ct == "" { - return "", "", fmt.Errorf("unknown file type %q", filepath.Ext(file)) - } - return file, ct, nil -} - -// prepareImage decodes HEIC and shrinks photos larger than 4K, which the -// device would otherwise download and scale itself, slowly. -func (m *mediaSession) prepareImage(it *queueItem) (string, string, error) { - file := it.source - if isHEIC(file) { - if !m.opts.ConvertVideos { - return "", "", errors.New("HEIC needs converting, which is off") - } - if err := need("heic"); err != nil { - return "", "", err - } - out, err := cacheFile(fmt.Sprintf("%p.jpg", it)) - if err != nil { - return "", "", err - } - it.files = append(it.files, out) - ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second) - defer cancel() - tool := heifTool() - if b, err := exec.CommandContext(ctx, tool, "-q", "90", file, out).CombinedOutput(); err != nil { - return "", "", fmt.Errorf("%s: %v: %s", tool, err, strings.TrimSpace(string(b))) - } - file = out - } - ct := typeByExt(file) - if !m.opts.ShrinkPhotos || !needsShrink(file) { - return file, ct, nil - } - if err := need("photos"); err != nil { - log.Printf("sending %s as it is: %v", file, err) - return file, ct, nil - } - out, err := cacheFile(fmt.Sprintf("%p-4k.jpg", it)) - if err != nil { - return "", "", err - } - it.files = append(it.files, out) - // videoflip applies the camera's orientation tag; the caps let videoscale - // pick the largest size within 4K that keeps the aspect ratio. - err = runGst(m.ctx, "filesrc name=src ! decodebin ! videoflip video-direction=auto ! videoconvert ! videoscale ! "+ - "video/x-raw,width=[1,3840],height=[1,2160],pixel-aspect-ratio=1/1 ! jpegenc quality=90 snapshot=true ! filesink name=sink", - map[string]map[string]string{"src": {"location": file}, "sink": {"location": out}}, nil) - if err != nil { - return "", "", err - } - return out, "image/jpeg", nil -} - -// needsShrink: photos above 4K, or big files, load slowly on the device. -func needsShrink(file string) bool { - st, err := os.Stat(file) - if err != nil { - return false - } - f, err := os.Open(file) - if err != nil { - return false - } - defer f.Close() - cfg, _, err := image.DecodeConfig(f) - if err != nil { - return st.Size() > 4<<20 // WebP and other formats Go doesn't read - } - return cfg.Width*cfg.Height > maxPixels || st.Size() > 4<<20 -} - -func isHEIC(file string) bool { - switch strings.ToLower(filepath.Ext(file)) { - case ".heic", ".heif": - return true - } - return false -} - -func (m *mediaSession) updateVolume(st receiverStatus) { - v := st.Status.Volume - if v == nil { - return - } - m.mu.Lock() - changed := m.volume != v.Level || m.canVolume != (v.ControlType != "fixed") - m.volume, m.canVolume = v.Level, v.ControlType != "fixed" - m.mu.Unlock() - if changed { - m.h.emitState() - } -} diff --git a/helpers/cast/queue_test.go b/helpers/cast/queue_test.go deleted file mode 100644 index d563a1b..0000000 --- a/helpers/cast/queue_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import "testing" - -func TestLookAhead(t *testing.T) { - m := newMediaSession(nil, device{}, []string{"/a.jpg", "/b.jpg", "/c.mp4", "/d.jpg", "/e.mp4"}, 0, queueOptions{}) - order := func() (got []int) { - for i := m.nextToPrepare(); i >= 0; i = m.nextToPrepare() { - got = append(got, i) - m.items[i].status = "ready" - } - return got - } - // The current photo, the next photo, and a video two ahead is skipped. - if got := order(); len(got) != 2 || got[0] != 0 || got[1] != 1 { - t.Errorf("from 0: %v", got) - } - m.index = 3 - m.items[0].files = []string{"/nonexistent"} - if got := order(); len(got) != 2 || got[0] != 3 || got[1] != 4 { - t.Errorf("from 3: %v", got) - } - // Items more than two behind give their prepared copies back. - m.prune() - if m.items[0].status != "waiting" || m.items[1].status != "ready" && m.items[1].files != nil { - t.Errorf("prune: %s %s", m.items[0].status, m.items[1].status) - } - // Looping wraps the look-ahead around the end. - m.opts.Loop, m.index = true, 4 - m.items[0].status = "waiting" - if got := order(); len(got) != 1 || got[0] != 0 { - t.Errorf("loop from 4: %v", got) - } -} diff --git a/helpers/cast/remux.go b/helpers/cast/remux.go deleted file mode 100644 index b5b0e3b..0000000 --- a/helpers/cast/remux.go +++ /dev/null @@ -1,398 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "log" - "os" - "path/filepath" - "regexp" - "strings" - "time" - "unicode/utf8" - "unsafe" -) - -// EBML element IDs, with their length marker bits kept. -const ( - ebmlHeader = 0x1A45DFA3 - ebmlDocType = 0x4282 - mkvSegment = 0x18538067 - mkvTracks = 0x1654AE6B - mkvEntry = 0xAE - mkvType = 0x83 - mkvCodec = 0x86 - mkvDefault = 0x88 - mkvLanguage = 0x22B59C - mkvName = 0x536E - mkvTypeVideo = 1 - mkvTypeAudio = 2 - mkvTypeText = 17 -) - -type mkvTrack struct { - kind int // 1 video, 2 audio, 17 subtitle - codec string - language string - name string - isDef bool -} - -type mkvInfo struct { - docType string - tracks []mkvTrack -} - -func isMatroska(file string) bool { - info, err := probeMKV(file) - return err == nil && info.docType == "matroska" -} - -// probeMKV reads the EBML header and the Tracks element, which muxers put -// before the clusters. -func probeMKV(file string) (mkvInfo, error) { - f, err := os.Open(file) - if err != nil { - return mkvInfo{}, err - } - defer f.Close() - return parseMKV(bufio.NewReader(io.LimitReader(f, 64<<20))) -} - -func parseMKV(r *bufio.Reader) (mkvInfo, error) { - var info mkvInfo - id, size, err := ebmlElement(r) - if err != nil || id != ebmlHeader { - return info, errors.New("not an EBML file") - } - head := make([]byte, size) - if _, err := io.ReadFull(r, head); err != nil { - return info, err - } - hr := bufio.NewReader(bytes.NewReader(head)) - for { - id, size, err := ebmlElement(hr) - if err != nil { - break - } - v := make([]byte, size) - io.ReadFull(hr, v) - if id == ebmlDocType { - info.docType = string(v) - } - } - if id, _, err = ebmlElement(r); err != nil || id != mkvSegment { - return info, errors.New("no segment") - } - // Walk the segment's top-level children, skipping everything but Tracks. - for { - id, size, err := ebmlElement(r) - if err != nil { - return info, errors.New("no tracks element") - } - if id != mkvTracks { - if _, err := r.Discard(int(size)); err != nil { - return info, err - } - continue - } - body := make([]byte, size) - if _, err := io.ReadFull(r, body); err != nil { - return info, err - } - info.tracks = parseTracks(body) - return info, nil - } -} - -func parseTracks(body []byte) []mkvTrack { - var tracks []mkvTrack - r := bufio.NewReader(bytes.NewReader(body)) - for { - id, size, err := ebmlElement(r) - if err != nil { - return tracks - } - entry := make([]byte, size) - io.ReadFull(r, entry) - if id != mkvEntry { - continue - } - t := mkvTrack{language: "eng", isDef: true} // spec defaults - er := bufio.NewReader(bytes.NewReader(entry)) - for { - id, size, err := ebmlElement(er) - if err != nil { - break - } - v := make([]byte, size) - io.ReadFull(er, v) - switch id { - case mkvType: - t.kind = int(uintBE(v)) - case mkvCodec: - t.codec = string(v) - case mkvDefault: - t.isDef = uintBE(v) == 1 - case mkvLanguage: - t.language = strings.TrimRight(string(v), "\x00") - case mkvName: - t.name = string(v) - } - } - tracks = append(tracks, t) - } -} - -// ebmlElement reads an element ID and its data size. An unknown size (all -// ones) comes back as -1, which only live streams use for the segment. -func ebmlElement(r *bufio.Reader) (id uint64, size int64, err error) { - id, n, err := ebmlVint(r) - if err != nil { - return 0, 0, err - } - id |= 1 << (7 * n) // IDs keep their marker bit - sz, n, err := ebmlVint(r) - if err != nil { - return 0, 0, err - } - if sz == 1<<(7*n)-1 { - return id, -1, nil - } - if sz > 1<<32 { - return 0, 0, errors.New("element too large") - } - return id, int64(sz), nil -} - -// ebmlVint reads a variable-length integer and returns it without its marker -// bit, along with its length in bytes. -func ebmlVint(r *bufio.Reader) (uint64, int, error) { - b, err := r.ReadByte() - if err != nil { - return 0, 0, err - } - n := 1 - for mask := byte(0x80); b&mask == 0; mask >>= 1 { - n++ - if n > 8 { - return 0, 0, errors.New("bad vint") - } - } - v := uint64(b) & (0xFF >> n) - for i := 1; i < n; i++ { - c, err := r.ReadByte() - if err != nil { - return 0, 0, err - } - v = v<<8 | uint64(c) - } - return v, n, nil -} - -func uintBE(b []byte) uint64 { - var v uint64 - for _, c := range b { - v = v<<8 | uint64(c) - } - return v -} - -// remuxPipeline builds a GStreamer pipeline that copies the first video -// track and one audio track of an MKV into an MP4. Matroska carries no -// decode timestamps, so H.264/HEVC with B-frames need the timestamper or -// mp4mux writes a broken timeline. Audio the TV can't take in MP4 is -// re-encoded to AAC; video never is. -func remuxPipeline(info mkvInfo, audioMode string, audioTrack int) (string, error) { - var video string - var audios []mkvTrack - for _, t := range info.tracks { - switch t.kind { - case mkvTypeAudio: - audios = append(audios, t) - case mkvTypeVideo: - if video != "" { - continue - } - switch t.codec { - case "V_MPEG4/ISO/AVC": - video = "h264parse ! h264timestamper" - case "V_MPEGH/ISO/HEVC": - video = "h265parse ! h265timestamper" - case "V_VP9": - video = "vp9parse" - default: - return "", fmt.Errorf("video codec %s would need re-encoding", t.codec) - } - } - } - if video == "" { - return "", errors.New("no video track") - } - pick := audioTrack - if pick >= len(audios) { - return "", fmt.Errorf("there are only %d audio tracks", len(audios)) - } - if pick < 0 && len(audios) > 0 { - pick = 0 - for i, t := range audios { - if t.isDef { - pick = i - break - } - } - } - p := "filesrc name=src ! matroskademux name=d d.video_0 ! queue ! " + video + " ! mux.video_0 " - if pick >= 0 { - pickCodec := audios[pick].codec - audio := "decodebin ! audioconvert ! audioresample ! avenc_aac bitrate=256000" - if audioMode != "aac" { - switch { - case strings.HasPrefix(pickCodec, "A_AAC"): - audio = "aacparse" - case pickCodec == "A_MPEG/L3": - audio = "mpegaudioparse" - case pickCodec == "A_OPUS": - audio = "opusparse" - case pickCodec == "A_AC3", pickCodec == "A_EAC3": - audio = "ac3parse" - } - } - p += fmt.Sprintf("d.audio_%d ! queue ! %s ! mux.audio_0 ", pick, audio) - } - return p + "mp4mux name=mux faststart=true ! filesink name=sink", nil -} - -// remuxMKV writes an MP4 copy of file into the cache directory, so it can be -// served with Range requests and the TV can seek. -func remuxMKV(ctx context.Context, file, audioMode string, audioTrack int) (string, error) { - info, err := probeMKV(file) - if err != nil { - return "", err - } - for _, t := range info.tracks { - log.Printf("mkv track: type=%d codec=%s lang=%s default=%v %s", t.kind, t.codec, t.language, t.isDef, t.name) - } - desc, err := remuxPipeline(info, audioMode, audioTrack) - if err != nil { - return "", err - } - cache, err := os.UserCacheDir() - if err != nil { - return "", err - } - dir := filepath.Join(cache, "dms", "cast") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", err - } - out := filepath.Join(dir, fmt.Sprintf("%d.mp4", os.Getpid())) - start := time.Now() - log.Printf("remuxing to %s: %s", out, desc) - if err := runGst(ctx, desc, map[string]map[string]string{ - "src": {"location": file}, - "sink": {"location": out}, - // Default is /tmp, often a RAM disk too small for a movie. - "mux": {"faststart-file": out + ".part"}, - }, nil); err != nil { - os.Remove(out) - os.Remove(out + ".part") - return "", err - } - st, _ := os.Stat(out) - log.Printf("remuxed %d MB in %s", st.Size()>>20, time.Since(start).Round(time.Millisecond)) - return out, nil -} - -// runGst runs a pipeline to EOS, or until ctx ends. Paths are set as -// properties after parsing, so they never pass through the pipeline syntax. -func runGst(ctx context.Context, desc string, props map[string]map[string]string, progress func(float64)) error { - if err := loadGst(); err != nil { - return fmt.Errorf("loading GStreamer: %w", err) - } - var gerr *gError - pipe := gstParseLaunch(desc, unsafe.Pointer(&gerr)) - if pipe == 0 || gerr != nil { - return fmt.Errorf("pipeline: %s", gerrorMessage(gerr)) - } - defer gstObjectUnref(pipe) - for name, kv := range props { - el := gstBinGetByName(pipe, name) - if el == 0 { - return fmt.Errorf("no element %s", name) - } - for k, v := range kv { - gstUtilSetObjectArg(el, k, v) - } - gstObjectUnref(el) - } - defer gstElementSetState(pipe, gstStateNull) - if gstElementSetState(pipe, gstStatePlaying) == 0 { - return errors.New("pipeline refused to start") - } - bus := gstElementGetBus(pipe) - defer gstObjectUnref(bus) - for ctx.Err() == nil { - msg := gstBusTimedPopFiltered(bus, uint64(500*time.Millisecond), gstMsgEOS|gstMsgError) - if msg == 0 { - var pos, dur int64 - if progress != nil && gstElementQueryPosition(pipe, 3, &pos) && gstElementQueryDuration(pipe, 3, &dur) && dur > 0 { - progress(float64(pos) / float64(dur)) - } - continue - } - defer gstMiniObjectUnref(msg) - if gstMessageGetStructure(msg) == 0 { - return nil // EOS - } - var debug uintptr - gstMessageParseError(msg, unsafe.Pointer(&gerr), unsafe.Pointer(&debug)) - return errors.New(gerrorMessage(gerr)) - } - return ctx.Err() -} - -var srtTime = regexp.MustCompile(`(\d+:\d{2}:\d{2}),(\d{3})`) -var srtFont = regexp.MustCompile(`(?i)]*>`) - -// loadSubtitle returns a subtitle file as WebVTT. -func loadSubtitle(file string) ([]byte, error) { - b, err := os.ReadFile(file) - if err != nil { - return nil, err - } - if strings.EqualFold(filepath.Ext(file), ".vtt") { - return b, nil - } - return srtToVTT(b), nil -} - -// srtToVTT converts SubRip to WebVTT: a header, dots in timestamps, and no -// tags, which VTT doesn't have. , and are valid in both. -func srtToVTT(b []byte) []byte { - b = bytes.TrimPrefix(b, []byte("\xEF\xBB\xBF")) - if !utf8.Valid(b) { - // ponytail: assumes Latin-1 for non-UTF-8 files; Windows-1252 differs in 0x80-0x9F. - r := make([]rune, len(b)) - for i, c := range b { - r[i] = rune(c) - } - b = []byte(string(r)) - } - s := strings.ReplaceAll(string(b), "\r\n", "\n") - var out strings.Builder - out.WriteString("WEBVTT\n\n") - for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { - if strings.Contains(line, "-->") { - line = srtTime.ReplaceAllString(line, "$1.$2") - } else { - line = srtFont.ReplaceAllString(line, "") - } - out.WriteString(line) - out.WriteByte('\n') - } - return []byte(out.String()) -} diff --git a/helpers/cast/remux_test.go b/helpers/cast/remux_test.go deleted file mode 100644 index cdf4d45..0000000 --- a/helpers/cast/remux_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "strings" - "testing" -) - -// el encodes one EBML element; ids are given with their marker bits. -func el(id uint64, body ...[]byte) []byte { - var b []byte - for s := 56; s >= 0; s -= 8 { - if c := byte(id >> s); c != 0 || len(b) > 0 { - b = append(b, c) - } - } - data := bytes.Join(body, nil) - n := len(data) - b = append(b, 0x10|byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) // 4-byte vint - return append(b, data...) -} - -func TestParseMKVAndPipeline(t *testing.T) { - file := bytes.Join([][]byte{ - el(ebmlHeader, el(ebmlDocType, []byte("matroska"))), - el(mkvSegment, - el(0x1549A966, []byte{1, 2, 3}), // Info, skipped - el(mkvTracks, - el(mkvEntry, el(mkvType, []byte{1}), el(mkvCodec, []byte("V_MPEGH/ISO/HEVC"))), - el(mkvEntry, el(mkvType, []byte{2}), el(mkvCodec, []byte("A_DTS")), el(mkvDefault, []byte{0})), - el(mkvEntry, el(mkvType, []byte{2}), el(mkvCodec, []byte("A_EAC3")), el(mkvLanguage, []byte("ger"))), - el(mkvEntry, el(mkvType, []byte{17}), el(mkvCodec, []byte("S_TEXT/UTF8"))), - )), - }, nil) - info, err := parseMKV(bufio.NewReader(bytes.NewReader(file))) - if err != nil { - t.Fatal(err) - } - if info.docType != "matroska" || len(info.tracks) != 4 || info.tracks[2].language != "ger" || info.tracks[1].isDef { - t.Fatalf("parsed %+v", info) - } - - cases := []struct { - mode string - track int - want string - }{ - {"auto", -1, "d.audio_1 ! queue ! ac3parse"}, // the default track, E-AC-3 copied - {"auto", 0, "d.audio_0 ! queue ! decodebin"}, // DTS re-encoded - {"aac", -1, "d.audio_1 ! queue ! decodebin"}, // forced - } - for _, c := range cases { - p, err := remuxPipeline(info, c.mode, c.track) - if err != nil || !strings.Contains(p, c.want) || !strings.Contains(p, "h265parse ! h265timestamper") { - t.Errorf("%s/%d: %q %v", c.mode, c.track, p, err) - } - } - info.tracks[0].codec = "V_MS/VFW/FOURCC" - if _, err := remuxPipeline(info, "auto", -1); err == nil { - t.Error("VC-1 should be refused") - } -} - -func TestSRTToVTT(t *testing.T) { - in := "\xEF\xBB\xBF1\r\n00:00:01,000 --> 00:01:02,500\r\nHi there, 1,5\r\n" - want := "WEBVTT\n\n1\n00:00:01.000 --> 00:01:02.500\nHi there, 1,5\n" - if got := string(srtToVTT([]byte(in))); got != want { - t.Errorf("got %q", got) - } - if got := string(srtToVTT([]byte("1\n00:00:01,000 --> 00:00:02,000\nM\xfcller\n"))); !strings.Contains(got, "Müller") { - t.Errorf("latin-1: %q", got) - } -} diff --git a/helpers/cast/sender.go b/helpers/cast/sender.go deleted file mode 100644 index a457a5c..0000000 --- a/helpers/cast/sender.go +++ /dev/null @@ -1,378 +0,0 @@ -package main - -import ( - "crypto/aes" - "crypto/cipher" - "encoding/binary" - "fmt" - "log" - "math/rand/v2" - "net" - "sync" - "time" -) - -const ( - maxPacketSize = 1500 - 20 - 8 // IPv4 UDP on Ethernet - rtpHeaderSize = 12 + 7 // RTP header + Cast header - maxPayload = maxPacketSize - rtpHeaderSize - maxUnacked = 100 // frames in flight before deltas get dropped - ntpUnixOffset = 2208988800 -) - -type sentFrame struct { - id int64 - sent time.Time - packets [][]byte - lastSent []time.Time -} - -// stream sends one media stream in Cast Streaming's RTP profile: each frame is -// AES-128-CTR encrypted, split into packets and kept until the receiver's -// checkpoint passes it, so NACKed packets can be resent. -type stream struct { - name string - conn *net.UDPConn - ssrc uint32 - pt byte - rate int // RTP clock rate - block cipher.Block - ivMask [16]byte - start time.Time - isVideo bool - codec string - keyframe func() // asks the encoder for a key frame, may be nil - bitrate *bitrateControl // may be nil - - mu sync.Mutex - seq uint16 - nextID int64 - checkpoint int64 - frames map[int64]*sentFrame - waitKey bool - lastRTP uint32 - lastRTPTime time.Time - lastSend time.Time - packets uint32 - octets uint32 - acked, nack int - - lastStuckLog time.Time -} - -func newStream(name string, conn *net.UDPConn, ssrc uint32, pt byte, rate int, key, ivMask [16]byte, start time.Time, isVideo bool) *stream { - block, err := aes.NewCipher(key[:]) - if err != nil { - panic(err) - } - return &stream{ - name: name, conn: conn, ssrc: ssrc, pt: pt, rate: rate, block: block, ivMask: ivMask, - start: start, isVideo: isVideo, seq: uint16(rand.Uint32()), checkpoint: -1, - frames: map[int64]*sentFrame{}, - } -} - -// rtpNow is the RTP timestamp for the current moment on this stream's clock. -func (s *stream) rtpNow(now time.Time) uint32 { - return uint32(now.Sub(s.start).Seconds() * float64(s.rate)) -} - -func (s *stream) enqueue(data []byte, key bool, rtp uint32) { - s.mu.Lock() - defer s.mu.Unlock() - if s.isVideo { - if s.nextID-1-s.checkpoint > maxUnacked && !key { - if !s.waitKey { - log.Printf("%s: %d frames unacked, dropping until next key frame", s.name, s.nextID-1-s.checkpoint) - if s.keyframe != nil { - s.keyframe() - } - } - s.waitKey = true - } - if s.waitKey && !key { - return - } - if key { - s.waitKey = false - } - } - - id := s.nextID - s.nextID++ - ref := id - 1 - if key || !s.isVideo { - ref = id - } - - var nonce [16]byte - binary.BigEndian.PutUint32(nonce[8:], uint32(id)) - for i := range nonce { - nonce[i] ^= s.ivMask[i] - } - enc := make([]byte, len(data)) - cipher.NewCTR(s.block, nonce[:]).XORKeyStream(enc, data) - - n := max(1, (len(enc)+maxPayload-1)/maxPayload) - f := &sentFrame{id: id, sent: time.Now(), packets: make([][]byte, n), lastSent: make([]time.Time, n)} - for i := range n { - chunk := enc[i*maxPayload : min(len(enc), (i+1)*maxPayload)] - p := make([]byte, rtpHeaderSize, rtpHeaderSize+len(chunk)) - p[0] = 0x80 - p[1] = s.pt - if i == n-1 { - p[1] |= 0x80 - } - binary.BigEndian.PutUint32(p[4:], rtp) - binary.BigEndian.PutUint32(p[8:], s.ssrc) - p[12] = 0x40 // reference frame id present - if key { - p[12] |= 0x80 - } - p[13] = byte(id) - binary.BigEndian.PutUint16(p[14:], uint16(i)) - binary.BigEndian.PutUint16(p[16:], uint16(n-1)) - p[18] = byte(ref) - f.packets[i] = append(p, chunk...) - } - s.frames[id] = f - s.lastRTP, s.lastRTPTime = rtp, time.Now() - for i := range f.packets { - s.sendPacketLocked(f, i) - } -} - -// sendPacketLocked sends (or resends) one packet with a fresh sequence number. -func (s *stream) sendPacketLocked(f *sentFrame, i int) { - p := f.packets[i] - binary.BigEndian.PutUint16(p[2:], s.seq) - s.seq++ - if _, err := s.conn.Write(p); err != nil { - log.Printf("%s: send: %v", s.name, err) - } - now := time.Now() - f.lastSent[i] = now - s.lastSend = now - s.packets++ - s.octets += uint32(len(p) - rtpHeaderSize) -} - -// Feedback carries only the low 8 bits of frame ids. As in Open Screen, the -// checkpoint expands to the newest id at or below the last frame sent, and -// loss and ACK entries to the first id above the checkpoint. -func expandAtMost(low byte, limit int64) int64 { - id := limit&^0xff | int64(low) - if id > limit { - id -= 256 - } - return id -} - -func expandAbove(low byte, base int64) int64 { - id := base&^0xff | int64(low) - if id <= base { - id += 256 - } - return id -} - -func (s *stream) onFeedback(checkpoint byte, losses [][3]int, received []int64) { - s.mu.Lock() - defer s.mu.Unlock() - ck := expandAtMost(checkpoint, s.nextID-1) - if ck == s.checkpoint && time.Since(s.lastStuckLog) > time.Second && s.nextID-1 > ck { - s.lastStuckLog = time.Now() - _, have := s.frames[ck+1] - debugf("%s: stuck at checkpoint %d (next frame held: %v, newest %d), losses %v, also received %v", - s.name, ck, have, s.nextID-1, losses, received) - } - for _, off := range received { - if f := s.frames[ck+off]; f != nil { - delete(s.frames, ck+off) - } - } - for id := s.checkpoint + 1; id <= ck; id++ { - if _, ok := s.frames[id]; ok { - delete(s.frames, id) - s.acked++ - } - } - s.checkpoint = max(s.checkpoint, ck) - - now := time.Now() - for _, l := range losses { - f := s.frames[expandAbove(byte(l[0]), ck)] - if f == nil { - continue - } - resend := func(i int) { - // A NACK can cross a retransmission already in flight. - if i < len(f.packets) && now.Sub(f.lastSent[i]) > 20*time.Millisecond { - s.sendPacketLocked(f, i) - s.nack++ - } - } - if l[1] == 0xffff { - for i := range f.packets { - resend(i) - } - continue - } - resend(l[1]) - for bit := range 8 { - if l[2]&(1< s.checkpoint && now.Sub(s.lastSend) > 250*time.Millisecond { - s.sendPacketLocked(f, len(f.packets)-1) - } -} - -func (s *stream) stats() string { - s.mu.Lock() - defer s.mu.Unlock() - return fmt.Sprintf("%s: sent %d frames, acked %d, in flight %d, resent %d packets", - s.name, s.nextID, s.acked, s.nextID-1-s.checkpoint, s.nack) -} - -func ntp(t time.Time) uint64 { - secs := uint64(t.Unix()) + ntpUnixOffset - frac := uint64(t.Nanosecond()) << 32 / 1e9 - return secs<<32 | frac -} - -// readRTCP dispatches receiver feedback to the stream it is about. -func readRTCP(conn *net.UDPConn, streams map[uint32]*stream) { - buf := make([]byte, 2048) - for { - n, err := conn.Read(buf) - if err != nil { - log.Printf("rtcp: %v", err) - return - } - parseRTCP(buf[:n], streams) - } -} - -func parseRTCP(b []byte, streams map[uint32]*stream) { - for len(b) >= 4 { - if b[0]>>6 != 2 { - return - } - sub, pt := b[0]&0x1f, b[1] - size := (int(binary.BigEndian.Uint16(b[2:])) + 1) * 4 - if size > len(b) { - return - } - pkt := b[:size] - b = b[size:] - if pt != 206 || len(pkt) < 12 { - continue - } - s := streams[binary.BigEndian.Uint32(pkt[8:])] - if s == nil { - continue - } - switch sub { - case 1: - s.onPictureLoss() - case 15: - if len(pkt) < 20 || string(pkt[12:16]) != "CAST" { - continue - } - count := int(pkt[17]) - var losses [][3]int - off := 20 - for i := 0; i < count && off+4 <= len(pkt); i++ { - l := pkt[off:] - losses = append(losses, [3]int{int(l[0]), int(binary.BigEndian.Uint16(l[1:])), int(l[3])}) - off += 4 - } - // Optional CST2 block: frames received beyond the checkpoint, as a - // bit vector starting at checkpoint+2, least significant bit first. - var received []int64 - if off+6 <= len(pkt) && string(pkt[off:off+4]) == "CST2" { - n := int(pkt[off+5]) - vec := pkt[off+6 : min(len(pkt), off+6+n)] - for i, b := range vec { - for bit := range 8 { - if b&(1< 400*time.Millisecond && since > 500*time.Millisecond: - next = max(b.min, b.kbps*2/3) - case lag < 150*time.Millisecond && since > 2*time.Second: - next = min(b.max, b.kbps*11/10) - } - if next == b.kbps { - return - } - log.Printf("bitrate %d -> %d kbit/s (oldest unacked frame %v old)", b.kbps, next, lag.Round(time.Millisecond)) - b.kbps, b.changed = next, now - b.set(next) -} diff --git a/helpers/cast/session.go b/helpers/cast/session.go deleted file mode 100644 index 5b31b48..0000000 --- a/helpers/cast/session.go +++ /dev/null @@ -1,589 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "strconv" - "strings" - "sync" - "time" -) - -const mirroringAppID = "0F5096E8" - -type castOptions struct { - Audio string `json:"audio"` // "tv" (the cast device only), "both", or "local" (this computer only) - Width int `json:"width"` - Height int `json:"height"` - FPS int `json:"fps"` - Kbps int `json:"kbps"` // 0: pick from resolution and frame rate - Codec string `json:"codec"` // "auto", "h264" or "hevc" - Delay int `json:"delay"` // target playout delay in ms - RestoreToken string `json:"restoreToken"` - Protocol string `json:"protocol"` // "airplay" to mirror over AirPlay where a device has both - AirplayPorts string `json:"airplayPorts"` // doubletake's UDP port range, e.g. "47840-47849" -} - -func (o *castOptions) defaults() { - if o.Width == 0 || o.Height == 0 { - o.Width, o.Height = 1920, 1080 - } - if o.FPS == 0 { - o.FPS = 30 - } - if o.Kbps == 0 { - // 12 Mbit/s held up at 1080p30; scale by pixels, and half again for 60 fps. - o.Kbps = 12000 * o.Width * o.Height / (1920 * 1080) - if o.FPS > 30 { - o.Kbps = o.Kbps * 3 / 2 - } - } - if o.Delay == 0 { - o.Delay = 200 - } - if o.Codec == "" { - o.Codec = "auto" - } -} - -// session is one cast to one Chromecast: portal, Cast control channel, the -// two pipelines and the Cast Streaming senders. -type session struct { - h *helper - dev device - opts castOptions - - ctx context.Context - cancel context.CancelFunc - done chan struct{} - - mu sync.Mutex - phase string - since time.Time - volume float64 - canVolume bool - cast *castConn - transport string - requestID int -} - -func newSession(h *helper, d device, opts castOptions) *session { - opts.defaults() - ctx, cancel := context.WithCancel(context.Background()) - return &session{h: h, dev: d, opts: opts, ctx: ctx, cancel: cancel, done: make(chan struct{}), - phase: "starting", since: time.Now()} -} - -func (s *session) deviceID() string { return s.dev.ID } - -func (s *session) status() map[string]any { - s.mu.Lock() - defer s.mu.Unlock() - return map[string]any{"kind": "mirror", "device": s.dev.ID, "name": s.dev.Name, "phase": s.phase, - "since": s.since.Unix(), "volume": s.volume, "canVolume": s.canVolume} -} - -func (s *session) setPhase(p string) { - s.mu.Lock() - s.phase = p - if p == "casting" { - s.since = time.Now() - } - s.mu.Unlock() - s.h.emitState() -} - -// Stop ends the cast; run tells the TV and cleans up. -func (s *session) Stop() { s.cancel() } - -func (s *session) Wait(d time.Duration) { - select { - case <-s.done: - case <-time.After(d): - } -} - -func (s *session) SetVolume(level float64) { - s.mu.Lock() - c, can := s.cast, s.canVolume - s.requestID++ - id := s.requestID - s.mu.Unlock() - if c == nil || !can { - return - } - c.send(platformID, nsReceiver, map[string]any{"type": "SET_VOLUME", "volume": map[string]any{"level": level}, "requestId": 1000 + id}) -} - -type cleanups []func() - -func (c *cleanups) add(f func()) { *c = append(*c, f) } -func (c cleanups) run() { - for i := len(c) - 1; i >= 0; i-- { - c[i]() - } -} - -// run casts until stopped or until something breaks, and returns an error -// code for the plugin ("" when the user stopped it). -func (s *session) run() (code string, err error) { - defer close(s.done) - var undo cleanups - defer func() { undo.run() }() - stopped := func(c string, e error) (string, error) { - if s.ctx.Err() != nil { - return "", nil - } - return c, e - } - - if err := need("mirroring"); err != nil { - return "missingDependency", err - } - if s.opts.Audio == "tv" { - // Without pw-metadata the cast sink can't become the default output, - // so sound would go nowhere: send it from both instead, and say why. - if err := need("tvOnlySound"); err != nil { - var m *missingError - errors.As(err, &m) - s.h.emit(map[string]any{"event": "warning", "code": "soundOnBoth", "feature": m.feature, "missing": m.missing}) - s.opts.Audio = "both" - } - } - codecs, err := s.videoCodecs() - if err != nil { - return "encoderMissing", err - } - - // The portal picker comes first, so the TV prompt doesn't time out behind it. - portal, token, err := requestScreencast(s.ctx, s.opts.RestoreToken) - if err != nil { - return stopped("portalDenied", err) - } - undo.add(portal.Close) - if token != "" && token != s.opts.RestoreToken { - s.h.emit(map[string]any{"event": "restoreToken", "token": token}) - s.h.mu.Lock() - s.h.restoreToken = token - s.h.mu.Unlock() - } - - cast, err := dialCast(net.JoinHostPort(s.dev.Host, strconv.Itoa(s.dev.Port))) - if err != nil { - return stopped("deviceLost", err) - } - undo.add(cast.Close) - s.mu.Lock() - s.cast = cast - s.mu.Unlock() - if s.ctx.Err() != nil { - return "", nil - } - - transport, sessionID, code, err := s.launch(cast) - if err != nil { - return stopped(code, err) - } - undo.add(func() { - cast.send(platformID, nsReceiver, map[string]any{"type": "STOP", "sessionId": sessionID, "requestId": 3}) - time.Sleep(200 * time.Millisecond) - }) - - audio, video, conn, err := s.negotiate(cast, transport, codecs) - if err != nil { - return stopped("launchRefused", err) - } - undo.add(func() { conn.Close() }) - - streams := map[uint32]*stream{video.ssrc: video} - if audio != nil { - streams[audio.ssrc] = audio - } - go readRTCP(conn, streams) - tick := time.NewTicker(50 * time.Millisecond) - undo.add(tick.Stop) - go func() { - for i := 0; ; i++ { - select { - case now := <-tick.C: - for _, st := range streams { - st.tick(now, i%10 == 0) - } - if i%200 == 0 { - for _, st := range streams { - debugf("%s", st.stats()) - } - } - case <-s.ctx.Done(): - return - } - } - }() - - vpipe, err := startGstPipeline(s.videoPipeline(portal, video.codec)) - if err != nil { - return stopped("pipelineFailed", err) - } - undo.add(vpipe.Stop) - video.keyframe = vpipe.KeyFrame - video.bitrate = newBitrate(s.opts.Kbps, max(1000, s.opts.Kbps/4), vpipe.SetBitrate) - videoEnded := make(chan error, 1) - go func() { - first := true - err := readAccessUnits(vpipe, nalCodec{hevc: video.codec == "hevc"}, func(au []byte, key bool) { - if first { - if !key { - return - } - first = false - s.setPhase("casting") - } - if key && video.codec == "h264" { - au = fixColour(au) - } - video.enqueue(au, key, video.rtpNow(time.Now())) - }) - videoEnded <- err - }() - - var apipe *gstPipeline - audioFailed := make(chan string) - if audio != nil { - apipe, err = startGstPipeline(audioPipelineFor(s.opts.Audio)) - if err != nil { - return stopped("pipelineFailed", err) - } - undo.add(apipe.Stop) - if s.opts.Audio == "tv" { - undo.add(holdDefault()) - } - audioFailed = apipe.failed - go func() { - var rtp uint32 - for first := true; ; first = false { - p, err := apipe.Pull() - if err != nil { - return - } - if first { - rtp = audio.rtpNow(time.Now()) - } - audio.enqueue(p, true, rtp) - rtp += 480 // 10 ms at 48 kHz, matches frame-size - } - }() - } - - stopMpris := startMirrorMpris(s.dev.Name, s.Stop) - undo.add(stopMpris) - - ended := s.watchReceiver(cast, sessionID) - select { - case <-s.ctx.Done(): - return "", nil - case reason := <-ended: - return stopped(reason, errors.New("the receiver ended the session")) - case msg := <-vpipe.failed: - return stopped("pipelineFailed", errors.New(msg)) - case msg := <-audioFailed: - return stopped("pipelineFailed", errors.New(msg)) - case err := <-videoEnded: - return stopped("pipelineFailed", fmt.Errorf("video ended: %v", err)) - } -} - -// videoCodecs lists the codecs to offer, in Chrome's order of preference, -// limited to the encoders this machine has. -func (s *session) videoCodecs() ([]string, error) { - want := []string{"hevc", "h264"} - if s.opts.Codec != "auto" { - want = []string{s.opts.Codec} - } - var have []string - for _, c := range want { - if hasElement(encoderFor(c)) { - have = append(have, c) - } - } - if len(have) == 0 { - return nil, fmt.Errorf("no VA-API encoder for %s", strings.Join(want, " or ")) - } - return have, nil -} - -func encoderFor(codec string) string { - if codec == "hevc" { - return "vah265enc" - } - return "vah264enc" -} - -// launch starts the mirroring receiver app. -func (s *session) launch(cast *castConn) (transport, sessionID, code string, err error) { - transport, sessionID, st, code, err := launchApp(s.ctx, cast, mirroringAppID, func() { s.setPhase("authorizing") }) - if err != nil { - return "", "", code, err - } - s.updateVolume(st) - s.setPhase("starting") - return transport, sessionID, "", nil -} - -// launchApp starts a receiver app and connects to it, waiting while the TV -// asks the viewer to allow the cast. -func launchApp(ctx context.Context, cast *castConn, appID string, onPending func()) (transport, sessionID string, st receiverStatus, code string, err error) { - cast.send(platformID, nsConnection, map[string]string{"type": "CONNECT"}) - cast.send(platformID, nsReceiver, map[string]any{"type": "LAUNCH", "appId": appID, "requestId": 1}) - pending := false - raw, err := cast.await(ctx, nsReceiver, 90*time.Second, func(p []byte) bool { - if !pending && strings.Contains(string(p), "USER_PENDING_AUTHORIZATION") { - pending = true - onPending() - } - t := typeOf(p) - return t == "LAUNCH_ERROR" || (t == "RECEIVER_STATUS" && strings.Contains(string(p), appID) && - strings.Contains(string(p), "transportId")) - }) - if err != nil { - if strings.Contains(err.Error(), "timed out") { - return "", "", st, "authorizationTimeout", err - } - return "", "", st, "deviceLost", err - } - if typeOf(raw) == "LAUNCH_ERROR" { - return "", "", st, "launchRefused", fmt.Errorf("%s", raw) - } - st = parseReceiverStatus(raw) - for _, a := range st.Status.Applications { - if a.AppID == appID { - cast.send(a.TransportID, nsConnection, map[string]string{"type": "CONNECT"}) - return a.TransportID, a.SessionID, st, "", nil - } - } - return "", "", st, "launchRefused", fmt.Errorf("app %s missing from status: %s", appID, raw) -} - -type receiverStatus struct { - Status struct { - Applications []struct { - AppID string `json:"appId"` - TransportID string `json:"transportId"` - SessionID string `json:"sessionId"` - } - Volume *struct { - Level float64 `json:"level"` - ControlType string `json:"controlType"` - } - } -} - -func parseReceiverStatus(raw []byte) receiverStatus { - var st receiverStatus - json.Unmarshal(raw, &st) - return st -} - -func (s *session) updateVolume(st receiverStatus) { - v := st.Status.Volume - if v == nil { - return - } - s.mu.Lock() - changed := s.volume != v.Level || s.canVolume != (v.ControlType != "fixed") - s.volume, s.canVolume = v.Level, v.ControlType != "fixed" - s.mu.Unlock() - if changed { - s.h.emitState() - } -} - -// watchReceiver follows the control channel after setup: volume changes, and -// the TV closing our app or the connection. -func (s *session) watchReceiver(cast *castConn, sessionID string) <-chan string { - ended := make(chan string, 1) - go func() { - for m := range cast.msgs { - switch { - case m.ns == nsReceiver && typeOf(m.payload) == "RECEIVER_STATUS": - st := parseReceiverStatus(m.payload) - s.updateVolume(st) - running := false - for _, a := range st.Status.Applications { - running = running || a.SessionID == sessionID - } - if !running { - ended <- "receiverStopped" - return - } - case m.ns == nsConnection && typeOf(m.payload) == "CLOSE": - ended <- "receiverStopped" - return - } - } - ended <- "deviceLost" - }() - return ended -} - -type keys struct{ key, iv [16]byte } - -func newKeys() (k keys) { - rand.Read(k.key[:]) - rand.Read(k.iv[:]) - return -} - -// negotiate asks for the receiver's capabilities, then offers Opus audio and -// one video stream per codec. The receiver's ANSWER picks the streams. -func (s *session) negotiate(cast *castConn, transport string, codecs []string) (audio, video *stream, conn *net.UDPConn, err error) { - const audioPT, videoPT = 96, 101 - o := s.opts - cast.send(transport, nsWebrtc, map[string]any{"type": "GET_CAPABILITIES", "seqNum": 1}) - if caps, err := cast.await(s.ctx, nsWebrtc, 5*time.Second, func(p []byte) bool { return typeOf(p) == "CAPABILITIES_RESPONSE" }); err == nil { - debugf("receiver capabilities: %s", caps) - } - - type offered struct { - index int - codec string - ssrc uint32 - keys keys - } - audioOffer := offered{index: 0, codec: "opus", ssrc: randSSRC(), keys: newKeys()} - var streams []any - if o.Audio != "local" { // "local" keeps the sound here and sends picture only - streams = append(streams, map[string]any{ - "index": 0, "type": "audio_source", "codecName": "opus", "channels": 2, - "rtpProfile": "cast", "rtpPayloadType": audioPT, "ssrc": audioOffer.ssrc, - "targetDelay": o.Delay, "aesKey": hex.EncodeToString(audioOffer.keys.key[:]), - "aesIvMask": hex.EncodeToString(audioOffer.keys.iv[:]), "receiverRtcpEventLog": false, - "timeBase": "1/48000", "codecParameter": "", "bitRate": 128000, - }) - } - var videoOffers []offered - for i, c := range codecs { - v := offered{index: 1 + i, codec: c, ssrc: randSSRC(), keys: newKeys()} - videoOffers = append(videoOffers, v) - streams = append(streams, map[string]any{ - "index": v.index, "type": "video_source", "codecName": c, "channels": 1, - "rtpProfile": "cast", "rtpPayloadType": videoPT, "ssrc": v.ssrc, - "targetDelay": o.Delay, "aesKey": hex.EncodeToString(v.keys.key[:]), - "aesIvMask": hex.EncodeToString(v.keys.iv[:]), "receiverRtcpEventLog": false, - "timeBase": "1/90000", "codecParameter": codecParameter(c, o.Width, o.Height, o.FPS), - "maxFrameRate": fmt.Sprint(o.FPS), "maxBitRate": o.Kbps * 1000, "protection": "", - "profile": "", "level": "", "errorRecoveryMode": "", - "resolutions": []any{map[string]int{"width": o.Width, "height": o.Height}}, - }) - } - if err := cast.send(transport, nsWebrtc, map[string]any{ - "type": "OFFER", "seqNum": 2, - "offer": map[string]any{"castMode": "mirroring", "supportedStreams": streams}, - }); err != nil { - return nil, nil, nil, err - } - raw, err := cast.await(s.ctx, nsWebrtc, 10*time.Second, func(p []byte) bool { return typeOf(p) == "ANSWER" }) - if err != nil { - return nil, nil, nil, err - } - var answer struct { - Result string - Answer struct { - UDPPort int `json:"udpPort"` - SendIndexes []int `json:"sendIndexes"` - } - } - if err := json.Unmarshal(raw, &answer); err != nil || answer.Result != "ok" { - return nil, nil, nil, fmt.Errorf("receiver rejected the offer: %s", raw) - } - - conn, err = net.DialUDP("udp", nil, &net.UDPAddr{IP: net.ParseIP(s.dev.Host), Port: answer.Answer.UDPPort}) - if err != nil { - return nil, nil, nil, err - } - start := time.Now() - for _, idx := range answer.Answer.SendIndexes { - if idx == 0 { - audio = newStream("audio", conn, audioOffer.ssrc, audioPT, 48000, audioOffer.keys.key, audioOffer.keys.iv, start, false) - continue - } - for _, v := range videoOffers { - if v.index == idx && video == nil { - video = newStream("video", conn, v.ssrc, videoPT, 90000, v.keys.key, v.keys.iv, start, true) - video.codec = v.codec - } - } - } - if video == nil { - conn.Close() - return nil, nil, nil, fmt.Errorf("receiver accepted no video stream: %s", raw) - } - log.Printf("receiver chose %s, audio: %v", video.codec, audio != nil) - return audio, video, conn, nil -} - -func (s *session) videoPipeline(portal *portalSession, codec string) string { - o := s.opts - enc, parse, caps := encoderFor(codec), "h264parse", "video/x-h264" - if codec == "hevc" { - parse, caps = "h265parse", "video/x-h265" - } - raw := fmt.Sprintf("video/x-raw(memory:VAMemory),format=NV12,width=%d,height=%d,pixel-aspect-ratio=1/1", o.Width, o.Height) - // The portal's buffer pool is small: always copy into a fresh VA surface so - // the encoder's reference frames never pin a portal buffer. - return fmt.Sprintf("pipewiresrc fd=%d path=%d do-timestamp=true keepalive-time=%d ! "+ - "video/x-raw(ANY),pixel-aspect-ratio=1/1 ! vapostproc disable-passthrough=true add-borders=true ! "+ - "%s ! videorate skip-to-first=true ! %s,framerate=%d/1 ! "+ - "queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream ! "+ - "%s name=enc aud=true b-frames=0 key-int-max=%d rate-control=cbr bitrate=%d target-usage=4 ! %s,profile=main ! "+ - "%s config-interval=-1 ! %s,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=4", - portal.fd.Fd(), portal.node, 1+999/o.FPS, raw, raw, o.FPS, enc, 2*o.FPS, o.Kbps, caps, parse, caps) -} - -// codecParameter describes a stream the way Chrome's mirroring does: Main -// profile, at the level the resolution and frame rate need. -func codecParameter(codec string, width, height, fps int) string { - big, fast := width*height > 1920*1080, fps > 30 - switch codec { - case "hevc": - level := 120 // 4.0: 1080p30 - switch { - case big && fast: - level = 153 // 5.1: 4K60 - case big: - level = 150 // 5.0: 4K30 - case fast: - level = 123 // 4.1: 1080p60 - } - return fmt.Sprintf("hev1.1.6.L%d.B0", level) - case "h264": - level := 0x28 // 4.0 - switch { - case big && fast: - level = 0x34 // 5.2 - case big: - level = 0x33 // 5.1 - case fast: - level = 0x2a // 4.2 - } - return fmt.Sprintf("avc1.4D40%02X", level) - } - return "" -} - -func typeOf(p []byte) string { - var m struct{ Type string } - json.Unmarshal(p, &m) - return m.Type -} - -func randSSRC() uint32 { - var b [4]byte - rand.Read(b[:]) - return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) -} diff --git a/helpers/cast/source.go b/helpers/cast/source.go deleted file mode 100644 index 64f8575..0000000 --- a/helpers/cast/source.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "bytes" - "io" -) - -var startCode = []byte{0, 0, 1} - -// nalCodec tells H.264 and HEVC NAL headers apart: the unit type sits in -// different bits, and so do the delimiter and key frame types. -type nalCodec struct{ hevc bool } - -func (c nalCodec) unitType(b byte) int { - if c.hevc { - return int(b>>1) & 0x3f - } - return int(b) & 0x1f -} - -func (c nalCodec) isAUD(t int) bool { - if c.hevc { - return t == 35 - } - return t == 9 -} - -func (c nalCodec) isKey(t int) bool { - if c.hevc { - return t >= 16 && t <= 23 // IRAP pictures - } - return t == 5 -} - -// readAccessUnits splits an Annex B stream into access units at its access -// unit delimiters (the encoder must emit them) and reports whether each one -// holds a key frame. -func readAccessUnits(r io.Reader, c nalCodec, emit func(au []byte, key bool)) error { - var buf []byte - chunk := make([]byte, 256<<10) - for { - n, err := r.Read(chunk) - buf = append(buf, chunk[:n]...) - for { - // The first AUD starts the current unit; the next one ends it. - first := c.nextAUD(buf, 0) - if first < 0 { - break - } - end := c.nextAUD(buf, first+4) - if end < 0 { - buf = buf[first:] - break - } - au := buf[first:end] - emit(bytes.Clone(au), c.hasKey(au)) - buf = buf[end:] - } - if err != nil { - return err - } - } -} - -// nextAUD returns the offset of the next AUD NAL unit's start code at or after -// from, including a leading zero byte of a 4-byte start code. -func (c nalCodec) nextAUD(b []byte, from int) int { - for i := from; i+3 < len(b); { - j := bytes.Index(b[i:], startCode) - if j < 0 || i+j+3 >= len(b) { - return -1 - } - pos := i + j - if c.isAUD(c.unitType(b[pos+3])) { - if pos > 0 && b[pos-1] == 0 { - return pos - 1 - } - return pos - } - i = pos + 3 - } - return -1 -} - -func (c nalCodec) hasKey(au []byte) bool { - for i := 0; ; { - j := bytes.Index(au[i:], startCode) - if j < 0 || i+j+3 >= len(au) { - return false - } - if c.isKey(c.unitType(au[i+j+3])) { - return true - } - i += j + 3 - } -} diff --git a/helpers/cast/source_test.go b/helpers/cast/source_test.go deleted file mode 100644 index 46a4168..0000000 --- a/helpers/cast/source_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "testing" -) - -func TestEncoderOutputSplits(t *testing.T) { - out, err := startGstPipeline("videotestsrc num-buffers=90 ! video/x-raw,width=1280,height=720,framerate=30/1 ! " + - "vapostproc ! video/x-raw(memory:VAMemory),format=NV12 ! vah264enc name=enc aud=true b-frames=0 key-int-max=30 ! " + - "h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false") - if err != nil { - t.Skip(err) - } - var units, keys []int - readAccessUnits(out, nalCodec{}, func(au []byte, key bool) { - if key { - keys = append(keys, len(units)) - } - units = append(units, len(au)) - }) - // The last unit stays buffered until a following delimiter arrives. - if len(units) != 89 { - t.Errorf("got %d access units, want 89", len(units)) - } - if len(keys) != 3 || keys[0] != 0 || keys[1] != 30 || keys[2] != 60 { - t.Errorf("key frames at %v, want [0 30 60]", keys) - } - - out, err = startGstPipeline("videotestsrc num-buffers=90 ! video/x-raw,width=1280,height=720,framerate=30/1 ! " + - "vapostproc ! video/x-raw(memory:VAMemory),format=NV12 ! vah265enc name=enc aud=true b-frames=0 key-int-max=30 ! " + - "h265parse config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! appsink name=out sync=false") - if err != nil { - t.Skip(err) - } - units, keys = nil, nil - readAccessUnits(out, nalCodec{hevc: true}, func(au []byte, key bool) { - if key { - keys = append(keys, len(units)) - } - units = append(units, len(au)) - }) - if len(units) != 89 || len(keys) != 3 || keys[1] != 30 { - t.Errorf("hevc: %d units, key frames at %v", len(units), keys) - } - - out, err = startGstPipeline("audiotestsrc num-buffers=50 samplesperbuffer=960 ! audio/x-raw,rate=48000,channels=2 ! " + - "opusenc name=enc frame-size=10 ! appsink name=out sync=false") - if err != nil { - t.Skip(err) - } - packets := 0 - for { - if _, err := out.Pull(); err != nil { - break - } - packets++ - } - // 50 buffers of 20 ms is one second, so about 100 packets of 10 ms. - if packets < 98 || packets > 101 { - t.Errorf("got %d opus packets, want about 100", packets) - } -} - -func TestSPSColourRewrite(t *testing.T) { - ffprobe, err := exec.LookPath("ffprobe") - if err != nil { - t.Skip(err) - } - out, err := startGstPipeline("videotestsrc num-buffers=10 ! video/x-raw,width=1920,height=1080,framerate=60/1 ! " + - "vapostproc ! video/x-raw(memory:VAMemory),format=NV12 ! vah264enc name=enc aud=true b-frames=0 key-int-max=5 ! " + - "video/x-h264,profile=main ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false") - if err != nil { - t.Skip(err) - } - var stream []byte - readAccessUnits(out, nalCodec{}, func(au []byte, key bool) { stream = append(stream, fixColour(au)...) }) - path := filepath.Join(t.TempDir(), "out.h264") - os.WriteFile(path, stream, 0o644) - got, err := exec.Command(ffprobe, "-v", "error", "-show_entries", - "stream=color_range,color_space,color_primaries,color_transfer,width,height", "-of", "compact", path).Output() - if err != nil { - t.Fatal(err) - } - want := "stream|width=1920|height=1080|color_range=tv|color_space=bt709|color_transfer=bt709|color_primaries=bt709\n" - if string(got) != want { - t.Errorf("ffprobe says %q", got) - } - frames, _ := exec.Command("ffprobe", "-v", "error", "-count_frames", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path).Output() - if string(frames) != "9\n" { - t.Errorf("decoded %q frames, want 9", frames) - } -} diff --git a/helpers/cast/thumbs.go b/helpers/cast/thumbs.go deleted file mode 100644 index 5d7c398..0000000 --- a/helpers/cast/thumbs.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// thumbSlots lets two thumbnails decode at once; a folder of videos asks for -// dozens. -var thumbSlots = make(chan struct{}, 2) - -// thumbnail writes a JPEG of a video's first frame, cached by path, size and -// modification time, and reports where it is. -func (h *helper) thumbnail(file string, size int) { - if size <= 0 || size > 1024 { - size = 256 - } - reply := func(out string) { - h.emit(map[string]any{"event": "thumbnail", "path": file, "file": out}) - } - st, err := os.Stat(file) - if !filepath.IsAbs(file) || err != nil { - reply("") - return - } - cache, err := os.UserCacheDir() - if err != nil { - reply("") - return - } - sum := sha1.Sum(fmt.Appendf(nil, "%s|%d|%d|%d", file, size, st.Size(), st.ModTime().UnixNano())) - out := filepath.Join(cache, "dms", "cast", "thumbs", hex.EncodeToString(sum[:])+".jpg") - if _, err := os.Stat(out); err == nil { - reply(out) - return - } - if need("thumbnails") != nil || os.MkdirAll(filepath.Dir(out), 0o700) != nil { - reply("") - return - } - - thumbSlots <- struct{}{} - defer func() { <-thumbSlots }() - // A song's thumbnail is its cover: the embedded picture, else the folder's. - if strings.HasPrefix(typeByExt(file), "audio/") { - song := probeSong(file) - switch cover := folderCover(file); { - case song.cover != nil: - out = strings.TrimSuffix(out, ".jpg") + "." + strings.TrimPrefix(song.coverType, "image/") - if os.WriteFile(out, song.cover, 0o600) == nil { - reply(out) - return - } - case cover != "": - reply(cover) - return - } - reply("") - return - } - v, err := probeVideo(file) - if err != nil { - reply("") - return - } - // An audio branch only when there is audio: an unlinked sink would never - // reach EOS and hold the pipeline open. - desc := fmt.Sprintf("filesrc name=src ! decodebin name=d d. ! queue ! videoflip video-direction=auto ! videoconvert ! "+ - "videoscale ! video/x-raw,width=%d,pixel-aspect-ratio=1/1 ! jpegenc quality=85 snapshot=true ! filesink name=sink ", size) - if v.audio { - desc += "d. ! queue ! fakesink" - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - tmp := out + ".part" - err = runGst(ctx, desc, map[string]map[string]string{"src": {"location": file}, "sink": {"location": tmp}}, nil) - if err != nil || os.Rename(tmp, out) != nil { - os.Remove(tmp) - reply("") - return - } - reply(out) -} diff --git a/helpers/cast/vui.go b/helpers/cast/vui.go deleted file mode 100644 index d4a58ba..0000000 --- a/helpers/cast/vui.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import "errors" - -// The VA-API H.264 encoder writes no colour description, so receivers have to -// guess the matrix, and some guess BT.601 for the BT.709 frames vapostproc -// produces. withBT709 rewrites an SPS NAL unit to state BT.709, limited range. - -type bitReader struct { - b []byte - pos int -} - -func (r *bitReader) bit() (uint, error) { - if r.pos >= len(r.b)*8 { - return 0, errors.New("sps: out of bits") - } - v := uint(r.b[r.pos/8]>>(7-r.pos%8)) & 1 - r.pos++ - return v, nil -} - -func (r *bitReader) bits(n int) (uint, error) { - var v uint - for range n { - b, err := r.bit() - if err != nil { - return 0, err - } - v = v<<1 | b - } - return v, nil -} - -func (r *bitReader) ue() (uint, error) { - zeros := 0 - for { - b, err := r.bit() - if err != nil { - return 0, err - } - if b == 1 { - break - } - zeros++ - if zeros > 31 { - return 0, errors.New("sps: bad exp-golomb") - } - } - rest, err := r.bits(zeros) - return 1<= 0; i-- { - if w.n%8 == 0 { - w.b = append(w.b, 0) - } - w.b[len(w.b)-1] |= byte(v>>i&1) << (7 - w.n%8) - w.n++ - } -} - -// copyBits copies bits [from, to) of src. -func (w *bitWriter) copyBits(src []byte, from, to int) { - for i := from; i < to; i++ { - w.put(uint(src[i/8]>>(7-i%8))&1, 1) - } -} - -func unescape(b []byte) []byte { - out := make([]byte, 0, len(b)) - for i := 0; i < len(b); i++ { - if i+2 < len(b) && b[i] == 0 && b[i+1] == 0 && b[i+2] == 3 { - out = append(out, 0, 0) - i += 2 - continue - } - out = append(out, b[i]) - } - return out -} - -func escape(b []byte) []byte { - out := make([]byte, 0, len(b)+8) - zeros := 0 - for _, c := range b { - if zeros >= 2 && c <= 3 { - out = append(out, 3) - zeros = 0 - } - out = append(out, c) - if c == 0 { - zeros++ - } else { - zeros = 0 - } - } - return out -} - -// withBT709 takes an SPS NAL unit (header byte included, no start code) and -// returns it with video_signal_type set to BT.709 primaries, transfer and -// matrix in limited range. Everything else is copied bit for bit. -func withBT709(nal []byte) ([]byte, error) { - rbsp := unescape(nal[1:]) - r := &bitReader{b: rbsp} - skip := func(n int) error { _, err := r.bits(n); return err } - ue := func() error { _, err := r.ue(); return err } - - profile, err := r.bits(8) - if err != nil { - return nil, err - } - if err := skip(16); err != nil { // constraint flags, level - return nil, err - } - if err := ue(); err != nil { // seq_parameter_set_id - return nil, err - } - switch profile { - case 100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135: - return nil, errors.New("sps: high profiles not handled") - } - if err := ue(); err != nil { // log2_max_frame_num_minus4 - return nil, err - } - pocType, err := r.ue() - if err != nil { - return nil, err - } - switch pocType { - case 0: - err = ue() - case 1: - return nil, errors.New("sps: pic_order_cnt_type 1 not handled") - } - if err != nil { - return nil, err - } - if err := ue(); err != nil { // max_num_ref_frames - return nil, err - } - if err := skip(1); err != nil { // gaps_in_frame_num_allowed - return nil, err - } - if err := ue(); err != nil { // pic_width_in_mbs_minus1 - return nil, err - } - if err := ue(); err != nil { // pic_height_in_map_units_minus1 - return nil, err - } - frameMbsOnly, err := r.bit() - if err != nil { - return nil, err - } - if frameMbsOnly == 0 { - if err := skip(1); err != nil { - return nil, err - } - } - if err := skip(1); err != nil { // direct_8x8_inference - return nil, err - } - cropping, err := r.bit() - if err != nil { - return nil, err - } - if cropping == 1 { - for range 4 { - if err := ue(); err != nil { - return nil, err - } - } - } - - w := &bitWriter{} - vuiFlag := r.pos - vui, err := r.bit() - if err != nil { - return nil, err - } - stop := lastSetBit(rbsp) - if stop < 0 { - return nil, errors.New("sps: no stop bit") - } - - if vui == 0 { - w.copyBits(rbsp, 0, vuiFlag) - w.put(1, 1) // vui_parameters_present - w.put(0, 2) // aspect_ratio_info, overscan_info - writeSignalType(w) - w.put(0, 6) // chroma_loc, timing, nal_hrd, vcl_hrd, pic_struct, bitstream_restriction - } else { - aspect, err := r.bit() - if err != nil { - return nil, err - } - if aspect == 1 { - idc, err := r.bits(8) - if err != nil { - return nil, err - } - if idc == 255 { - if err := skip(32); err != nil { - return nil, err - } - } - } - overscan, err := r.bit() - if err != nil { - return nil, err - } - if overscan == 1 { - if err := skip(1); err != nil { - return nil, err - } - } - w.copyBits(rbsp, 0, r.pos) - present, err := r.bit() - if err != nil { - return nil, err - } - if present == 1 { - if err := skip(4); err != nil { // video_format, full_range - return nil, err - } - desc, err := r.bit() - if err != nil { - return nil, err - } - if desc == 1 { - if err := skip(24); err != nil { - return nil, err - } - } - } - writeSignalType(w) - w.copyBits(rbsp, r.pos, stop) - } - w.put(1, 1) // rbsp_stop_one_bit - for w.n%8 != 0 { - w.put(0, 1) - } - return append([]byte{nal[0]}, escape(w.b)...), nil -} - -func writeSignalType(w *bitWriter) { - w.put(1, 1) // video_signal_type_present - w.put(5, 3) // video_format: unspecified - w.put(0, 1) // full range: no - w.put(1, 1) // colour_description_present - w.put(1, 8) // primaries: BT.709 - w.put(1, 8) // transfer: BT.709 - w.put(1, 8) // matrix: BT.709 -} - -func lastSetBit(b []byte) int { - for i := len(b) - 1; i >= 0; i-- { - if b[i] != 0 { - for bit := 0; bit < 8; bit++ { - if b[i]>>bit&1 == 1 { - return i*8 + 7 - bit - } - } - } - } - return -1 -} - -// fixColour rewrites the SPS inside an H.264 access unit; on any parse -// problem the unit is returned unchanged. -func fixColour(au []byte) []byte { - for i := 0; ; { - j := indexStartCode(au, i) - if j < 0 || j+3 >= len(au) { - return au - } - start := j + 3 - if au[start]&0x1f != 7 { - i = start - continue - } - end := indexStartCode(au, start) - if end < 0 { - end = len(au) - } else if end > start && au[end-1] == 0 { - end-- // leading zero of a 4-byte start code belongs to the next unit - } - sps, err := withBT709(au[start:end]) - if err != nil { - return au - } - out := append(append(append([]byte{}, au[:start]...), sps...), au[end:]...) - return out - } -} - -func indexStartCode(b []byte, from int) int { - for i := from; i+2 < len(b); i++ { - if b[i] == 0 && b[i+1] == 0 && b[i+2] == 1 { - return i - } - } - return -1 -} diff --git a/helpers/cast/youtube.go b/helpers/cast/youtube.go deleted file mode 100644 index ea1dfea..0000000 --- a/helpers/cast/youtube.go +++ /dev/null @@ -1,171 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// YouTube links play in the TV's own YouTube app, started the way pychromecast -// and catt do it (through the casttube library): ask the app for its screen -// id, get a lounge token for that screen from youtube.com, bind a remote -// control session and hand it the video. Playback is then controlled through -// the normal Cast media namespace. -const ( - youtubeAppID = "233637DE" - nsYouTube = "urn:x-cast:com.google.youtube.mdx" - loungeBase = "https://www.youtube.com/api/lounge/" -) - -type youtubeVideo struct { - id, list string - start int // seconds -} - -var youtubeID = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`) - -// parseYouTube recognizes watch, short, live, embed and youtu.be links. -func parseYouTube(link string) (youtubeVideo, bool) { - u, err := url.Parse(link) - if err != nil { - return youtubeVideo{}, false - } - host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.") - q := u.Query() - v := youtubeVideo{list: q.Get("list"), start: parseStart(q.Get("t"))} - switch host { - case "youtu.be": - v.id = strings.Trim(u.Path, "/") - case "youtube.com", "m.youtube.com", "music.youtube.com", "youtube-nocookie.com": - v.id = q.Get("v") - for _, prefix := range []string{"/shorts/", "/live/", "/embed/"} { - if rest, ok := strings.CutPrefix(u.Path, prefix); ok { - v.id = strings.Split(rest, "/")[0] - } - } - default: - return youtubeVideo{}, false - } - return v, youtubeID.MatchString(v.id) -} - -// parseStart reads YouTube's t parameter: "90", "90s" or "1h2m3s". -func parseStart(t string) int { - if n, err := strconv.Atoi(strings.TrimSuffix(t, "s")); err == nil { - return n - } - total := 0 - for _, m := range regexp.MustCompile(`(\d+)([hms])`).FindAllStringSubmatch(t, -1) { - n, _ := strconv.Atoi(m[1]) - total += n * map[string]int{"h": 3600, "m": 60, "s": 1}[m[2]] - } - return total -} - -var ( - sidRe = regexp.MustCompile(`"c","(.*?)","`) - gsessionRe = regexp.MustCompile(`"S","(.*?)"]`) -) - -// playOnYouTube starts the video in the YouTube app, whose transport the -// caller has already connected to. -func playOnYouTube(ctx context.Context, cast *castConn, transport string, v youtubeVideo) error { - cast.send(transport, nsYouTube, map[string]any{"type": "getMdxSessionStatus"}) - raw, err := cast.await(ctx, nsYouTube, 10*time.Second, func(p []byte) bool { return typeOf(p) == "mdxSessionStatus" }) - if err != nil { - return fmt.Errorf("YouTube app status: %w", err) - } - var status struct { - Data struct { - ScreenID string `json:"screenId"` - } - } - json.Unmarshal(raw, &status) - if status.Data.ScreenID == "" { - return errors.New("YouTube app sent no screen id") - } - - body, err := loungePost(ctx, "pairing/get_lounge_token_batch", nil, "", url.Values{"screen_ids": {status.Data.ScreenID}}) - if err != nil { - return err - } - var tokens struct { - Screens []struct { - LoungeToken string `json:"loungeToken"` - } - } - if json.Unmarshal(body, &tokens) != nil || len(tokens.Screens) == 0 || tokens.Screens[0].LoungeToken == "" { - return fmt.Errorf("no lounge token in %.200s", body) - } - token := tokens.Screens[0].LoungeToken - - body, err = loungePost(ctx, "bc/bind", url.Values{"RID": {"0"}, "VER": {"8"}, "CVER": {"1"}}, token, url.Values{ - "device": {"REMOTE_CONTROL"}, "id": {remoteID()}, "name": {"Cast"}, "mdx-version": {"3"}, - "pairing_type": {"cast"}, "app": {"android-phone-13.14.55"}, - }) - if err != nil { - return err - } - sid, gsession := sidRe.FindSubmatch(body), gsessionRe.FindSubmatch(body) - if sid == nil || gsession == nil { - return fmt.Errorf("unexpected bind response %.200s", body) - } - - _, err = loungePost(ctx, "bc/bind", url.Values{"SID": {string(sid[1])}, "gsessionid": {string(gsession[1])}, - "RID": {"1"}, "VER": {"8"}, "CVER": {"1"}}, token, url.Values{ - "count": {"1"}, "req0__sc": {"setPlaylist"}, "req0_videoId": {v.id}, "req0_listId": {v.list}, - "req0_currentTime": {strconv.Itoa(v.start)}, "req0_currentIndex": {"-1"}, "req0_audioOnly": {"false"}, - }) - return err -} - -func loungePost(ctx context.Context, path string, params url.Values, token string, form url.Values) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - u := loungeBase + path - if params != nil { - u += "?" + params.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Origin", "https://www.youtube.com/") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if token != "" { - req.Header.Set("X-YouTube-LoungeId-Token", token) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("youtube.com %s: %s", path, resp.Status) - } - return body, nil -} - -// remoteID names this remote control for the TV's YouTube session. -func remoteID() string { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789" - b := make([]byte, 26) - rand.Read(b) - for i := range b { - b[i] = chars[int(b[i])%len(chars)] - } - return string(b) -} diff --git a/helpers/cast/youtube_test.go b/helpers/cast/youtube_test.go deleted file mode 100644 index 97b8202..0000000 --- a/helpers/cast/youtube_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import "testing" - -func TestParseYouTube(t *testing.T) { - for link, want := range map[string]youtubeVideo{ - "https://www.youtube.com/watch?v=dQw4w9WgXcQ": {id: "dQw4w9WgXcQ"}, - "https://youtu.be/dQw4w9WgXcQ?t=42": {id: "dQw4w9WgXcQ", start: 42}, - "https://m.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s": {id: "dQw4w9WgXcQ", list: "PL123", start: 90}, - "https://www.youtube.com/shorts/dQw4w9WgXcQ": {id: "dQw4w9WgXcQ"}, - "https://youtube.com/live/dQw4w9WgXcQ?feature=share": {id: "dQw4w9WgXcQ"}, - "https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=RDAMVM1234": {id: "dQw4w9WgXcQ", list: "RDAMVM1234"}, - } { - got, ok := parseYouTube(link) - if !ok || got != want { - t.Errorf("%s: got %+v %v, want %+v", link, got, ok, want) - } - } - for _, link := range []string{"https://example.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch?v=short", "https://www.youtube.com/@channel"} { - if _, ok := parseYouTube(link); ok { - t.Errorf("%s: should not parse", link) - } - } -} - -func TestBindResponse(t *testing.T) { - // The shape casttube parses: a length-prefixed JSON array of events. - body := []byte(`262 -[[0,["c","ABCDEF123","",8] -] -,[1,["S","gsess-42"]]]`) - sid, gs := sidRe.FindSubmatch(body), gsessionRe.FindSubmatch(body) - if sid == nil || string(sid[1]) != "ABCDEF123" || gs == nil || string(gs[1]) != "gsess-42" { - t.Errorf("sid %q gsessionid %q", sid, gs) - } -}