mousedown does not expose the input source — replaced with pointerdown
which carries pointerType='touch', allowing noTouch=true to reliably
skip touch input without affecting mouse/stylus on PC.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
noTouch=true now also adds a touchstart preventDefault listener so the
browser does not fire synthetic mousedown/click after a tap, which was
still triggering seeks in the PWA despite touch events being disabled.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Math.min(160) cap on 'lift' meant the overlay froze after 160px of drag
(at ~70% down the screen) while the finger kept moving — the felt 'stop'.
Now: player bar caps at its own height (once it slides off, no more movement
needed), overlay tracks the finger with no cap at 2.5× speed so ~360px of
upward drag fully reveals the now-playing screen. Release animation (from
the previous fix) then carries it smoothly to position 0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Swipe animation:
- _resetDrag(true) now reads the overlay's current drag position and starts
the CSS transition from there instead of resetting to translateY(100%) first.
That reset was causing the visible stutter (overlay jumped off-screen then
slid back up). Now the overlay continues seamlessly from wherever the finger
released to translateY(0).
Playing highlight:
- makeSongItem: adds data-song-id attribute to every .song-item element.
- updatePlayerUI: calls _updatePlayingHighlights() after every track change.
This toggles .playing on all visible song items in any open list/playlist/
favorites view so the highlighted row always matches what's actually playing,
without requiring the user to navigate away and back.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- initProgressScrub: pass noTouch=true for the bottom mini-bar so touch
events are never registered on it; only mouse events remain (desktop-only).
The now-playing scrubber is unchanged and keeps full touch support.
- Export seekToMs(ms) from player.js; use it in the lyrics click handler
instead of S.audio.currentTime which has no effect when HAS_BRIDGE
(native ExoPlayer handles playback, not the HTML audio element).
- styles.css: add will-change:transform to .player and .np-overlay to keep
them on GPU compositor layers during the swipe gesture; tweak NP open
transition to cubic-bezier(.4,0,.2,1) for a snappier feel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- button:focus{outline:none} — removes the blue tap-highlight box in WebView
- Playlist reorder: replaced always-on drag handles with explicit 'Neu anordnen'
mode. Handles only appear after tapping the button; a sticky Speichern/Abbrechen
bar appears above the list. Touch drag is now fully supported. Context-menu
(long-press) is blocked while reorder mode is active to prevent conflicts.
- styles.css: .pl-drag-handle always visible when pl-draggable (no hover needed)
- Edit modal footer: wrap buttons on small screens so Save doesn't overflow
- Edit modal overlay: remove backdrop-filter:blur — causes WebView compositor
glitch where 3/4 of the modal disappears after returning from background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both 'Als Nächstes einfügen' and 'Am Ende der Queue' updated S.queue in JS
but never called NSBridge, so the native ExoPlayer ignored the change and
kept playing the original next item.
- Export nativeInsertAt(song, position) from player.js
- Call it in showCtxMenu with the exact splice index for insert-next
and -1 (append) for add-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- fillRadioQueue: use addToQueue instead of setQueue so toggling radio mode
no longer resets position to 0 on the current song
- loadStarred: pass noArtistLink/noAlbumLink to prevent accidental navigation
taps on mobile (matches playlist behavior)
- styles.css: add pointer:coarse alongside hover:none so song-actions are
visible on Android WebView which may report hover:hover
- components.js: remove inline download button from song row; keep only star
and 3-dots (download stays accessible via context menu)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Single track and album downloads now navigate to the album page
- Artist downloads continue to navigate to the artist page
- Button label changes to 'Zum Album →' or 'Zum Künstler →' accordingly
- album name stored in job metadata so it survives until poll completes
- Search uses exact album+artist match, falls back to album-name-only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api.js: cache coverUrl() per id+size so WebView HTTP cache can work;
previously each call generated a new random salt/token → unique URL →
no cache hits → images re-fetched on every render
- player.js: replace _nsBridge('next')/'prev' with explicit seekToIndex
in nextSong/prevSong so native always jumps to the exact same index JS
computed; 'next'/'prev' relied on native's internal pointer being in sync
which caused 'UI shows new song but audio plays old one' when they drifted
- MusicService: set setSessionActivity PendingIntent on MediaSession so
tapping the playback notification opens the app
- MainActivity: handle open_now_playing extra in onCreate (cold start)
and onNewIntent (app already running) to show the Now Playing screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Add /api/romaji-index endpoint using kuromoji morphological analyzer
- Fetches all Navidrome songs, converts kanji → hiragana readings → romaji
- Only indexes songs with Japanese characters; caches for 20 min
- /api/romaji-index DELETE clears cache (force rebuild)
Frontend:
- fetchRomajiIndex() in api.js: lazy-fetches and caches the index
- doLocalSearch: when query is romaji (ASCII), queries romaji index in
parallel with Navidrome search3 and merges+deduplicates results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Touch scrubbing now requires ≥8px horizontal drag before committing.
A plain tap on the progress bar is ignored — no unintentional position jump.
Also removed redundant onclick="seekTo()" since _initBarScrub already
handles mouse seeking via mousedown/mousemove/mouseup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When search input is pure ASCII (romaji), converts it to hiragana in
parallel using wanakana and merges both result sets. Deduplicates by ID
so no double entries. Helps find artists/songs tagged with hiragana in
sort fields (e.g. 'inabakumori' → 'いなばくもり').
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ⋮ button to every song-item triggering the context menu (same as right-click)
- Show song-actions always visible on touch devices via @media (hover:none)
- Search results: songs now render as makeSongItem rows instead of cards
(artists/albums remain as horizontal card rows)
- Fix Android app opening system settings on every launch after notification
permission denial — now only redirects to settings once using notif_settings_shown flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_crossfadeNpBg only preloaded the 1024px cover when the NP overlay was
already open. Opening it fresh showed the old cover for ~0.5s while the
new image downloaded. Preloading in updatePlayerUI caches it immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Android, onTrackChanged fires asynchronously so opening the overlay
quickly after a track change could show the previous song. Query
NSBridge.getState() synchronously at open time to get the real index.
Same flash can also occur on web — openNowPlaying now always renders
from current S.qIdx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When absPlayItem() paused S.audio, the stale canplay listener from the
last playCurrent() call still had a matching _playGen value and would
call S.audio.play() — triggering absInterrupt() which immediately paused
the ABS audio. Fix: export cancelMusicAutoplay() that bumps _playGen
before pausing, invalidating any pending canplay/guard callbacks.
Also shrink OK button and widen time input in sleep timer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frontend was calling itunes.apple.com directly; browsers block it (no CORS headers).
Added /api/itunes/:endpoint proxy on the backend and updated all itunesSearch/Lookup
calls in api.js to route through it when backendUrl is configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server tracks _spRateLimitUntil and rejects retries instantly without
hitting Spotify, returning the remaining wait time. Retrying before the
window expired was resetting Spotify's timer (19s → 7s → 48s).
Frontend now shows a live countdown with a note not to retry early.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- 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>