Skip to content

feat(sideplay): add Spotify playback prototype - #45

Draft
manuel-rtsch wants to merge 46 commits into
HarlenPostill:mainfrom
manuel-rtsch:pr/sideplay
Draft

manuel-rtsch wants to merge 46 commits into
HarlenPostill:mainfrom
manuel-rtsch:pr/sideplay

Conversation

@manuel-rtsch

@manuel-rtsch manuel-rtsch commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What changed?

This PR adds Sideplay, Sidekit's Spotify sub-app: sign-in, library browsing, search, playback, queue/history, lock-screen/notification transport, and a keypad-first UI drawn with Sidekit chrome.

Warning

This is an early prototype. Use with caution.

Sideplay is not production-ready. It uses a native librespot bridge, stores Spotify credentials on-device, and exercises playback, account, and notification paths that are still being hardened. Expect rough edges and possible breakage. Prefer a test Spotify account, and do not rely on it as your only playback client.

The branch is intentionally focused on Sideplay plus the shared seams it needs (media-session ownership, remote artwork, encrypted credential storage, and the native librespot bridge). It is based on the latest upstream/main at the time of opening.

Included

  • Spotify PKCE sign-in with encrypted credential storage and token refresh.
  • Native librespot 0.8 bridge for playback, with a JNI surface and vendored core patch.
  • Authenticated catalog reads for library, collections, artists, search, and paging.
  • Library write-back: like/unlike, save/remove albums and shows, follow/unfollow artists, add/remove playlists.
  • Process-wide playback owner, media session, foreground notification, and lock-screen transport.
  • Sideplay-owned queue, shuffle, repeat, history, and persistence.
  • Autoplay continues with the current artist's top tracks when the queue ends; toggle in Settings.
  • On-disk audio cache (1 GiB, LRU eviction) with a Settings action that clears artwork and audio.
  • Local-only recently played; no Web API history quota.
  • Sideplay UI: Home, Library, Search, Collection, Artist, Now Playing, Queue, Settings, Sign-in. Home keeps two action buttons; Settings lives in Options, and Library is an emphasized icon tile.
  • Now Playing: artwork opens the album or show, Save is track-only, and the seek row no longer exposes its hidden width label to accessibility.
  • Documentation in docs/spotify/SIDEPLAY.md, including guardrails, known limitations, and the remaining roadmap.

How to test

  1. Build the dev variant: ./gradlew :app:assembleDev.
  2. Install app/build/outputs/apk/dev/app-dev.apk on a Sidephone.
  3. Open Sideplay from the launcher.
  4. Sign in with a Spotify account.
  5. Verify library filters, search, collection/artist pages, playback, queue actions, shuffle/repeat, and the notification/lock-screen controls.
  6. Verify sign-out clears the session and that reopening Sideplay does not restore a stopped track.

Real-device testing

  • Device model: SP01_FE_GE (Sidephone)
  • Android version: 12 / SDK 31
  • Keypad or attached module, if relevant: Sidephone keypad
  • Commit tested: e6ddc88 on integration; this PR branch contains the same Sideplay changes rebased onto upstream/main (916fe56).
  • Test steps and results: Device testing was completed on the Sidephone and reported working end-to-end. Covered sign-in, library filters, search, playback, queue/history, notification and lock-screen transport, audio-focus hand-off with local Music, and headphone-unplug pause.
  • Screenshots, recording, or relevant logs: Sideplay screenshot baselines are in app/screenshots/sideplay_*.png.

Screenshots

Rendered from the current pr/sideplay tip (916fe56) with the Roborazzi screenshot tests (SideplayScreenshotTest, mock catalog data — no account or library data).

Home
Home — Now playing, recently played, and the two primary destinations.
Library
Library — Album filter with explicit rows and paging.
Album
Album — Collection view with play, save, and artist actions.
Search
Search — Grouped results for tracks, albums, and playlists.
Now Playing
Now Playing — Artwork, seek, transport, shuffle/repeat, and save.
Queue
Queue — Current item, upcoming queue, and history.

Checklist

  • I created this branch from the latest main and this PR targets main.
  • I kept this PR focused and described its changes above.
  • I built the affected app and recorded the build command and result below.
  • I tested the changed behavior on a physical device and completed the device details above.
  • I checked nearby behavior for regressions, including keypad navigation and input when affected.
  • I ran the relevant automated tests and recorded commands and results below.
  • I added or updated tests where practical, or explained why no test is needed.
  • I updated documentation when behavior or setup changed.
  • I removed debug code, credentials, and personal data from the diff and test evidence.

Build and automated test results

Run on pr/sideplay:

  • ./gradlew :app:testDebugUnitTest spotlessCheck :app:assembleDev — pass
  • ./gradlew :app:testDebugUnitTest --tests 'hrln.interactive.sidekit.apps.spotify.*' spotlessCheck — pass
  • cargo test --manifest-path native/sidekit-spotify/Cargo.toml — 2 passed
  • cargo fmt --manifest-path native/sidekit-spotify/Cargo.toml -- --check — pass
  • Dev APK: app/build/outputs/apk/dev/app-dev.apk
  • Installed dev build during device testing: 1.2.3-158-g1d296e0-dev (versionCode=251)

Known issues and exceptions

  • Early prototype. Treat playback, account, and notification behavior as unstable.
  • Recently played is local-only. There is no first-party history endpoint and no Web API quota spend for it.
  • Sideplay owns queue, shuffle, and repeat. librespot 0.8 has no shuffle/repeat setters; native shuffle/repeat events are adopted when another client changes them.
  • Playlist owner names. Your own playlists show You; other users' playlists show their Spotify username.
  • Playback does not auto-resume after process death. Pressing play starts the restored current track.
  • Native build requirements. librespot 0.8, NDK 27.1.12297006, and the vendored librespot-core patch are required.
  • Clean-room reference. .sidespot/ is GPLv3 reference-only and is not committed or linked.
  • Music is untouched. This PR does not modify MusicScreens.kt; the integration branch keeps Harlen's Now Playing layout.

Maintainer review

This is a draft PR for early review. Please do not merge until the remaining P0 device checklist and P1 test-coverage items in docs/spotify/SIDEPLAY.md are complete.

Spotify is coming as a second media provider, and nothing currently stops
two players from holding a session and talking over each other. Add a
neutral MediaSessionOwner (identity, notification slot, active flag,
pause) plus a process-wide registry that pauses every other active owner
when one starts.

MusicPlayback implements it: it claims the session in publish() and
releases it on pause/stop. MusicService takes its channel and notification
id from the owner instead of private constants, so a second provider can
register its own slot without touching the service.

No track/queue types cross the boundary; the Music UI keeps MusicPlayer.
PrivateSettings already encrypts with KeystoreCipher, which lived in
BankingRepository.kt, so every app that wants sealed storage was importing
the banking app for it. Move the cipher into apps/integrations next to
PrivateSettings, name the interface SecretCipher since it is not
banking-specific, and leave banking a BookCipher typealias so its call
sites don't change. The default key alias stays "sidekit_banking" so
existing banking.sealed files still open.
System.loadLibrary ran from EmulatorCore's initialiser, and the emulator
caught only Exceptions, so a build without that ABI died with an
UnsatisfiedLinkError before any UI could say why. NativeLibraries loads
once, caches success and failure, and returns false. The emulator checks
it and shows "not available in this build".

The Spotify bridge needs the same convention: arm64-only device builds
and x86_64 emulator builds will each miss the other's library.
spotlessCheck ratchets from origin/main, so any file this branch changes
must satisfy ktlint. These are formatter-only rewrites (trailing commas,
brace placement, import order) in BankingRepository, PrivateSettings,
SecretCipher, MusicPlayback and MusicService.

Unrelated files the formatter also rewrites on this branch are left alone;
they were already out of date before this work.
Spotify artwork is HTTPS, while Thumbnails only asks MediaProvider for a
local content:// thumbnail, and the catalog has no Coil/Glide/OkHttp.
RemoteArtwork does the smaller job: same 1/32-of-heap LRU as Thumbnails, a
16 MB disk cache keyed by url+size and trimmed oldest-first, two fetches at
a time with timeouts and an 8 MB cap, cancellation that disconnects the
socket when a row leaves composition, and a bounds/inSampleSize decode
into an RGB_565 crop so a 104 px tile never holds a full cover.

RemoteArtworkImage draws it into a box and shows a Sidekit icon until the
image arrives or if it never does. Failures are not cached, so scrolling
back retries.
- Read library, search, collections, artists and shows through librespot SpClient/Pathfinder instead of the rate-limited Web API.
- Sync Now Playing with native playback events and refresh the native catalog token.
- Fix remote-artwork OOM crashes and format podcast dates/playlist owners.
- Document the vendored librespot-core patch, implementation status and remaining work.
Retry show and episode metadata once when Spotify's extended-metadata endpoint returns a transient error, and fall back to the context resolver for show details and episode order. Mark video episodes as non-playable.

Show ten search results per section and let More reveal the next ten locally before fetching another page. The More row no longer reports how many items it will reveal.
- Move Modifier to the first optional parameter in SpotifyIconTile and
  EmptyMessage, updating call sites to named arguments.
- Replace Uri.parse with the KTX String.toUri() extension.
…value

The shared SidekitItem started rendering the link arrow even when a
secondary value was present, which would silently add an arrow to
existing rows such as Apps and Mail folders. Gate that behind
showLinkArrowWithSecondary (default false) and opt in only from
Sideplay's settings rows, which were designed with the trailing arrow.
@manuel-rtsch

Copy link
Copy Markdown
Contributor Author

Follow-up in 85f5fa1: the shared SidekitItem change is now opt-in. showLinkArrow no longer renders with a secondary value unless showLinkArrowWithSecondary = true, so existing rows (Apps, Mail folders) keep their current look. Only Sideplay’s settings rows opt in. :sidekit:compileDebugKotlin, :app:compileDebugKotlin, and spotlessCheck pass.

…ery track

After an hour or two idle, librespot logs 'Connection to server closed'
and marks its session invalid, but the Rust engine kept the Session handle
and accepted token updates. The gateway only reconnected on a token change,
so playback kept using the dead session: every track failed to fetch an
audio key, the decoder hit end-of-stream, and the player emitted Unavailable
for each item ('This track isn't available'), while HTTP catalog reads still
worked.

- engine::set_access_token now reports SESSION_INVALID when
  Session::is_invalid(), so the Kotlin side can rebuild.
- NativeSpotifyGateway revalidates the session on every ensureConnected()
  instead of returning early when the token is unchanged, and reconnects
  when validation fails. Catalog session checks do the same.
- ensureConnected() reports whether it rebuilt the session; resume() reloads
  the current item at its position because a rebuild discards the loaded
  track.

Verified on SP01GE260600886: after closing the access point with airplane
mode, the next play logs 'Connecting to AP' / 'Authenticated as ...' and
loads the track normally.
A dead librespot session was already rebuilt on the next play, but if the
access point had stalled, Session::shutdown could block behind a component
mutex while the engine held its global STATE lock. That wedged every native
call, so the play button hung with no error.

- bound the native connect handshake (20s) and the Kotlin wrapper (25s)
- take player/session out of STATE and release the lock before stopping
  the player or shutting the session down
- run the old session's shutdown on a detached thread so reconnect is not
  blocked by a teardown that may never return
@manuel-rtsch

Copy link
Copy Markdown
Contributor Author

Fixed the "every track isn't available" failure after Sideplay sits idle.

Root cause: when librespot's access-point connection or keep-alive dies, the session is marked invalid, but the engine kept reusing the stale Session. Playback then failed to fetch audio keys for every track while the HTTP catalog stayed browsable. ensureConnected() only reconnected when the OAuth token changed, so it never rebuilt the session.

Also found while testing: rebuilding the session called Session::shutdown() while holding the engine's global STATE lock. On a stalled access point that shutdown can block on a librespot component mutex, which wedged every native call — the play button then hung with no error and no audio.

Changes:

  • ensureConnected() revalidates the session on every play/resume (setAccessToken now reports SESSION_INVALID) and rebuilds from scratch when dead; a rebuilt session reloads the current item at its position.
  • Native connect is bounded at 20 s, with a 25 s Kotlin wrapper, so a stalled handshake surfaces an error instead of hanging the transport.
  • disconnect() takes the player/session out under STATE, releases the lock, then stops the player and runs the old session's shutdown on a detached thread. A teardown that cannot complete no longer blocks reconnect.

Verified on device (SP01GE260600886, dev build): killed the access point with airplane mode for ~95 s, restored the network, then played. Log now shows a fresh Connecting to AP / Authenticated as / Loading … loaded, audio focus is requested, and an active AudioTrack is created. Before the second fix the same path hung immediately after the player stop.

spotlessCheck and :app:testDebugUnitTest (incl. the new dead-session-reconnect test) pass. docs/spotify/SIDEPLAY.md documents the reconnect, timeout, and teardown behaviour.

Note: this does not change the P0/P1 device checklist in the PR description; the session-liveness item still wants a >1 h token-rotation check.

Home and Artist render Spotify items with the media id as the LazyColumn key. Older history files could contain the same id twice, and artist album pages can overlap because the native catalog chains albums_current with singles_current. Either case throws IllegalArgumentException during the draw pass.

Deduplicate history on load, deduplicate artist albums and tracks on first load and when paging, and prefix the remaining raw-id keys with the row index so a future duplicate cannot crash the list.
Share the Item API with HarlenPostill#42 so the two branches merge cleanly.
manuel-rtsch added a commit to manuel-rtsch/Sidekit that referenced this pull request Nov 10, 2026
Share the Item API with HarlenPostill#45 so the two branches merge cleanly, and leave
the Sundial demo layout to HarlenPostill#44.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants