Skip to content

Latest commit

 

History

History
209 lines (183 loc) · 25 KB

CLAUDE.md

File metadata and controls

209 lines (183 loc) · 25 KB

CLAUDE.md — Sonance (Navidrome TV Client for Samsung Tizen)

Project Overview

Sonance is a music player app for Samsung Tizen TVs (specifically Q90R / Tizen 5.0) that connects to a Navidrome server via the Subsonic REST API, or (since v3.13, D184) to a Jellyfin 12.x server through a translation adapter (see "Jellyfin" below). It is a vanilla web app (HTML/CSS/JS) packaged as a .wgt widget for sideloading via Jellyfin2Samsung.

Brand: Sonance — "By Simmo" Target: Samsung Q90R (2019), Tizen 5.0, 1920×1080 display Server: Navidrome via Subsonic REST API (v1.16.1), or Jellyfin 12.x (12.2 tested) via js/jellyfin-api.js (v3.13, D184)

v3 UI Design System (current)

Navigation

  • Top nav bar replaces the sidebar. Floating, centred, pill-shaped container with 80% opacity dark background.
  • Nav items: Home, Library, Playlists, Queue, Now Playing, Search (🔍 icon), Settings (⚙ icon)
  • Sliding pill highlight behind focused item — moves with transform: translateX(). Light fill when focused, accent-tinted when selected.
  • Auto-navigate on slide, after a dwell (v3.10, D51). Left/Right moves the pill and its label at once; the page follows ~180 ms after the last press, so a flick across several items renders only the destination. No Enter needed. Down or Enter during the dwell navigates at once and drops into the page's content.
  • Down enters page content, Up returns to nav bar.
  • Entry index (v3.10, D54). A focus zone may declare entryIndex: Down/Up into it, and the drop from the top nav, land there. The Now Playing controls row always enters on Play/Pause, except that Up from the credits list returns to ⓘ (v3.10 S6, D118). An unavailable NP control is dimmed, never display:none, so focus can never land on an invisible element (D55).
  • Bottom bar (v3.10, D56). Repeated Down from any zone on any screen ends on the NP bar's first target, .np-bar-open (art + title + artist), a solid-fill pill; OK on it opens Now Playing. Then Previous, Play/Pause, Next. Up returns to the zone and item focus came from. The bar is not focusable while hidden (no track, or on Now Playing). Every screen registers it through App.registerNowPlayingBarZone(upNeighbour). On Now Playing, where the bar is hidden, repeated Down ends on the Up Next strip (v3.10 S6, A6, D123), or on the sleep-timer chip when Up Next is hidden in Settings (v3.10-fix2, D157).
  • Hold OK (v3.10 S7, A5, D133). On track rows (album, playlist, Songs, genre songs, Search songs, Queue) and Up Next tiles, OK is press-and-release aware: a short press activates on keyup; holding opens the options sheet (js/options-sheet.js, its own isolated zone; Back closes it onto the row). A zone opts in with onLongPress (and hasLongPress(idx) to exclude items); every other zone keeps activation on keydown. Since 3.14 (for the silver remote, whose hold likely arrives as release/press pairs): a keyup is provisional, and an Enter keydown within RELEASE_GRACE_MS (100 ms) continues the hold, so a short press activates 100 ms after its keyup (H1, D207); once an Enter keyup has been seen since launch, a key still down at 500 ms opens the sheet with or without repeats, and with none ever seen 3.13's 1,200 ms rule stays (H2, D211); after a hold opens the sheet, Enter is swallowed until the key is released (H3, D211).
  • There is NO sidebar. Do not create or reference a sidebar.

Library Sub-Navigation

  • Vertical pill menu on the left side of the Library screen (Albums, Artists, Songs, Genres).
  • Same sliding pill pattern as the top nav but vertical (translateY).
  • Auto-navigate on Up/Down after the same ~180 ms dwell (v3.10, D51): the pill moves at once, the tab is built once the presses stop. Right enters content (building a pending tab first), Left returns to sub-nav. Down on the last tab goes to the NP bar (v3.10, D56).

Transitions (ALL must be GPU-accelerated)

  • Page slide (top nav Left/Right): outgoing page slides + fades out, incoming slides + fades in. transform: translateX() + opacity.
  • Zoom in (Enter on item → sub-page): outgoing scale(1.08) + opacity:0, incoming starts at scale(0.92) and animates to scale(1).
  • Zoom out (Back from sub-page): reverse of zoom in.
  • Cross-fade (library tab switch): simple opacity fade, 0.15s.

Focus Styles (v4, v3.10 R6, D50)

  • Focus is a solid accent fill (--focus-fill) with a computed ink (--focus-ink: white if white-on-accent contrast ≥ 3:1, else #15151c; --focus-ink-soft = ink at 0.86 alpha). App.applyAccentColor() sets all three; :root holds the Pink defaults. Never an alpha-tinted fill.
  • Cards (album, artist, genre, playlist, quick-access, Home): transform: scale(1.12) (with translateZ(0) kept on the D6 cards) plus a ring and deep shadow on the art: box-shadow: 0 0 0 0.4rem var(--focus-fill), 0 2.2rem 4.4rem rgba(0,0,0,.7). Labels under art: #fff 700 focused, rgba(240,240,245,.72) 500 at rest.
  • Track/song rows: background --focus-fill, title/number/duration/icons in --focus-ink, meta in --focus-ink-soft, transform: scale(1.02), transform-origin: left center.
  • Buttons (Play, Shuffle, Settings rows, Logout, dialogs, keyboard keys): --focus-fill + --focus-ink. NP transport buttons get a circular fill platter; NP Play/Pause keeps its white platter at rest.
  • Nav pills: sliding pill highlight (top nav and library sub-nav), solid --focus-fill when focused, rgba(255,255,255,.15) when selected. The label colour is derived from the pill's state (D71), so they always agree.
  • Colour and background snap; only transforms animate. Transition duration: 0.12-0.15s for focus changes, 0.2-0.25s for page transitions.
  • A focused element's scale and ring must not be cut by a clipping container: give the container room (D76). Spec: docs/UI-MOCKUP-REFERENCE.md "Focus".

Layout

  • Full-width (1920px). Content centred with max-width: 1400px; margin: 0 auto;.
  • Background: --bg-main: #1a1a24 (warm dark grey).
  • Font: 'SamsungOne', 'SamsungOneUIFW', sans-serif (system font on Tizen).
  • No sidebar offset — content starts at left edge.
  • Interface size (v3.10, D49). Every length in css/styles.css is rem on a root font-size of 10px × scale: scale 1 / 1.25 / 1.5 / 1.75 / 2, default 1.5, set in Settings → Appearance → Interface size, stored in localStorage['sonance-ui-scale'], and applied before first paint by the inline <head> script in index.html. Write new CSS in rem (Npx → (N/10)rem); only 1px hairline borders and the Now Playing backdrop blur stay px. In JS, design lengths use SonanceUtils.rem(px), layout maths uses SonanceUtils.px(n), and measured geometry stays measured. Never use CSS zoom to scale the UI: it mis-positions measured elements, and Chromium 63's zoom differs from today's. Spec: docs/UI-MOCKUP-REFERENCE.md "Interface size".

Animation Rules (CRITICAL)

  • ONLY animate transform and opacity — these are GPU-composited.
  • NEVER use transition: all — it catches layout properties.
  • NEVER transition width, height, margin, padding, left, right, font-size, border.
  • Properties like color, background, font-weight should snap instantly (no transition).
  • Use will-change: transform, opacity on elements that animate.
  • Keep durations short: 0.12-0.25s maximum.

Critical Rules

Architecture

  • Vanilla JS only. No React, no build step, no bundler, no npm at runtime. The .wgt must be self-contained static files.
  • The app is a single-page application (SPA) with screen-based routing managed by a simple JS router.
  • All files must work when served from app:// origin inside a Tizen WebView AND from http://localhost during browser dev/testing.
  • Target ES2017 syntax (Tizen 5.0 Chromium ~v63).

Tizen 5.0 Constraints (CRITICAL)

  • Chromium ~v63. No optional chaining (?.), no nullish coalescing (??), no BigInt, no top-level await, no Array.flat(), no Object.fromEntries(). Use polyfills or manual alternatives.

  • CSS backdrop-filter is NOT supported. Do not use it anywhere. Use solid semi-transparent backgrounds (e.g. rgba(34, 34, 48, 0.95)) instead of blur effects.

  • CSS position: sticky may have quirks. Prefer position: fixed for persistent elements.

  • <audio> element has limited codec support. Do NOT use HTML5 <audio> for playback. Use the Tizen AVPlay API (webapis.avplay) as the primary and only playback method. See the Audio Playback section below.

  • overflow: auto scrolling may need explicit container heights. Always set height on scrollable containers.

  • CSS gap on flex containers is NOT supported (Chrome 84+). NEVER use gap on display: flex elements. Use margin-left / margin-top on child elements instead (the > * + * pattern or explicit margins on each child).

    CSS gap shorthand on grid containers is NOT supported (Chrome 66+). Use the legacy grid-gap property instead. grid-gap works from Chrome 57.

Audio Playback (AVPlay)

  • Use webapis.avplay for ALL audio playback. This is Samsung's native media framework and handles FLAC, MP3, AAC, OGG, and other codecs through the TV's hardware decoder.
  • AVPlay lifecycle: open(url) → setDisplayRect(...) → prepareAsync(callback) → play() → stop() → close().
  • For audio-only playback, set display rect to a 1×1 pixel off-screen area (AVPlay requires a display rect even for audio).
  • AVPlay callbacks: onbufferingstart, onbufferingcomplete, onstreamcompleted, oncurrentplaytime, onerror.
  • Use oncurrentplaytime for progress tracking (fires with ms value).
  • Use onstreamcompleted for track-ended detection (triggers next track).
  • Seek: seekTo(ms).
  • Browser fallback: AVPlay is only available on Tizen. For browser dev/testing, fall back to HTML5 <audio> element. The Player engine must abstract this: detect window.webapis at startup and use AVPlay if available, <audio> otherwise.
  • config.xml must include privilege: http://tizen.org/privilege/tv.audio.
  • Transcoded formats (v3.12, D173). Opus (suffix opus or a contentType naming opus) streams through the server as format=mp3&maxBitRate=320; every other URL is unchanged. All stream URLs come from Player._streamUrlFor. A transcode has no byte ranges: a seek reloads it with timeOffset (whole seconds), the engine's position is offset by it, and its duration is the track's metadata (D174–D176). Never avplay.seekTo a transcode.

Network & Security

  • The app MUST only connect to the user-configured server (Navidrome or, since v3.13, Jellyfin; D200). No CDNs, no external fonts, no analytics, no third-party APIs, no external image sources.
  • All assets (CSS, JS, SVGs, icons) are bundled in the .wgt. Zero external dependencies at runtime.
  • config.xml uses <access origin="*" subdomains="true"/> because the server IP/port (Navidrome or Jellyfin) is user-configured at runtime. This is necessary but the app code itself must NEVER make requests to any URL other than the stored sonance_server_url.
  • Stream and cover art URLs are constructed from the Subsonic API (or Jellyfin's, D200) using the configured server URL — they are NOT external resources.

Test Configuration

  • Test Navidrome server: http://192.168.0.2:4534
  • Use this for all browser testing during development. The user will provide credentials at runtime.
  • Jellyfin test server: see TEST-ACCOUNT.local.md (rows "Jellyfin server" / "Jellyfin username"; a passwordless profile). Its address and profile name are written nowhere else: parse them inside Node scripts, never print them (v3.13, D186).
  • The app itself does NOT hardcode any server address — it is always user-configured via the login screen.

UI Design

  • The v3 UI Design System above is authoritative. See docs/UI-MOCKUP-REFERENCE.md for the full design specification.
  • Colour palette (legacy reference, superseded by v3 where they conflict): BG_PRIMARY: #0a0a0c, BG_SECONDARY: #121217, BG_CARD: #1a1a22, BG_ELEVATED: #222230, ACCENT: #e44d8a, TEXT_PRIMARY: #f0f0f5, TEXT_SECONDARY: #8e8ea0, TEXT_MUTED: #55556a, BORDER: #2a2a38.
  • Dark theme only. No light mode.
  • Persistent now-playing bar at the bottom (76px tall).
  • All interactive elements must be focusable for d-pad navigation. Focus styling follows the Focus Styles rules above (solid accent fill for rows and buttons, scale + ring for cards, sliding pill for nav). Do NOT use outline or border focus rings; a box-shadow ring is allowed for cards only (v3.10, D50). The one exception is the accent swatch's white border: its fill is the colour it offers (D74).

D-Pad / Remote Navigation

  • The Samsung TV remote has: Arrow keys (Up/Down/Left/Right), Enter/OK, Back, Play/Pause, and colour buttons.
  • All navigation is keyboard/d-pad driven. No mouse, no touch.
  • Every interactive element must be focusable and reachable via d-pad.
  • Focus management is handled by a central FocusManager class.
  • Samsung remote keycodes: Back=10009, Play/Pause=10252, Stop=10253, Rewind=10412, FastForward=10417.
  • Standard arrow keys and Enter use normal browser keycodes (37/38/39/40/13).

Subsonic API Integration

  • Base path: /rest/
  • Auth: Token-based. token = md5(password + salt), send as query params: u, t, s, v=1.16.1, c=Sonance, f=json.
  • All API calls go through a single SubsonicAPI class.
  • Stream URLs: /rest/stream.view?id={songId}&...auth_params
  • Cover art: /rest/getCoverArt.view?id={coverId}&size={size}&...auth_params
  • Error handling: check subsonic-response.status === 'ok', surface errors to user.

Jellyfin (v3.13)

  • Translation adapter (P1, D196). JellyfinAPI (js/jellyfin-api.js) inherits SubsonicAPI.prototype and replaces the transport: each Subsonic endpoint the app calls is translated to Jellyfin requests and answered in the subsonic-response shape, so every public method, both caches, abortAll, the album cursor and the song pager are shared. js/api.js gets additive seams only (today: SubsonicAPI._trackRequest). A new endpoint the app calls needs a translator, or the adapter rejects it ("Not available on Jellyfin: …").
  • Auth (P4). API calls: Authorization: MediaBrowser Client="Sonance", Device=…, DeviceId=…, Version=…, Token=…, values URL-encoded. Media: ApiKey= on stream URLs only; image URLs carry no token (anonymous on Jellyfin). Never the legacy lower-case query key, X-Emby-* or X-MediaBrowser-Token (12.x answers 401), and never both the header and ApiKey on one request. Never log a token, the header or a URL with ApiKey.
  • Libraries. GET /UserViews?includeHidden=true, CollectionType == "music": a library hidden from the user's Jellyfin home screen is still theirs. /Library/MediaFolders is admin-only. An unscoped /Genres is not the per-library sum: ask each music library (D188).
  • Data. Durations are ticks (10^7 per second); song requests carry Fields=MediaStreams (the codec, D187); Jellyfin JSON omits null fields; album play data is not aggregated (Recently/Most played come from tracks, P7); counts come from TotalRecordCount (D197).
  • Media URLs. Stream /Audio/{id}/stream?static=true&ApiKey=…; a transcode /Audio/{id}/stream.mp3?audioCodec=mp3&audioBitRate=320000[&startTimeTicks=…] (D173's rules: seek by reload, never avplay.seekTo); cover /Items/{id}/Images/Primary?maxWidth&maxHeight&quality=90&format=Jpg[&tag], the tag carried inside coverArt. An artist without coverArt has no image: no request (D201).

Authentication & Session

  • Login screen collects: server URL (with protocol), port, username, password.
  • Session persists in localStorage — user stays logged in indefinitely.
  • Logout button in Settings clears localStorage and returns to login screen.
  • On app start, check localStorage for existing session; if valid, skip login.
  • Validate connection with ping.view before storing credentials.
  • Server detection (v3.13, P3, D200). With a password, the Subsonic login runs first, unchanged; only a not-Subsonic failure (HTTP 404, invalid response, JSON parse error) probes GET /System/Info/Public, and a ProductName starting "Jellyfin" signs in with POST /Users/AuthenticateByName. Wrong password, timeout and unreachable keep their messages with no probe. An empty password probes first (Jellyfin accounts may have none); anything else shows "Please enter a password" with no Subsonic request.
  • Jellyfin session keys (P5, D200): sonance_backend (jellyfin; absent = Subsonic), sonance_jf_token, sonance_jf_user_id, sonance_jf_server_version, all cleared at logout; sonance_jf_device_id kept (one device per install; a second sign-in from it revokes its previous token). The password stays in sonance_password ('' when passwordless) for a silent re-sign-in: a 401 at boot signs in once again; failing that, Login shows. Logout also revokes the token (POST /Sessions/Logout, best effort). Resume last queue is saved on the TV (sonance_jf_queue__<server>|<user id>, kept at logout).

File Structure

sonance/
├── config.xml              # Tizen widget manifest
├── icon.png                # App icon (256×256)
├── index.html              # Single HTML entry point
├── android/                # Android TV host (v3.15); built by build-android.sh
│   ├── AndroidManifest.xml
│   ├── res/                # Launcher banner and icons, theme, network security config
│   ├── shim/webapis.js     # The Tizen API shim, served at $WEBAPIS/webapis/webapis.js
│   └── src/dev/simmo/sonance/   # MainActivity, SonanceWebView, NativeBridge, PlayerEngine, PlaybackService
├── css/
│   └── styles.css          # All styles
├── js/
│   ├── app.js              # App init, router, screen manager
│   ├── api.js              # SubsonicAPI class
│   ├── jellyfin-api.js     # JellyfinAPI: Jellyfin 12.x behind the SubsonicAPI surface (v3.13, D196); loads after api.js
│   ├── auth.js             # Auth/session management
│   ├── focus.js            # FocusManager for d-pad navigation
│   ├── player.js           # Audio playback engine + queue
│   ├── screens/
│   │   ├── login.js        # Login screen
│   │   ├── home.js         # Home screen
│   │   ├── library.js      # Library screen (tabs)
│   │   ├── album.js        # Album detail screen
│   │   ├── search.js       # Search screen with on-screen keyboard
│   │   ├── nowplaying.js   # Full-screen now playing
│   │   ├── queue.js        # Queue/up next screen
│   │   ├── playlists.js    # Playlists screen
│   │   └── settings.js     # Settings screen (logout, server info)
│   └── utils.js            # Helpers (MD5, formatting, etc.)
├── docs/
│   ├── ARCHITECTURE.md
│   ├── UI-MOCKUP-REFERENCE.md
│   └── TESTING.md
├── tests/
│   └── browser/            # Browser-based test harness
├── PROGRESS.md
└── tickets/

Development & Testing

  • Dev server: Use Python http.server or Node http-server on port 8080 for browser testing.
  • Browser testing first. All screens and API integration must work in Chrome/Firefox before deploying to TV.
  • All API calls must handle network errors gracefully (timeout, unreachable server, auth failure).
  • Console logging with [Sonance] prefix for all debug output.
  • Test with keyboard navigation in browser (arrow keys + Enter + Escape for Back).

Build & Packaging

  • .wgt is a renamed .zip containing all app files + config.xml.
  • Build script: build.sh that zips the app into Sonance.wgt.
  • Deploy via Jellyfin2Samsung (sideload the .wgt to TV in developer mode).

Android TV (v3.15)

  • The host. android/ is a Java wrapper, on Android framework APIs only (no androidx, Media3/ExoPlayer, Leanback, Kotlin, Gradle or any other library), that runs the .wgt's web payload, byte for byte, in the system WebView at a 1920×1080 CSS viewport (setInitialScale; never CSS zoom). MainActivity (keys, lifecycle, renderer recovery), SonanceWebView (the WebView: keys before the system keyboard, and the keyboard itself; fix round 1), NativeBridge (window.SonanceNative), PlayerEngine (playback on MediaExtractor, MediaCodec and AudioTrack, and the audio focus) and PlaybackService (the mediaPlayback foreground service that keeps music playing in the background). The package id dev.simmo.sonance is permanent. Spec: tickets/ticket-3.xx-androidtv.md; design: docs/ARCHITECTURE.md §5a; device checks: tests/tools/android-*.js on the SDK's Google TV emulator (AVD sonance-tv).
  • The shim. android/shim/webapis.js is served at $WEBAPIS/webapis/webapis.js, before the bundles, and does nothing without the bridge. It defines webapis.avplay (over PlayerEngine), webapis.appcommon, tizen.*, SonancePlatform and SonanceShim.receive (the one entry point for native calls), so Player.IS_TIZEN is true and the app runs its AVPlay path, transcode rules included (D173–D176). The Tizen 5.0 rules above apply to it. The only shared-code seam: SonancePlatform names the platform in Settings → About and in Jellyfin's Device= ("Android TV").
  • Keys (K1). Native code translates the remote: the D-pad and OK pass through (37–40, 13); BACK becomes 10009, the media keys 10252, 10253, 10412 and 10417 (and 415, 19, 10232, 10233), and the colour keys 403–406, injected as synthetic key events; TV-only keys are swallowed (D221); Home, volume and power go to the system. focus.js and the hold-OK timing (D207, D211) never change for Android: the host adapts to them.
  • The system keyboard (fix round 1). Keys before the keyboard (X2, D237): SonanceWebView.dispatchKeyEventPreIme gives the D-pad, OK, ENTER and NUMPAD_ENTER straight to the WebView unless Sonance opened the keyboard and it is showing (a hidden Gboard on the SHIELD ran away with a Right press); the route is chosen at a key's down. The keyboard only on OK (X4, D236, superseding D223): the shim keeps inputmode="none" on every editable while it is closed; OK on a field is consumed (the page sees no Enter) and opens it; Back, the end of editing, onStop and window-focus loss close it. Typing ducks (X3, D238): while it is up, a transient focus loss ducks to 0.2 instead of pausing. login.js is unchanged.
  • The engine (X1, D240–D245). PlayerEngine plays through an AudioTrack opened in the PCM encoding the decoder, or a raw extractor track, actually delivers (Android 11's MediaPlayer played the SHIELD's float 24-bit FLAC into a 16-bit sink: static). Integer PCM wider than 16 bits becomes float on every API level. An app's FLAC and WAV extractors deliver 24-bit files as 16-bit (D244). The engine thread owns state, focus, ticks and the track's play, pause, volume and position; a decoder thread per stream owns the extractor, the codec and the track's creation, writes and release, and the engine never waits on it. A stream ends with a buffer of silence, never stop() (D241). Log lines: [Sonance] player N: open|prepared, D ms|pcm <in> -> <out>, <rate> Hz, <ch> ch|started|paused|completed|released; the harness matches them. Engine checks: tests/tools/android-engine.js.
  • The sleep timer (X5, D239, shared code). SleepTimer also checks its deadline on Player's progress events, because a hidden WebView page's timer chains stop after about 5 minutes while native-driven events still run.
  • The build. ./build.sh, then ./build-android.sh --debug (android/build/sonance-debug.apk) or --release (Sonance3-AndroidTV.apk, signed with the user's keystore from SONANCE_KEYSTORE, SONANCE_KEYSTORE_PASS, SONANCE_KEY_ALIAS and SONANCE_KEY_PASS; a session never creates, reads or commits a keystore). The build fails if the staged payload differs from Sonance3.wgt, if the dex holds a class outside dev.simmo.sonance, or if the badging lacks the leanback launcher, the banner, minSdk 26 or targetSdk 35. versionName is config.xml's version, and versionCode is major×10000 + minor×100 + patch. Never edit anything under android/build/. Deployment: docs/DEPLOYMENT-ANDROID-TV.md.
  • targetSdk 35, pinned (G9). At 36, Android 16 stops delivering KEYCODE_BACK and onBackPressed, and Back is the app's own navigation; 37 adds a local-network runtime permission and stricter background audio. The APK is sideloaded, so no store deadline forces a raise. Raising it is ticket §8 Q4, not routine.

Workflow Rules

  • One ticket per Claude Code session. Do not combine phases.
  • Update PROGRESS.md at the end of every session with: what was completed, what was tested, any issues found.
  • Do not install packages without first listing them and waiting for user confirmation. If the user is not available, log the requirement to PROGRESS.md and stop.
  • Rebuild/restart dev server after every code change to verify.
  • Test in browser before marking any task complete.
  • Always commit working state before moving to next phase.
  • Run autonomously. Do not stop to ask questions mid-phase. Use the test config values above for browser testing. If something is ambiguous, make the pragmatic choice, document it in PROGRESS.md, and keep going.`