Commit graph

23 commits

Author SHA1 Message Date
BoulderBadgeDad
8b7b9d8f3f #809 review follow-up: crossfade preload also streams un-mounted Navidrome tracks
Self-review of df929dc0 found one gap: the crossfade preloader hits
/stream/library-audio with the file PATH, which 404s for a streamed (not
disk-mounted) Navidrome track — main playback worked, crossfade didn't.
/stream/library-audio now uses the same _build_library_stream_url fallback on
a disk-miss (resolving the song id from the new track_id param, or a DB
lookup by path), and the preloader passes next.id. Crossfade now works for
streamed libraries too.

Review also confirmed (no change needed): /api/stream/status returns only
status/progress/track_info/error_message — the Subsonic token in stream_url
never reaches the browser; it stays server-side and the browser only hits
/stream/audio. Proxy verified live: Range forwarded, 206 + Content-Range/
Accept-Ranges passthrough, body streamed in 64KB chunks, upstream closed.
2026-06-07 13:55:58 -07:00
BoulderBadgeDad
df929dc022 #809 Navidrome playback: stream via the server's API when the library isn't mounted on disk
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.

Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:

- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
  /rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
  uses the song id sent by the player, or a DB lookup by file_path) sets a
  session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
  seeking works, streaming upstream bytes through in 64KB chunks (no full-file
  buffering).
- session state gains stream_url; the two library-play callers now send the
  track's server id.

Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.

Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
2026-06-07 13:52:14 -07:00
BoulderBadgeDad
07d09d7d0e Stream button: the player never learns its stream is ready (2.6.5 regression)
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.

Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
  (the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
  the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener

Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.

34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
2026-06-06 12:43:20 -07:00
BoulderBadgeDad
3fe635fcd6 no-sound fix: resume context on the 'play' event (covers all play paths)
Harden the previous fix: setPlayingState(true) misses resume/play calls that
bypass it (lines that just do 'if paused, play()'). Move the resume onto the
audio element's 'play' event, which fires on every playback start regardless
of code path. Keep the resume in npInitVisualizer for the first-play case
(context is created suspended after the 'play' event already fired). Drop the
now-redundant setPlayingState hook.
2026-06-04 14:36:06 -07:00
BoulderBadgeDad
5cb65ab00e Fix: streamed tracks play with no sound (suspended Web Audio context)
The visualizer calls createMediaElementSource(audioPlayer), which permanently
reroutes the shared <audio> element's output through npAudioContext. That
context is created from an async play().then() callback (outside the user
gesture), so browsers start it 'suspended' under the autoplay policy — and the
only resume() lived in the visualizer loop, which runs when the Now Playing
modal opens, NOT on play. Result: the element advances (looks like it's
playing) but its audio drains into a suspended context = no sound, everywhere.

Add npEnsureAudioContextRunning() and call it on every play start
(setPlayingState(true)) plus right after the context is created in
npInitVisualizer. Resuming an already-running/absent context is a safe no-op.
2026-06-04 14:29:13 -07:00
BoulderBadgeDad
112ecbb24f Player: seek hover tooltip on the Now Playing progress bar
The mockup had a seek tooltip (timestamp tracks the cursor over the progress
bar) but it was never ported to the real player. Added it: mousemove computes
the hovered fraction -> formatTime(duration*frac), positions the tip, shows on
hover / hides on leave. Guarded when no duration. Frontend-only; JS + CSS clean.
2026-05-30 15:15:58 -07:00
BoulderBadgeDad
bf2a2ca928 Player: log SoulSync web-player plays (recently-played + smart-radio recency)
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.

- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
  player payload -> listening_history event shape. Caller supplies the
  timestamp (stays pure). Composite/streamed ids never become the int
  db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
  play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
  (logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
  threshold (flag reset in setTrackInfo, set-before-fetch so it can't
  double-fire), fully fire-and-forget.

Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
2026-05-30 15:11:46 -07:00
BoulderBadgeDad
f9bc96bd90 Player: 'Playing from' context header (Radio / <Artist> Radio)
Spotify-style context line above the track title. npSetPlayContext(text) shows/
hides it; set to 'Radio' when radio mode turns on, '<Artist> Radio' from
playArtistRadio (specific label wins over generic), cleared on stop/clearTrack
and when radio mode is turned off. Accent-colored name, uppercase label.

Frontend-only; JS + CSS clean.
2026-05-30 15:07:31 -07:00
BoulderBadgeDad
ab2b5c64f4 Player: shuffle + repeat on the mini-player (parity with modal)
The sidebar mini-player had prev/play/next/stop/expand but not the two
set-and-forget controls you reach for without opening the full view. Added
shuffle + repeat (3-mode, with a repeat-one badge) to the mini-controls.

State stays in sync both ways: handleNpShuffle/handleNpRepeat now call a shared
syncShuffleRepeatUI() that reflects state onto BOTH the modal and mini buttons,
so toggling in either place updates the other. Mini buttons reuse the same
handlers. Accent-active styling via --accent-light-rgb.

JS clean; CSS balance consistent with HEAD.
2026-05-30 14:57:04 -07:00
BoulderBadgeDad
1e514693f1 Player: 'Play next' action + accent-correct queue buttons
- Added playNext(track): inserts a track right after the current one (Spotify
  'Play next'), vs addToQueue which appends to the end. Falls back to
  addToQueue when nothing is playing.
- Artist-detail track rows now show BOTH a Play-next (⇥) and Add-to-queue (+)
  button; the delegated handler builds one shared library-track payload and
  routes to playNext / addToQueue. (Add-to-queue was already wired; play-next
  + the second button are new.)
- Fixed the queue button's hardcoded 29,185,84 to var(--accent-rgb) so it
  follows the settings accent (kettui UI-consistency), and styled the new
  play-next button to match.

Note: deliberately NOT adding queue buttons to SEARCH results — those are
stream/download (non-library) tracks the queue's auto-advance can't reliably
play. JS syntax clean on both files.
2026-05-30 14:54:14 -07:00
BoulderBadgeDad
65f49ccecd Player: N/P next-prev keys + global mute + persist volume across reloads
- Keyboard: added N (next) / P (previous) track shortcuts; 'm' mute now works
  whether or not the modal is open (was modal-only). Space/seek/volume/escape
  unchanged.
- Volume persistence: volume now saved to localStorage on every change (slider
  + arrow keys, via npPersistVolume) and restored on load instead of always
  resetting to 70%. npLoadSavedVolume validates the stored 0..100 value.
  initializeMediaPlayer applies it + syncs both slider UIs.

Frontend-only; init runs from init.js after full parse so the module consts
are defined. JS syntax clean.
2026-05-30 14:52:16 -07:00
BoulderBadgeDad
592b68c16c Player revamp: harden crossfade race conditions + global decl (audit fixes)
Self-audit of the revamp surface found real bugs, now fixed:

- DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the
  track actually 'ended' fired, onAudioEnded ALSO advanced — two skips.
  onAudioEnded now bails when npXfadeActive (crossfade owns the advance).
- STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the
  interval running, firing npFinishCrossfade on top of the manual change, and
  left the second <audio> playing. Added npCancelCrossfade() (clears the timer,
  tears down the 2nd audio, restores main volume) called at the top of
  playQueueItem and in handleStop. The fade interval also self-checks
  npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the
  legitimate handoff isn't treated as an abort.
- stream_start: moved 'global stream_background_task' to function top (it was
  declared inside an if-block — parsed, but brittle/bad form).

web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance
unchanged from HEAD.
2026-05-30 14:38:28 -07:00
BoulderBadgeDad
866f2e4a23 Now Playing: complete Media Session lock-screen controls
The Media Session API was partial — play/pause/stop/seek±10/prev/next handlers
+ metadata/artwork existed, but the OS lock-screen/Bluetooth/notification
control had a DEAD scrubber (no position, no drag-to-seek). Completed it:

- setPositionState (duration/position/rate) so the lock screen shows a live
  progress bar, pushed throttled (~1/s) from timeupdate, reset on
  loadedmetadata of a new track, and on manual seek.
- 'seekto' action handler so dragging the lock-screen/notification scrubber
  actually seeks (with fastSeek when available).

Now hardware/Bluetooth keys + the lock-screen scrubber fully drive playback
with art, metadata, and live position. Feature-detected throughout.
2026-05-30 14:23:29 -07:00
BoulderBadgeDad
843f4081cf Now Playing: click-to-seek synced lyrics
Click any synced lyric line to jump playback to that line's timestamp (and
resume if paused). Reuses the existing _npLyricsState.lines {time,text} data.
Hover affordance: accent-tinted line + pointer cursor. Synced lyrics only
(plain lyrics have no timestamps).
2026-05-30 14:17:51 -07:00
BoulderBadgeDad
a8985b317f Now Playing: fix squashed stop button + queue persistence + crafted entrance
- Stop button fix: my round .np-btn { width/height 46px; border-radius:50% }
  override was also hitting .np-btn-stop (it carries both classes), squashing
  the 'Stop' text pill into a tiny circle. Exempted .np-btn.np-btn-stop back to
  an auto-width pill.
- Queue persistence: npPersistQueue() (called from renderNpQueue, the single
  mutation hook) saves the queue to localStorage; npRestoreQueue() on init
  repopulates the panel on reload WITHOUT auto-playing (index reset to -1).
  Queue no longer vanishes on refresh.
- Crafted entrance: controls stagger-fade/rise in when the modal opens
  (npRiseIn keyframe, delays cascading util->progress->controls->volume->
  upnext). Art container excluded so its transform stays free for the
  play-scale.

Frontend-only; Boulder verifying live.
2026-05-30 14:17:07 -07:00
BoulderBadgeDad
ccfb3fb042 Now Playing: real crossfade for library tracks (experimental)
Crossfade was a no-op toggle. Real crossfade needs two tracks audible at once,
but /stream/audio only serves the ONE current track (single global
stream_state). So:

- web_server: extracted the range-serving body of /stream/audio into
  _serve_audio_file_with_range, and added /stream/library-audio?path= which
  serves an arbitrary LIBRARY file through it. Security: the path is resolved
  via _resolve_library_file_path (same validator /api/library/play uses) so it
  only serves files inside the configured transfer/download/media-library
  dirs — not arbitrary disk.
- frontend: a second hidden <audio> (#audio-player-xfade) preloads the NEXT
  library track when the current one is within 6s of ending (crossfade on,
  not repeat-one), ramps the two volumes in opposite directions, then hands
  off to playQueueItem so all normal now-playing state is set.

Honest limits (documented in code): library→library only (streamed tracks
hard-cut as before); there's a brief silent reload at hand-off because
playQueueItem re-points the single stream_state — the perceived crossfade has
already happened by then. EXPERIMENTAL — needs Boulder's live audio
verification; I can't test audio in-sandbox.

33 streaming tests still pass (stream_audio refactor is behavior-preserving).
2026-05-30 11:48:56 -07:00
BoulderBadgeDad
ffbe669c67 Now Playing: vibrant album-art color extraction + drag-to-reorder queue
Two next-level player features (frontend-only):

1. Album-art ambient color — replaced the flat pixel AVERAGE (which muddied
   every cover to grey-brown) with dominant-VIBRANT extraction: coarse
   histogram binning weighted by saturation² × population, then a punch-up
   pass (boost saturation ~1.3x, floor brightness) so the modal glow reads as
   the cover's real standout color, Apple-Music style. Feeds the existing
   --np-ambient-r/g/b hooks.

2. Drag-to-reorder queue — queue rows are now draggable; npReorderQueue moves
   the item AND recomputes npQueueIndex so the currently-playing track stays
   correctly tracked after a reorder. Accent drop-line indicator, grab cursor,
   dragging opacity.

Verified live in-browser by Boulder.
2026-05-30 11:46:31 -07:00
BoulderBadgeDad
3461d9235b Now Playing modal: full visual redesign + click-art visualizer, sleep timer, up-next
Player-revamp frontend (Phase 1). Brings the Now Playing modal to the approved
mockup look + features:

- Full restyle (override block in style.css): 28px modal radius, stronger
  art-driven ambient glow, 340px rounded art that scales while playing, bold
  28px title, accent artist name, accent FLAC pill, dominant 70px gradient
  play button, accent-gradient progress/volume/visualizer. All driven by the
  existing --accent-rgb / --accent-light-rgb so it follows the settings accent.
- Click album art -> Plexamp-style visualizer takeover, fed by the REAL
  music-synced Web Audio analyser (npStartVisualizerLoop), click again -> art.
- Rich queue rows: album thumbnail + title/artist + duration, equalizer
  animation on the now-playing row, hover-reveal remove.
- Up-next peek below the controls (shows the next queued track).
- Sleep timer (cycles 15/30/60m, real setTimeout -> handleStop).
- Crossfade toggle present (visual state + persisted pref; the dual-audio
  crossfade engine is the next step, not yet wired).

Frontend-only; verified live in-browser by Boulder. No backend/test surface.
2026-05-30 11:43:45 -07:00
Broque Thomas
a3ba79a9ce Improve radio mode UI and behavior
Refactor and enhance the player radio feature: add npSetRadioMode, npQueueHasNext, and npEnsureCurrentTrackInQueue helpers to centralize radio-state changes and conditional radio fetch logic; replace direct npRadioMode toggles with npSetRadioMode in the expanded player and artist-radio flow (now awaits playLibraryTrack and triggers fetchIfNeeded). Add accessibility (aria-pressed) and label/pulse elements to the radio button, and update CSS for improved visuals and active-state animation. Also adjust toasts/messages and ensure the current library track is seeded into the queue when needed.
2026-05-24 11:02:19 -07:00
Broque Thomas
de8e079a6d feat(media-player): playable tracks across modals + lyrics + cleanups
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.

1. Play buttons across track-list modals
   Every track row in the download-missing modals (Spotify, Tidal,
   YouTube, services, artist album, wishlist download-missing) and
   the add-to-wishlist modal now carries a play button. Click runs
   playTrackFromLibraryOrStream:
     - If the track has a local file_path → playLibraryTrack
     - Else POST /api/stats/resolve-track to find it in the library
       by title + artist → playLibraryTrack
     - Else fall back to _gsPlayTrack streaming
   Backend ownership response gains track_id / title / file_path so
   the wishlist modal's owned tracks can hand the right metadata
   to the player without an extra round trip.
   The add-to-wishlist modal previously showed the play button only
   on owned tracks; now the button is unconditional so the streaming
   fallback can take over for unowned ones (matches the standard
   pattern from the rest of the app).

2. Clean media-player display titles
   YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
   source-side identifier into the filename field as
   <source_id>||<display> so download() can recover it later. The
   media player's track-title renderer never knew about this
   convention and showed strings like
   "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
   now-playing UI. extractTrackTitle and setTrackInfo now strip the
   <id>|| prefix defensively so any path into the player gets a
   clean display.
   Local library playback also fetches canonical metadata from
   /api/stats/resolve-track when track.id is present so title /
   artist / album / album art come straight from the SoulSync DB
   instead of whatever the caller passed in. Falls back silently
   to caller values on any error so playback never blocks on the
   metadata fetch.

3. Lyrics panel + View Artist close
   New collapsed lyrics panel between the playback controls and
   queue panel. POST /api/lyrics/fetch (new backend endpoint)
   prefers the local .lrc / .txt sidecar files SoulSync writes
   during post-processing so downloaded tracks resolve lyrics with
   zero network hits; falls back to LRClib exact-match (when album
   + duration are available) then to LRClib search.
   Synced LRC results are parsed (handles multi-stamp lines for
   repeated choruses), and the active line highlights + smooth-
   scrolls into the middle of the viewport on every audio
   timeupdate. Plain-text results render without highlighting.
   Per-track cache prevents re-fetching when the user revisits the
   same track. Lyrics fetch is fire-and-forget — failure shows
   "No lyrics found" without ever blocking playback.
   View Artist on the expanded player now calls
   closeNowPlayingModal before navigating; the modal was previously
   sitting open over the artist page, hiding it. Handler is bound
   once and is a no-op when no artist_id is attached.

CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.

WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
2026-05-22 21:19:50 -07:00
Antti Kettunen
0d683d87c0
refactor(webui): link artist detail navigation
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
2026-05-19 10:22:59 +03:00
Antti Kettunen
5e39f1ee09
refactor(webui): centralize artist-detail handoff
- add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target
- expose page-level artist-detail navigation on the shell bridge for legacy callers
- remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers
2026-05-19 09:26:10 +03:00
JohnBaumb
a66c4d06e1 Split monolithic script.js (78K lines) into 17 domain modules
Extracts the single 77,957-line script.js into focused modules:

  core.js            (874)   - Global state, confirm dialog, websocket, constants
  init.js            (2358)  - Initialization, personal settings, navigation
  media-player.js    (2398)  - Media player, audio, visualizer, radio
  settings.js        (3657)  - Settings page, quality profiles, API keys, auth
  search.js          (1542)  - Search functionality, page data loading
  sync-spotify.js    (2538)  - Spotify sync, YouTube backend, hero section
  downloads.js       (6398)  - Wing It, batched polling, cancel, notifications
  wishlist-tools.js  (7234)  - Wishlist, matched downloads, tools, retag
  sync-services.js   (9076)  - Tidal, Deezer, Beatport, YouTube, ListenBrainz sync
  artists.js         (4610)  - Artists page, artist downloads
  api-monitor.js     (3798)  - API rate monitor gauges
  library.js         (6652)  - Library, artist detail, enhanced management
  beatport-ui.js     (3902)  - Beatport sliders, genre browser
  discover.js        (8920)  - Discover page and all sub-sections
  enrichment.js      (3551)  - All enrichment workers, library repair
  stats-automations.js (7575) - Stats, automations, issues, import
  pages-extra.js     (2874)  - Playlist explorer, server playlists, active downloads

Load order: core.js first (globals), init.js last (DOMContentLoaded).
All other modules define functions and load in any order.
No functional changes - pure extraction along existing section boundaries.
2026-04-21 23:52:30 -07:00