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>
- 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>
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>
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>
- 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>
- 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>
- 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>
- 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>
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>
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>
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>
- 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>
- 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>
- Set playbackState='playing' explicitly before MediaMetadata swap so
Samsung One UI never sees a gap where state is ambiguous during skip
- Add retry loop (500ms→1s→2s→4s→8s) in playCurrent() catch — handles
Android Doze rejecting play() in background; stops retrying if the
song changes mid-flight (AbortError)
- Fix error handler: set _trackChanging=true before audio.load() so
the load()'s pause event can't slip past the Samsung notification guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Set navigator.audioSession.type = 'playback' on first play — tells Android
OS this is a music app, keeping background network alive through Doze mode
- Replace flat 2s error retry with exponential backoff (1s/2s/4s/8s)
- Add pageshow event alongside visibilitychange for Samsung/iOS PWA reliability
- Attempt audio resume on foreground if OS killed playback while backgrounded
- Switch preload from hidden audio element to link rel=prefetch (lighter,
avoids triggering two-audio-stream detection on Android)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the current song ends, Android's battery optimization briefly kills
background network access. By starting the next song's stream request while
the current track is still playing (and Chrome holds an active audio session),
the buffer is ready before the gap hits. The same URL is reused by the main
element so Chrome can serve from its in-memory buffer.
Also reset _trackChanging on play() failure to avoid permanently suppressing
pause events.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- playCurrent: set _trackChanging flag and update media session metadata
before changing src, so the pause event fired by src-swap does not
set playbackState='paused' and dismiss the Samsung cover screen notification
- Remove stalled->load() handler that silently paused audio with no recovery
- Error handler now retries regardless of paused state (load() causes paused)
- Add .catch() to audio.play() in playCurrent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /api/abs-proxy/* endpoint in the backend that forwards all ABS API
calls server-side (X-ABS-URL + X-ABS-Token headers). Browser never
touches ABS directly for API calls — no CORS issue.
- abs.js routes through proxy when backendUrl is set; falls back to direct
if no backend (requires user to configure CORS on ABS server)
- Audio streaming still uses direct <audio src> (HTML media bypasses CORS)
- Settings: status text above buttons, buttons full-width flex row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace username/password login with API key (Bearer token) auth
- New absVerifyApiKey() verifies via GET /api/me instead of POST /login
- Settings shows single API Key field with hint (Profil → API Token in ABS)
- API key never pre-filled in settings for security
- Add "Bücher" button to mobile bottom-nav (bnav-library), hidden until connected
- _showAbsNavItem() helper keeps sidebar + bnav in sync on connect/disconnect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Using the full artist name with Unicode symbols (e.g. ☆NoAki☆) in ytsearch
caused YouTube to return 0-1 completely irrelevant results, letting wrong songs
fall through to the no-filter fallback. Strip only \p{S} (Unicode symbol chars
like ☆ ★ ♥) but keep ASCII punctuation — so N!GHT stays "N!GHT" (important for
disambiguation) while ☆NoAki☆ becomes "NoAki" (searchable).
YTM URL still uses the original artist name with all chars intact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use mainArtist (with special chars) for ytsearch queries so e.g. "N!GHT - Yume"
searches correctly instead of "NGHT - Yume" which YouTube misreads as "night"
and returns ambience videos.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- YTM URL now uses unstripped artist name (e.g. "N!GHT" instead of "NGHT") for
better search ranking on YouTube Music
- Steps 1-2 of cascade now include a soft channel ~= filter using the artist's
first word, preventing wrong-artist songs with matching titles from being
downloaded (e.g. a different "Moonlight" for N!GHT)
- Steps 3+ drop the channel filter so label-hosted tracks still get found
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cap Navidrome scan poll from 3 min to 60s, shrink post-scan buffer from 5s to 3s, and error fallback from 30s to 20s — playlist now appears sooner after downloads complete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ytdlp.js: use \b word boundaries in all variant filter regexes so that
partial matches like "nightcoreals" containing "nightcore" no longer
falsely reject official videos; also fixes sped regression (!~= \bsped\b
instead of the too-narrow "sped up" pattern)
- squid.js: restructure verify payload to mirror challenge response layout
{ parameters, solution: { number, derivedKey }, signature }
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- squid.js: add top-level signature from challenge response to verify payload
(server added signature validation, causing HTTP 400 without it)
- ytdlp.js: switch variant filter from !*= to !~= (?i) so Remix/REMIX/remix
are all rejected, not just lowercase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ytdlp.js: watch for [youtube:tab] in yt-dlp output during flat-playlist step;
return [] when channel expansion is detected so cascade falls through to ytsearch
- Revert player.js / main.js / state.js PWA _changingTrack changes (not effective)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /api/altcha/challenge response is { parameters: { nonce, salt, ... } }.
Destructuring the top level left nonce/salt/keyPrefix as undefined, causing
Buffer.from(undefined) crash on every squid.wtf download attempt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--match-filter 'webpage_url !*= /browse/' was applied to individual videos
AFTER yt-dlp expanded an artist channel into its uploads playlist — by then
each video had a /watch?v= URL, so the filter never fired.
New approach: run yt-dlp --flat-playlist --print webpage_url first to get
the raw search result URLs, filter /browse/ entries in Node.js, then pass
only the actual watch?v= video URLs to yt-dlp for download with --max-downloads 1.
The channel is never expanded; we get the real song from the search results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add --match-filter 'webpage_url !*= /browse/' to baseArgs so yt-dlp
skips YTM artist channel pages returned as search results (previously
caused recursive channel-upload fetches that wasted time)
- Call _renderNotifList() in _addNotif so playlist-created notifications
appear immediately without needing a page reload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parallel downloads to the same album dir (e.g. two tracks from same
album) both resolved to the last-written file, causing wrong metadata
to be embedded. Now each download targets its known output path
(trackPrefix + safeTitle + audioFmt) directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use Unicode property escapes to strip only symbols (☆, !, etc.)
while keeping Unicode letters (ø, ä, é) — fixes "Kløn" becoming
"Kln" instead of staying "Kløn" in the search query.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- buildTitleKeywords: strip parenthetical tags before picking words so
"Eleven Night (Jersey Club)" → keywords ["Eleven","Night"] instead of
["Eleven","Jersey"] — "Jersey" was never in the YT video title
- Cascade: add top-10 steps (3+4) with duration still active before
dropping duration filter, so "Whispers" (position 6-10) can be found
without falling through to the unfiltered last resort
- search query: strip non-ASCII chars from artist name so "☆NoAki☆"
becomes "NoAki" in the query → cleaner YTM search ranking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-word filter was too loose: "Echoes of Eternity" produced only
"Eternity" which matched unrelated tracks. Now the top 2 longest words
are used so the filter becomes e.g.
title ~= (?i)Eternity & title ~= (?i)Echoes?
title ~= (?i)Amethyst & title ~= (?i)Flowers?
Both words must appear — drastically reduces false matches.
Trailing-s stays optional (Whispers? matches Whisper/Whispers).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The normD regex used actual Unicode combining chars (U+0300-U+036F)
in the source which some build tools mangle, causing a SyntaxError at
runtime and silently killing playlist creation. Replaced with normA
that strips non-word/non-space chars via plain ASCII regex, which is
safe and achieves the same loose artist-first-word comparison.
Also fixed race condition in _pollAndCreatePlaylist — save lastStatus
before job is auto-deleted so the done check still works.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without ¶ms=EgWKAQII..., yt-dlp gets a mixed feed of videos,
channels and playlists alongside tracks — same query that shows the
correct song first in the browser "Titel" tab returned nursery rhyme
videos and unrelated channels. This param is YTM's internal 'Songs'
filter (equivalent to clicking the Titel tab).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>