//! deck: the radically skinned sibling of `examples/soundboard` — the same //! local music player (albums, tracks, transport, seek, search) //! wearing vintage rack-unit hardware identity, in the true two-window //! shape: a SMALL, FIXED player window (the window IS the device — //! CHROMELESS, so the enamel cap band is the drag region and carries //! the skin's own working close/minimize keys) and a matching playlist //! unit declared through `windows_fn` while the model says it is open //! (the PL key and `primary+L` flip the flag). //! Everything visual comes from the deck theme's design tokens plus //! Zig-drawn chrome (the `ui.chart` spectrum, pixel-face readouts, the //! seven-segment elapsed readout, the volume knob face) — pure fills, //! lines, gradients, and paths; this skin ships no bitmap texture //! assets, and nothing forks the engine. Playback is REAL: the audio //! effect channel drives the platform player over the shared committed //! catalog (the mp3s live once, in the soundboard's gitignored assets), //! and a failed load lands the honest NO MEDIA remedy on the display //! instead of a crash or silence. //! //! Authoring split (markup where it fits): the playlist's status strip is //! a `.native` view compiled at comptime; the faceplate and the playlist //! rack are Zig views because they need what the closed markup grammar //! deliberately excludes — the chart widget, scaled mono spans, per-row //! native context menus, and the registered-image cover leaf. const std = @import("std"); const runner = @import("runner"); const native_sdk = @import("native_sdk"); pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic); const canvas = native_sdk.canvas; const geometry = native_sdk.geometry; const chrome = @import("chrome.zig"); const model_mod = @import("model.zig"); const theme = @import("theme.zig"); const view_mod = @import("view.zig"); pub const Model = model_mod.Model; pub const Msg = model_mod.Msg; pub const update = model_mod.update; pub const rootView = view_mod.rootView; pub const canvas_label = "deck-canvas"; pub const window_width: f32 = view_mod.window_width; pub const window_height: f32 = view_mod.window_height; pub const playlist_window_label = model_mod.playlist_window_label; pub const playlist_canvas_label = "playlist-canvas"; const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view }; const shell_views = [_]native_sdk.ShellView{ .{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Deck canvas", .accessibility_label = "Deck music player", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true }, }; const shell_windows = [_]native_sdk.ShellWindow{.{ .label = model_mod.main_window_label, .title = "Native SDK Deck", .width = window_width, .height = window_height, // The player is a piece of hardware: fixed size (the chrome pass // machines absolute geometry) and NO OS chrome at all — the // explicit `chromeless` opt-in, honest here because the enamel cap // band is the drag region AND carries the skin's own working // close/minimize keys (wired to the real window-action effects). // Apps that do not draw their own controls should use the hidden // styles instead, which keep the real OS buttons. .resizable = false, .titlebar = .chromeless, .restore_state = false, .views = &shell_views, }}; pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows }; // -------------------------------------------------------------- app icons /// The deck's own vector glyphs, parsed at comptime from the common /// stroke-icon dialect (24x24, stroke-width 2, currentColor): the /// transport STOP square (the built-in set carries every other /// transport verb but no square, and this skin refuses a text-labeled /// stop key) and the cap band's MINIMIZE bar (the chromeless windows /// draw their own window controls; the built-in set has no minus). const stop_icon = canvas.svg_icon.parseComptime(@embedFile("icons/stop.svg")); const minimize_icon = canvas.svg_icon.parseComptime(@embedFile("icons/minimize.svg")); /// The registered icon table: ONE declaration feeds boot-time /// registration (`registerIcons`, called from main and the test /// harness) AND the model contract's `app_icons` list, so `app:` /// references are verified by `native check` against exactly what the /// app registers. pub const app_icons = [_]canvas.icons.Entry{ .{ .name = "stop", .icon = &stop_icon }, .{ .name = "minimize", .icon = &minimize_icon }, }; /// Install the app icon table; once, before views build (main does it /// first thing, and the tests' harness setup mirrors it). pub fn registerIcons() void { canvas.icons.registerAppIcons(&app_icons); } // ---------------------------------------------------------------- covers /// Album cover image ids ARE the album ids (1-based): the covers are /// the only registered images this app carries — 8 of the runtime's 16 /// slots. (An earlier round also registered two bitmap chassis /// textures; the vintage-enamel skin draws its texture with fills, /// lines, and gradients instead, so the image channel is covers-only.) pub fn coverImageId(album_id: u8) canvas.ImageId { return album_id; } /// Boot effect: decode and register every album's committed cover from /// the manifest's art slots. Registration is synchronous on the effects /// channel; ids reach the model only on success, so a failed decode /// leaves that surface on its vector fallback (the art bay and sleeve /// pane stay engraved plates) — a bad asset can never break /// presentation. The covers are JPEG: live macOS decodes them through /// the platform codec, while the null platform's strict test decoder /// refuses them and the suite pins the degrade instead. pub fn boot(model: *Model, fx: *model_mod.Effects) void { inline for (model_mod.albums, 0..) |album, index| { if (album.art) |art_path| { const image_id = coverImageId(album.id); if (fx.registerImageBytes(image_id, @embedFile(art_path))) |_| { model.covers[index] = image_id; } else |_| {} } } } // --------------------------------------------------------------- commands // Shortcut command ids: registered in app.zon (`.shortcuts`), delivered as // command events, mapped to Msgs here. One spelling, two homes: app.zon // and this table (the README documents the bindings). pub const cmd_play_pause = "deck.play-pause"; // primary+P pub const cmd_next = "deck.next"; // primary+arrowright pub const cmd_prev = "deck.prev"; // primary+arrowleft pub const cmd_playlist = "deck.playlist"; // primary+L pub const cmd_dismiss = "deck.dismiss"; // escape pub fn command(name: []const u8) ?Msg { if (std.mem.eql(u8, name, cmd_play_pause)) return .toggle_play; if (std.mem.eql(u8, name, cmd_next)) return .next_track; if (std.mem.eql(u8, name, cmd_prev)) return .prev_track; if (std.mem.eql(u8, name, cmd_playlist)) return .toggle_playlist; if (std.mem.eql(u8, name, cmd_dismiss)) return .clear_search; return null; } /// The media-app space convention: SPACE toggles the transport from /// anywhere — both windows, focused or not. Bare space cannot be a /// chrome shortcut (unmodified character keys and space are rejected by /// `validateShortcut` so registration can never steal typing), so it /// rides the app-level key FALLBACK instead: the framework's precedence /// rule runs first, meaning a focused ledger row consumes space to play /// THAT row, a focused transport button activates itself, and a focused /// editable field keeps typing spaces — the text-entry exception is /// structural (by widget kind), so the playlist's search field blocks /// the toggle without this function naming it. `primary+P` (the chrome /// shortcut above) stays the works-even-while-typing chord. pub fn onKey(keyboard: canvas.WidgetKeyboardEvent) ?Msg { if (keyboard.modifiers.hasNavigationModifier() or keyboard.modifiers.shift) return null; if (std.ascii.eqlIgnoreCase(keyboard.key, "space")) return .toggle_play; return null; } // -------------------------------------------------------------------- app pub const DeckApp = native_sdk.UiApp(Model, Msg); // ------------------------------------------------------------------ fonts /// The deck's primary text face: Geist Pixel (Square), committed at /// src/fonts/ with its OFL license and registered at boot through the /// app-fonts seam — the theme's typography tokens point BOTH face slots /// at this id (see theme.zig), so every span on the fascia prints in /// the pixel face. The face is TrueType-glyf and well inside the /// registry's per-font byte budget. pub const app_fonts = [_]DeckApp.FontRegistration{.{ .id = theme.primary_font_id, .name = "GeistPixel-Square.ttf", .ttf = @embedFile("fonts/GeistPixel-Square.ttf"), }}; pub fn deckOptions() DeckApp.Options { return .{ .name = "deck", .scene = shell_scene, .canvas_label = canvas_label, .update_fx = update, .init_fx = boot, .view = rootView, .fonts = &app_fonts, .tokens_fn = tokensFromModel, // The sculpted hardware layer: enamel chassis and cap band, // bevels, wells, screws, scanlines, the seven-segment readout, // the band ladders, and the volume knob face — a fixed-count // display-list pass drawn behind (prefix) and in front of // (suffix) the widgets. See chrome.zig. .chrome = .{ .prefix_commands = chrome.prefix_commands, .suffix_commands = chrome.suffix_commands, .build = chrome.build, }, // The playlist rack: presence in the declared set IS visibility. .windows_fn = deckWindows, .window_view = deckWindowView, .on_appearance = onAppearance, .on_command = command, .on_key = onKey, .on_frame = onFrame, .sync = sync, }; } /// The declared window set derives from the model: the playlist window /// exists exactly while `playlist_open` is set. A Msg opens it, a Msg /// closes it, and the user's titlebar close dispatches /// `.playlist_closed` so the model agrees. fn deckWindows(model: *const Model, scratch: *DeckApp.WindowsScratch) []const DeckApp.WindowDescriptor { var count: usize = 0; if (model.playlist_open) { scratch.windows[count] = .{ .label = playlist_window_label, .canvas_label = playlist_canvas_label, .title = "Deck Playlist", .width = view_mod.playlist_width, .height = view_mod.playlist_height, // A matching rack unit: fixed size and chromeless like the // player — its cap strip is the drag region and carries the // skin's own working close/minimize keys. .resizable = false, .titlebar = .chromeless, .on_close = .playlist_closed, }; count += 1; } return scratch.windows[0..count]; } fn deckWindowView(ui: *DeckApp.Ui, model: *const Model, window_label: []const u8) DeckApp.Ui.Node { std.debug.assert(std.mem.eql(u8, window_label, playlist_window_label)); return view_mod.playlistView(ui, model); } /// One finish, by the brief: hardware has exactly one enamel, so the OS /// color scheme never reaches the theme. The appearance still matters /// for high contrast (which abandons the skin for the framework /// palette) and reduce motion. pub fn tokensFromModel(model: *const Model) canvas.DesignTokens { return theme.tokens(model.appearance.high_contrast, model.appearance.reduce_motion); } /// Appearance changes land in the model so `tokens_fn` re-derives; only /// contrast and motion are consumed (see `tokensFromModel`). fn onAppearance(appearance: native_sdk.Appearance) ?Msg { return Msg{ .set_appearance = appearance }; } /// The frame clock, WHILE PLAYING ONLY (the soundboard idiom): each /// presented frame's timestamp advances the rendered playback clock /// (`advanceRenderedClock`), whose changed spectrum/marquee/timecode /// present the next frame — the dispatch loop sustains itself exactly /// as long as audio moves. Pause, stop, buffering, or idle return null, /// the display list stops changing, and the frame channel starves on /// its own: zero frames while nothing moves (the idle law), with no /// arming flag to forget to clear. Journaled like every Msg, so replay /// is deterministic. pub fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg { if (model.playing and model.now != null and !model.buffering) { return Msg{ .frame_clock = .{ .timestamp_ns = frame.timestamp_ns, .interval_ns = frame.frame_interval_ns } }; } return null; } /// The runtime owns transient slider state (`.change` carries no value); /// mirror both faders into the model before each update so the `.seeked` /// and `.volume_changed` arms read the positions the user dragged to. /// Main canvas only — the playlist window has no sliders by design. fn sync(model: *Model, layout: canvas.WidgetLayoutTree) void { for (layout.nodes) |node| { if (node.widget.kind != .slider) continue; if (std.mem.eql(u8, node.widget.semantics.label, "Seek")) { model.seek_fraction = node.widget.value; } else if (std.mem.eql(u8, node.widget.semantics.label, "Volume")) { model.volume_fraction = node.widget.value; } } } // ------------------------------------------------------------------- main pub fn main(init: std.process.Init) !void { // The app icon table installs before any view builds. registerIcons(); // Streaming configuration, resolved once at launch (same story as // the soundboard): NATIVE_SDK_MUSIC_URL_BASE overrides the // manifest's committed base, and the platform caches directory // hosts the track cache in its audio/ child — delete it to clear. // Launch-time only, so replay's deterministic-init contract holds: // no env read ever happens inside update. var model: Model = .{}; if (init.environ_map.get("NATIVE_SDK_MUSIC_URL_BASE")) |base| model.setUrlBase(base); var cache_dir_buffer: [model_mod.max_cache_dir]u8 = undefined; const cache_dir = native_sdk.app_dirs.resolveOne( .{ .name = "deck" }, native_sdk.app_dirs.currentPlatform(), native_sdk.debug.envFromMap(init.environ_map), .cache, &cache_dir_buffer, ) catch ""; model.setCacheDir(cache_dir); const app_state = try std.heap.page_allocator.create(DeckApp); defer std.heap.page_allocator.destroy(app_state); app_state.* = DeckApp.init(std.heap.page_allocator, model, deckOptions()); defer app_state.deinit(); try runner.runWithOptions(app_state.app(), .{ .app_name = "deck", .window_title = "Native SDK Deck", .bundle_id = "dev.native_sdk.deck", .default_frame = geometry.RectF.init(0, 0, window_width, window_height), .restore_state = false, .js_window_api = false, .security = .{ .permissions = &app_permissions, .navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } }, }, }, init); } test { _ = @import("tests.zig"); }