diff --git a/.github/actions/setup-native/action.yml b/.github/actions/setup-native/action.yml index 6917c5f..6ad9755 100644 --- a/.github/actions/setup-native/action.yml +++ b/.github/actions/setup-native/action.yml @@ -26,7 +26,7 @@ inputs: default: "0.16.0" native-sdk-version: description: "@native-sdk/cli version to install" - default: "0.8.4" + default: "0.9.0" apply-patches: description: Whether to run scripts/apply-sdk-patches.sh default: "true" diff --git a/.gitignore b/.gitignore index 8b86673..736956d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ zig-out/ .zig-cache/ dist/ .claude/worktrees/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index ffb9ff6..17d3d6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A native desktop player (macOS + Linux + Windows) for the SUB/WAVE internet radio station, built on the **Vercel Native SDK**: declarative `.native` markup + Zig logic, rendered by the SDK's own engine — no browser, no WebView. Requires **Zig 0.16.0** and a global `@native-sdk/cli` **0.8.4+** (`npm i -g @native-sdk/cli`). +A native desktop player (macOS + Linux + Windows) for the SUB/WAVE internet radio station, built on the **Vercel Native SDK**: declarative `.native` markup + Zig logic, rendered by the SDK's own engine — no browser, no WebView. Requires **Zig 0.16.0** and a global `@native-sdk/cli` **0.9.0+** (`npm i -g @native-sdk/cli`). ## Commands @@ -64,8 +64,8 @@ Data flow at runtime: timers poll `/api/now-playing`, `/api/state`, `/api/themes - **The SDK cannot resize a live window.** Per-mode window shapes apply at next launch only. - **A `hidden_inset_tall` masthead must pad `insets.left` AND `insets.right`.** The window-control cluster sits on a different edge per platform — macOS traffic lights lead, Windows min/max/close trail — so `onChrome` maps both into `chrome_leading` / `chrome_trailing` and `player-top.native` spacers both ends. Dropping the trailing one put the gear button under the DWM caption buttons on Windows, and fed the host's per-present caption-colour sampler (which reads the pixel 8px leading of the cluster and pushes it through `DWMWA_CAPTION_COLOR` + `USE_IMMERSIVE_DARK_MODE`) an antialiased glyph edge instead of flat header — the caption buttons flickered. - **The FFT spectrum feed only emits while a window is visibly on screen** (occlusion gate in the SDK). A flat visualizer from an app launched in the background is not a bug — activate the app first. -- SDK 0.8.4 still has **no OS media-controls surface** (no MPNowPlayingInfoCenter / MPRemoteCommandCenter / MPRIS / hardware media keys — re-checked at the 0.8.4 upgrade). The substitutes are the tray extra, the in-window keyboard transport, and the background track toast: 0.8.4 added `fx.showNotification`, so a track change while the app is backgrounded posts a desktop notification. It is opt-in (back panel → NOTIFICATIONS) and the whole decision lives in `Model.shouldNotifyTrack` — pure, because the effect is inert and unrecorded under fake execution, so no test can observe the call itself. -- SDK 0.8.4 also has **no audio output-device API** — the platform seam is load/play/pause/stop/seek/volume, with no enumeration and no device property, so there is no in-app "play through these speakers" picker to build. Route it at the OS (`pavucontrol`/PipeWire, Windows volume mixer; macOS has nothing native). The upstream request lives in `docs/sdk-audio-device-request.md`. +- SDK 0.9.0 still has **no OS media-controls surface** (no MPNowPlayingInfoCenter / MPRemoteCommandCenter / MPRIS / hardware media keys — re-checked at the 0.9.0 upgrade). The substitutes are the tray extra, the in-window keyboard transport, and the background track toast: 0.8.4 added `fx.showNotification`, so a track change while the app is backgrounded posts a desktop notification. It is opt-in (back panel → NOTIFICATIONS) and the whole decision lives in `Model.shouldNotifyTrack` — pure, because the effect is inert and unrecorded under fake execution, so no test can observe the call itself. +- SDK 0.9.0 also has **no audio output-device API** — the platform seam is load/play/pause/stop/seek/volume, with no enumeration and no device property, so there is no in-app "play through these speakers" picker to build. Route it at the OS (`pavucontrol`/PipeWire, Windows volume mixer; macOS has nothing native). The upstream request lives in `docs/sdk-audio-device-request.md`. - **Cover art loads through `fx.loadImage`, not `fx.fetch` + `registerImageBytes`** — the fetch and the platform decode run on a worker thread, with a content-addressed disk cache under the OS caches dir. Its ImageId **is** the effect key, which is why the counter starts at `keys.cover_image_base` (1000) clear of every other effect key. An id only reaches `model.cover_id` once the runtime reports `.loaded`; anything else leaves the initials disc standing. - Stream format is listener-selectable (`stream_format.zig`): MP3 is the always-available floor, AAC additionally decodes on macOS (AVPlayer) and Windows (Media Foundation), and the Ogg-encapsulated Opus/FLAC mounts are Linux-only (neither AVPlayer nor Media Foundation has an Ogg demuxer). Every host asserts its **full** matrix in the `platformSupports` test — Windows fell through the defensive `else` for two releases and shipped MP3-only because cross-compiling never ran the suite for the target. Linux offers Ogg mounts optimistically and `scheduleReconnect` drops a failing non-MP3 pick back to MP3 after 3 retries. The picker **lists** every platform-decodable mount and lets you tune only the ones the station advertises via the `stream` flags on `/api/now-playing` — unserved mounts say so on their detail line and their press is absorbed in `update`, so a dead value never reaches `format_pref`. Its entry point is the pressable format chip in the transport deck's SIGNAL row (plus the always-present back-panel row). - `native automate assert` regex does **not** support `|` alternation. diff --git a/README.md b/README.md index 734e9c8..e52391f 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ ones appear. ## Build & run -Requires **Zig 0.16.0** and `@native-sdk/cli` **0.8.4+** +Requires **Zig 0.16.0** and `@native-sdk/cli` **0.9.0+** (`npm i -g @native-sdk/cli`) — **plus one local SDK patch**. After every SDK install/upgrade: @@ -204,7 +204,7 @@ serve are listed saying exactly that rather than quietly absent. The same row is also in the back panel under SIGNAL. **Playing through a different output device.** Not offered in-app: the SDK's -audio surface (re-checked at 0.8.4) is load/play/pause/stop/seek/volume, with +audio surface (re-checked at 0.9.0) is load/play/pause/stop/seek/volume, with no device enumeration or output-device property on any host, so there is nothing honest to build a picker on. Route it at the OS instead — `pavucontrol` or any PipeWire patchbay on Linux, Settings → System → Sound → @@ -212,7 +212,7 @@ Volume mixer on Windows. macOS has no per-app routing without a third-party virtual audio driver. The API request is written up in [`docs/sdk-audio-device-request.md`](docs/sdk-audio-device-request.md). -**OS media integration.** The SDK (re-checked at 0.8.4) has no system +**OS media integration.** The SDK (re-checked at 0.9.0) has no system now-playing or media-key surface — no `MPNowPlayingInfoCenter`/ `MPRemoteCommandCenter` on macOS, no MPRIS on Linux — so hardware play/pause keys and the OS Now Playing widget can't be wired up yet (SDK feature request). diff --git a/design-reference/desktop-player.dc.html b/design-reference/desktop-player.dc.html new file mode 100644 index 0000000..7482348 --- /dev/null +++ b/design-reference/desktop-player.dc.html @@ -0,0 +1,826 @@ + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+
+ {{ stationName }} + +
+ + ▸ {{ showName }} + + + WITH {{ hostName }} + + graveyard shift · slow burn · 12° clear + +
+
+ + + +
+
+ + +
+
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ Stations + +
+
+ TUNED IN + +
+
+
+ {{ stationName }} + + + ON AIR + +
+ radio.subwave.fm +
+
+ RECENT + +
+ +
+ + + {{ st.name }} + {{ st.url }} + + +
+
+
+ DISCOVER + +
+ +
+ + {{ st.name }} + {{ st.sub }} + {{ st.status }} + + +
+
+
+ + + Add a station +
+
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ NOW PLAYING — {{ elapsedLabel }} + · + + {{ tokensLabel }} +
+
{{ trackTitle }}
+
{{ trackArtist }}{{ trackAlbumLine }}
+
{{ metaTokens }} ↳ {{ moodPhrase }}
+
+ + {{ djLine }} +
+
+
+ +
+
+ + +
+
+
+ {{ panelTitle }} + + {{ panelSub }} + + +
+
+
+ + +
+ + STATION TIME + {{ stationTime }} + + + LOCATION + Marfa, TX + +
+ +
+ ON NOW + {{ onNowShow }} + with {{ onNowHost }} +
+
+
+ + + +
+ +
+ {{ slot.range }} + + {{ slot.initials }} + + + {{ slot.show }} + {{ slot.host }} + + NOW +
+
+
+ + +
UP NEXT
+ +
+ {{ t.n }} + + {{ t.title }} + {{ t.artist }} + + ↳ REQUESTED BY {{ t.req }} + + +
+
+
PLAYED
+ +
+ + {{ t.title }} + {{ t.artist }} + + {{ t.ago }} +
+
+
+ + +
+ + + +
+ +
+
+ {{ turn.time }} + {{ turn.kind }} +
+
{{ turn.text }}
+
+
+
+ + + +
+
+ + + LINE OPEN + + REQUEST SLIP +
+
+
DEAR DJ —
+
+ +
+
+ + +
+
+
+
Describe a mood, a memory, an artist. The agentic AI DJ reads your note, digs the library, and answers you on-air.
+
ON THE WIRE
+
+ + + +
+ +
+ +
+
+ + {{ reqStateLabel }} +
+
“{{ reqAck }}”
+
+
{{ reqCardLabel }}
+ +
finding your track…
+
+ +
Chrome Lagoon
+
Motel Pool
+
+
+ +
POSITION #1 IN QUEUE
+
+
{{ reqFootnote }}
+ +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ SIGNAL · {{ signalLabel }} + {{ signalReadout }} +
+
+
+
+
+ + +
+
+
+ 050100150200250 +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ + + +
+
+
+ {{ sheetTitle }} + +
+ + +
OUTPUT
+
+
+
+ +
+ SYSTEM + Built-in Output +
+
+
+ +
+ CAST + Chromecast · TV +
+
+
TIMER
+
+ + + Sleep timer + + + {{ sleepValue }} + + +
+
+
FASCIA
+
+ + + Theme + + + {{ themeValue }} + + +
+
+
SHORTCUTS
+
+ TUNESpace + DIAL1–5 + MUTEM + VOLUME↑ ↓ + STATIONS⌘K + BACK TO LIVEEsc +
+
MODEL SW-D1 · SERIAL ∞ · MADE FOR THE INTERNET
+
+ + + +
+ + + + TUNING OUT IN + {{ sleepCountdown }} + + + +
+
+ +
The radio tunes itself out when the timer lapses — drift off without playing all night.
+
+ +
+ {{ opt.label }} + +
+
+
← back panel
+
+ + +
+ + Follow station + Use whatever palette the station broadcasts + + +
+
+ +
+
+ + + +
+
+ {{ th.name }} + +
+
+
+
+
← back panel
+
+
+
+
+
+ + + diff --git a/docs/sdk-notes.md b/docs/sdk-notes.md index ddd3e88..748bed9 100644 --- a/docs/sdk-notes.md +++ b/docs/sdk-notes.md @@ -9,11 +9,13 @@ native test # verify The unified diff lives at `patches/native-sdk-local.patch`, generated against the pristine 0.7.1 npm tarball and re-verified against 0.8.0 (whose -`gtk_host.c` is byte-identical) and 0.8.4 (whose `gtk_host.c` changed, but -entirely outside the patched hunks — regenerating produced the same file byte -for byte). In every case the hunks land with zero offset and the patch itself -needed no edit; only `patch_sdk_version` below moves. Symptom of a lost patch: -pixelated text on a fractional-scale Linux display. +`gtk_host.c` is byte-identical), 0.8.4 and 0.9.0 (whose `gtk_host.c` both +changed, but entirely outside the patched hunks). Through 0.8.4 the patch body +never moved at all; 0.9.0 is the first release where the file grew enough +*before* the patched regions that the hunk **headers** had to be re-cut — the +eight `@@` line numbers shift, every `+`/`-` line is unchanged. The hunks still +land with zero fuzz. Symptom of a lost patch: pixelated text on a +fractional-scale Linux display. ## Two version pins, and they must agree @@ -69,6 +71,53 @@ workarounds they replaced are still visible in the git log: [native#148]: https://github.com/vercel-labs/native/issues/148 [native#149]: https://github.com/vercel-labs/native/issues/149 +## What 0.9.0 changed (upgraded 2026-08-13) + +A big release, and **additive everywhere this app touches** — no app edits, no +markup edits, the same 106 passed / 1 skipped before and after with nothing +under `src/` changed. The bulk of it is a new persistence stack plus a much wider embed/C +API and mobile hosts, none of which a desktop Zig core has to adopt. + +| Surface | 0.8.4 → 0.9.0 | +| --- | --- | +| `Effects` `pub fn` list | additions only (~35): `persist`, the `store*` KV verbs, the `db*` relational verbs, `credentials*`, `bindSystemServices`, **`hideWindow`**, **`setDockPresence`** | +| Platform `*_fn` services | additions only: `hide_window_fn`, `set_dock_presence_fn`, `set_launch_at_login_fn` / `launch_at_login_status_fn`, `update_tray_presentation_fn`, `format_local_time_fn`, `note_blocking_call_abandoned_fn` | +| `PlatformFeature` enum | identical | +| `app.zon` manifest permissions | identical — nothing new to declare | +| Markup vocabulary | identical: no new elements, no new attributes | +| Built-in icons | identical (still nothing for picture-in-picture — hence `src/icons/mini.svg`) | +| Automation protocol | `0x096c8aa4730c11ec`, unchanged — existing `native automate` calls keep working | +| `minimum_zig_version` | 0.16.0, unchanged | +| Trace default | still `.events` — `-Dtrace=off` stays mandatory | +| `src/platform/linux/gtk_host.c` | changed (13 hunks), but **not in the patched regions** — see below | + +**`fx.hideWindow` is the one worth acting on later.** `model.zig`'s +`toggle_mini` currently calls `fx.minimizeWindow("main")` because, as the +comment there says, minimize was "the only reversible app-driven get-it-off-the-glass +verb the SDK has" — `fx.closeWindow` is a real close and a closed shell window +cannot come back. 0.9.0 removes that constraint, so mini mode could hide the +full player outright instead of animating it into the Dock/taskbar. That is a +behavior change, not an upgrade step; it is deliberately **not** part of this +bump. Same for `setDockPresence` (an LSUIElement-style hide) and +`setLaunchAtLogin`, both plausible features this app does not have yet. + +The persistence stack (`persist` / `store*` / `db*` / `credentials*`) could in +principle replace `settings.zig`'s hand-rolled `settings.json`. It should not, +yet: the current path is debounced, serialized, and covered by tests, and +swapping it buys nothing a radio player needs. + +**The HiDPI patch survived again, with one wrinkle.** All seven scale sites in +the stock 0.9.0 tree still read an integer API — four `gtk_widget_get_scale_factor()` +and three `gdk_surface_get_scale_factor()`, zero uses of the fractional +`gdk_surface_get_scale()` — so [vercel-labs/native#156] remains open and the +patch remains necessary. `gtk_host.c` changed in 13 hunks (0.9.0 adds +`native_sdk_gtk_hide_window`, a libsecret-backed credentials store, and a +deferred-show path), none overlapping the eight patched regions: a +`patch --fuzz=0 --dry-run` against the pristine tarball still applies. Unlike +0.8.4, though, regenerating did **not** reproduce a byte-identical file — the +additions at line 358 push everything down, so the eight `@@` headers were +re-cut against 0.9.0. Patch body unchanged line for line. + ## What 0.8.4 changed (upgraded 2026-08-12) Four releases (0.8.1–0.8.4, 5–10 August). Additive across every surface this @@ -265,15 +314,15 @@ surface at 1.6667 yields an 1889px buffer for 1888px of screen) and maps buffer pixels 1:1 to device pixels when it matches, letting the surplus edge column fall outside the clip. -0.6.0, 0.7.1, 0.8.0 and 0.8.4 all still read the integer API at every one of -those sites (0.6.0 added `gdk_surface_get_scale_factor` calls, which is the -*integer* GDK entry point, not the fractional `gdk_surface_get_scale`; 0.7.1 -changed none of them, 0.8.0 does not touch `gtk_host.c` at all, and 0.8.4 -changes it only outside the patched hunks), so the patch still applies — with -zero fuzz. **Re-apply after every +0.6.0, 0.7.1, 0.8.0, 0.8.4 and 0.9.0 all still read the integer API at every +one of those sites (0.6.0 added `gdk_surface_get_scale_factor` calls, which is +the *integer* GDK entry point, not the fractional `gdk_surface_get_scale`; +0.7.1 changed none of them, 0.8.0 does not touch `gtk_host.c` at all, and +0.8.4 and 0.9.0 change it only outside the patched hunks), so the patch still +applies — with zero fuzz. **Re-apply after every `npm i -g @native-sdk/cli` upgrade**, or drop it once [vercel-labs/native#156](https://github.com/vercel-labs/native/issues/156) -ships — still open as of 0.8.0. Integer scales (100%/200%) are unaffected, which is why it can look fine +ships — still open as of 0.9.0. Integer scales (100%/200%) are unaffected, which is why it can look fine on a second machine. Verify it took on a fractional display: run the app under automation and read diff --git a/patches/native-sdk-local.patch b/patches/native-sdk-local.patch index dc5a16f..b323467 100644 --- a/patches/native-sdk-local.patch +++ b/patches/native-sdk-local.patch @@ -1,6 +1,6 @@ --- a/src/platform/linux/gtk_host.c +++ b/src/platform/linux/gtk_host.c -@@ -1000,6 +1000,41 @@ +@@ -1001,6 +1001,41 @@ g_free(preedit); } @@ -42,7 +42,7 @@ static double native_sdk_gpu_surface_width(native_sdk_gtk_native_view_t *view) { int width = view->widget ? gtk_widget_get_width(view->widget) : 0; if (width > 0) return (double)width; -@@ -1057,7 +1092,7 @@ +@@ -1058,7 +1093,7 @@ const double width = native_sdk_gpu_surface_width(view); const double height = native_sdk_gpu_surface_height(view); if (width <= 0 || height <= 0) return; @@ -51,7 +51,7 @@ if (native_sdk_gpu_surface_sync_geometry(view, width, height, scale)) { gtk_widget_queue_draw(view->widget); } -@@ -1097,7 +1132,7 @@ +@@ -1098,7 +1133,7 @@ const double width = native_sdk_gpu_surface_width(view); const double height = native_sdk_gpu_surface_height(view); @@ -60,7 +60,7 @@ if (width <= 0 || height <= 0) return; (void)native_sdk_gpu_surface_sync_geometry(view, width, height, scale); -@@ -1207,7 +1242,7 @@ +@@ -1208,7 +1243,7 @@ const double logical_width = native_sdk_gpu_surface_width(view); const double logical_height = native_sdk_gpu_surface_height(view); if (logical_width <= 0 || logical_height <= 0) return; @@ -69,7 +69,7 @@ if (native_sdk_gpu_surface_sync_geometry(view, logical_width, logical_height, scale)) { gtk_widget_queue_draw(view->widget); } -@@ -1222,18 +1257,29 @@ +@@ -1223,18 +1258,29 @@ return; } cairo_save(cr); @@ -110,7 +110,7 @@ cairo_set_source_surface(cr, surface, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), exact ? CAIRO_FILTER_NEAREST : CAIRO_FILTER_BILINEAR); } else { -@@ -2508,7 +2554,7 @@ +@@ -2509,7 +2555,7 @@ int w = 0, h = 0; native_sdk_window_content_size(win, &w, &h); GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window)); @@ -119,7 +119,7 @@ int focused = gtk_window_is_active(win->gtk_window) ? 1 : 0; native_sdk_emit(host, (native_sdk_gtk_event_t){ .kind = NATIVE_SDK_GTK_EVENT_WINDOW_FRAME, -@@ -2530,7 +2576,7 @@ +@@ -2532,7 +2578,7 @@ int w = 0, h = 0; native_sdk_window_content_size(win, &w, &h); GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window)); @@ -128,7 +128,7 @@ native_sdk_emit(host, (native_sdk_gtk_event_t){ .kind = NATIVE_SDK_GTK_EVENT_RESIZE, .window_id = win->id, -@@ -2675,7 +2721,7 @@ +@@ -2677,7 +2723,7 @@ const double w = (double)gtk_widget_get_width(GTK_WIDGET(win->gtk_window)); const double h = (double)gtk_widget_get_height(GTK_WIDGET(win->gtk_window)); GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window)); diff --git a/scripts/apply-sdk-patches.sh b/scripts/apply-sdk-patches.sh index f0ff8cc..796a48f 100755 --- a/scripts/apply-sdk-patches.sh +++ b/scripts/apply-sdk-patches.sh @@ -10,7 +10,7 @@ set -euo pipefail # lines, or reporting success against a tree the app can no longer build. Bump # this together with .github/actions/setup-native's native-sdk-version whenever # the patch is regenerated (docs/sdk-notes.md walks through it). -patch_sdk_version="0.8.4" +patch_sdk_version="0.9.0" sdk="$(npm root -g)/@native-sdk/cli" repo="$(cd "$(dirname "$0")/.." && pwd)"