* feat(audio): rust preview engine with secondary sink
Adds a parallel rodio Sink on the existing OutputStream for 30s
mid-track previews. Two new Tauri commands (audio_preview_play,
audio_preview_stop) plus three events (audio:preview-start /
-progress / -end). The main sink is paused with Sink::pause() and
auto-resumed on preview end iff it was playing beforehand.
* feat(playlists): migrate suggestion preview to rust audio engine
Replaces the HTML5 <audio> path with the new rust preview engine.
previewStore mirrors the engine's start/progress/end events so any
tracklist row can render preview UI from a single source of truth.
Spacebar redirects to stopPreview while a preview plays, hardware
mediakeys are silently dropped (Q5), and tray clicks cancel the
preview before forwarding the original action.
* feat(albums): inline play + preview buttons in tracklist rows
Track number stays static on hover instead of swapping to a play
icon — the dedicated Play and Preview buttons in the title cell
take over click-to-play and click-to-preview. Active+playing rows
keep the eq-bars (also on hover), active+paused rows fall back to
the static accent-coloured number.
Pilot for the wider rollout to other tracklists.
* feat(tracklists): roll out inline play + preview buttons
Mirrors the AlbumTrackList pilot across the remaining track-row
based lists: PlaylistDetail main tracks, Favorites, ArtistDetail
top tracks, RandomMix (both genre-mix and filtered-songs lists).
Track number stays static, the dedicated Play + Preview buttons
in the title cell take over click-to-play and click-to-preview.
* feat(settings): track preview toggle + configurable position and duration
Adds an opt-out switch and two sliders to Settings → Audio:
start position (0-90 % of track length, default 33 %) and preview
duration (5-60 s, default 30 s). The progress-ring animation
follows the duration via a CSS variable so the visual matches the
engine's auto-stop. Disabling the feature hides every inline
preview button via a single root-level data attribute, no per-row
conditional rendering required.
i18n keys added in all 8 locales.
* fix(audio): cancel preview when main playback (re)starts
audio_play, audio_play_radio, audio_resume and audio_stop did not
know about the parallel preview sink, so clicking Play on a track
that was currently being previewed left the preview running on top
of the freshly started main playback. New helper clears the resume
flag, bumps the preview generation, drops the sink and emits an
'interrupted' end event before any of those commands touches the
main sink.
* feat(settings): per-location track preview toggles
Splits the single trackPreviewsEnabled toggle into a master + 6 per-location
sub-toggles (suggestions, albums, playlists, favorites, artist, randomMix).
Master remains the kill switch; sub-toggles are only honoured when master
is on. Each tracklist container is marked with `data-preview-loc="<id>"` and
hidden via scoped CSS when the matching root attribute is "off".
startPreview now takes a location argument so the store can guard logic too.
i18n added in all 8 locales.
* fix(contextmenu): use ChevronsRight for Play Next to distinguish from preview
* feat(playlists): suggestion-row preview UX (30s preview, double-click play-next, scroll keep)
Reworks the interaction on the suggestion rows below a playlist so users
can audition songs before deciding what to do with them.
- New "Preview" pill button left of each track title plays a 30-second
mid-song sample via a parallel HTML5 audio element. The main player
pauses on the first preview and auto-resumes when the preview ends.
Switching previews chains without re-resuming. External main-player
playback (spacebar, mediakey) cancels the preview without resuming.
Animated underline shows the 30 s progress.
- Double-click the row to insert the song at queueIndex + 1 and skip to
it ("Play next"). Single-click is intentionally inert so a stray click
next to the preview button no longer drops a song into the queue by
accident. Tooltip on the row makes the affordance discoverable.
- The + button still adds to the playlist, and now restores the
.main-content scroll position so the page no longer jumps to the top
after pressing it.
- i18n keys in all 8 locales: playlists.preview / previewStop / previewShort
/ previewStopShort / suggestionDoubleClickPlayNext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playlists): WebKitGTK NULL-instance crash on preview teardown
cucadmuh hit GLib-GObject-CRITICAL: invalid (NULL) pointer instance /
g_signal_connect_data assertion failed, with the UI freezing after
clicking a preview button. Root cause was the audio-element lifecycle:
- `audio.src = ''` to "stop" leaves WebKitGTK's GStreamer playbin in a
half-initialized state. The next signal_connect dereferences NULL.
- Creating `new Audio()` per click stacked half-torn-down playbins
during rapid switches.
- `loadedmetadata` listeners on orphaned audio elements could call
play() on an already-discarded instance.
Fix: reuse one <audio> element per component, tear down the source
via removeAttribute('src') + load() (which resets the playbin
cleanly), and gate async listeners behind a session counter so stale
metadata events on switched-away previews are ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(playlists): icon-based action buttons + add-on-doubleclick + hint
- Replace text-pill Preview button with circular icon button + animated
SVG progress ring (Play/Square icon swap when active).
- New Play-next button (filled accent circle, white triangle) sits
left of the Preview button — explicit affordance for the action that
used to be a hidden double-click.
- Double-click on a suggestion row now triggers Add-to-playlist (the
same action as the + button on the right) — discoverable shortcut
for mouse users.
- Subtitle under the Suggested Songs header announces the add-on-
doubleclick affordance, in all 8 locales.
- Drop the now-obsolete previewShort / previewStopShort short-label
keys (text replaced by icons); rename suggestionDoubleClickPlayNext
→ playNextSuggestion to match its new role on the Play-next button.
- Keep the session-counter guard from the prior commit so async
loadedmetadata handlers from a switched-away preview can't play()
on a discarded element.
Note: header column labels visually drift from the data columns when
suggestion rows have the action buttons; left as-is for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playlists): apply LUFS pre-analysis attenuation to suggestion previews
When the main player is on loudness normalization, analysed tracks come
out reduced toward target while the unanalysed preview <audio> blasts
the file at its natural level — cucadmuh reported the previews are
audibly louder than the playlist playback.
Apply the user's stored pre-analysis attenuation (the slider value, not
the target-offset effective form) as a linear gain on the preview's
audio.volume. Default −4.5 dB lands the preview at ~60% of the player
volume, which roughly tracks how aggressively the Rust engine pulls
naturally-loud tracks toward target.
If the engine is off, behavior is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(playlists): use chevron icon for suggestion preview button
Distinguishes the preview button from the adjacent play-next button
by mirroring the Play Next chevron from the context menu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(loudness): target sync, -14 pre-analysis ref, queue UI, and reseed
- Front: coalesce loudness refresh by target LUFS; replay-gain IPC dedupe keys
include norm target and effective pre-attenuation so TGT changes apply.
- Rust: placeholder gain before integrated LUFS uses pivot at -14 LUFS; UI gain
from effective trim; reseed loudness after delete when waveform cache would skip.
- Pre-analysis: store attenuation relative to -14 LUFS; engine and UI use an
offset for other targets; migrate legacy absolute values on rehydrate.
- Queue/Settings: Loudness/TGT labels vs value buttons; styles; i18n for help.
* fix(i18n): simplify loudness pre-analysis helper copy
Remove reference-target wording from loudness pre-analysis helper text and keep
only the effective adjustment shown for the current LUFS target in all locales.
Closes#327.
When every selected song is already in the target playlist, the
"Add to Playlist" flow used to silently show an "all skipped" toast.
Users could not tell whether they had hit the wrong target or whether
the action was deliberately ignored, and they had no way to add
duplicates intentionally (e.g. for a song they actually want twice).
New behavior: if every id is a duplicate, ask via the existing
GlobalConfirmModal — "Add anyway" appends them as duplicates,
"Cancel" keeps the previous silent-skip toast.
Mixed cases (some new, some duplicate) and the all-new path are
unchanged. Applied at all three add-to-playlist call sites in the
context menu (single/multi-track, album selection, artist selection).
Strings added to all 8 locales (en, de, es, fr, nl, nb, ru, zh).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mode picker (Off / ReplayGain / LUFS) used to live in the right-hand
action slot of a settings-toggle-row and the per-mode controls were
stacked into the same parent with footer-style help text. Hard to scan,
visually cramped, and odd compared to the rest of the Settings page.
Refactor:
- Mode picker becomes a full-width segmented row with even-flex buttons,
using the new .settings-segmented utility.
- Each mode renders its own .settings-norm-block sub-section with a
subtle accent tint and border so the active configuration reads as
one coherent group.
- Inside the block, every setting is its own .settings-norm-field
(control row + per-control help text immediately below). 1.1 rem gap
between fields, 0.45 rem between row and help — clearly groups
related text without crowding.
- Sliders no longer max-cap at 200 px and instead flex to fill the row.
- Inactive ghost buttons (Off, ReplayGain, RG mode, LUFS targets) get a
visible border and a faint surface tint so they read as selectable
slots in dark themes too.
- LUFS mode gets a dedicated note-box explaining that brief volume drift
on the very first play of a new track is the analysis pass at work,
not a bug — subsequent plays use the cached measurement, and queued
tracks are usually pre-analysed during the previous song.
- "Trim before measurement (dB)" renamed to "Pre-analysis attenuation"
(and equivalents in 8 locales).
- New i18n keys: normalizationDesc, normalizationOff/ReplayGain/Lufs,
loudnessTargetLufsDesc, loudnessFirstPlayNote, replayGainPreGainDesc,
replayGainFallbackDesc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The waveform-loudness-cache merge replaced the existing deterministic
per-track-ID waveform with a bins-based one driven by the analysis
cache. The bins-based variant is the better default but the old
deterministic look is still valuable when no analysis is available
(brand-new track, cache-miss, etc.) and several users prefer it.
Split into two explicit options in the seekbar style picker:
- 'truewave' (default, replaces old 'waveform') — bins from the analysis
cache, with morph-on-arrival animation and flat-line fallback while
empty.
- 'pseudowave' — pseudo-random heights derived deterministically from
the track ID. No analysis dependency, no morph, instant render.
Existing persisted seekbarStyle: 'waveform' is migrated to 'truewave'
in onRehydrateStorage so users keep the visual they have today. The
useEffect that builds heightsRef now lists seekbarStyle in its deps so
switching between the two is live.
i18n labels added in all 8 locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve integrated loudness from SQLite only at decode bind; keep pre-trim
until a row exists, then allow provisional gain from live updates. Pass
DB-stable hints from the web app into play and gapless preload.
Default pre-measurement attenuation -4.5 dB with an icon reset in Settings;
drop redundant normalization copy and shorten pre-trim descriptions.
analysis_cache seed_from_bytes returns an outcome and skips redundant
waveform work on cache hits; wire callers and related frontend/backend glue.
Improve seekbar fallback behavior by using consistent bar-based rendering and smoother transitions to analyzed waveform data. Reduce hot-path analysis overhead during ranged streaming and add controls to clear cached waveform entries.
Conflicts resolved in:
- src/pages/SearchResults.tsx
- src/pages/AdvancedSearch.tsx
Both pages were rewritten on main (PR #303) to use the shared
<SongRow> component with click-to-enqueueAndPlay semantics. Orbit's
playSong helper that branched on orbit-active is no longer needed
at the page level — instead, orbit awareness moved INTO SongRow and
SongCard themselves: in an active orbit session both buttons collapse
into addTrackToOrbit (suggest for guests, host-enqueue for the host)
so we don't ship a queue replacement to every guest.
Also kept main's IntersectionObserver-based pagination on both pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reuses the SongRail component from the Tracks hub — 18 random songs
fetched in parallel with the other Home queries. No reroll button.
Click-to-play uses enqueueAndPlay so the existing queue stays intact.
New homeStore section id 'discoverSongs' inserted after 'discover'
in DEFAULT_HOME_SECTIONS. onRehydrateStorage now appends any newly
introduced sections to a previously persisted layout — existing users
get the rail without a manual Reset. Settings → Personalisation
Home-Customizer surfaces it automatically via SECTION_LABELS.
i18n key home.discoverSongs added to all 8 locales.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /tracks route with three sections:
- Hero "Track of the moment" — random pick with play / enqueue / reroll
- Random Pick rail — 18 song cards, rerollable; hero song deduped
- Browse all tracks — virtualized list (@tanstack/react-virtual),
paginated 50 at a time
Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for
proper A-Z order (no Subsonic equivalent), with automatic fallback
to search3 on non-Navidrome servers. Search input drives search3
with 300ms debounce. Bearer token cached module-level, re-auth on 401.
Play button on rows + cards calls a new enqueueAndPlay() helper that
appends to the existing queue (skip if duplicate) and jumps to the
song — different from playSongNow which replaces the queue. Enqueue
button stays opaque (no hover-only).
i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales.
New AudioLines sidebar icon. Sidebar entry inserted between
"All Albums" and "Build a Mix".
Performance: cover thumbnails dropped (uniform layout instead),
RAF-throttled scroll prefetch, hover transforms removed from cards
(WebKitGTK compositing-friendly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled UX fixes around the header live search and the global context
menu, born from the same testing pass.
LiveSearch / MobileSearchOverlay
- Single click on a song no longer calls playTrack(track) without a
queue argument. The store fell back to the existing queue, didn't
find the new track, and ended up playing something the user couldn't
navigate or see in the queue list — the player header showed metadata
but the queue itself didn't contain it.
- New behaviour: enqueue([track]) + "Added to queue" toast. Whatever was
playing keeps playing; the new track lands at the bottom and is
visible/navigable. Mobile gets the same behaviour (tap-only).
- Desktop also gets a right-click context menu on song rows for users
who want immediate playback or different routing (Play Now, Play Next,
Add to Playlist, etc.). The dropdown stays open while the CM is up,
and the row picks up the .context-active highlight so the user can
see which song the menu refers to.
ContextMenu
- Removed the transparent fullscreen backdrop (z-index 998) that
previously caught outside clicks. It also blocked right-clicks from
reaching elements *underneath* it — so right-clicking a different
song row to reposition the menu hit the backdrop instead, closed the
menu, and never opened a new one for the row the user actually
pointed at.
- Replaced with a document-level mousedown listener (gated on
`contextMenu.isOpen`) that closes the menu when the click lands
outside `menuRef`. Standard outside-click pattern, doesn't occlude
the underlying UI, and right-clicking another row now naturally
pivots the CM to that row.
i18n: new search.addedToQueueToast key in 8 locales.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Topbar trigger button + start-modal hero brand both drop the "Psy"
prefix. Help-section copy and the settings toggle that references the
button by name are renamed in lockstep so the UI stays consistent —
otherwise the settings row would still read "Show Psy Orbit in the
header" while the actual button reads "Orbit". 8 locales updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pending counter desynced from the actual approval list (state.queue
holds approved items as history, so the count never decreased after a
host approve). The host-pushed pendingApprovalCount workaround didn't
hold up under live testing either, so we're rolling the whole cap
feature back rather than ship something flaky.
What's gone:
- OrbitSettings.maxPending + state.pendingApprovalCount
- cap branch in applyOutboxSnapshotsToState (now back to mute-only)
- maxPending number input in settings popover
- pending counter chip in OrbitQueueHead
- 'cap-reached' branch in evaluateOrbitSuggestGate / OrbitSuggestGateReason
- cap-related toasts in ContextMenu / useOrbitSongRowBehavior
- cap-related i18n keys (suggestBlockedCap, settingMaxPending*, pendingCounter*)
- cap CSS (.orbit-queue-head__pending, .orbit-settings-pop__number)
What stays: per-guest suggestion mute (works correctly) and everything
that fed into both features (OrbitState.suggestionBlocked,
setOrbitSuggestionBlocked, evaluateOrbitSuggestGate, the participants
popover Mic/MicOff toggle, the suggestBlockedMuted toast).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two anti-spam knobs the host can dial during a live session:
1. Per-guest suggestion mute — Mic / MicOff toggle next to the
kick/ban buttons in the participants popover. Symmetric (re-enable
later). State lives in OrbitState.suggestionBlocked: string[]; the
guest reads it and disables its own Suggest controls so the user
sees a clear "muted" state instead of silent failures. Host-side
sweep also drops their outbox entries as a safety net.
2. Max pending approvals cap — number input in the session-settings
popover, default 0 (= unlimited so existing sessions are unaffected).
When set, the host sweep stops folding new outbox entries into the
approval list once the cap is reached. The OrbitQueueHead surfaces
"X / Y pending" so guests can see when they're getting close.
State changes are additive on the wire — both fields are optional, with
parseOrbitState defaulting them, so older clients keep working.
evaluateOrbitSuggestGate() centralises the guest-side allow/block check
shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session
items, plus suggestOrbitTrack as a defensive last line.
i18n: en + de + fr + nl + zh + nb + ru + es.
Also fixes an earlier dedupe-key collision: the (user, trackId) cache
keys were missing their separator (NULL byte slipped in during the
previous patch), so two tracks could share a key and one of them
silently overwrite the other. Restored the space separator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: the guest queue already labelled guest-suggested tracks with
"Suggested by {user}", but host picks had no label — and the host's own
queue had no attribution at all. The host couldn't tell which upcoming
rows came from guests without checking the pending-approvals list.
- Guest queue: host-pick rows now show "Added by host" instead of hiding
the attribution line.
- Host queue: each row (and the current track) shows "Added by you" or
"Added by {user}" while an Orbit session is active. Lookup uses the
existing OrbitState.queue / currentTrack addedBy field, so no new
protocol fields.
New i18n keys (en+de+fr+nl+zh+nb+ru+es):
orbit.queueAddedByHost · queueAddedByYou · queueAddedByUser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns orbit invites with the existing magic-string family (psysonic1- for
server invites, psysonic2- for library shares) by folding orbit into the
psysonic2- payload as a new k:'orbit' variant. The JSON body is
intentionally extendable so future layers (passwords, permissions,
invite expiry) can be added without a format migration.
- SharePayloadV1 split into EntitySharePayloadV1 + OrbitSharePayloadV1
- decodeSharePayloadFromText filters orbit out; orbit has its own decoder
- applySharePastePayload param narrowed to EntitySharePayloadV1
- buildOrbitShareLink / parseOrbitShareLink kept as thin wrappers
- slug parameter dropped (magic string is opaque, so the slug was
cosmetic-only in the old URL form); slugifyOrbitName removed as dead code
- joinModalLinkPlaceholder updated in all 8 locales
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selecting playlists via the header toggle only exposed the delete action
through the right-click context menu. Add a visible Delete-selected button
next to Cancel that kicks off the same handleDeleteSelected flow, with a
tooltip hint when some of the selected playlists aren't deletable (foreign
owner). Button is only rendered when selection mode is on and at least one
playlist is selected.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Open smart playlist editing from playlist cards, load rules via Navidrome single-playlist API with safer fallbacks, and keep edit visibility aligned with ownership rules.
Also add/clean smart playlist locale keys across all supported languages and preserve smarter autogenerated naming behavior.
Move smart playlist creation and management into Playlists, including Navidrome-only gating, smart-name/icon presentation, and smarter refresh handling while server-side smart rules are being applied.
- Drop the ~110 lines of dead full-screen overlay CSS that was never
referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen
@keyframes). Keep the .lucky-mix-pip* rules since the inline queue
variant re-uses them.
- Extract availability gate into hooks/useLuckyMixAvailable.ts (pure
predicate + hook). Replaces five duplicated inline checks in Sidebar,
MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the
internal re-check in buildAndPlayLuckyMix.
- Add a cancel mechanic:
* luckyMixStore gets a cancelRequested flag + cancel() method
* LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper
sprinkled between await boundaries in the build loop
* catch-block swallows the sentinel silently (no toast, no error state)
* auto-cancel subscription on playerStore detects manual user track
changes (anything not in queuedIds) and flips cancelRequested so the
finished mix does not later overwrite the user's choice
* inline .queue-lucky-loading dice indicator is now a button — click
triggers explicit cancel, hover fades the dice to signal the action
- Restore the queue snapshot when the build fails before ever starting
playback, so the user does not land in an empty player after an error.
If playTrack already ran, leave current state alone (their track plus
whatever was enqueued is more useful than a stale pre-click queue).
- Rework locale labels to consistently carry the "Mix" suffix so the
entry reads well next to "Random Mix" / "Random Albums" in the
sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks,
NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays
«Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new
names. New luckyMix.cancelTooltip key added in all 8 locales.
AudioMuse gating stays — as Cuca confirmed, the feature's quality relies
on a correct similar-track selection, so the requirement is intentional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lucky Mix targets ~50 tracks in one run: pick seeds from your most-played artists/albums and 4+ rated songs, add similar tracks, then fill the mix with random library picks, skipping anything you rated 1–2.
On start it trims the old “upcoming” tail so the queue does not show stale next tracks, starts playback on the first viable seed, routes to Now Playing, and can emit structured steps to the backend debug log.
Guest suggestions now auto-merge into the host's play queue at random
positions inside the upcoming range, so they actually surface alongside
host-picked tracks instead of piling up at the end. Per-item dedupe via
addedBy:addedAt:trackId keys survives reshuffles.
Guests get a read-only mirror of the session in place of their queue
panel — live-card for the host's current track, suggestion list with
submitter attribution, and a footer reminding them the host owns
playback. Track metadata is resolved lazily and cached locally.
New host-only settings popover (gear in the bar) with auto-approve and
auto-shuffle toggles plus a "Shuffle now" action. Settings live in the
OrbitState blob and push immediately to Navidrome; missing fields on
older blobs default to "both on".
15-min shuffle now touches the real playerStore queue via a new
shuffleUpcomingQueue action — previous behaviour only reshuffled the
guest-facing suggestion history. A manual shuffle is available from the
settings popover so the interval can be verified without waiting.
Start-modal reworked into a single step: share-link is visible and
copy-able from the start, auto-copied on Start if the host hasn't
already hit Copy. Link carries a live name-derived slug (parser strips
it, SID is authoritative). LAN/remote-server hint above the facts.
Random session-name suggester pulls from three pattern pools for ~10k
unique combinations.
All user-visible Orbit strings moved to a dedicated orbit i18n
namespace (en + de); other locales fall back to en via i18next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the text-pill schedule badge with a circular SVG progress ring
around the play/pause button. Accent→lavender gradient stroke, counter-
clockwise depletion synced to the armed deadline, subtle accent glow.
While a timer is armed, the Play/Pause icon inside the button is
swapped for a two-line stack: a small Moon (sleep timer) or Sunrise
(delayed start) glyph above the countdown (m:ss / h:mm:ss). The icon
is the mode marker so the ring can stay unified with the rest of the
app's accent palette.
Redesigns the schedule modal with a mood-tinted header (Moon for
"Pause after", Sunrise for "Start after"), soft radial accent glow in
the background, slide-up open animation. Circular glass close-button
scoped to this modal, with hover rotation + focus ring. Custom-minutes
field gains an inline "min" suffix. A live preview line at the bottom
shows when the action fires ("Pauses at 23:47" / "Starts at 23:47"),
updating as the user hovers a preset or types. Chips gain a small
hover lift plus accent-coloured shadow.
Also fixes a pre-existing duplicate-tooltip on the play/pause button:
title= attributes removed alongside the data-tooltip (title violates
the project's "never native tooltips" rule, so both showed at once).
Adds scheduledPauseStartMs / scheduledResumeStartMs to the player
store so the progress ring has a total-duration baseline, set and
cleared alongside the existing deadline fields.
New usePlaybackScheduleRemaining hook wraps the store subscription
and 500 ms tick into a single hook used by all three player views
(PlayerBar / FullscreenPlayer / MobilePlayerView).
i18n: delayPreviewPause / delayPreviewStart keys across all 8 locales.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add scheduled pause and resume timers in the player store, cleared on stop
and track changes. Long-press opens a compact preset modal anchored above
the transport row; one-tap presets plus custom minutes. Portaled countdown
badge on the play button; clear the long-press click guard when the modal
closes so the first play/pause click works after scheduling.
Each dashboard card (Album, Top Songs, Credits, Artist, Discography,
On Tour) is now a widget the user grabs and drops anywhere — reorder
within a column or move across columns. Layout persists per-install
via the new nowPlayingLayoutStore (Zustand + localStorage, same shape
as sidebarStore with an onRehydrateStorage guard for unknown IDs).
Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event
based system from DragDropContext) — HTML5 native DnD is a no-go on
WebKitGTK Linux where it always shows a forbidden cursor. The whole
card is the drag handle; the column listens via a document-level
mousemove for the drop indicator and via a global psy-drop listener
that reads the latest hovered column from a ref, so drops below the
last card still land correctly regardless of column height.
Visibility is toggled from a layout menu in the top-right of the
dashboard — two sections (visible / hidden) plus a reset-to-defaults
button. No hide buttons on the cards themselves (keeps the card UI
uncluttered). An equal-height grid (align-items: stretch + min-height
on columns) keeps the drop zone active for the full vertical span of
either column.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the flat Now Playing page with a two-column dashboard: hero
(cover, metadata, badges, Last.fm stats + love button, release-age
tagline), and a grid of themed cards (Album tracklist with sliding
window, most-played-by-artist, credits, about-the-artist with Last.fm
bio fallback, compact discography contact-sheet with expand, upcoming
tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats
for global listener counts + userplaycount, plus Last.fm love/unlove
wired to the new hero button. Module-level TTL caches per entity keep
same-artist track switches instant. Artist-image guard filters the
well-known Last.fm no-image placeholder so the card collapses cleanly
when no real image exists.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>