mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
45b9229ceb
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3) cucadmuh's phase-3 steps: - Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[] fallback (until phase 4), F4 star/rating overrides merged on read. - QueueList rows source their track from the resolver (queue fallback); rows show title/artist/duration only, so no override merge there. - pendingStarSync star/rating success → invalidateQueueResolver so the cache reflects the synced value. - queueResolverBridge re-seeds on queueIndex change too — the prefetch window travels with the playing track. Additive: queue: Track[] stays canonical and behaviour is unchanged (rows resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks. * docs(changelog): queue panel reads through track cache (#860) * fix(queue): stop a render loop that froze the UI on long queues A long virtualized queue + a track change could lock the WebView for ~2 min: - useVirtualizer was handed a fresh `initialRect` object literal every render, so it kept re-initializing in a loop. Hoisted it to a stable module constant. - getCachedTrack did an LRU bump (Map delete+set) during render — a render-time side effect. Made it a pure read; recency is set at write time in cacheSet. * perf(mobile): virtualize the mobile player queue drawer The mobile now-playing queue drawer rendered the full queue with .map; a multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with @tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at O(visible rows), matching the desktop QueuePanel. Active track is centred on open via scrollToIndex. * perf(mini): virtualize the mini-player queue list The mini-player queue rendered the full MiniSyncPayload queue with .map. Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep data-mq-idx alongside the virtualizer's measureElement. * refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4) Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[]. Resolver cache → caller fallback (legacy queue[idx] during dual-write) → placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous read, no cache mutation (the freeze landmine), so it is safe in render. * refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4) Step 1b: dual-write the thin queueItems ref list at every queue write site (the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix, radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks queue: Track[] in memory, not only at persist time. Identity-preserving maps (star/rating overrides) keep the same refs and are intentionally left untouched. Resolves the restore double-role flagged for 1b: queueItemsIndex is now the restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems stays canonical -- rebuilt from the whole queue after a full hydrate instead of cleared. Normal mutations never set the sentinel, so it only fires on a fresh cold-start restore, not on later server switches. No behaviour change; queue: Track[] stays the source consumers read until phase 3. tsc + full vitest suite (1119 tests) green. * refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4) Step 2: the mobile now-playing queue drawer resolves each row's track from the resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop QueueList wired in the phase-3 commit. Subscribes to the resolver version so rows re-render as the cache fills. Structure (count, order, keys, the playTrack arg) still comes from queue: Track[] until it is dropped in the final step. The mobile drawer was the last queue display surface still reading track metadata straight off the fat queue. tsc + full vitest suite green. * refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4) Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[] (matching by trackId + the ref's queue-only flags) instead of Track[]. `bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new refs by id — purely structural (no resolver/override merge), so behaviour is byte-identical and playerStore.queue.test.ts stays unchanged green. The working ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the final step swaps that one line to state.queueItems once the fat queue is gone. enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks (seed-before-splice) so they resolve without a network round-trip after the fat queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems); dev-runtime only, silent in vitest and prod. tsc + full vitest suite (1119) green; contract test unchanged. * refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4) Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue mutations), and seed the resolver cache with the freshly fetched tracks so they resolve without a network round-trip after the fat queue is dropped. The radio top-up keeps its HISTORY_KEEP front-trim, now expressed on refs. The exhausted-queue refill paths hand their new queue to playTrack, which keeps its fat-queue handling until the final step (its no-arg case needs the resolver- derived queue that lands with the queue: Track[] removal). tsc + full vitest (1119) green; contract test unchanged. * refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4) Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[], killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display queue from the refs via resolveQueueTrack: resolver cache → the live queue by id (covers tracks the edit didn't remove) → placeholder. currentTrack stays a full track in the snapshot and is restored to the engine unchanged. The snapshot refs derive from queue: Track[] for now (so the undo/redo contract cases, which seed only `queue`, stay green); the final step swaps that to [...s.queueItems]. tsc + full vitest suite (1119) green. * perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4) Step 5: the mini bridge no longer serializes the full queue over IPC on every push — a 50k Artist-Radio queue would otherwise re-encode in full on every track advance. snapshot() sends a window of 100 tracks before/after the playing song; queueIndex is made slice-relative. The mini component stays unchanged (slice- relative); jump/reorder/remove control events are translated back to absolute queue indices via the window offset captured on the last push. tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly). * refactor(queue): make queueItems a required PlayerState field (thin-state phase 4) Foundation for the final consumer migration off queue: Track[]: queueItems has been written at every queue write site since phase 1b, so promoting it from optional to required is a no-op at runtime (tsc confirms zero new errors) and lets the upcoming reader migrations read state.queueItems without `?? []` noise. * refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4) First reader batch toward dropping queue: Track[]: the queue-length selectors (usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag) now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour- identical during dual-write (queueItems is in lockstep with queue). tsc + full vitest suite (1119) green. Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for now — it is called from many partially-mocked test stores, so it migrates with the final field removal where the seedQueue helper covers those tests. * refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4) The id/length reads (save to playlist, share link, create playlist, empty-queue guards, next-tracks divider) now read state.queueItems instead of the fat queue. Behaviour-identical during dual-write; queue: Track[] stays for the rendered QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green. * refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4) The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole canonical queue; full `Track`s resolve on demand via the resolver (index batch → getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At 50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track objects. - **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF). A `merge` migrates every historical blob shape → `queueItems` (existing `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops the obsolete `queue` key, so saved queues survive the upgrade. - **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole ref list into the cache on cold start (index → getSong, so an index-off queue still plays), clears the restore sentinel. - **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`. - **Mutations / actions / playback:** operate on refs; the playing track is `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation (next/previous/row-jump) keeps `queueItems` and only moves the index — no full resolve or queue rebuild per track change. - **Persist tests** cover the three old-blob migrations; `seedQueue` test helper replaces the `setState({ queue })` seeds. tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract; the gapless track change + cold-start restore + mini cap still want a live smoke before merge. * fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state) Rating/starring a queue song flashed the row's title to the "…" placeholder until the next track change. Root cause: on sync success pendingStarSync called invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[] gone there's no fat fallback, so the row resolved to a placeholder until the resolver bridge re-fetched the window. Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to update the cached entry in place (title kept, synced starred/userRating applied) instead of dropping it. No placeholder flash, no re-fetch. tsc + full vitest suite (1115) green. * fix(player): quota-safe persist so a full localStorage can't kill playback A very large queue (~50k refs) overflows the localStorage quota; the persist write then threw QuotaExceededError from inside set(), which aborted playTrack before audio_play — no audio output at all. Back the player persist with a quota-safe storage wrapper so a failed write degrades to a no-op instead of throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is left as a follow-up. * polish(player): throttle the quota-skip persist warning to once per key The quota-safe persist logs a skip on every failed write; on a huge queue that floods the dev console once per mutation. Warn once per key per quota-exceeded streak, re-armed when a write to that key next succeeds. * fix(queue): port new cover-pipeline readers to thin-state Main's cover pipeline (#870) reads s.queue.length and seeds the player store with queue: [track] in its tests. Under thin-state, queue: Track[] no longer exists — the canonical queue is queueItems: QueueItemRef[]. These four files were brought across in the merge but still spoke the old shape; this commit aligns them with the thin-state contract. - src/cover/usePlaybackCoverArt: queueLength = queueItems.length - src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs - src/api/coverCache.test: same - src/hooks/useNowPlayingPrewarm.test: same (two test cases) * fix(queue): canonicalize thin-state server identity for mixed-server queues `QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as the URL-derived index key on every writer path, matching the library index direction. Mixed-server queues with duplicate `trackId` across servers stay unambiguous because the resolver cache, persistence, and playback bindings all share one key shape. - new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer) - `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and `hydrateQueueFromIndex` emit canonical keys - `getCachedTrack` falls back to the canonical lookup so refs persisted in the legacy UUID shape still resolve through the migration window - persist `merge` rewrites `queueServerId` and every ref `serverId` on rehydrate, so the live store never holds mixed shapes - `removeServer` compares against the resolved id so a profile delete still clears the matching queue binding - the two `playbackServer.test.ts` asserts that hard-coded the UUID shape are updated to the canonical key (existing reader-tolerance is unchanged) * fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity When `applyQueueHistorySnapshot` has to prepend the still-playing track (the snapshot's queue does not contain it), the new ref must follow the snapshot's playback server, not the live `queueServerId`. A server switch racing the undo would otherwise stamp the prepended ref with the new server, mis-resolving the playing track on the very next render. - `QueueUndoSnapshot` now carries `queueServerId` (captured by `queueUndoSnapshotFromState`); older in-memory entries fall back through the snapshot's own refs and finally the live store value - the prepend in `applyQueueHistorySnapshot` plus the post-restore `seedQueueResolver` both source the server identity from this snapshot context, run through `canonicalQueueServerKey` so cache bucket and ref shape stay in lockstep * test(queue): regression cluster for mixed-server queues with duplicate trackId Covers the four invariants the thin-state review called out: - resolver correctness: same `trackId` on two servers maps to two distinct cache entries via canonical keys, and legacy UUID-shaped refs still read the same entries through the compat lookup path - restore/hydrate: persist `merge` forward-migrates UUID-form blobs in three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server `queueItems`) to canonical keys - undo snapshot application: prepended ref follows the snapshot's playback server even when the live queue has been rebound to a different one, with fallback to snapshot refs and live state for legacy entries - queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue` passes plain track ids and the playback server out of band, no per-ref `serverId` ever leaks into the request body Also asserts the write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`) emit canonical keys directly. * perf(queue-header): coalesce resolver burst updates and aggregate in one pass `QueueHeader` recomputed total and remaining queue durations on every resolver cache version bump via two separate full-queue reduces. A mass resolve burst (queue restore, prefetch window slide) bumps the version dozens of times in one frame, and very long queues turned that into visible main-thread stutter. - one pass: a single for-loop produces both total and future-tracks duration; a 50k-track queue costs one walk per recompute, not two - `useDeferredValue(version)` coalesces the burst into a single low-priority commit so the cache version is only sampled once per React frame instead of once per cache write * fix(queue): use stable artist seed for radio top-up The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and `getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next ref is still cold in the resolver cache, the placeholder track has empty artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently returning nothing and leaving the queue dry just before the radio rail would have refilled. - prefer the just-played `currentTrack` (always fully resolved in the player store) and the stored radio seed artist id - fall back to the next-track metadata only when those are missing - skip the top-up entirely when no stable seed is available, instead of emitting a non-deterministic empty request * docs(changelog): queue mixed-server routing and quota-safe persist (#872)
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import type { Track } from '../store/playerStoreTypes';
|
|
import { useState, useRef, useMemo } from 'react';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { useOrbitStore } from '../store/orbitStore';
|
|
import OrbitGuestQueue from './OrbitGuestQueue';
|
|
import OrbitQueueHead from './OrbitQueueHead';
|
|
import HostApprovalQueue from './HostApprovalQueue';
|
|
import { usePlaylistStore } from '../store/playlistStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { encodeSharePayload } from '../utils/share/shareLink';
|
|
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { useThemeStore } from '../store/themeStore';
|
|
import { useLyricsStore } from '../store/lyricsStore';
|
|
import LyricsPane from './LyricsPane';
|
|
import NowPlayingInfo from './NowPlayingInfo';
|
|
import { TFunction } from 'i18next';
|
|
import { useLuckyMixStore } from '../store/luckyMixStore';
|
|
import { useQueueToolbarStore } from '../store/queueToolbarStore';
|
|
import { SavePlaylistModal } from './queuePanel/SavePlaylistModal';
|
|
import { LoadPlaylistModal } from './queuePanel/LoadPlaylistModal';
|
|
import { QueueHeader } from './queuePanel/QueueHeader';
|
|
import { QueueCurrentTrack } from './queuePanel/QueueCurrentTrack';
|
|
import { useQueuePanelDrag } from '../hooks/useQueuePanelDrag';
|
|
import { useQueueLufsTgtPopover } from '../hooks/useQueueLufsTgtPopover';
|
|
import { QueueToolbar } from './queuePanel/QueueToolbar';
|
|
import { QueueList } from './queuePanel/QueueList';
|
|
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
|
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
|
|
|
export default function QueuePanel() {
|
|
const orbitRole = useOrbitStore(s => s.role);
|
|
if (orbitRole === 'guest') {
|
|
return (
|
|
<aside className="queue-panel queue-panel--orbit-guest">
|
|
<OrbitGuestQueue />
|
|
</aside>
|
|
);
|
|
}
|
|
return <QueuePanelHostOrSolo />;
|
|
}
|
|
|
|
function QueuePanelHostOrSolo() {
|
|
const { t } = useTranslation();
|
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
|
const orbitRole = useOrbitStore(s => s.role);
|
|
const orbitState = useOrbitStore(s => s.state);
|
|
/** trackId → addedBy (host username or guest username) — only populated while
|
|
* hosting an Orbit session, so the queue rows can surface attribution. */
|
|
const orbitAddedByByTrack = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
if (orbitRole !== 'host' || !orbitState) return map;
|
|
if (orbitState.currentTrack) {
|
|
map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy);
|
|
}
|
|
for (const q of orbitState.queue) map.set(q.trackId, q.addedBy);
|
|
return map;
|
|
}, [orbitRole, orbitState]);
|
|
const orbitHostUsername = orbitState?.host ?? '';
|
|
/** Attribution label for a queue row / current track while hosting. Null when
|
|
* not in a hosted session. Bulk-adds (album / playlist enqueue) bypass
|
|
* `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we
|
|
* default those to "Added by you" rather than showing nothing. */
|
|
const orbitAttributionLabel = (trackId: string): string | null => {
|
|
if (orbitRole !== 'host' || !orbitState) return null;
|
|
const addedBy = orbitAddedByByTrack.get(trackId);
|
|
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
|
return t('orbit.queueAddedByUser', { user: addedBy });
|
|
};
|
|
// Thin-state: the queue is the canonical `QueueItemRef[]`; rows resolve their
|
|
// Track from the resolver. List, header, toolbar and id/length reads (save /
|
|
// share / playlist) all read off the refs.
|
|
const queueItems = usePlayerStore(s => s.queueItems);
|
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
|
const { src: currentCoverSrc } = usePlaybackCoverArt(currentTrack?.coverArt, 128);
|
|
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
|
const playTrack = usePlayerStore(s => s.playTrack);
|
|
const clearQueue = usePlayerStore(s => s.clearQueue);
|
|
|
|
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
|
const removeTrack = usePlayerStore(s => s.removeTrack);
|
|
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
|
const enqueue = usePlayerStore(s => s.enqueue);
|
|
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
|
const contextMenu = usePlayerStore(s => s.contextMenu);
|
|
|
|
// When the user picks a track *from* the queue list, suppress the
|
|
// upcoming auto-scroll so their click target stays in view instead of
|
|
// the list rebasing onto the next track. Auto-advance (natural playback)
|
|
// never sets this flag, so it keeps its original "show what's next" behavior.
|
|
const suppressNextAutoScrollRef = useRef(false);
|
|
|
|
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
|
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
|
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
|
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
|
|
|
|
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
|
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
|
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
|
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
|
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
|
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
|
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
|
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
|
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
|
|
|
const activeTab = useLyricsStore(s => s.activeTab);
|
|
const setTab = useLyricsStore(s => s.setTab);
|
|
const luckyRolling = useLuckyMixStore(s => s.isRolling);
|
|
|
|
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
|
|
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
|
|
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
|
|
const durationMode = useAuthStore(s => s.queueDurationDisplayMode);
|
|
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
|
|
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
|
|
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
|
|
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
|
|
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
|
|
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
|
|
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
|
|
|
|
const {
|
|
lufsTgtOpen,
|
|
setLufsTgtOpen,
|
|
lufsTgtPopStyle,
|
|
lufsTgtBtnRef,
|
|
lufsTgtMenuRef,
|
|
} = useQueueLufsTgtPopover(expandReplayGain);
|
|
|
|
const queueListRef = useRef<HTMLDivElement>(null);
|
|
const asideRef = useRef<HTMLElement>(null);
|
|
|
|
const {
|
|
psyDragFromIdxRef,
|
|
externalDropTarget,
|
|
externalDropTargetRef,
|
|
setExternalDropTarget,
|
|
isQueueDrag,
|
|
startDrag,
|
|
} = useQueuePanelDrag({
|
|
asideRef,
|
|
isQueueVisible,
|
|
reorderQueue,
|
|
enqueueAt,
|
|
removeTrack,
|
|
});
|
|
|
|
useQueueAutoScroll({
|
|
queue: queueItems,
|
|
queueIndex,
|
|
currentTrack,
|
|
queueListRef,
|
|
suppressNextAutoScrollRef,
|
|
});
|
|
|
|
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
|
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
|
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
|
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
if (queueItems.length === 0) return;
|
|
if (activePlaylist) {
|
|
setSaveState('saving');
|
|
try {
|
|
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
|
setSaveState('saved');
|
|
setTimeout(() => setSaveState('idle'), 1500);
|
|
} catch (e) {
|
|
console.error('Failed to update playlist', e);
|
|
setSaveState('idle');
|
|
}
|
|
} else {
|
|
setSaveModalOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleLoad = () => {
|
|
setLoadModalOpen(true);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
clearQueue();
|
|
setActivePlaylist(null);
|
|
};
|
|
|
|
const handleCopyQueueShare = async () => {
|
|
if (queueItems.length === 0) {
|
|
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
|
return;
|
|
}
|
|
const srv = useAuthStore.getState().getBaseUrl();
|
|
if (!srv) return;
|
|
const ids = queueItems.map(r => r.trackId);
|
|
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
|
if (ok) showToast(t('contextMenu.shareCopied'));
|
|
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
ref={asideRef}
|
|
className={`queue-panel${isQueueDrag ? ' queue-drop-active' : ''}`}
|
|
onMouseMove={e => {
|
|
if (!isQueueDrag || !queueListRef.current) return;
|
|
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
|
let found = false;
|
|
for (let i = 0; i < items.length; i++) {
|
|
const rect = items[i].getBoundingClientRect();
|
|
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
|
const before = e.clientY < rect.top + rect.height / 2;
|
|
const idx = parseInt(items[i].dataset.queueIdx!);
|
|
const target = { idx, before };
|
|
externalDropTargetRef.current = target;
|
|
setExternalDropTarget(target);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
externalDropTargetRef.current = null;
|
|
setExternalDropTarget(null);
|
|
}
|
|
}}
|
|
style={{
|
|
borderLeftWidth: isQueueVisible ? 1 : 0,
|
|
}}
|
|
>
|
|
{orbitRole === 'host' && orbitState && (
|
|
<>
|
|
<OrbitQueueHead state={orbitState} />
|
|
<HostApprovalQueue />
|
|
</>
|
|
)}
|
|
<QueueHeader
|
|
queue={queueItems}
|
|
queueIndex={queueIndex}
|
|
activePlaylist={activePlaylist}
|
|
isNowPlayingCollapsed={isNowPlayingCollapsed}
|
|
setIsNowPlayingCollapsed={setIsNowPlayingCollapsed}
|
|
durationMode={durationMode}
|
|
setDurationMode={setDurationMode}
|
|
t={t}
|
|
/>
|
|
|
|
{currentTrack && !isNowPlayingCollapsed && (
|
|
<QueueCurrentTrack
|
|
currentTrack={currentTrack}
|
|
currentCoverSrc={currentCoverSrc}
|
|
userRatingOverrides={userRatingOverrides}
|
|
orbitAttributionLabel={orbitAttributionLabel}
|
|
navigate={navigatePlaybackLibrary}
|
|
playbackSource={playbackSource}
|
|
normalizationEngine={normalizationEngine}
|
|
normalizationEngineLive={normalizationEngineLive}
|
|
normalizationNowDb={normalizationNowDb}
|
|
normalizationTargetLufs={normalizationTargetLufs}
|
|
authLoudnessTargetLufs={authLoudnessTargetLufs}
|
|
loudnessPreAnalysisAttenuationDb={loudnessPreAnalysisAttenuationDb}
|
|
expandReplayGain={expandReplayGain}
|
|
setExpandReplayGain={setExpandReplayGain}
|
|
reanalyzeLoudnessForTrack={reanalyzeLoudnessForTrack}
|
|
setLoudnessTargetLufs={setLoudnessTargetLufs}
|
|
lufsTgtOpen={lufsTgtOpen}
|
|
setLufsTgtOpen={setLufsTgtOpen}
|
|
lufsTgtBtnRef={lufsTgtBtnRef}
|
|
lufsTgtMenuRef={lufsTgtMenuRef}
|
|
lufsTgtPopStyle={lufsTgtPopStyle}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'queue' ? (<>
|
|
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
|
<QueueToolbar
|
|
queue={queueItems}
|
|
activePlaylist={activePlaylist}
|
|
saveState={saveState}
|
|
toolbarButtons={toolbarButtons}
|
|
shuffleQueue={shuffleQueue}
|
|
handleSave={handleSave}
|
|
handleLoad={handleLoad}
|
|
handleCopyQueueShare={handleCopyQueueShare}
|
|
handleClear={handleClear}
|
|
gaplessEnabled={gaplessEnabled}
|
|
setGaplessEnabled={setGaplessEnabled}
|
|
crossfadeEnabled={crossfadeEnabled}
|
|
setCrossfadeEnabled={setCrossfadeEnabled}
|
|
crossfadeSecs={crossfadeSecs}
|
|
setCrossfadeSecs={setCrossfadeSecs}
|
|
infiniteQueueEnabled={infiniteQueueEnabled}
|
|
setInfiniteQueueEnabled={setInfiniteQueueEnabled}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{currentTrack && queueItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
|
|
|
<QueueList
|
|
queue={queueItems}
|
|
queueIndex={queueIndex}
|
|
contextMenu={contextMenu}
|
|
playTrack={playTrack}
|
|
activeTab={activeTab}
|
|
queueListRef={queueListRef}
|
|
suppressNextAutoScrollRef={suppressNextAutoScrollRef}
|
|
isQueueDrag={isQueueDrag}
|
|
psyDragFromIdxRef={psyDragFromIdxRef}
|
|
externalDropTarget={externalDropTarget}
|
|
startDrag={startDrag}
|
|
orbitAttributionLabel={orbitAttributionLabel}
|
|
luckyRolling={luckyRolling}
|
|
t={t}
|
|
/>
|
|
</>) : activeTab === 'lyrics' ? (
|
|
<LyricsPane currentTrack={currentTrack} />
|
|
) : (
|
|
<NowPlayingInfo />
|
|
)}
|
|
|
|
<QueueTabBar activeTab={activeTab} setTab={setTab} t={t} />
|
|
|
|
{saveModalOpen && (
|
|
<SavePlaylistModal
|
|
onClose={() => setSaveModalOpen(false)}
|
|
onSave={async (name) => {
|
|
try {
|
|
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
|
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
|
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
|
setSaveModalOpen(false);
|
|
} catch (e) {
|
|
console.error('Failed to save playlist', e);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{loadModalOpen && (
|
|
<LoadPlaylistModal
|
|
onClose={() => setLoadModalOpen(false)}
|
|
onLoad={async (id, name, mode) => {
|
|
try {
|
|
const data = await getPlaylist(id);
|
|
const tracks: Track[] = data.songs.map(songToTrack);
|
|
if (tracks.length > 0) {
|
|
if (mode === 'append') {
|
|
enqueue(tracks);
|
|
} else {
|
|
clearQueue();
|
|
playTrack(tracks[0], tracks);
|
|
}
|
|
}
|
|
setActivePlaylist({ id, name });
|
|
setLoadModalOpen(false);
|
|
} catch (e) {
|
|
console.error('Failed to load playlist', e);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|