Removing the separator span from the HTML broke updatePlayerUI which still
called getElementById('player-album-sep').style — null crash on every
song play. Removed both occurrences since album now has its own line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hideCtxMenu now guards against null #ctx-menu element. scroll/touchmove
listeners only fire when menu is actually visible, preventing spurious
calls on every scroll event.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added _reorderCtx.active flag — plReorderEnter bails early if already
active, preventing duplicate handles and event listeners from stacking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Player bar sub-line now shows album (clickable) on first row, artist below
- Context menu now closes on scroll and touchmove (was staying open on mobile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Adds a synchronous inline script that checks localStorage before first
paint and sets html.ns-authed, hiding #setup-screen via CSS immediately.
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>
-webkit-tap-highlight-color was only on 2 specific elements; every
div/song-item/card still showed a blue flash on tap in Android WebView.
*:focus{outline:none} replaces the button-only rule and covers clickable
divs as well.
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>
The backend uses NODE_PATH=/deps/node_modules with its own npm install
step — not the root package.json. Added kuromoji there and reverted
the incorrect root package.json change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.get/delete for /api/romaji-index were placed before const app = express()
causing ReferenceError on startup — moved into the ROUTES section
- kuromoji moved from backend/package.json to root package.json so Docker
picks it up during the existing npm install step
- Fixed dicPath resolution to use path.dirname(require.resolve(...))
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>
The magnifier icon in the top-right already opens search.
Having a second search box in the sidebar was redundant.
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>
Each tryYtmSearch() call made a fresh HTTP request, so YouTube could
return different result orderings between steps. The correct video
(right title, wrong channel name) appeared in step 1 but was gone by
step 3 — a worse video with matching keywords slipped through instead.
Now fetch 10 YTM URLs once upfront and filter the same list with
progressively relaxed rules. Also saves 2-4 extra YTM requests per
download.
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>
Picker is only ~90px wide; single-line label+input+OK was too cramped.
Now stacks vertically so input gets full row width minus the OK button.
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>
Mobile needs the button since there's no keyboard Enter key in all cases.
Button is compact and styled to match the accent color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove OK button and expand-on-click from sleep timer custom row
- Always show label + plain number input (no spinners, Enter to confirm)
- Update README with all current features (Spotify importer, ABS, lyrics, filters, etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
song_info returns GenericError for unauthenticated track lookups.
Fall back to scraping og:title/og:description/og:image from the public
Spotify track page — description format is "Song · Artist · Album · Year".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Album: flatten generator pages with list comprehension, parse flat track.{name,artists,...} directly
- Track: try both URI and URL, report GenericError clearly instead of crashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Track: pass spotify:track:ID URI instead of full https:// URL
- Album: Public().album_info() yields track items (generator); metadata from PublicAlbum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both are the correct unauthenticated API methods on the Public class.
Song class requires a logged-in session and has no public equivalent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Song() requires a client instance as first arg; pass Public() for unauthenticated access
- Album response is data.albumUnion.{name,artists,coverArt,tracks.items} not flat keys
- Add per-field debug logging for track and album responses
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>
PublicSong doesn't exist in the installed spotapi version. Now discovers
available class names at startup and picks the right one dynamically.
Missing track/album class no longer breaks playlist resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api.spotify.com/v1/ aggressively rate-limits server IPs regardless of
TLS fingerprint or token. SpotipyFree was already hitting the correct
endpoint (api-partner.spotify.com) but crashed on KeyError: 'ownerV2'
due to its own outdated field parsing — not a network block.
spotapi uses the same internal GraphQL API (Spotify's web player API)
which is on a separate rate-limit bucket from the public REST endpoint.
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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.
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>