diff --git a/cast-tv/README.md b/cast-tv/README.md new file mode 100644 index 00000000..d6ffebc5 --- /dev/null +++ b/cast-tv/README.md @@ -0,0 +1,89 @@ +# Cast to TV + +A [Noctalia](https://github.com/noctalia-dev/noctalia) plugin that casts your +desktop to a smart TV over Wi-Fi Direct (Miracast), using +[FluxCast](https://github.com/IlyaP358/fluxcast) — a bar icon and a Control +Center shortcut, both opening the same panel: pick a TV from a live scan, +toggle 720p / TV audio / bitrate, click Cast. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `andresparrab/cast-tv` | +| Entries | Bar widget: `bar`; panel: `picker`; shortcut: `toggle` | + +## Requirements + +Install `fluxcast` and `wf-recorder` on `PATH` (AUR: `paru -S fluxcast-git +wf-recorder`). + +- A Wi-Fi adapter with Wi-Fi Direct support, driven by NetworkManager (the + plugin scans via `fluxcast --protocol wfd --wfd-scan`, which shells out to + NetworkManager's Wi-Fi Direct discovery) +- `wf-recorder` is used as fluxcast's capture backend — see "Why + `wf-recorder`" below for why it's forced rather than left as fluxcast's + default + +The plugin only talks to the `fluxcast` binary directly — no dependency on +any personal dotfiles or wrapper scripts. If `fluxcast` isn't found, the +panel says so instead of failing silently. + +## Usage + +Add the bar icon (recommended — one click, no Control Center detour) or the +Control Center shortcut tile, or both — neither placement is automatic, both +are opt-in like any other Noctalia widget/shortcut: + +- **Bar icon**: add `"cast-tv"` to `[bar.default].end` (or `.start`/ + `.center`) in `settings.toml`, and add + ```toml + [widget.cast-tv] + type = "andresparrab/cast-tv:bar" + ``` +- **Control Center shortcut tile**: add + ```toml + [[control_center.shortcuts]] + type = "andresparrab/cast-tv:toggle" + ``` + to `settings.toml`. The Home tab's shortcut grid is capped at 6 tiles, so + this may mean swapping one out. + +Then `noctalia msg config-reload`, or restart Noctalia if you added the bar +widget (a brand-new widget type needs a real restart to register — a reload +alone won't pick it up). + +Click the bar icon (or the Control Center shortcut) to open the panel. It +scans for Wi-Fi Direct peers automatically (a few seconds — that's inherent +to Wi-Fi Direct discovery, not the plugin). Pick a TV, adjust options if +needed, click **Cast**. Click **Stop Casting** to end it. + +Or open the panel directly: + +```sh +noctalia msg panel-toggle andresparrab/cast-tv:picker +``` + +## Why `wf-recorder` as the capture backend + +FluxCast's default portal-based screen capture negotiates through two +capability checks before falling back to one that works — on a wlroots +compositor (niri, Sway, ...) that can take ~15s, long enough that some TVs +give up and disconnect before the stream starts. Forcing +`--wfd-capture-backend wf-recorder` skips that negotiation and connects in +about 2 seconds. This is hardcoded rather than exposed as an option because +it's a strict improvement on this class of compositor, not a tradeoff. + +## Notes + +- **Stop Casting** sends `pkill -f wfd-peer`, which cleanly kills the + specific `fluxcast` process this plugin started (matched by the + `--wfd-peer` flag in its command line), not any other `fluxcast` instance + you might have running (e.g. `fluxcast --tray`). +- If the plugin is ever misbehaving, you can still cast from a plain + terminal with the same tuned flags this plugin uses: + ```sh + fluxcast --protocol wfd --wfd-scan + fluxcast --wfd-capture-backend wf-recorder --wfd-monitor \ + --bitrate 6M --wfd-peer --wfd-no-audio + ``` diff --git a/cast-tv/panel.luau b/cast-tv/panel.luau new file mode 100644 index 00000000..25ec7473 --- /dev/null +++ b/cast-tv/panel.luau @@ -0,0 +1,333 @@ +--!strict + +-- Talks to `fluxcast` (github.com/IlyaP358/fluxcast) directly — no dependency +-- on any personal dotfiles wrapper script, so this plugin is self-contained: +-- install it anywhere `fluxcast` is installed and it works. The flag choices +-- below (wf-recorder capture backend, --wfd-no-audio default, 6M bitrate) +-- were tuned interactively on a niri/wlroots setup — see README.md. + +local BITRATE_OPTIONS = { "4M", "6M", "8M", "10M" } +local DEFAULT_BITRATE_INDEX = 1 -- "6M" — a good default for slides/tickets over Wi-Fi Direct + +type Peer = { mac: string, label: string } + +local peers: { Peer } = {} +local selectedIndex = 0 +local scanning = false +local scanError: string? = nil +local res720 = false +local audio = false +local bitrateIndex = DEFAULT_BITRATE_INDEX +local casting = false +local fluxcastAvailable = true + +-- Host callback args always arrive as strings (ControlCallback is +-- string-typed on the wire), regardless of what the .d.luau type hints say. +local function asBool(value: any): boolean + return value == true or value == "true" +end + +local function asIndex(value: any): number + return tonumber(value) or 0 +end + +local function peerNames(): { string } + local names = {} + for i, p in peers do + names[i] = p.label + end + return names +end + +-- noctalia.focusedOutputName() tracks keyboard-focused-window state, which a +-- panel isn't tied to the way a bar widget instance is — it can legitimately +-- come back nil here. Scan noctalia.outputs() for the focused one instead, +-- falling back to the first output (always resolves on a single-monitor setup). +local function detectMonitor(): string? + local outputs = noctalia.outputs() + for _, o in outputs do + if o.focused then + return o.name + end + end + if #outputs > 0 then + return outputs[1].name + end + return nil +end + +local render +local startScan +local startCast +local stopCast + +-- POSIX single-quote escaping: wrap in '...', turn each embedded ' into '\''. +-- Needed because this build's `runAsync` only accepts a shell command string +-- (the argv-table calling form errors "string expected, got table" here — +-- it's a real plugin_api >= 24 feature this binary doesn't implement, not +-- just a manifest-declared-version formality). +local function shellQuote(s: string): string + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +function render() + if not fluxcastAvailable then + panel.render(ui.column( + { padding = 12, gap = 8 }, + { + ui.row( + { align = "center", gap = 8 }, + { + ui.glyph({ name = "cast-off", size = 18, color = "on_surface_variant" }), + ui.label({ text = "Cast to TV", fontSize = 16, fontWeight = "semibold", color = "on_surface", flexGrow = 1 }), + } + ), + ui.label({ + text = "fluxcast is not installed. Install it (e.g. `paru -S fluxcast-git`) and reopen this panel.", + fontSize = 13, + color = "on_surface_variant", + }), + } + )) + return + end + + local tvSection + if scanning then + tvSection = ui.label({ + text = "Scanning for TVs…", + fontSize = 13, + color = "on_surface_variant", + }) + elseif scanError ~= nil then + tvSection = ui.label({ + text = scanError, + fontSize = 13, + color = "error", + }) + elseif #peers == 0 then + tvSection = ui.label({ + text = "No Wi-Fi Direct TVs found nearby.", + fontSize = 13, + color = "on_surface_variant", + }) + else + tvSection = ui.select({ + options = peerNames(), + selectedIndex = selectedIndex, + controlSize = "sm", + flexGrow = 1, + onChange = function(idx: any, _text: any) + selectedIndex = asIndex(idx) + render() + end, + }) + end + + local castLabel = casting and "Stop Casting" or "Cast" + local castGlyph = casting and "cast-off" or "cast" + local castVariant = casting and "destructive" or "primary" + local castEnabled = casting or #peers > 0 + + panel.render(ui.column( + { padding = 12, gap = 12 }, + { + ui.row( + { align = "center", gap = 8 }, + { + ui.glyph({ name = "cast", size = 18, color = "primary" }), + ui.label({ + text = "Cast to TV", + fontSize = 16, + fontWeight = "semibold", + color = "on_surface", + flexGrow = 1, + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + tooltip = "Rescan", + enabled = not scanning, + onClick = function() + startScan() + end, + }), + } + ), + ui.column( + { fill = "surface_variant/0.55", radius = 6, padding = 8, gap = 6 }, + { + ui.row( + { align = "center", gap = 6 }, + { + ui.glyph({ name = "device-tv", size = 14, color = "on_surface_variant" }), + ui.label({ text = "Smart TV", fontSize = 13, color = "on_surface_variant", flexGrow = 1 }), + } + ), + tvSection, + } + ), + ui.column( + { fill = "surface_variant/0.55", radius = 6, padding = 8, gap = 8 }, + { + ui.label({ text = "Options", fontSize = 13, color = "on_surface_variant" }), + ui.row( + { align = "center", gap = 8, minHeight = 32 }, + { + ui.label({ text = "1280×720 (more fluid)", fontSize = 14, color = "on_surface", flexGrow = 1 }), + ui.toggle({ + checked = res720, + onChange = function(value: any) + res720 = asBool(value) + render() + end, + }), + } + ), + ui.row( + { align = "center", gap = 8, minHeight = 32 }, + { + ui.label({ text = "Include TV audio", fontSize = 14, color = "on_surface", flexGrow = 1 }), + ui.toggle({ + checked = audio, + onChange = function(value: any) + audio = asBool(value) + render() + end, + }), + } + ), + ui.row( + { align = "center", gap = 8, minHeight = 32 }, + { + ui.label({ text = "Bitrate", fontSize = 14, color = "on_surface", flexGrow = 1 }), + ui.select({ + options = BITRATE_OPTIONS, + selectedIndex = bitrateIndex, + controlSize = "sm", + width = 90, + onChange = function(idx: any, _text: any) + bitrateIndex = asIndex(idx) + render() + end, + }), + } + ), + } + ), + ui.button({ + text = castLabel, + glyph = castGlyph, + variant = castVariant, + enabled = castEnabled, + onClick = function() + if casting then + stopCast() + else + startCast() + end + end, + }), + } + )) +end + +function startScan() + if not fluxcastAvailable then + return + end + scanning = true + scanError = nil + peers = {} + render() + noctalia.runAsync("fluxcast --protocol wfd --wfd-scan", function(result) + scanning = false + if result.timedOut then + scanError = "Scan timed out." + elseif result.exitCode ~= 0 then + -- A nonzero exit is always a real failure, whether or not fluxcast + -- printed anything to stdout (its own error messages go to stdout, + -- not stderr, so the old `and result.stdout == ""` check let real + -- failures — e.g. WiFi radio soft-blocked, wpa_supplicant not + -- managing the interface — silently masquerade as "no TVs found"). + scanError = "Scan failed — is Wi-Fi Direct available?" + end + local found: { Peer } = {} + for line in result.stdout:gmatch("[^\n]+") do + local mac, name = line:match("%[%d+%]%s+(%x%x:%x%x:%x%x:%x%x:%x%x:%x%x)%s+(.-)%s+via NetworkManager") + if mac then + table.insert(found, { mac = mac, label = (name ~= "" and name) or mac }) + end + end + peers = found + if #peers > 0 then + selectedIndex = 0 + end + render() + end, 15000) +end + +function startCast() + if #peers == 0 then + return + end + local peer = peers[selectedIndex + 1] + if peer == nil then + return + end + local monitor = detectMonitor() + if monitor == nil then + noctalia.notifyError("Cast to TV", "Could not detect the active output.") + return + end + + -- --wfd-capture-backend wf-recorder: skips FluxCast's default portal capture + -- negotiation, which can take ~15s and time out some TVs (confirmed on a + -- niri/wlroots setup — see README.md). --wfd-no-audio is the default since + -- typical use is screen-sharing slides/tickets, not video/sound. + local argv = { + "fluxcast", + "--wfd-capture-backend", "wf-recorder", + "--wfd-monitor", shellQuote(monitor), + "--bitrate", BITRATE_OPTIONS[bitrateIndex + 1], + "--wfd-peer", shellQuote(peer.mac), + } + if not audio then + table.insert(argv, "--wfd-no-audio") + end + if res720 then + table.insert(argv, "--output-res") + table.insert(argv, "1280x720") + end + + noctalia.runAsync(table.concat(argv, " ")) + casting = true + noctalia.notify("Casting to " .. peer.label) + render() +end + +function stopCast() + -- "wfd-peer" (no leading dashes) — pkill's getopt parser treats a + -- "--"-prefixed pattern as an unrecognized option, not the search text. + noctalia.runAsync("pkill -f wfd-peer", function(_result) + casting = false + render() + end) +end + +function onOpen(_context: any) + fluxcastAvailable = noctalia.commandExists("fluxcast") + casting = false + render() + if not fluxcastAvailable then + return + end + noctalia.processMatches(function(matched: boolean) + if matched ~= casting then + casting = matched + render() + end + end, "fluxcast", "--wfd-peer") + startScan() +end diff --git a/cast-tv/plugin.toml b/cast-tv/plugin.toml new file mode 100644 index 00000000..b7a50eb2 --- /dev/null +++ b/cast-tv/plugin.toml @@ -0,0 +1,28 @@ +id = "andresparrab/cast-tv" +name = "Cast to TV" +version = "1.0.1" +author = "andresparrab" +license = "MIT" +icon = "cast" +description = "Cast this desktop to a Wi-Fi Direct (Miracast) smart TV via FluxCast." +plugin_api = 23 +tags = ["bar", "panel", "shortcut", "network", "media"] +dependencies = ["fluxcast", "wf-recorder"] + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +[[widget]] +id = "bar" +entry = "widget.luau" + +[[panel]] +id = "picker" +entry = "panel.luau" +width = 340 +height = 460 +placement = "attached" +position = "auto" +dismiss_on_outside_click = true +keyboard_focus = "on_demand" diff --git a/cast-tv/shortcut.luau b/cast-tv/shortcut.luau new file mode 100644 index 00000000..6b176db6 --- /dev/null +++ b/cast-tv/shortcut.luau @@ -0,0 +1,21 @@ +--!strict + +shortcut.setLabel("Cast to TV") +shortcut.setIcon("cast-off") +noctalia.setUpdateInterval(2000) + +local lastActive = false + +function update() + noctalia.processMatches(function(matched: boolean) + if matched ~= lastActive then + lastActive = matched + shortcut.setActive(matched) + shortcut.setIcon(matched and "cast" or "cast-off") + end + end, "fluxcast", "--wfd-peer") +end + +function onClick() + noctalia.togglePanel("andresparrab/cast-tv:picker") +end diff --git a/cast-tv/thumbnail.webp b/cast-tv/thumbnail.webp new file mode 100644 index 00000000..22042f7b Binary files /dev/null and b/cast-tv/thumbnail.webp differ diff --git a/cast-tv/translations/en.json b/cast-tv/translations/en.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/cast-tv/translations/en.json @@ -0,0 +1 @@ +{} diff --git a/cast-tv/widget.luau b/cast-tv/widget.luau new file mode 100644 index 00000000..a5e914ed --- /dev/null +++ b/cast-tv/widget.luau @@ -0,0 +1,25 @@ +--!strict + +local lastActive = false + +local function refreshIcon() + noctalia.processMatches(function(matched: boolean) + if matched ~= lastActive then + lastActive = matched + barWidget.setGlyph(matched and "cast" or "cast-off") + barWidget.setGlyphColor(matched and "primary" or "on_surface") + end + end, "fluxcast", "--wfd-peer") +end + +barWidget.setGlyph("cast-off") +barWidget.setTooltip("Cast to TV") +noctalia.setUpdateInterval(2000) + +function update() + refreshIcon() +end + +function onClick() + noctalia.togglePanel("andresparrab/cast-tv:picker") +end