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)
- 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, neverdisplay: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 throughApp.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 withonLongPress(andhasLongPress(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 withinRELEASE_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.
- 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).
- 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 atscale(0.92)and animates toscale(1). - Zoom out (Back from sub-page): reverse of zoom in.
- Cross-fade (library tab switch): simple
opacityfade, 0.15s.
- 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;:rootholds the Pink defaults. Never an alpha-tinted fill. - Cards (album, artist, genre, playlist, quick-access, Home):
transform: scale(1.12)(withtranslateZ(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:#fff700 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-fillwhen 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".
- 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.cssisremon a rootfont-sizeof10px × scale: scale 1 / 1.25 / 1.5 / 1.75 / 2, default 1.5, set in Settings → Appearance → Interface size, stored inlocalStorage['sonance-ui-scale'], and applied before first paint by the inline<head>script inindex.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 useSonanceUtils.rem(px), layout maths usesSonanceUtils.px(n), and measured geometry stays measured. Never use CSSzoomto scale the UI: it mis-positions measured elements, and Chromium 63'szoomdiffers from today's. Spec:docs/UI-MOCKUP-REFERENCE.md"Interface size".
- ONLY animate
transformandopacity— 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-weightshould snap instantly (no transition). - Use
will-change: transform, opacityon elements that animate. - Keep durations short: 0.12-0.25s maximum.
- Vanilla JS only. No React, no build step, no bundler, no npm at runtime. The
.wgtmust 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 fromhttp://localhostduring browser dev/testing. - Target ES2017 syntax (Tizen 5.0 Chromium ~v63).
-
Chromium ~v63. No optional chaining (
?.), no nullish coalescing (??), noBigInt, no top-levelawait, noArray.flat(), noObject.fromEntries(). Use polyfills or manual alternatives. -
CSS
backdrop-filteris 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: stickymay have quirks. Preferposition: fixedfor 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: autoscrolling may need explicit container heights. Always set height on scrollable containers. -
CSS
gapon flex containers is NOT supported (Chrome 84+). NEVER usegapondisplay: flexelements. Usemargin-left/margin-topon child elements instead (the> * + *pattern or explicit margins on each child).CSS
gapshorthand on grid containers is NOT supported (Chrome 66+). Use the legacygrid-gapproperty instead.grid-gapworks from Chrome 57.
- Use
webapis.avplayfor 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
oncurrentplaytimefor progress tracking (fires with ms value). - Use
onstreamcompletedfor 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: detectwindow.webapisat startup and use AVPlay if available,<audio>otherwise. config.xmlmust include privilege:http://tizen.org/privilege/tv.audio.- Transcoded formats (v3.12, D173). Opus (
suffixopus or acontentTypenaming opus) streams through the server asformat=mp3&maxBitRate=320; every other URL is unchanged. All stream URLs come fromPlayer._streamUrlFor. A transcode has no byte ranges: a seek reloads it withtimeOffset(whole seconds), the engine's position is offset by it, and its duration is the track's metadata (D174–D176). Neveravplay.seekToa transcode.
- 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.xmluses<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 storedsonance_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 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.
- The v3 UI Design System above is authoritative. See
docs/UI-MOCKUP-REFERENCE.mdfor 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).
- 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
FocusManagerclass. - 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).
- 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
SubsonicAPIclass. - 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.
- Translation adapter (P1, D196).
JellyfinAPI(js/jellyfin-api.js) inheritsSubsonicAPI.prototypeand replaces the transport: each Subsonic endpoint the app calls is translated to Jellyfin requests and answered in thesubsonic-responseshape, so every public method, both caches,abortAll, the album cursor and the song pager are shared.js/api.jsgets 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-*orX-MediaBrowser-Token(12.x answers 401), and never both the header andApiKeyon one request. Never log a token, the header or a URL withApiKey. - Libraries.
GET /UserViews?includeHidden=true,CollectionType == "music": a library hidden from the user's Jellyfin home screen is still theirs./Library/MediaFoldersis admin-only. An unscoped/Genresis 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 fromTotalRecordCount(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, neveravplay.seekTo); cover/Items/{id}/Images/Primary?maxWidth&maxHeight&quality=90&format=Jpg[&tag], the tag carried insidecoverArt. An artist withoutcoverArthas no image: no request (D201).
- Login screen collects: server URL (with protocol), port, username, password.
- Session persists in
localStorage— user stays logged in indefinitely. - Logout button in Settings clears
localStorageand returns to login screen. - On app start, check
localStoragefor existing session; if valid, skip login. - Validate connection with
ping.viewbefore 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 aProductNamestarting "Jellyfin" signs in withPOST /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_idkept (one device per install; a second sign-in from it revokes its previous token). The password stays insonance_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).
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/
- Dev server: Use Python
http.serveror Nodehttp-serveron 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).
.wgtis a renamed.zipcontaining all app files +config.xml.- Build script:
build.shthat zips the app intoSonance.wgt. - Deploy via Jellyfin2Samsung (sideload the
.wgtto TV in developer mode).
- 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 CSSzoom).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) andPlaybackService(themediaPlaybackforeground service that keeps music playing in the background). The package iddev.simmo.sonanceis permanent. Spec:tickets/ticket-3.xx-androidtv.md; design:docs/ARCHITECTURE.md§5a; device checks:tests/tools/android-*.json the SDK's Google TV emulator (AVDsonance-tv). - The shim.
android/shim/webapis.jsis served at$WEBAPIS/webapis/webapis.js, before the bundles, and does nothing without the bridge. It defineswebapis.avplay(overPlayerEngine),webapis.appcommon,tizen.*,SonancePlatformandSonanceShim.receive(the one entry point for native calls), soPlayer.IS_TIZENis 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:SonancePlatformnames the platform in Settings → About and in Jellyfin'sDevice=("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.jsand 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.dispatchKeyEventPreImegives 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 keepsinputmode="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,onStopand 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.jsis unchanged. - The engine (X1, D240–D245).
PlayerEngineplays 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, neverstop()(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).
SleepTimeralso checks its deadline onPlayer'sprogressevents, 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 fromSONANCE_KEYSTORE,SONANCE_KEYSTORE_PASS,SONANCE_KEY_ALIASandSONANCE_KEY_PASS; a session never creates, reads or commits a keystore). The build fails if the staged payload differs fromSonance3.wgt, if the dex holds a class outsidedev.simmo.sonance, or if the badging lacks the leanback launcher, the banner, minSdk 26 or targetSdk 35. versionName isconfig.xml's version, and versionCode is major×10000 + minor×100 + patch. Never edit anything underandroid/build/. Deployment:docs/DEPLOYMENT-ANDROID-TV.md. - targetSdk 35, pinned (G9). At 36, Android 16 stops delivering
KEYCODE_BACKandonBackPressed, 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.
- 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.`