Commit Graph
735 Commits
Author SHA1 Message Date
HoneyandClaude Sonnet 4.6 5265aa1f33 fix: replace SpotipyFree with curl_cffi, remove dead Node.js TOTP code
SpotipyFree crashes with KeyError: 'ownerV2' — Spotify changed their
internal API response format and the library is out of date.

curl_cffi presents a real Chrome TLS fingerprint so open.spotify.com/api/
server-time responds (plain Node.js TLS is filtered by Spotify's WAF).
Node.js TOTP fallback always failed on this for the same reason — removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:17:15 +02:00
HoneyandClaude Sonnet 4.6 750089fc8e revert: remove proxy support, restore SpotipyFree-only resolver
IP is not actually blocked (api.spotify.com returns 401, not 403/timeout).
Previous rate limits were temporary. Reverting to the clean state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:04:10 +02:00
HoneyandClaude Sonnet 4.6 207e241600 fix: add SPOTIFY_PROXY support to bypass server IP ban
Spotify blocks API calls from server IPs (open.spotify.com/api/* times
out, api.spotify.com/v1/ returns 429). Setting SPOTIFY_PROXY routes all
Spotify calls through curl_cffi via that proxy, bypassing the IP ban.

Without proxy, SpotipyFree is still tried (works on non-banned IPs).
A clear tip is printed if SpotipyFree fails without a proxy configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:45:18 +02:00
HoneyandClaude Sonnet 4.6 7f7bedc15e fix: remove rate-limited REST fallback, explicitly install SpotipyFree
The curl_cffi fallback in spotify_resolve.py was silently catching
SpotipyFree ImportErrors and hitting api.spotify.com/v1/ directly —
the same rate-limited endpoint. Removed it entirely.

Also explicitly pip-install SpotipyFree before TzurSoffer's package
so the import is always available regardless of dependency resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:38:23 +02:00
HoneyandClaude Sonnet 4.6 0f1e6ff531 refactor: remove dead code from backend and lib
- Remove unused spotifyClientId/spotifyClientSecret from config
- Remove AUDIO_EXTS from metadata exports (internal-only)
- Remove tryYtdlp wrapper in ytdlp.js (direct spawnYtdlp calls)
- Remove _spArtistName wrapper in server (direct _spAllArtists calls)
- Fix stale comment: primary resolver is SpotipyFree, not curl_cffi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:18:50 +02:00
HoneyandClaude Sonnet 4.6 c36dbb3c94 fix: use SpotipyFree as primary resolver (SpotAPI + Chrome TLS fingerprint)
SpotipyFree wraps SpotAPI which uses tls_client to impersonate Chrome's
TLS fingerprint and hits Spotify's internal endpoints. This is exactly
what TzurSoffer's spotdl fork uses. Removes the complex GQL hash
extraction (which couldn't find hashes in lazy-loaded webpack chunks).
curl_cffi + TOTP kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:09:33 +02:00
HoneyandClaude Sonnet 4.6 06b78be199 fix: full stderr logging per-line, scan embed page for GQL hash
- Log each Python stderr line separately (no 500-char truncation) so
  we can see exactly what's happening in the bundle scan
- Try embed page (open.spotify.com/embed/...) first for GQL hashes —
  focused app, fewer bundles, GQL chunks directly referenced
- Suppress noisy bundle-fetch errors to keep logs readable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:26:51 +02:00
HoneyandClaude Sonnet 4.6 46eb8c826f fix: scan resource page JS for GQL hash (Spotify lazy-loads feature chunks)
fetchPlaylist/getAlbum/getTrack GraphQL hashes are not in homepage bundles
— they live in lazy-loaded chunks that only appear on the playlist/album/track
page. Fetch the specific page HTML to find the extra JS bundles, then scan
those for the persisted query hash. Also improved hash regex (no semicolon
restriction, wider window, fallback nearest-hex search).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:45:32 +02:00
HoneyandClaude Sonnet 4.6 e87d48d6b0 fix: use api-partner.spotify.com GraphQL instead of rate-limited REST API
api.spotify.com/v1 is IP-banned for 24h. Switch to Spotify's internal
partner API (api-partner.spotify.com/pathfinder/v1/query) which uses
separate infrastructure and rate limits. Persisted query hashes are
extracted dynamically from JS bundles alongside the TOTP secret.
Falls back to REST only if GQL hash not found or partner API fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:35:44 +02:00
HoneyandClaude Sonnet 4.6 8bf481a2da fix: replace spotdl CLI with direct curl_cffi Chrome TLS impersonation
spotdl save hangs for 60s initializing its full pipeline. Rewrite
spotify_resolve.py to use curl_cffi (already installed) which
impersonates Chrome's TLS fingerprint directly. TOTP token fetched
in Python too. RATE_LIMITED propagates without TOTP fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:21:32 +02:00
HoneyandClaude Sonnet 4.6 b53910ed45 fix: switch backend to node:20-slim (Debian) for glibc compatibility
tls-client (used by SpotAPI/spotdl) bundles a native .so that requires
libresolv.so.2 from glibc. Alpine uses musl libc which doesn't provide
this — causing spotdl to crash on import. Debian-based image has glibc.
Also install yt-dlp via direct download (latest) instead of apk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:07:50 +02:00
HoneyandClaude Sonnet 4.6 eacd68aea7 fix: remove Spotify credential requirement — no API keys needed
spotdl (SpotAPI) works without developer credentials. Remove the
health-check warning, credential error UI, and Client Credentials
fallback that were blocking the importer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:01:35 +02:00
HoneyandClaude Sonnet 4.6 9b197e119a fix: strip ?si= from Spotify URL, improve spotdl error logging
spotdl doesn't handle tracking params in URLs. Also separate stdout/stderr
in Python script and always try parsing stdout before checking exit code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:55:41 +02:00
HoneyandClaude Sonnet 4.6 3c85deab40 feat: use spotdl (TzurSoffer fork) as primary Spotify resolver
Replaces rate-limited api.spotify.com calls with spotdl's SpotAPI
approach, which uses Spotify's internal browser endpoints — no
developer credentials needed, no api.spotify.com rate limits.
TOTP anonymous token kept as automatic fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:49:19 +02:00
HoneyandClaude Sonnet 4.6 8cc6653898 fix: add Spotify web-player headers to API calls, pace paginated requests
Without app-platform/spotify-app-version headers, requests look like
bot traffic and get rate-limited aggressively. Add 300ms delay between
playlist track pages to avoid burst throttling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:08:44 +02:00
HoneyandClaude Sonnet 4.6 859f4a458a fix: fail fast on Spotify 429 instead of retrying into timeout
Retrying with wait times (23s + 60s + 60s) exceeded the 120s global
timeout and showed a generic timeout error. Now returns immediately
with a RATE_LIMITED error and shows the user how long to wait.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:05:41 +02:00
HoneyandClaude Sonnet 4.6 fbb00baa11 feat: TOTP-based anonymous Spotify token (no developer credentials needed)
Dynamically extracts Spotify's TOTP secret from their web-player JS bundle,
computes a 6-digit HMAC-SHA1 code synced to open.spotify.com/api/server-time,
and requests a token from open.spotify.com/api/token — completely bypassing
the rate-limited Client Credentials token. Client Credentials kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:12:29 +02:00
HoneyandClaude Sonnet 4.6 61271bff27 fix: use anonymous clienttoken as primary Spotify auth, Client Credentials as fallback
Developer Client ID is 24h rate-banned. clienttoken.spotify.com uses
Spotify's own web-player client ID (separate rate-limit bucket).
Also: fast-fail when Retry-After > 60s and invalidate the cached token
so the next request immediately fetches a fresh one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:59:55 +02:00
HoneyandClaude Sonnet 4.6 3633b4c5fa fix: handle 429 rate-limit within timeout window
- Cap per-retry wait at 15s (Spotify's Retry-After can be very long)
- Increase global resolve timeout from 25s to 120s so retries can complete
- Log rate-limit waits for visibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:15:24 +02:00
HoneyandClaude Sonnet 4.6 bb52717c95 fix: force HTTP/1.1 via ALPNProtocols to prevent h2 framing mismatch
api.spotify.com negotiates HTTP/2 via TLS ALPN but https.request uses
HTTP/1.1 framing — the protocol mismatch silently hangs the connection.
wget on Alpine doesn't advertise h2 so it works fine. ALPNProtocols:
['http/1.1'] forces HTTP/1.1 on both ends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:01:12 +02:00
HoneyandClaude Sonnet 4.6 d2cb1bed68 fix: add User-Agent, dual timeout, done-guard, and response logging to _fetchTimeout
- User-Agent header (Spotify may throttle anonymous requests)
- req.setTimeout + socket.setTimeout for belt-and-suspenders
- done() guard prevents double-resolve/reject
- [sp-http] log shows status code of each Spotify response
  so logs reveal whether the hang is before or after HTTP headers arrive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:50:18 +02:00
HoneyandClaude Sonnet 4.6 f4766acc74 fix: switch Spotify HTTP to https module with socket.setTimeout for OS-level timeout
undici cannot abort a TCP connect in progress even with AbortController.
Node's https.request + socket.setTimeout fires at the OS socket level
and aborts within ms even when the TCP SYN is silently dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:45:10 +02:00
HoneyandClaude Sonnet 4.6 d2620519aa fix: replace AbortSignal.timeout with AbortController+setTimeout for reliable fetch timeouts
undici (Node.js native fetch) silently ignores AbortSignal.timeout when
TCP connect is blocked. Using AbortController driven by Node's setTimeout
guarantees the abort fires and the hanging request is cancelled within 10s.
Adds [spotify] token ok log to confirm when credentials work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:43:31 +02:00
HoneyandClaude Sonnet 4.6 76271c1e54 fix: 25s backend timeout + 30s frontend timeout on Spotify resolve
Extracts resolve logic into _resolveSpotify() and wraps it in
Promise.race with a 25s timer so the backend always sends a response
even if undici's AbortSignal.timeout silently fails on blocked sockets.
Also reduces frontend timeout from 90s to 30s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:39:07 +02:00
HoneyandClaude Sonnet 4.6 7d38fdf601 fix: add 90s timeout to Spotify resolve fetch, prevent infinite spinner
Frontend fetch to /api/spotify/resolve had no timeout — if the backend
stalled (Spotify API slow or unreachable), the spinner would never stop.
Also caps the health check at 5s so loadSpotifyImporter can't hang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:32:38 +02:00
HoneyandClaude Sonnet 4.6 f7e4059f2d fix: friendly error for Spotify editorial playlist 403
Backend returns {error:'EDITORIAL_PLAYLIST'} on 403 playlist routes.
Frontend shows a step-by-step workaround card instead of a red error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:22:56 +02:00
HoneyandClaude Sonnet 4.6 468827571a feat: switch Spotify auth to official Client Credentials Flow
Replaces the broken open.spotify.com/get_access_token scrape with
accounts.spotify.com/api/token (Basic auth, client_credentials grant).
Adds SPOTIFY_CLIENT_ID/SECRET to config, docker-compose, and .env.example.
Health endpoint now reports spotify.configured; frontend disables the
importer and shows a setup guide when credentials are missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:57:01 +02:00
Honey 7c1c444bf2 fix: 429 retry, safety counter, local file filter in Spotify pagination 2026-05-09 20:58:16 +02:00
Honey b7f09058a9 fix: use sp_t cookie trick for Spotify anonymous token (spotdl approach) 2026-05-09 20:55:00 +02:00
Honey 6c4caebc69 fix: regex accessToken directly from embed HTML instead of brittle JSON path 2026-05-09 20:17:37 +02:00
Honey 60c74a60a2 fix: get Spotify token from embed page instead of blocked get_access_token endpoint 2026-05-09 20:02:18 +02:00
Honey f218b66ad2 fix: replace Spotify embed scraper with anonymous token + real API
Uses open.spotify.com/get_access_token for an anonymous bearer token,
then paginates through the full Spotify API — removes the 100-track
embed limit for playlists and albums.
2026-05-09 19:58:07 +02:00
Honey 7dd22d9639 fix: apply square curve to volume slider so low positions are genuinely quiet 2026-05-09 17:12:37 +02:00
Honey e2c28926b2 feat: redesign sleep timer custom input as clickable row with inline edit 2026-05-09 17:10:35 +02:00
Honey b4c12a2940 feat: add custom minutes input to audiobook sleep timer 2026-05-09 16:25:06 +02:00
Honey 5ee6614ac9 fix: lower default audio volume from 50% to 30% 2026-05-09 16:21:09 +02:00
Honey 2feb00be62 fix: don't auto-resume audio on tab/app focus if user explicitly paused 2026-05-09 16:17:50 +02:00
HoneyandClaude Sonnet 4.6 8e752fa892 fix: player bar instant update + stop music on app close
player.js: move NSBridge.setQueue() into requestAnimationFrame so the
updatePlayerUI DOM changes are committed and painted before the blocking
@JavascriptInterface call. setQueue serializes a large JSON payload and
blocks the JS/Blink main thread while Java parses it — deferring it one
frame ensures the player bar visually updates immediately on tap.

MusicService: onTaskRemoved now calls player.stop() + stopSelf() so
swiping the app from recents stops playback instead of leaving it running
in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:00:18 +02:00
HoneyandClaude Sonnet 4.6 7084fc4118 fix: player bar freezes on manual song start in bridge mode
- playQueue: update UI immediately before NSBridge.setQueue so the bar
  reflects the new song without waiting for onTrackChanged
- onTrackChanged: remove index guard that blocked UI refresh when
  S.qIdx was already set; always call updatePlayerUI/onNpSongChange
- nextSong/prevSong: optimistic UI update before calling native next/prev
- _computeNextIdx: extracted helper for shuffle/repeat/linear logic
- _lastPositionMs: new variable for prev() 3s threshold in bridge mode
- updatePlayerUI/_syncNpDirect: guard img.src assignment to prevent
  redundant reloads and flicker on double update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:04:33 +02:00
HoneyandClaude Sonnet 4.6 cf2921c56f fix: cover art on auto-advance + native back-button overlay routing
Issue 1 (MusicService): wrap DefaultHttpDataSource in CacheBitmapLoader so
Media3 notification artwork is fetched once and served from memory on every
subsequent auto-advance. warmArtworkCache() pre-loads current+next 3 tracks
on both onMediaItemTransition and onTimelineChanged (queue replace via setQueue).
Adds explicit media3-datasource dependency.

Issue 2 (back button): replace deprecated onKeyDown with OnBackPressedCallback.
WebBridge.setOverlayState(bool) tracks whether JS has an overlay open;
consumeBackPress() is called first — if true, MainActivity delegates to
window.NSPlayer.handleBack() which closes the topmost layer. Falls back to
webView.goBack() then moveTaskToBack(true) so the MusicService binding survives.
JS side: _syncOverlayState() + MutationObserver keep the native flag current;
handleBack covers NP overlay, queue panel, action sheet, all modals, sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:11:09 +02:00
HoneyandClaude Sonnet 4.6 76ecfe2268 feat: wire NSBridge fully + HD artwork + disable watchdog in wrapper mode
player.js
- Replace `_native()` function with `const HAS_BRIDGE` (evaluated once at
  module load so bridge detection is rock-solid)
- Add `buildTrackList()` — maps S.queue to NSBridge Track array with 1024px
  artworkUrl so lock-screen cover is crisp on high-DPR displays
- Add `_pushQueueIfBridge()` — called by `fillRadioQueue()` so radio top-ups
  are pushed to ExoPlayer in real time (fixes autoplay gap after radio refill)
- `playQueue`: route through `NSBridge.setQueue` when bridge present; old
  `S.audio` path retained for browser/PWA fallback
- `playCurrent`: `NSBridge.seekToIndex + play` in bridge mode
- `nextSong / prevSong / togglePlay / onEnded`: delegate to bridge, skip local
  S.qIdx manipulation (native sends onTrackChanged callback instead)
- `seekTo / _initBarScrub`: use `_lastDurationMs` for ms-accurate seeks
- `onTrackChanged`: guard with `p.index !== S.qIdx` to avoid redundant redraws
- `updateMediaSession`: artwork sizes 96/300/512 → 256/512/1024
- Player-bar cover: 100 → 256px; NP cover/bg: 400 → 1024px;
  queue items: 60 → 128px; preload hint sizes: 96/300/512 → 256/512/1024

main.js
- Wrap watchdog setInterval in `typeof window.NSBridge === 'undefined'` guard;
  the foreground service keeps ExoPlayer alive — JS watchdog not needed there

views.js
- Album detail / playlist detail covers: 300 → 512px
- Playlist grid cards: 200 → 512px
- Search result song cards: 200 → 256px
- Artist top-songs / all-songs thumbnails: 60 → 128px
- Partial album art in _renderArtistAlbums: 300 → 512px

components.js
- makeAlbumCard: 200 → 512px
- makeSongItem: 60 → 128px

nowplaying.js
- NP queue items: 60 → 128px
- NP action sheet thumb: 80 → 256px

sw.js
- Bump CACHE to ns-v41 so stale small-cover entries are evicted on next load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:33:43 +02:00
HoneyandClaude Sonnet 4.6 0ee2a62adb fix: ensure foreground service + notification permission on Android 14
- startService() before bindService() so Android 14 allows foreground
  promotion (bind-only no longer satisfies the "started from foreground"
  requirement since API 34)
- smarter POST_NOTIFICATIONS flow: re-ask once, then redirect to app
  notification settings instead of silently doing nothing after denial
- track first-ask via SharedPreferences so the settings redirect only
  triggers on a second-or-later launch after denial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:42:11 +02:00
HoneyandClaude Sonnet 4.6 0e17d2e5db feat: native Android audio via Media3 ExoPlayer + full NSBridge JS adapter
- Replace legacy MediaBrowserServiceCompat/MediaSessionCompat with
  Media3 MediaSessionService (MusicService.kt) + ExoPlayer set to
  WAKE_MODE_NETWORK — holds WakeLock + WifiLock through Doze so audio
  plays uninterrupted on locked screen
- Media3 manages the notification; no manual NotificationCompat.Builder
  → no disappear/reappear flicker when skipping tracks
- Full bidirectional bridge:
    JS → native: window.NSBridge.* (WebBridge.kt, 13 methods)
    Native → JS: window.NSPlayer.* callbacks (onReady, onTrackChanged,
      onPositionUpdate, onPlaybackStateChanged, onQueueEnded, onError)
- player.js: progressive enhancement — all playback routes through
  NSBridge when running inside the wrapper, falls back to S.audio on
  plain PWA/browser; removes all window.Android.* calls
- main.js: guard _onForeground mediaSession playbackState setter behind
  !window.NSBridge so native Media3 session is never stomped
- ProGuard rules updated to keep WebBridge @JavascriptInterface methods
  and Media3 classes
- bridge-test.html: manual integration test page for every bridge method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:14:28 +02:00
HoneyandClaude Sonnet 4.6 1ede8727a2 fix: arm canplay before play(), reset on any error, remove stale MediaSession on restore
- playCurrent(): arm canplay listener BEFORE calling play() so it's never missed
  when play() hangs (common on locked Android — promise neither resolves nor rejects)
- playCurrent(): on ANY play() failure reset audio element (src + load()) so the
  audio engine starts fresh and canplay fires reliably; previously only AbortError
  triggered the reset — NotAllowedError (audio focus lost on lock screen) did not
- playCurrent(): single _armCanplay() helper replaces two separate guard/listener
  setups; re-arms after failure in case canplay already fired before the error
- _restoreLastPlayed(): remove updateMediaSession() call — pre-setting stale song
  metadata caused Samsung to treat the first actual playCurrent() as a new session
  (old song metadata → new song metadata while playing = notification close/reopen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:55:10 +02:00
HoneyandClaude Sonnet 4.6 d355129134 fix: lock screen audio + notification — audioSession once, deferred metadata, SW artwork cache
- Set navigator.audioSession.type='playback' only once (guarded flag) — repeated
  reassignment was resetting the OS audio session and triggering Doze network cuts
- Defer updateMediaSession() until after play() resolves, not before audio.src swap —
  new MediaMetadata during HAVE_NOTHING state caused Samsung to close/reopen notification
- Fix silent bug: updatePlayerUI(s, true) now actually skips MediaSession (added param)
- 10s safety guards now attempt play() instead of just resetting _trackChanging flag
- SW v40: cache-first for getCoverArt with token-stripped keys — artwork served from
  cache even when Doze blocks network, survives session restarts with new auth tokens
- Pre-warm next track artwork 2s after song starts (earlier than 30s-remaining trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:37:23 +02:00
HoneyandClaude Sonnet 4.6 ec13124d99 fix: generation counter prevents stale _reset() from clobbering active transition
When skipping rapidly, old playCurrent() closures had _reset() callbacks
that could fire _trackChanging=false while a newer transition was still
in progress — breaking both notification guard and background recovery.

Each playCurrent() now captures a generation number (++_playGen).
_reset() is a no-op if _playGen has advanced (newer call took over).
canplay and play().catch handlers also check the generation before acting.

This makes all stale callbacks safe regardless of how quickly the user skips.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 00:38:55 +02:00
HoneyandClaude Sonnet 4.6 b941672a77 fix: keep _trackChanging=true through entire recovery window (lock screen)
When play() fails on locked screen, Android may revoke audio focus and
fire a spurious pause event. Previously we reset _trackChanging=false
right in the catch, leaving a window for that pause event to set
playbackState='paused' and dismiss the Samsung cover screen notification.

Now _trackChanging stays true for the ENTIRE recovery (initial fail ->
canplay retry -> success/fail), with a 10s Kima-style safety net as the
only exit. This means:
- AbortError: reload src+load(), _trackChanging stays true, canplay retries
- NotAllowed/network: same — _trackChanging stays true, canplay retries
- If canplay fires and play() succeeds: play event resets _trackChanging
- If canplay fires and play() fails: _reset() is called
- If canplay never fires (Doze): 10s timeout resets _trackChanging

Net effect: Samsung lock screen notification never sees playbackState='paused'
during the recovery window, so it stays open instead of dismissing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 00:24:11 +02:00
HoneyandClaude Sonnet 4.6 688348545d fix: clean rewrite of audio recovery matching Kima Hub design
playCurrent():
- Remove canplay listener from before play() — it caused a race where
  both the listener and the initial play() fired simultaneously
- canplay is now only registered in the catch (failure path only),
  eliminating the double-play race entirely
- AbortError: reload src+load() with _trackChanging=true (OS interruption
  recovery); canplay fires once buffered and retries play()
- Other errors: reset _trackChanging, let canplay retry (not throttled
  by Android background timers unlike setTimeout)
- Add 10s safety net to reset _trackChanging if Doze kills everything
  (matches Kima Hub's reload failsafe)

main.js error handler:
- Skip when _trackChanging=true (playCurrent already recovering) to
  prevent conflicting dual recovery attempts
- _onForeground: reset _trackChanging on play() failure so the flag
  can't get stuck when foreground recovery also fails

index.html:
- Add crossorigin=anonymous: Navidrome and NovaSonic backend both send
  Access-Control-Allow-Origin:* so this is safe, and it enables proper
  cross-context HTTP caching for cover art and audio responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 23:34:28 +02:00
HoneyandClaude Sonnet 4.6 0344dcdcb6 fix: preload=auto, artwork pre-warm, AbortError reload (matching Kima Hub)
- Add preload="auto" playsinline to <audio> element: browser starts
  buffering as soon as src is set, not only when play() is called —
  critical for background where play() has no data to work with
- Fix AbortError handling: instead of silently returning (was swallowing
  audio element bad-state errors), reload src + load() like Kima Hub
  so the already-registered canplay listener retries play() once buffered
- Pre-warm next song's cover art in _preloadNext() using _cachedCoverUrl
  so the lock screen notification never cold-starts artwork on first skip
- Move _coverCache to top of file so _preloadNext and updateMediaSession
  share the same stable token URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 23:09:46 +02:00
HoneyandClaude Sonnet 4.6 a0c6d09c41 fix: stable notification artwork + canplay retry for background audio
- Cache cover art URLs per song so the same auth token is reused across
  metadata updates; Samsung/Chrome can cache the artwork image and the
  notification no longer visually reloads on every skip
- Add canplay listener as background-safe play() retry: the audio
  element fires canplay from its internal state machine (not throttled
  like setTimeout by Android Doze), so play() is retried as soon as
  the browser has buffered enough data
- Set navigator.audioSession.type='playback' on each _tryPlay attempt
  (not just on play event) so the OS audio session claim happens before
  Chrome requests the play grant
- Guard _onForeground playbackState assignment with !_trackChanging to
  prevent visibilitychange/pageshow from stomping 'playing' to 'paused'
  during an in-progress track transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:49:35 +02:00