refactor(queue): co-locate QueuePanel UI into features/queue

Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).

Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.

Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
This commit is contained in:
Psychotoxical
2026-06-30 08:40:51 +02:00
parent 7ad196711e
commit d5bbabac1d
20 changed files with 89 additions and 84 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ import { useIsMobile } from '@/lib/hooks/useIsMobile';
import { LiveSearch } from '@/features/search';
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
import { NowPlayingDropdown } from '@/features/nowPlaying';
import QueuePanel from '../components/QueuePanel';
import QueuePanel from '@/features/queue';
import AppRoutes from './AppRoutes';
import FullscreenPlayer from '@/features/fullscreenPlayer';
import ContextMenu from '../components/ContextMenu';
@@ -44,7 +44,7 @@ import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { usePrefetchReleaseNotes } from '../hooks/usePrefetchReleaseNotes';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
import { useServerCapabilitiesProbe } from '../hooks/useServerCapabilitiesProbe';
import { useQueueResizer } from '../hooks/useQueueResizer';
import { useQueueResizer } from '@/features/queue';
import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers';
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
@@ -13,7 +13,7 @@ import { useArtistFanart } from '@/cover/useArtistFanart';
import { backdropFromConfig } from '@/cover/artistBackdrop';
import { useThemeStore } from '@/store/themeStore';
import { useFsIdleFade } from '@/hooks/useFsIdleFade';
import { useQueueTrackAt } from '@/hooks/useQueueTracks';
import { useQueueTrackAt } from '@/features/queue';
import { WaveformSeek } from '@/features/waveform';
import { FsQueueModal } from '@/features/fullscreenPlayer/components/FsQueueModal';
import { FsLyricsApple } from '@/features/fullscreenPlayer/components/FsLyricsApple';
@@ -1,24 +1,24 @@
import React from 'react';
import { ChevronDown, FolderOpen, HardDrive, Music, Waves } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { Track } from '../../store/playerStoreTypes';
import type { LoudnessLufsPreset, NormalizationEngine } from '../../store/authStoreTypes';
import type { PlaybackSourceKind } from '../../utils/playback/resolvePlaybackUrl';
import type { Track } from '@/store/playerStoreTypes';
import type { LoudnessLufsPreset, NormalizationEngine } from '@/store/authStoreTypes';
import type { PlaybackSourceKind } from '@/utils/playback/resolvePlaybackUrl';
import {
formatQueueReplayGainParts,
renderStars,
} from '../../utils/componentHelpers/queuePanelHelpers';
import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/audio/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
import { formatQueueBpmTech, formatQueueMoodLabels } from '../../utils/library/trackEnrichment';
import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { CoverArtImage } from '../../cover/CoverArtImage';
} from '@/utils/componentHelpers/queuePanelHelpers';
import { loudnessGainPlaceholderUntilCacheDb } from '@/utils/audio/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/utils/library/trackEnrichment';
import { useQueueTrackEnrichment } from '@/features/queue/hooks/useQueueTrackEnrichment';
import { QueueLufsTargetMenu } from '@/features/queue/components/QueueLufsTargetMenu';
import { PlaybackBufferingOverlay } from '@/components/playback/PlaybackBufferingOverlay';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { OpenArtistRefInline } from '@/features/artist';
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
import { usePlayerStore } from '../../store/playerStore';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlayerStore } from '@/store/playerStore';
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
interface Props {
currentTrack: Track;
@@ -2,18 +2,18 @@ import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { QueueDisplayMode } from '../../store/authStoreTypes';
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef } from '@/store/playerStoreTypes';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { formatClockTime } from '@/lib/format/formatClockTime';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../../utils/library/queueTrackResolver';
} from '@/utils/library/queueTrackResolver';
interface Props {
queue: QueueItemRef[];
@@ -3,20 +3,20 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { Play } from 'lucide-react';
import type { TFunction } from 'i18next';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { useLuckyMixStore } from '../../store/luckyMixStore';
import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
import type { QueueDisplayMode } from '../../store/authStoreTypes';
import { usePlayerStore } from '@/store/playerStore';
import { useLuckyMixStore } from '@/store/luckyMixStore';
import type { QueueItemRef, PlayerState } from '@/store/playerStoreTypes';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../../utils/library/queueTrackResolver';
import { findQueueItemRefIndex } from '../../utils/playback/queueIdentity';
import type { TimelineDisplayRow } from '../../utils/queue/buildTimelineDisplayRows';
import { findTimelineScrollLocalIndex } from '../../utils/queue/buildTimelineDisplayRows';
import { playTimelineHistoryTrack } from '../../utils/queue/playTimelineHistoryTrack';
} from '@/utils/library/queueTrackResolver';
import { findQueueItemRefIndex } from '@/utils/playback/queueIdentity';
import type { TimelineDisplayRow } from '@/utils/queue/buildTimelineDisplayRows';
import { findTimelineScrollLocalIndex } from '@/utils/queue/buildTimelineDisplayRows';
import { playTimelineHistoryTrack } from '@/utils/queue/playTimelineHistoryTrack';
type StartDrag = (
payload: { data: string; label: string },
@@ -1,6 +1,6 @@
import React from 'react';
import { createPortal } from 'react-dom';
import type { LoudnessLufsPreset } from '../../store/authStoreTypes';
import type { LoudnessLufsPreset } from '@/store/authStoreTypes';
interface Props {
menuRef: React.RefObject<HTMLDivElement | null>;
@@ -30,7 +30,7 @@ vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
import QueuePanel from './QueuePanel';
import QueuePanel from '@/features/queue/components/QueuePanel';
import { fireEvent } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
@@ -224,7 +224,7 @@ describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
// `dragenter` / `dragover` props are allowed because the document
// listens for them to render the drop indicator without acting as
// a sink for HTML5 payloads.
const source = readFileSync(join(process.cwd(), 'src/components/QueuePanel.tsx'), 'utf8');
const source = readFileSync(join(process.cwd(), 'src/features/queue/components/QueuePanel.tsx'), 'utf8');
expect(source).not.toMatch(/dataTransfer\.setData/);
expect(source).not.toMatch(/dataTransfer\.getData/);
expect(source).not.toMatch(/\bonDragStart\s*=/);
@@ -232,7 +232,7 @@ describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
});
it('the source file does not use `application/json` MIME anywhere (WebView2 restriction)', () => {
const source = readFileSync(join(process.cwd(), 'src/components/QueuePanel.tsx'), 'utf8');
const source = readFileSync(join(process.cwd(), 'src/features/queue/components/QueuePanel.tsx'), 'utf8');
expect(source).not.toMatch(/application\/json/);
});
});
@@ -1,40 +1,40 @@
import { Play } from 'lucide-react';
import { updatePlaylist } from '@/lib/api/subsonicPlaylists';
import { resolvePlaylist, resolveMediaServerId } from '@/features/offline';
import { songToTrack } from '../utils/playback/songToTrack';
import type { Track } from '../store/playerStoreTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import type { Track } from '@/store/playerStoreTypes';
import { useState, useRef, useMemo } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore } from '@/store/playerStore';
import { useOrbitStore } from '@/features/orbit';
import { OrbitGuestQueue, OrbitQueueHead } from '@/features/orbit';
import HostApprovalQueue from './HostApprovalQueue';
import HostApprovalQueue from '@/components/HostApprovalQueue';
import { usePlaylistStore } from '@/features/playlist';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '../store/authStore';
import { encodeSharePayload } from '../utils/share/shareLink';
import { serverShareBaseUrl } from '../utils/server/serverEndpoint';
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 { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '@/store/authStore';
import { encodeSharePayload } from '@/utils/share/shareLink';
import { serverShareBaseUrl } from '@/utils/server/serverEndpoint';
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 '@/components/LyricsPane';
import { NowPlayingInfo } from '@/features/nowPlaying';
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 { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '../hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '../utils/queue/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
import { useLuckyMixStore } from '@/store/luckyMixStore';
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
import { SavePlaylistModal } from '@/features/queue/components/SavePlaylistModal';
import { LoadPlaylistModal } from '@/features/queue/components/LoadPlaylistModal';
import { QueueHeader } from '@/features/queue/components/QueueHeader';
import { QueueCurrentTrack } from '@/features/queue/components/QueueCurrentTrack';
import { useQueuePanelDrag } from '@/features/queue/hooks/useQueuePanelDrag';
import { useQueueLufsTgtPopover } from '@/features/queue/hooks/useQueueLufsTgtPopover';
import { QueueToolbar } from '@/features/queue/components/QueueToolbar';
import { QueueList } from '@/features/queue/components/QueueList';
import { QueueTabBar } from '@/features/queue/components/QueueTabBar';
import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '@/utils/queue/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '@/utils/playback/trackServerScope';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -3,12 +3,12 @@ import {
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { QueueItemRef } from '@/store/playerStoreTypes';
import type {
QueueToolbarButtonConfig,
QueueToolbarButtonId,
} from '../../store/queueToolbarStore';
import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition';
} from '@/store/queueToolbarStore';
import { getTransitionMode, setTransitionMode } from '@/utils/playback/playbackTransition';
import { useOrbitStore } from '@/features/orbit';
interface Props {
@@ -1,6 +1,6 @@
import React, { useLayoutEffect } from 'react';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '@/store/queueUndo';
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
interface Args {
queue: QueueItemRef[];
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import { songToTrack } from '../utils/playback/songToTrack';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useDragDrop, registerQueueDragHitTest } from '@/lib/dnd/DragDropContext';
import { usePlayerStore } from '../store/playerStore';
import type { Track } from '../store/playerStoreTypes';
import { usePlayerStore } from '@/store/playerStore';
import type { Track } from '@/store/playerStoreTypes';
/** Drag types that may be dropped into the queue panel. */
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { libraryGetFacts, libraryGetTrack } from '@/lib/api/library';
import { usePlaybackServerId } from './usePlaybackServerId';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import {
enrichmentDisplayComplete,
OXIMEDIA_MOOD_UI_ENABLED,
parseTrackEnrichmentFacts,
type ParsedTrackEnrichment,
} from '../utils/library/trackEnrichment';
import { libraryIsReady } from '../utils/library/libraryReady';
import { normalizeAnalysisTrackId } from '../utils/playback/queueIdentity';
} from '@/utils/library/trackEnrichment';
import { libraryIsReady } from '@/utils/library/libraryReady';
import { normalizeAnalysisTrackId } from '@/utils/playback/queueIdentity';
const EMPTY: ParsedTrackEnrichment = {
serverBpm: null,
@@ -4,7 +4,7 @@ import { usePlayerStore } from '@/store/playerStore';
import type { Track } from '@/store/playerStoreTypes';
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
import { seedQueue } from '@/test/helpers/factories';
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from '@/features/queue/hooks/useQueueTracks';
const track = (id: string, over: Partial<Track> = {}): Track =>
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
@@ -1,11 +1,11 @@
import { useMemo, useSyncExternalStore } from 'react';
import { usePlayerStore } from '../store/playerStore';
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { usePlayerStore } from '@/store/playerStore';
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../utils/library/queueTrackResolver';
} from '@/utils/library/queueTrackResolver';
/**
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
+5
View File
@@ -0,0 +1,5 @@
// Public API of the queue feature (QueuePanel UI + its hooks). Consumes the
// playback store; holds no audio-engine state itself.
export { default } from './components/QueuePanel';
export { useQueueResizer } from './hooks/useQueueResizer';
export { useQueueTrackAt } from './hooks/useQueueTracks';