mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
This commit is contained in:
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '@/features/album/hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { isOfflinePinComplete } from '@/features/offline';
|
||||
@@ -16,7 +16,7 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
|
||||
import { resolveCoverDisplayTier } from '@/cover/tiers';
|
||||
import { acquireUrl } from '@/utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from '@/features/artist';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '@/utils/playback/playAlbum';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '@/utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '@/lib/hooks/useIsMobile';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { AudioLines } from 'lucide-react';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,18 +4,18 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ColDef } from '@/utils/useTracklistColumns';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import StarRating from '@/components/StarRating';
|
||||
import { codecLabel, type ColKey } from '@/features/album/utils/albumTrackListHelpers';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
|
||||
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
|
||||
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
|
||||
interface UseAlbumTrackListSelectionArgs {
|
||||
songs: SubsonicSong[];
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
|
||||
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
|
||||
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
|
||||
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
|
||||
import { getArtistInfo } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { shuffleArray } from '@/utils/playback/shuffleArray';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import { useAlbumDetailData } from '@/features/album/hooks/useAlbumDetailData';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { resolveAlbum } from '@/features/offline';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';
|
||||
import AlbumCard from '@/features/album/components/AlbumCard';
|
||||
import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '@/cover/layoutSizes';
|
||||
@@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
import { useDownloadModalStore } from '@/features/offline';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { resolveAlbum } from '@/features/offline';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AlbumCard from '@/features/album/components/AlbumCard';
|
||||
import { LOSSLESS_MODE_QUERY } from '@/utils/library/losslessMode';
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
import { useDownloadModalStore } from '@/features/offline';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useZipDownloadStore } from '@/features/offline';
|
||||
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
|
||||
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getAlbumList } from '@/lib/api/subsonicLibrary';
|
||||
import { resolveAlbum } from '@/features/offline';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
import { shuffleArray } from '@/utils/playback/shuffleArray';
|
||||
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
|
||||
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
|
||||
import SelectionToggleButton from '@/components/SelectionToggleButton';
|
||||
|
||||
@@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import ArtistTopTrackCover from '@/features/artist/components/ArtistTopTrackCover';
|
||||
import { topSongAlbumForCover } from '@/features/artist/components/topSongAlbumForCover';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { PlayerState } from '@/store/playerStoreTypes';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
||||
import { ArtistCardAvatar } from '@/features/artist/components/ArtistAvatars';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { PlayerState } from '@/store/playerStoreTypes';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { OTHER_BUCKET, type ArtistListFlatRow } from '@/features/artist/utils/artistsHelpers';
|
||||
import { ArtistRowAvatar } from '@/features/artist/components/ArtistAvatars';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { ALL_SENTINEL, artistLetterBucket, compareBuckets, type ArtistListFlatRow } from '@/features/artist/utils/artistsHelpers';
|
||||
|
||||
interface UseArtistsFilteringArgs {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { AlbumCard } from '@/features/album';
|
||||
import { ArrowDownUp } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '@/features/artist/store/artistLayoutStore';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import SelectionToggleButton from '@/components/SelectionToggleButton';
|
||||
import StarFilterButton from '@/components/StarFilterButton';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '@/utils/playback/runBulkPlay';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJob
|
||||
import { useDeviceSyncStore } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { trackToSyncInfo } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { fetchTracksForSource } from '@/features/playback/utils/playback/fetchTracksForSource';
|
||||
|
||||
export function useDeviceSyncJobEvents(
|
||||
t: TFunction,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { fetchTracksForSource } from '@/features/playback/utils/playback/fetchTracksForSource';
|
||||
import { trackToSyncInfo, type SyncStatus } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync
|
||||
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { trackToSyncInfo, uuid } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { fetchTracksForSource } from '@/features/playback/utils/playback/fetchTracksForSource';
|
||||
|
||||
export interface SyncDelta {
|
||||
addBytes: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncSto
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { applyLegacyTemplate } from '@/features/deviceSync/utils/deviceSyncLegacyTemplate';
|
||||
import { trackToSyncInfo } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { fetchTracksForSource } from '@/features/playback/utils/playback/fetchTracksForSource';
|
||||
import { IS_WINDOWS } from '@/lib/util/platform';
|
||||
|
||||
export type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
|
||||
|
||||
@@ -9,7 +9,7 @@ import i18n from '@/lib/i18n';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import StarRating from '@/components/StarRating';
|
||||
import { OpenArtistRefInline } from '@/features/artist';
|
||||
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
|
||||
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
|
||||
export interface FavoriteSongRowCallbacks {
|
||||
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
|
||||
import GenreFilterBar from '@/components/GenreFilterBar';
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ColDef } from '@/utils/useTracklistColumns';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { appendServerQuery } from '@/utils/navigation/detailServerScope';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { useElementClientHeightById } from '@/lib/hooks/useResizeClientHeight';
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
|
||||
} from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { TopFavoriteArtist } from '@/features/favorites/components/TopFavoriteArtists';
|
||||
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
|
||||
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const MIN_YEAR = 1950;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
|
||||
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTracklistColumns, type ColDef } from '@/utils/useTracklistColumns';
|
||||
import { TopFavoriteArtistsRow } from '@/features/favorites/components/TopFavoriteArtists';
|
||||
@@ -11,7 +11,7 @@ import { useFavoritesSelection } from '@/features/favorites/hooks/useFavoritesSe
|
||||
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
|
||||
import { AlbumRow } from '@/features/album';
|
||||
import { ArtistRow } from '@/features/artist';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelectionStore } from '@/store/selectionStore';
|
||||
import FavoritesOfflineHeader from '@/features/favorites/components/FavoritesOfflineHeader';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLyrics, type WordLyricsLine } from '@/hooks/useLyrics';
|
||||
import { useWordLyricsSync } from '@/hooks/useWordLyricsSync';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
|
||||
import type { LrcLine } from '@/api/lrclib';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { EaseScroller, targetForFraction } from '@/utils/ui/easeScroll';
|
||||
|
||||
// Fullscreen synced lyrics.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { Moon, Pause, Play, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import PlaybackDelayModal from '@/components/PlaybackDelayModal';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { memo, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Shuffle, ListMusic, ChevronDown, Star, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
|
||||
import { useAlbumCoverRef, useArtistCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '@/hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '@/ui/CachedImage';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { registerQueueDragHitTest } from '@/lib/dnd/DragDropContext';
|
||||
import MiniContextMenu from '@/features/miniPlayer/components/MiniContextMenu';
|
||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { emit } from '@tauri-apps/api/event';
|
||||
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { MiniSyncPayload } from '@/features/miniPlayer/utils/miniPlayerBridge';
|
||||
import { getTransitionMode } from '@/utils/playback/playbackTransition';
|
||||
import { getTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
|
||||
interface Props {
|
||||
state: MiniSyncPayload;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
interface Args {
|
||||
queueOpen: boolean;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setTransitionMode, type TransitionMode } from '@/utils/playback/playbackTransition';
|
||||
import { setTransitionMode, type TransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import type { SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
export const MINI_WINDOW_LABEL = 'mini';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import type { MiniSyncPayload, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
/** Half-width of the mini initial-snapshot queue window (matches the bridge). */
|
||||
const MINI_SNAPSHOT_HALF = 100;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
|
||||
import { PlayCircle, Pause, User, Radio, RefreshCw } from 'lucide-react';
|
||||
import { nowPlayingPresence } from '@/features/nowPlaying/api/nowPlayingPresence';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
|
||||
import CachedImage from '@/ui/CachedImage';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { primaryTrackArtistRef } from '@/utils/playback/trackArtistRefs';
|
||||
import { primaryTrackArtistRef } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
|
||||
const TOUR_LIMIT = 5;
|
||||
const BIO_CLAMP_LINES = 4;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, Clock, Radio, SkipForward, Users } from 'lucide-react';
|
||||
import type { useRadioMetadata } from '@/features/radio';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
|
||||
|
||||
@@ -5,7 +5,7 @@ import { coverIndexKeyFromRef } from '@/cover/storageKeys';
|
||||
import { useNowPlayingPrewarm } from '@/features/nowPlaying/hooks/useNowPlayingPrewarm';
|
||||
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
|
||||
@@ -10,9 +10,9 @@ import { coverArtIdFromRadio } from '@/cover/ids';
|
||||
import type { CoverArtRef } from '@/cover/types';
|
||||
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { primaryTrackArtistRef } from '@/utils/playback/trackArtistRefs';
|
||||
import { primaryTrackArtistRef } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { queueSongStar } from '@/store/pendingStarSync';
|
||||
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import type { TrackStats } from '@/music-network';
|
||||
import { getMusicNetworkRuntime } from '@/music-network';
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLyricsStore } from '@/store/lyricsStore';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useRadioMetadata } from '@/features/radio';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
@@ -38,7 +38,7 @@ import DiscographyCard from '@/features/nowPlaying/components/DiscographyCard';
|
||||
import { useNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
|
||||
import { useNowPlayingStarLove } from '@/features/nowPlaying/hooks/useNowPlayingStarLove';
|
||||
import { useArtistInfoBatch } from '@/features/artist';
|
||||
import { primaryTrackArtistRef, resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
|
||||
import { primaryTrackArtistRef, resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
import type { ArtistCardTab } from '@/features/nowPlaying/components/ArtistCard';
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||
import { coverServerScopeForServerId } from '@/cover/serverScope';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type OfflineLibraryCard,
|
||||
} from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { shuffleArray } from '@/utils/playback/shuffleArray';
|
||||
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
import { reconcileAllLibraryTiersFromDisk } from '@/features/offline/utils/libraryTierReconcile';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { findLocalPlaybackEntry, hasLocalLibraryBytes } from '@/store/localPlayb
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { resolveTrackCoverArtId, trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Copy, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { computeOrbitDriftMs } from '@/features/orbit/utils/orbit';
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { useHelpModalStore } from '@/store/helpModalStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
endOrbitSession,
|
||||
leaveOrbitSession,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/features/orbit/utils/orbit';
|
||||
import { randomOrbitSessionName } from '@/features/orbit/utils/orbitNames';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { isLanUrl, serverShareBaseUrl } from '@/utils/server/serverEndpoint';
|
||||
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/api/orbit';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
readOrbitState,
|
||||
applyOrbitTransitionSettings,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useEffect } from 'react';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
writeOrbitState,
|
||||
sweepGuestOutboxes,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlaybackRateStore } from '@/store/playbackRateStore';
|
||||
import { usePlaybackRateStore } from '@/features/playback/store/playbackRateStore';
|
||||
|
||||
/** Re-sync playback rate when Orbit enters or leaves shared playback. */
|
||||
export function usePlaybackRateOrbitSync() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
orbitOutboxPlaylistName,
|
||||
type OrbitQueueItem,
|
||||
|
||||
@@ -23,10 +23,10 @@ vi.mock('@/features/orbit/utils/remote', () => ({
|
||||
}));
|
||||
vi.mock('@/features/orbit/store/orbitStore', () => ({ useOrbitStore: { getState: () => orbitStore } }));
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({}) } }));
|
||||
vi.mock('@/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
|
||||
vi.mock('@/lib/api/subsonicPlaylists', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({ getSong: vi.fn() }));
|
||||
vi.mock('@/utils/playback/songToTrack', () => ({ songToTrack: vi.fn() }));
|
||||
vi.mock('@/features/playback/utils/playback/songToTrack', () => ({ songToTrack: vi.fn() }));
|
||||
|
||||
import { updateOrbitSettings } from '@/features/orbit/utils/host';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createPlaylist, deletePlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
makeInitialOrbitState,
|
||||
orbitOutboxPlaylistName,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { playbackReportStart } from '@/features/playback/store/playbackReportSession';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { getPlaybackSourceKind } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import {
|
||||
bumpPlayGeneration,
|
||||
getPlayGeneration,
|
||||
setIsAudioPaused,
|
||||
} from '@/features/playback/store/engineState';
|
||||
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import type { PlayerState, QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
|
||||
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
|
||||
import {
|
||||
setPendingQueueListScrollTop,
|
||||
type QueueUndoSnapshot,
|
||||
} from '@/features/playback/store/queueUndo';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { stopRadio } from '@/features/playback/store/radioPlayer';
|
||||
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
|
||||
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
/**
|
||||
* Apply a queue-undo snapshot to the player store and resync the Rust
|
||||
* audio engine where needed. Used by both `undoLastQueueEdit` and
|
||||
* `redoLastQueueEdit` actions inside the store.
|
||||
*
|
||||
* Behaviour matrix:
|
||||
* - **Snapshot has no current track but playback is live** — keep the
|
||||
* playing track and prepend it to the restored queue (or rebind by
|
||||
* id when it's already there).
|
||||
* - **Snapshot's current track matches the live one** — keep playback
|
||||
* going, restore queue + position only.
|
||||
* - **Snapshot's current track differs** — issue a full audio_play via
|
||||
* `queueUndoRestoreAudioEngine` to put the engine on the snapshot
|
||||
* track at the captured position.
|
||||
*
|
||||
* Returns false only when nothing changed (caller shows no toast).
|
||||
*/
|
||||
export function applyQueueHistorySnapshot(
|
||||
snap: QueueUndoSnapshot,
|
||||
prior: PlayerState,
|
||||
set: SetState,
|
||||
get: GetState,
|
||||
): boolean {
|
||||
if (prior.currentRadio) {
|
||||
stopRadio();
|
||||
}
|
||||
// Rebuild the display queue from the snapshot's thin refs (thin-state):
|
||||
// resolver cache → placeholder. The canonical queue is the snapshot's refs;
|
||||
// this resolved `nextQueue` is only for the engine restore / normalization /
|
||||
// prepend logic below. The playing track is restored separately from the full
|
||||
// `snap.currentTrack`.
|
||||
let nextQueue = snap.queueItems.map(ref => resolveQueueTrack(ref));
|
||||
let nextItems: QueueItemRef[] = [...snap.queueItems];
|
||||
let nextIndex = snap.queueIndex;
|
||||
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
|
||||
|
||||
if (snap.currentTrack == null && prior.currentTrack) {
|
||||
const playing = prior.currentTrack;
|
||||
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
|
||||
if (pos === -1) {
|
||||
// Prepend ref must bind to the *snapshot's* playback server (H3): a live
|
||||
// server switch racing the undo would otherwise stamp the prepended ref
|
||||
// with the new server, mis-resolving the still-playing track. Snapshot
|
||||
// fields take precedence; existing refs in the snapshot are the next
|
||||
// fallback (they share the snapshot's server); live `queueServerId` is
|
||||
// last resort. Canonical key everywhere (B1).
|
||||
const snapshotSid =
|
||||
snap.queueServerId
|
||||
?? snap.queueItems[0]?.serverId
|
||||
?? get().queueServerId
|
||||
?? '';
|
||||
const prependServerId = canonicalQueueServerKey(snapshotSid);
|
||||
nextQueue = [{ ...playing }, ...nextQueue];
|
||||
nextItems = [
|
||||
{ serverId: prependServerId, trackId: playing.id },
|
||||
...nextItems,
|
||||
];
|
||||
nextIndex = 0;
|
||||
nextTrack = { ...playing };
|
||||
} else {
|
||||
nextTrack = { ...playing };
|
||||
nextIndex = pos;
|
||||
}
|
||||
}
|
||||
|
||||
nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1)));
|
||||
|
||||
const keepPlaybackFromPrior =
|
||||
prior.currentTrack != null
|
||||
&& nextTrack != null
|
||||
&& sameQueueTrackId(prior.currentTrack.id, nextTrack.id)
|
||||
&& nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id))
|
||||
&& (
|
||||
(snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id))
|
||||
|| snap.currentTrack == null
|
||||
);
|
||||
|
||||
if (keepPlaybackFromPrior) {
|
||||
const playingKeep = prior.currentTrack;
|
||||
if (playingKeep) {
|
||||
const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id));
|
||||
if (idxPrior >= 0) {
|
||||
nextIndex = idxPrior;
|
||||
nextTrack = { ...playingKeep };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime)
|
||||
? snap.currentTime
|
||||
: 0;
|
||||
let playingRestore = snap.isPlaying !== false;
|
||||
if (keepPlaybackFromPrior && prior.currentTrack) {
|
||||
tRestoreRaw = prior.currentTime;
|
||||
playingRestore = prior.isPlaying;
|
||||
}
|
||||
const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null;
|
||||
let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress)
|
||||
? snap.progress
|
||||
: (durForProgress != null && durForProgress > 0
|
||||
? Math.max(0, Math.min(1, tRestoreRaw / durForProgress))
|
||||
: 0);
|
||||
if (keepPlaybackFromPrior) {
|
||||
pRestore = prior.progress;
|
||||
}
|
||||
const tRestore = durForProgress != null
|
||||
? Math.max(0, Math.min(tRestoreRaw, durForProgress))
|
||||
: Math.max(0, tRestoreRaw);
|
||||
|
||||
const keepWaveform =
|
||||
prior.currentTrack?.id != null &&
|
||||
nextTrack?.id != null &&
|
||||
sameQueueTrackId(prior.currentTrack.id, nextTrack.id);
|
||||
const norm =
|
||||
nextTrack != null
|
||||
? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex)
|
||||
: ({
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
} as Pick<
|
||||
PlayerState,
|
||||
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
|
||||
>);
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const playbackSourceUndo = nextTrack
|
||||
? getPlaybackSourceKind(nextTrack.id, playbackSid, null)
|
||||
: null;
|
||||
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
|
||||
? prior.currentPlaybackSource
|
||||
: playbackSourceUndo;
|
||||
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({
|
||||
scheduledPauseAtMs: null,
|
||||
scheduledPauseStartMs: null,
|
||||
scheduledResumeAtMs: null,
|
||||
scheduledResumeStartMs: null,
|
||||
});
|
||||
|
||||
clearPreloadingIds();
|
||||
|
||||
let gen = getPlayGeneration();
|
||||
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
|
||||
if (resyncEngine || !nextTrack) {
|
||||
gen = bumpPlayGeneration();
|
||||
if (resyncEngine) {
|
||||
setIsAudioPaused(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the resolver with the playing track so its ref always resolves (it may
|
||||
// have been prepended and not yet in the cache window). Same canonical key
|
||||
// source as the prepend above — keeps cache bucket and ref serverId in lockstep
|
||||
// even when a server switch races the undo.
|
||||
const seedSid = canonicalQueueServerKey(
|
||||
snap.queueServerId
|
||||
?? snap.queueItems[0]?.serverId
|
||||
?? get().queueServerId
|
||||
?? '',
|
||||
);
|
||||
if (seedSid && nextTrack) seedQueueResolver(seedSid, [nextTrack]);
|
||||
set({
|
||||
queueItems: nextItems,
|
||||
queueIndex: nextIndex,
|
||||
currentTrack: nextTrack,
|
||||
currentRadio: null,
|
||||
currentTime: tRestore,
|
||||
progress: pRestore,
|
||||
isPlaying: playingRestore,
|
||||
waveformBins: keepWaveform ? prior.waveformBins : null,
|
||||
enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null,
|
||||
currentPlaybackSource: playbackSourceFinal,
|
||||
...norm,
|
||||
});
|
||||
|
||||
if (!nextTrack) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
setIsAudioPaused(false);
|
||||
syncUserQueueMutationToServer(nextItems, null, 0);
|
||||
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
||||
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void refreshWaveformForTrack(nextTrack.id);
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
get().updateReplayGainForCurrentTrack();
|
||||
|
||||
if (!keepPlaybackFromPrior) {
|
||||
playbackReportStart(nextTrack.id, getPlaybackServerId());
|
||||
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: gen,
|
||||
track: nextTrack,
|
||||
queue: nextQueue,
|
||||
queueIndex: nextIndex,
|
||||
atSeconds: tRestore,
|
||||
wantPlaying: playingRestore,
|
||||
});
|
||||
}
|
||||
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
||||
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
||||
}
|
||||
syncUserQueueMutationToServer(nextItems, nextTrack, tRestore);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
const getPlayQueueForServerMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api/subsonicPlayQueue', () => ({
|
||||
getPlayQueueForServer: (...args: unknown[]) => getPlayQueueForServerMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/server/serverLookup', () => ({
|
||||
resolveServerIdForIndexKey: (id: string) => id,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/utils/playback/songToTrack', () => ({
|
||||
songToTrack: (s: { id: string }) => ({
|
||||
id: s.id,
|
||||
title: s.id,
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 60,
|
||||
serverId: 'srv-a',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/pausedRestorePrepare', () => ({
|
||||
preparePausedRestoreOnStartup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/waveformRefresh', () => ({
|
||||
refreshWaveformForTrack: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/queueSyncUiState', () => ({
|
||||
clearQueueHandoffPending: vi.fn(),
|
||||
}));
|
||||
|
||||
const playerState = {
|
||||
queueItems: [] as QueueItemRef[],
|
||||
queueIndex: 0,
|
||||
currentTrack: null as { id: string; title: string; artist: string; album: string; albumId: string; duration: number } | null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
};
|
||||
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => playerState,
|
||||
setState: (partial: Partial<typeof playerState>) => {
|
||||
Object.assign(playerState, partial);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { applyServerPlayQueue } from '@/features/playback/store/applyServerPlayQueue';
|
||||
import {
|
||||
_resetQueuePlaybackIdleForTest,
|
||||
getIdlePullGeneration,
|
||||
isIdleQueuePullSuspended,
|
||||
touchQueueMutationClock,
|
||||
} from '@/features/playback/store/queuePlaybackIdle';
|
||||
|
||||
describe('applyServerPlayQueue idle guards', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueuePlaybackIdleForTest();
|
||||
getPlayQueueForServerMock.mockReset();
|
||||
playerState.queueItems = [{ serverId: 'srv-a', trackId: 'local-only' }];
|
||||
playerState.queueIndex = 0;
|
||||
playerState.currentTrack = {
|
||||
id: 'local-only',
|
||||
title: 'local-only',
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 60,
|
||||
};
|
||||
playerState.currentTime = 12;
|
||||
playerState.isPlaying = false;
|
||||
});
|
||||
|
||||
it('does not apply server queue in idle mode while local edits suspend pull', async () => {
|
||||
getPlayQueueForServerMock.mockResolvedValue({
|
||||
songs: [{ id: 'remote-a' }, { id: 'remote-b' }],
|
||||
current: 'remote-a',
|
||||
position: 5000,
|
||||
});
|
||||
touchQueueMutationClock();
|
||||
|
||||
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
|
||||
|
||||
expect(result).toBe('noop');
|
||||
expect(getPlayQueueForServerMock).not.toHaveBeenCalled();
|
||||
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
|
||||
expect(isIdleQueuePullSuspended()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores stale idle pull responses after a local mutation during fetch', async () => {
|
||||
const generationAtFetch = getIdlePullGeneration();
|
||||
getPlayQueueForServerMock.mockImplementation(async () => {
|
||||
touchQueueMutationClock();
|
||||
expect(getIdlePullGeneration()).toBe(generationAtFetch + 1);
|
||||
return {
|
||||
songs: [{ id: 'remote-a' }],
|
||||
current: 'remote-a',
|
||||
position: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
|
||||
|
||||
expect(result).toBe('noop');
|
||||
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
fingerprintFromLocalQueue,
|
||||
fingerprintFromServer,
|
||||
playQueueFingerprintsEqual,
|
||||
} from '@/features/playback/store/applyServerPlayQueue';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
|
||||
describe('playQueueFingerprintsEqual', () => {
|
||||
beforeEach(() => {
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
it('compares track order, current id, and position within tolerance', () => {
|
||||
const a = { trackIds: ['1', '2'], currentId: '1', positionMs: 1000 };
|
||||
const b = { trackIds: ['1', '2'], currentId: '1', positionMs: 2500 };
|
||||
expect(playQueueFingerprintsEqual(a, b)).toBe(true);
|
||||
expect(playQueueFingerprintsEqual(a, { ...b, positionMs: 4000 })).toBe(false);
|
||||
});
|
||||
|
||||
it('fingerprintFromLocalQueue reads the player store', () => {
|
||||
usePlayerStore.setState({
|
||||
queueItems: [{ serverId: 'a.test', trackId: 't1' }],
|
||||
currentTrack: { id: 't1', title: 'T', artist: '', album: 'A', albumId: 'al', duration: 60 },
|
||||
currentTime: 3.5,
|
||||
});
|
||||
expect(fingerprintFromLocalQueue()).toEqual({
|
||||
trackIds: ['t1'],
|
||||
currentId: 't1',
|
||||
positionMs: 3500,
|
||||
});
|
||||
});
|
||||
|
||||
it('fingerprintFromServer maps Subsonic playQueue fields', () => {
|
||||
expect(fingerprintFromServer({
|
||||
songs: [{ id: 'a' }, { id: 'b' }] as never,
|
||||
current: 'b',
|
||||
position: 1200,
|
||||
})).toEqual({
|
||||
trackIds: ['a', 'b'],
|
||||
currentId: 'b',
|
||||
positionMs: 1200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { getPlayQueueForServer, type PlayQueueResult } from '@/lib/api/subsonicPlayQueue';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { bindQueueServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { preparePausedRestoreOnStartup } from '@/features/playback/store/pausedRestorePrepare';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import {
|
||||
getIdlePullGeneration,
|
||||
isIdleQueuePullSuspended,
|
||||
resumeIdleQueuePull,
|
||||
clearQueueNaturallyEnded,
|
||||
} from '@/features/playback/store/queuePlaybackIdle';
|
||||
import { clearQueueHandoffPending } from '@/features/playback/store/queueSyncUiState';
|
||||
|
||||
export type ApplyPlayQueueMode = 'startup' | 'idle' | 'manual';
|
||||
|
||||
export type PlayQueueFingerprint = {
|
||||
trackIds: string[];
|
||||
currentId: string | null;
|
||||
positionMs: number;
|
||||
};
|
||||
|
||||
export type ApplyPlayQueueResult = 'applied' | 'noop' | 'empty' | 'error';
|
||||
|
||||
const POSITION_TOLERANCE_MS = 2000;
|
||||
|
||||
export function fingerprintFromServer(q: PlayQueueResult): PlayQueueFingerprint {
|
||||
const trackIds = q.songs.map(s => s.id);
|
||||
const currentId = q.current ?? trackIds[0] ?? null;
|
||||
return {
|
||||
trackIds,
|
||||
currentId,
|
||||
positionMs: q.position ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function fingerprintFromLocalQueue(): PlayQueueFingerprint {
|
||||
const s = usePlayerStore.getState();
|
||||
return {
|
||||
trackIds: s.queueItems.map(r => r.trackId),
|
||||
currentId: s.currentTrack?.id ?? null,
|
||||
positionMs: Math.floor((s.currentTime ?? 0) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function playQueueFingerprintsEqual(
|
||||
a: PlayQueueFingerprint,
|
||||
b: PlayQueueFingerprint,
|
||||
positionToleranceMs = POSITION_TOLERANCE_MS,
|
||||
): boolean {
|
||||
if (a.currentId !== b.currentId) return false;
|
||||
if (a.trackIds.length !== b.trackIds.length) return false;
|
||||
for (let i = 0; i < a.trackIds.length; i++) {
|
||||
if (a.trackIds[i] !== b.trackIds[i]) return false;
|
||||
}
|
||||
return Math.abs(a.positionMs - b.positionMs) <= positionToleranceMs;
|
||||
}
|
||||
|
||||
function resolveServerProfileId(serverId: string): string {
|
||||
return resolveServerIdForIndexKey(serverId) || serverId;
|
||||
}
|
||||
|
||||
function applyMappedQueue(
|
||||
mappedTracks: Track[],
|
||||
q: PlayQueueResult,
|
||||
serverProfileId: string,
|
||||
preferServerPosition: boolean,
|
||||
localTimeFallback: number,
|
||||
): void {
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
|
||||
if (q.current) {
|
||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||
if (idx >= 0) {
|
||||
currentTrack = mappedTracks[idx];
|
||||
queueIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
const serverTime = q.position ? q.position / 1000 : 0;
|
||||
const atSeconds = preferServerPosition
|
||||
? serverTime
|
||||
: (serverTime > 0 ? serverTime : localTimeFallback);
|
||||
|
||||
seedQueueResolver(serverProfileId, mappedTracks);
|
||||
bindQueueServerId(serverProfileId);
|
||||
const queueItems = toQueueItemRefs(serverProfileId, mappedTracks);
|
||||
|
||||
const player = usePlayerStore.getState();
|
||||
const wasPlaying = player.isPlaying;
|
||||
const sameCurrent = player.currentTrack?.id === currentTrack.id;
|
||||
|
||||
usePlayerStore.setState({
|
||||
queueItems,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
currentTime: atSeconds,
|
||||
});
|
||||
void refreshWaveformForTrack(currentTrack.id);
|
||||
|
||||
if (wasPlaying) {
|
||||
if (!sameCurrent) {
|
||||
player.playTrack(currentTrack, mappedTracks, true, false, queueIndex);
|
||||
if (atSeconds > 0.05) {
|
||||
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
|
||||
}
|
||||
} else if (atSeconds > 0.05 && Math.abs(player.currentTime - atSeconds) > 0.5) {
|
||||
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
|
||||
}
|
||||
|
||||
export async function applyServerPlayQueue(
|
||||
serverId: string,
|
||||
options: {
|
||||
mode: ApplyPlayQueueMode;
|
||||
preferServerPosition?: boolean;
|
||||
pushUndo?: boolean;
|
||||
},
|
||||
): Promise<ApplyPlayQueueResult> {
|
||||
const profileId = resolveServerProfileId(serverId);
|
||||
if (!profileId) return 'error';
|
||||
|
||||
if (options.mode === 'idle' && isIdleQueuePullSuspended()) {
|
||||
return 'noop';
|
||||
}
|
||||
const idleGenerationAtStart = options.mode === 'idle' ? getIdlePullGeneration() : null;
|
||||
|
||||
try {
|
||||
const q = await getPlayQueueForServer(profileId);
|
||||
if (q.songs.length === 0) return 'empty';
|
||||
|
||||
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
|
||||
if (options.mode === 'idle') {
|
||||
if (isIdleQueuePullSuspended()) return 'noop';
|
||||
if (idleGenerationAtStart !== getIdlePullGeneration()) return 'noop';
|
||||
const serverFp = fingerprintFromServer(q);
|
||||
const localFp = fingerprintFromLocalQueue();
|
||||
if (playQueueFingerprintsEqual(serverFp, localFp)) return 'noop';
|
||||
}
|
||||
|
||||
if (options.pushUndo) {
|
||||
pushQueueUndoFromGetter(usePlayerStore.getState);
|
||||
}
|
||||
|
||||
const mappedTracks: Track[] = q.songs.map(songToTrack);
|
||||
const localTime = usePlayerStore.getState().currentTime;
|
||||
applyMappedQueue(mappedTracks, q, profileId, preferServerPosition, localTime);
|
||||
clearQueueHandoffPending();
|
||||
return 'applied';
|
||||
} catch (e) {
|
||||
console.error('[psysonic] applyServerPlayQueue failed', e);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchActiveServerPlayQueueFingerprint(): Promise<PlayQueueFingerprint | null> {
|
||||
const activeId = useAuthStore.getState().activeServerId;
|
||||
if (!activeId) return null;
|
||||
try {
|
||||
const q = await getPlayQueueForServer(activeId);
|
||||
if (q.songs.length === 0) return null;
|
||||
return fingerprintFromServer(q);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullPlayQueueFromActiveServer(): Promise<ApplyPlayQueueResult> {
|
||||
const activeId = useAuthStore.getState().activeServerId;
|
||||
if (!activeId) return 'error';
|
||||
|
||||
clearQueueNaturallyEnded();
|
||||
|
||||
try {
|
||||
const q = await getPlayQueueForServer(activeId);
|
||||
if (q.songs.length === 0) {
|
||||
resumeIdleQueuePull();
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
const serverFp = fingerprintFromServer(q);
|
||||
const localFp = fingerprintFromLocalQueue();
|
||||
if (playQueueFingerprintsEqual(serverFp, localFp)) {
|
||||
resumeIdleQueuePull();
|
||||
return 'noop';
|
||||
}
|
||||
|
||||
const result = await applyServerPlayQueue(activeId, {
|
||||
mode: 'manual',
|
||||
preferServerPosition: true,
|
||||
pushUndo: true,
|
||||
});
|
||||
if (result === 'applied' || result === 'noop') {
|
||||
resumeIdleQueuePull();
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('[psysonic] pullPlayQueueFromActiveServer failed', e);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import { scrobbleSong } from '@/lib/api/subsonicScrobble';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import {
|
||||
playbackReportPlaying,
|
||||
playbackReportStart,
|
||||
playbackReportStopped,
|
||||
} from '@/features/playback/store/playbackReportSession';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
|
||||
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
|
||||
import { notifyLibraryPlaybackHint } from '@/store/libraryPlaybackHint';
|
||||
import {
|
||||
playListenSessionFinalize,
|
||||
playListenSessionOnProgress,
|
||||
playListenSessionOnTrackSwitched,
|
||||
playListenSessionOpen,
|
||||
} from '@/features/playback/store/playListenSession';
|
||||
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
|
||||
import { getPerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
import { bumpPerfCounter } from '@/utils/perf/perfTelemetry';
|
||||
import {
|
||||
getPlaybackCacheServerKey,
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForRef,
|
||||
playbackProfileIdForRef,
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '@/utils/audio/hiResCrossfadeResample';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
import {
|
||||
clearPreloadingIds,
|
||||
getBytePreloadingId,
|
||||
getGaplessPreloadingId,
|
||||
getLastGaplessSwitchTime,
|
||||
markGaplessSwitch,
|
||||
setBytePreloadingId,
|
||||
setGaplessPreloadingId,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
import {
|
||||
isReplayGainActive,
|
||||
loudnessGainDbForEngineBind,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import {
|
||||
emitPlaybackProgress,
|
||||
getPlaybackProgressSnapshot,
|
||||
} from '@/features/playback/store/playbackProgress';
|
||||
import {
|
||||
LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
|
||||
LIVE_PROGRESS_EMIT_MIN_MS,
|
||||
STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
|
||||
STORE_PROGRESS_COMMIT_MIN_MS,
|
||||
getLastLiveProgressEmitAt,
|
||||
getLastStoreProgressCommitAt,
|
||||
markLiveProgressEmit,
|
||||
markStoreProgressCommit,
|
||||
resetProgressEmitThrottles,
|
||||
} from '@/features/playback/store/playbackThrottles';
|
||||
import {
|
||||
playbackSourceHintForResolvedUrl,
|
||||
} from '@/features/playback/store/playbackUrlRouting';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||
import {
|
||||
flushQueueSyncToServer,
|
||||
getLastQueueHeartbeatAt,
|
||||
syncQueueToServer,
|
||||
} from '@/features/playback/store/queueSync';
|
||||
import { clearQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
|
||||
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
|
||||
import {
|
||||
SEEK_FALLBACK_VISUAL_GUARD_MS,
|
||||
getSeekFallbackVisualTarget,
|
||||
setSeekFallbackVisualTarget,
|
||||
} from '@/features/playback/store/seekFallbackState';
|
||||
import {
|
||||
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
||||
clearSeekTarget,
|
||||
getSeekTarget,
|
||||
getSeekTargetSetAt,
|
||||
} from '@/features/playback/store/seekTargetState';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { analyzeBoundary, computeWaveformSilence } from '@/utils/waveform/waveformSilence';
|
||||
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
|
||||
import {
|
||||
autodjJsTriggerAtSec,
|
||||
clampCrossfadeSecs,
|
||||
computeAutodjJsOverlap,
|
||||
nextQueueRefForTransition,
|
||||
shouldJsDriveAutodjTransition,
|
||||
} from '@/features/playback/utils/playback/autodjAutoAdvance';
|
||||
import { isInterruptHandoffPending } from '@/features/playback/utils/playback/autodjInterruptPrep';
|
||||
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from '@/features/playback/store/crossfadePreload';
|
||||
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from '@/features/playback/store/crossfadeTrimCache';
|
||||
import { armAutodjMixing } from '@/features/playback/store/autodjTransitionUi';
|
||||
|
||||
// Silence-aware crossfade (A-tail): guards the early advance to once per play
|
||||
// generation so a single playback instance triggers at most one trim-advance
|
||||
// (re-arms automatically on the next play / repeat-all loop, never loops on a
|
||||
// backward seek within the same playback).
|
||||
let crossfadeTrimAdvanceGen = -1;
|
||||
let autodjEngineMixArmGen = -1;
|
||||
|
||||
// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
|
||||
// invoke the setter on change. When a content fade is pending for the upcoming
|
||||
// transition we suppress the engine's autonomous crossfade timer and let the JS
|
||||
// A-tail advance drive it (gated on the next track being playable) — otherwise
|
||||
// the engine would start a still-buffering next track and fade over it (a jump).
|
||||
let autodjSuppressSent: boolean | null = null;
|
||||
function syncAutodjSuppress(want: boolean): void {
|
||||
if (autodjSuppressSent === want) return;
|
||||
autodjSuppressSent = want;
|
||||
invoke('audio_set_autodj_suppress', { enabled: want }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Rust-side `audio:normalization-state` event payload. */
|
||||
export type NormalizationStatePayload = {
|
||||
engine: 'off' | 'replaygain' | 'loudness' | string;
|
||||
currentGainDb: number | null;
|
||||
targetLufs: number;
|
||||
};
|
||||
|
||||
export function handleAudioPlaying(duration: number): void {
|
||||
clearQueueNaturallyEnded();
|
||||
setDeferHotCachePrefetch(false);
|
||||
resetProgressEmitThrottles();
|
||||
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
|
||||
notifyLibraryPlaybackHint('playing');
|
||||
const { currentTrack: track, queueItems, queueIndex } = usePlayerStore.getState();
|
||||
if (track) {
|
||||
const ref = queueItems[queueIndex];
|
||||
void playListenSessionOpen(track, playbackProfileIdForTrack(track, ref), duration);
|
||||
// Engine-confirmed play (initial start + resume) — keep live now-playing in
|
||||
// the `playing` state for servers with the playbackReport extension.
|
||||
playbackReportPlaying();
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAudioProgress(
|
||||
current_time: number,
|
||||
duration: number,
|
||||
buffering = false,
|
||||
): void {
|
||||
bumpPerfCounter('audioProgressEvents');
|
||||
const perfFlags = getPerfProbeFlags();
|
||||
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
|
||||
// While a seek is pending, the store already holds the optimistic target
|
||||
// position. Accepting stale progress from the Rust engine would briefly
|
||||
// snap the waveform back to the old position before the seek completes.
|
||||
if (isSeekDebouncePending()) return;
|
||||
// After the debounce fires, Rust may still emit 1–2 ticks with the old
|
||||
// position before the seek takes effect. Block until current_time is
|
||||
// within 2 s of the requested target, then clear the guard.
|
||||
const activeSeekTarget = getSeekTarget();
|
||||
if (activeSeekTarget !== null) {
|
||||
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
||||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||
clearSeekTarget();
|
||||
} else {
|
||||
clearSeekTarget();
|
||||
}
|
||||
}
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
|
||||
usePlayerStore.setState({ isPlaybackBuffering: buffering });
|
||||
}
|
||||
// Some backends can emit stale progress ticks shortly after pause/stop.
|
||||
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
||||
const transportActive = store.isPlaying || store.currentRadio != null;
|
||||
let visualTarget = getSeekFallbackVisualTarget();
|
||||
if (!transportActive && !visualTarget) return;
|
||||
if (visualTarget && visualTarget.trackId !== track.id) {
|
||||
setSeekFallbackVisualTarget(null);
|
||||
visualTarget = null;
|
||||
}
|
||||
let displayTime = buffering ? 0 : current_time;
|
||||
if (visualTarget && visualTarget.trackId === track.id) {
|
||||
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
||||
if (nearTarget) {
|
||||
setSeekFallbackVisualTarget(null);
|
||||
visualTarget = null;
|
||||
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||||
// Keep UI at the requested position while backend catches up.
|
||||
displayTime = visualTarget.seconds;
|
||||
} else {
|
||||
setSeekFallbackVisualTarget(null);
|
||||
visualTarget = null;
|
||||
}
|
||||
}
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = displayTime / dur;
|
||||
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
|
||||
if (!progressUiDisabled) {
|
||||
const nowLive = Date.now();
|
||||
const live = getPlaybackProgressSnapshot();
|
||||
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
|
||||
if (
|
||||
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
||||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
||||
visualTarget != null
|
||||
) {
|
||||
emitPlaybackProgress({
|
||||
currentTime: displayTime,
|
||||
progress: buffering ? 0 : progress,
|
||||
buffered: 0,
|
||||
buffering,
|
||||
});
|
||||
markLiveProgressEmit(nowLive);
|
||||
}
|
||||
}
|
||||
// Heartbeat: push current position to the server every 15 s while playing so
|
||||
// cross-device resume works even on a hard close — pause() and the close
|
||||
// handler flush on top of this for clean shutdowns.
|
||||
if (store.isPlaying && !store.currentRadio) {
|
||||
const now = Date.now();
|
||||
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
||||
void flushQueueSyncToServer(store.queueItems, track, displayTime);
|
||||
// Same 15 s cadence keeps the server's now-playing position fresh so it
|
||||
// can extrapolate accurately between reports (playbackReport extension).
|
||||
playbackReportPlaying(displayTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrobble at 50%: Music Network + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
scrobbleSong(
|
||||
track.id,
|
||||
Date.now(),
|
||||
playbackProfileIdForTrack(track, store.queueItems[store.queueIndex]),
|
||||
);
|
||||
void getMusicNetworkRuntimeOrNull()?.dispatchScrobble({
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (progressUiDisabled) return;
|
||||
// Critical architectural guard: avoid high-frequency writes to the persisted
|
||||
// Zustand store (each write serializes queue state). Keep only coarse commits.
|
||||
const nowCommit = Date.now();
|
||||
const commitDelta = Math.abs(store.currentTime - displayTime);
|
||||
const shouldCommitStore =
|
||||
visualTarget != null ||
|
||||
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
||||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
||||
if (shouldCommitStore) {
|
||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||
markStoreProgressCommit(nowCommit);
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track for gapless and crossfade.
|
||||
const {
|
||||
gaplessEnabled,
|
||||
crossfadeEnabled,
|
||||
crossfadeSecs,
|
||||
crossfadeTrimSilence,
|
||||
} = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
|
||||
// Silence-aware crossfade — current track's trailing silence, derived once
|
||||
// from its cached waveform. Drives both the early A-tail advance AND a wider
|
||||
// pre-buffer window (the early advance must not outrun the next track's
|
||||
// download, or its stream starts late and the fade has nothing to overlap).
|
||||
const trimActive =
|
||||
crossfadeEnabled && crossfadeTrimSilence && !gaplessEnabled && !store.currentRadio;
|
||||
const curTrailSilenceSec = trimActive
|
||||
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
|
||||
: 0;
|
||||
|
||||
// A-tail: start the next track early so the fade overlaps *audible* tail/head.
|
||||
// Overlap is content-driven ("by fact"); loud→loud always uses the standard
|
||||
// ~2 s JS blend (not the engine crossfadeSecs slider). When JS drives we
|
||||
// suppress the engine's autonomous crossfade timer so B is readiness-gated.
|
||||
let autodjSuppressWant = false;
|
||||
const nextRef = trimActive && store.isPlaying && store.repeatMode !== 'one'
|
||||
? nextQueueRefForTransition(store.queueItems, store.queueIndex, store.repeatMode)
|
||||
: null;
|
||||
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
|
||||
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
|
||||
if (nextTrackId) {
|
||||
const cf = clampCrossfadeSecs(crossfadeSecs);
|
||||
const plan = getCrossfadeTransition(nextTrackId);
|
||||
let contentOverlap: number;
|
||||
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
|
||||
// at full engine gain (no double fade) and bring B up underneath.
|
||||
let aRidesOwnFade: boolean;
|
||||
if (plan && plan.overlapSec > 0) {
|
||||
contentOverlap = plan.overlapSec;
|
||||
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
|
||||
} else {
|
||||
// No next-track envelope (cold plan) → judge A from its own waveform.
|
||||
const aShape = analyzeBoundary(store.waveformBins, dur);
|
||||
contentOverlap = aShape.outroFadeSec;
|
||||
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
|
||||
}
|
||||
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
|
||||
autodjSuppressWant = true;
|
||||
const maxCapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
|
||||
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade, maxCapSec);
|
||||
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
|
||||
const gen = getPlayGeneration();
|
||||
// Readiness gate: only advance when B's audio is actually available (RAM
|
||||
// preload slot or local on disk). A cold stream can't sustain a stable
|
||||
// fade, so we leave the gen guard unset and re-check on later ticks — if
|
||||
// B readies before A ends we fade then; if never, A plays out (engine
|
||||
// timer suppressed) and the source-exhaustion end gives a clean cut.
|
||||
if (
|
||||
current_time >= triggerAt
|
||||
&& crossfadeTrimAdvanceGen !== gen
|
||||
&& isCrossfadeNextReady(
|
||||
nextTrackId,
|
||||
playbackProfileIdForRef(nextRef),
|
||||
playbackCacheKeyForRef(nextRef),
|
||||
)
|
||||
) {
|
||||
crossfadeTrimAdvanceGen = gen;
|
||||
armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
|
||||
armAutodjMixing(overlapSec);
|
||||
store.next(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Queue tail with no successor — play A through its real ending; suppress
|
||||
// the engine's early crossfade timer so `audio:ended` fires on exhaustion.
|
||||
autodjSuppressWant = true;
|
||||
}
|
||||
}
|
||||
syncAutodjSuppress(autodjSuppressWant);
|
||||
|
||||
if (trimActive && store.isPlaying && !autodjSuppressWant && nextTrackId) {
|
||||
const cf = clampCrossfadeSecs(crossfadeSecs);
|
||||
if (remaining > 0 && remaining <= cf) {
|
||||
const gen = getPlayGeneration();
|
||||
if (autodjEngineMixArmGen !== gen) {
|
||||
autodjEngineMixArmGen = gen;
|
||||
armAutodjMixing(cf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crossfade pre-buffer (next-track byte download + leading-silence probe).
|
||||
// Self-gating; also invoked right after a seek into the window (see seekAction).
|
||||
maybeCrossfadeBytePreload(current_time, dur);
|
||||
|
||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||
|
||||
if (gaplessEnabled) {
|
||||
const { queueItems, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
// Next track for preload/chain. The resolver bridge keeps the window around
|
||||
// queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
|
||||
// back to a placeholder (correct trackId, so URL building still works) on a
|
||||
// cold miss. current track = `track` (full) — never resolved.
|
||||
const nextRef = repeatMode === 'one'
|
||||
? null
|
||||
: (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextRef ? resolveQueueTrack(nextRef) : null);
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||
// the boundary. Start earlier for larger files / slower conservative link.
|
||||
const estBytes = (() => {
|
||||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||
return nextTrack.size;
|
||||
}
|
||||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||
? nextTrack.bitRate
|
||||
: 320;
|
||||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||
})();
|
||||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||
const shouldBytePreloadForGaplessBackup =
|
||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||
|
||||
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
|
||||
const analysisServerId = nextRef
|
||||
? playbackCacheKeyForRef(nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||
if (
|
||||
shouldBytePreloadForGaplessBackup
|
||||
&& nextTrack.id !== getBytePreloadingId()
|
||||
) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
||||
setGaplessPreloadingId(nextTrack.id);
|
||||
// Ensure loudness gain is already cached for the chained request payload.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
const authState = useAuthStore.getState();
|
||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
|
||||
// — only its replaygain tags matter, which a placeholder lacks → fallback).
|
||||
const nextNeighbourRef = nextIdx + 1 < queueItems.length
|
||||
? queueItems[nextIdx + 1]
|
||||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
nextTrack, track, nextNeighbour,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive()
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAudioEnded(): void {
|
||||
notifyLibraryPlaybackHint('idle');
|
||||
|
||||
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInterruptHandoffPending()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void playListenSessionFinalize('ended');
|
||||
// Track finished — clear live now-playing. A follow-on track (next / repeat)
|
||||
// opens a fresh session via playbackReportStart.
|
||||
void playbackReportStopped();
|
||||
|
||||
const storeBeforeAdvance = usePlayerStore.getState();
|
||||
if (storeBeforeAdvance.currentTrack && !storeBeforeAdvance.currentRadio) {
|
||||
appendTimelineLeaveTrack(
|
||||
storeBeforeAdvance.currentTrack,
|
||||
storeBeforeAdvance.queueItems,
|
||||
storeBeforeAdvance.queueIndex,
|
||||
);
|
||||
}
|
||||
|
||||
// Radio stream disconnected — just stop; don't advance queue.
|
||||
if (usePlayerStore.getState().currentRadio) {
|
||||
setIsAudioPaused(false);
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
|
||||
setIsAudioPaused(false);
|
||||
usePlayerStore.setState({
|
||||
isPlaying: false,
|
||||
isPlaybackBuffering: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
});
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
const authState = useAuthStore.getState();
|
||||
const repeatPromoteSid = getPlaybackCacheServerKey();
|
||||
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||
await promoteCompletedStreamToHotCache(
|
||||
currentTrack,
|
||||
repeatPromoteSid,
|
||||
authState.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
// Pin to the current slot — the track may appear elsewhere in the queue.
|
||||
// No-arg queue: playTrack keeps the canonical refs and just re-binds.
|
||||
usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex);
|
||||
} else {
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
})();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gapless auto-advance: the Rust engine has already switched to the
|
||||
* next source sample-accurately. We just need to update the UI state without
|
||||
* touching the audio stream (no playTrack() call!).
|
||||
*/
|
||||
export function handleAudioTrackSwitched(_duration: number): void {
|
||||
markGaplessSwitch();
|
||||
clearPreloadingIds(); // allow preloading for the track after this one
|
||||
setIsAudioPaused(false);
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
if (store.currentTrack?.id) {
|
||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||
}
|
||||
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
|
||||
if (currentTrack && !currentRadio) {
|
||||
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
|
||||
}
|
||||
const nextIdx = queueIndex + 1;
|
||||
let nextTrack: Track | null = null;
|
||||
let newIndex = queueIndex;
|
||||
|
||||
if (repeatMode === 'one' && store.currentTrack) {
|
||||
nextTrack = store.currentTrack;
|
||||
// queueIndex stays the same
|
||||
} else if (nextIdx < queueItems.length) {
|
||||
// The Rust engine already chained this source sample-accurately, so it must
|
||||
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
|
||||
// returns the full Track from cache (placeholder only on an unexpected miss).
|
||||
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
|
||||
newIndex = nextIdx;
|
||||
} else if (repeatMode === 'all' && queueItems.length > 0) {
|
||||
nextTrack = resolveQueueTrack(queueItems[0]);
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
void playListenSessionOnTrackSwitched(nextTrack);
|
||||
|
||||
const switchRef = queueItems[newIndex];
|
||||
const switchServerId = playbackCacheKeyForRef(switchRef);
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||
|
||||
// Neighbour window for normalization (replaygain album-mode reads prev/next).
|
||||
// current track on the left, the track after `nextTrack` on the right.
|
||||
const switchPrev = store.currentTrack;
|
||||
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
|
||||
const switchNeighbourWindow: Track[] = [
|
||||
switchPrev ?? nextTrack,
|
||||
nextTrack,
|
||||
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
|
||||
];
|
||||
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
|
||||
normalizationDbgSource: 'track-switched',
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: switchPlaybackSource === 'stream',
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
networkLoved: false,
|
||||
currentPlaybackSource: switchPlaybackSource,
|
||||
});
|
||||
emitNormalizationDebug('track-switched', {
|
||||
trackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
engineRequested: useAuthStore.getState().normalizationEngine,
|
||||
});
|
||||
void refreshWaveformForTrack(nextTrack.id);
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
|
||||
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
|
||||
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
|
||||
// on the master toggle, per-account enable and the nowPlaying capability
|
||||
// internally). playbackReportStart opens the FSM on extension-capable servers
|
||||
// and falls back to the legacy presence call otherwise (gating is internal).
|
||||
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: nextTrack.title,
|
||||
artist: nextTrack.artist,
|
||||
album: nextTrack.album,
|
||||
duration: nextTrack.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
|
||||
.then(loved => {
|
||||
usePlayerStore.getState().setNetworkLoved(loved);
|
||||
});
|
||||
}
|
||||
syncQueueToServer(queueItems, nextTrack, 0);
|
||||
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
|
||||
}
|
||||
|
||||
export function handleAudioError(message: string): void {
|
||||
console.error('[psysonic] Audio error from backend:', message);
|
||||
setIsAudioPaused(false);
|
||||
void playbackReportStopped();
|
||||
|
||||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||
|
||||
const gen = getPlayGeneration();
|
||||
usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
|
||||
setTimeout(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
usePlayerStore.getState().next(false);
|
||||
}, 1500);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { streamUrlTrackId } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { normalizationAlmostEqual } from '@/features/playback/utils/audio/normalizationCompare';
|
||||
import { normalizeAnalysisTrackId } from '@/features/playback/utils/playback/queueIdentity';
|
||||
import {
|
||||
handleAudioEnded,
|
||||
handleAudioError,
|
||||
handleAudioPlaying,
|
||||
handleAudioProgress,
|
||||
handleAudioTrackSwitched,
|
||||
type NormalizationStatePayload,
|
||||
} from '@/features/playback/store/audioEventHandlers';
|
||||
import {
|
||||
getCachedLoudnessGain,
|
||||
hasStableLoudness,
|
||||
setCachedLoudnessGain,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
import { isTrackInsideLoudnessBackfillWindow } from '@/features/playback/store/loudnessBackfillWindow';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import {
|
||||
NORMALIZATION_UI_THROTTLE_MS,
|
||||
getLastNormalizationUiUpdateAtMs,
|
||||
markNormalizationUiUpdate,
|
||||
} from '@/features/playback/store/playbackThrottles';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
|
||||
import { setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState';
|
||||
|
||||
type PreloadEventPayload = {
|
||||
url: string;
|
||||
trackId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tauri event listeners for the Rust audio engine + analysis pipeline. Returns
|
||||
* a cleanup function that unlistens every registered listener.
|
||||
*/
|
||||
export function setupAudioEngineListeners(): () => void {
|
||||
// Dev-only: warn when audio:progress events arrive faster than 10/s.
|
||||
// This would indicate the Rust emit interval was accidentally lowered.
|
||||
let _devEventCount = 0;
|
||||
let _devWindowStart = 0;
|
||||
|
||||
const pending = [
|
||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||
listen<{ current_time: number; duration: number; buffering?: boolean }>('audio:progress', ({ payload }) => {
|
||||
if (import.meta.env.DEV) {
|
||||
_devEventCount++;
|
||||
const now = Date.now();
|
||||
if (_devWindowStart === 0) _devWindowStart = now;
|
||||
if (now - _devWindowStart >= 1000) {
|
||||
if (_devEventCount > 10) {
|
||||
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
|
||||
}
|
||||
_devEventCount = 0;
|
||||
_devWindowStart = now;
|
||||
}
|
||||
}
|
||||
handleAudioProgress(payload.current_time, payload.duration, payload.buffering ?? false);
|
||||
}),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
|
||||
const current = usePlayerStore.getState().currentTrack;
|
||||
if (!current || !payload) return;
|
||||
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
if (payloadTrackId && payloadTrackId !== current.id) return;
|
||||
if (!Number.isFinite(payload.gainDb)) return;
|
||||
if (hasStableLoudness(current.id)) return;
|
||||
// Skip when the cached gain is already within ~0.05 dB of the new payload —
|
||||
// float jitter from the partial-loudness heuristic would otherwise re-trigger
|
||||
// updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo
|
||||
// every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed.
|
||||
const existing = getCachedLoudnessGain(current.id);
|
||||
if (Number.isFinite(existing) && Math.abs(existing! - payload.gainDb) < 0.05) return;
|
||||
setCachedLoudnessGain(current.id, payload.gainDb);
|
||||
emitNormalizationDebug('partial-loudness:apply', {
|
||||
trackId: current.id,
|
||||
gainDb: payload.gainDb,
|
||||
targetLufs: payload.targetLufs,
|
||||
});
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
}),
|
||||
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
|
||||
if (!payload?.trackId) return;
|
||||
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
if (!payloadTrackId) return;
|
||||
const currentRaw = usePlayerStore.getState().currentTrack?.id;
|
||||
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
|
||||
if (currentId && payloadTrackId === currentId) {
|
||||
bumpWaveformRefreshGen(currentRaw!);
|
||||
void refreshWaveformForTrack(currentRaw!);
|
||||
void refreshLoudnessForTrack(currentId);
|
||||
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
||||
return;
|
||||
}
|
||||
// Library aggressive backfill completes thousands of tracks — only warm loudness
|
||||
// for the playback window (current + next N). Skip IPC for the rest.
|
||||
const live = usePlayerStore.getState();
|
||||
if (
|
||||
!isTrackInsideLoudnessBackfillWindow(
|
||||
payloadTrackId,
|
||||
live.queueItems,
|
||||
live.queueIndex,
|
||||
live.currentTrack,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
|
||||
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
|
||||
}),
|
||||
listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
|
||||
if (!payload?.trackId) return;
|
||||
emitNormalizationDebug('enrichment:applied', {
|
||||
trackId: normalizeAnalysisTrackId(payload.trackId) ?? payload.trackId,
|
||||
serverId: payload.serverId,
|
||||
});
|
||||
}),
|
||||
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
const engine =
|
||||
payload.engine === 'loudness' || payload.engine === 'replaygain'
|
||||
? payload.engine
|
||||
: 'off';
|
||||
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
|
||||
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
|
||||
const prev = usePlayerStore.getState();
|
||||
// Avoid UI flicker from noisy duplicate emits and transient nulls.
|
||||
if (
|
||||
engine === prev.normalizationEngineLive
|
||||
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
|
||||
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
|
||||
return;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const isFirstNumericGain =
|
||||
engine === 'loudness'
|
||||
&& nowDb != null
|
||||
&& prev.normalizationNowDb == null;
|
||||
if (
|
||||
!isFirstNumericGain
|
||||
&& nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS
|
||||
&& engine === prev.normalizationEngineLive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markNormalizationUiUpdate(nowMs);
|
||||
emitNormalizationDebug('event:audio:normalization-state', {
|
||||
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
|
||||
payload,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
normalizationEngineLive: engine,
|
||||
normalizationNowDb: nowDb,
|
||||
normalizationTargetLufs: targetLufs,
|
||||
normalizationDbgSource: 'event:audio:normalization-state',
|
||||
normalizationDbgLastEventAt: Date.now(),
|
||||
});
|
||||
}),
|
||||
listen<PreloadEventPayload>('audio:preload-ready', ({ payload }) => {
|
||||
const tid = payload.trackId ?? streamUrlTrackId(payload.url);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-ready]', {
|
||||
payload,
|
||||
parsedTrackId: tid,
|
||||
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
|
||||
});
|
||||
}
|
||||
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
|
||||
else if (import.meta.env.DEV) {
|
||||
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
||||
}
|
||||
}),
|
||||
listen<PreloadEventPayload>('audio:preload-cancelled', ({ payload }) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-cancelled]', payload);
|
||||
}
|
||||
setBytePreloadingId(null);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { onAnalysisStorageChanged } from '@/store/analysisSync';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import { invokeAudioSetNormalizationDeduped } from '@/features/playback/store/normalizationIpcDedupe';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { clearLoudnessCacheStateForTrackId } from '@/features/playback/store/loudnessGainCache';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
|
||||
|
||||
/**
|
||||
* Keeps the Rust audio engine in sync whenever the auth store changes
|
||||
* (crossfade / gapless / normalization), plus a cross-tab analysis-storage
|
||||
* listener that refreshes waveform + loudness for the current track. Returns a
|
||||
* cleanup function.
|
||||
*/
|
||||
export function setupAuthSync(): () => void {
|
||||
const normCfg = useAuthStore.getState();
|
||||
let prevNormEngine = normCfg.normalizationEngine;
|
||||
let prevNormTarget = normCfg.loudnessTargetLufs;
|
||||
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
invoke('audio_set_crossfade', {
|
||||
enabled: state.crossfadeEnabled,
|
||||
secs: state.crossfadeSecs,
|
||||
}).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
||||
const normChanged =
|
||||
state.normalizationEngine !== prevNormEngine
|
||||
|| state.loudnessTargetLufs !== prevNormTarget
|
||||
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
|
||||
if (!normChanged) return;
|
||||
const onlyPreAnalysisChanged =
|
||||
state.normalizationEngine === prevNormEngine
|
||||
&& state.loudnessTargetLufs === prevNormTarget
|
||||
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
|
||||
const targetLufsChanged =
|
||||
state.normalizationEngine === 'loudness'
|
||||
&& state.loudnessTargetLufs !== prevNormTarget;
|
||||
prevNormEngine = state.normalizationEngine;
|
||||
prevNormTarget = state.loudnessTargetLufs;
|
||||
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
|
||||
usePlayerStore.setState({
|
||||
normalizationEngineLive: state.normalizationEngine,
|
||||
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
|
||||
normalizationNowDb: state.normalizationEngine === 'loudness'
|
||||
? usePlayerStore.getState().normalizationNowDb
|
||||
: null,
|
||||
normalizationDbgSource: 'auth:normalization-changed',
|
||||
});
|
||||
emitNormalizationDebug('auth:normalization-changed', {
|
||||
engine: state.normalizationEngine,
|
||||
targetLufs: state.loudnessTargetLufs,
|
||||
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
|
||||
});
|
||||
invokeAudioSetNormalizationDeduped({
|
||||
engine: state.normalizationEngine,
|
||||
targetLufs: state.loudnessTargetLufs,
|
||||
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
|
||||
state.loudnessPreAnalysisAttenuationDb,
|
||||
state.loudnessTargetLufs,
|
||||
),
|
||||
});
|
||||
if (state.normalizationEngine === 'loudness') {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (onlyPreAnalysisChanged) {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
} else if (currentId) {
|
||||
if (targetLufsChanged) {
|
||||
clearLoudnessCacheStateForTrackId(currentId);
|
||||
}
|
||||
void refreshLoudnessForTrack(currentId).finally(() => {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
}
|
||||
});
|
||||
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (!currentId) return;
|
||||
if (detail.trackId && detail.trackId !== currentId) return;
|
||||
bumpWaveformRefreshGen(currentId);
|
||||
void refreshWaveformForTrack(currentId);
|
||||
void refreshLoudnessForTrack(currentId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubAnalysisSync();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { resolvePlaybackCoverScope } from '@/cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
|
||||
import { coverArtUrlForDiscord } from '@/cover/integrations/discord';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
|
||||
|
||||
/**
|
||||
* Discord Rich Presence sync. Updates on track change or play/pause toggle —
|
||||
* no per-tick updates needed, Discord auto-counts up the elapsed timer from the
|
||||
* start_timestamp we set. Returns a cleanup function.
|
||||
*/
|
||||
export function setupDiscordPresence(): () => void {
|
||||
let discordPrevTrackId: string | null = null;
|
||||
let discordPrevIsPlaying: boolean | null = null;
|
||||
let discordPrevTemplateDetails: string | null = null;
|
||||
let discordPrevTemplateState: string | null = null;
|
||||
let discordPrevTemplateLargeText: string | null = null;
|
||||
let discordPrevTemplateName: string | null = null;
|
||||
let discordPrevCoverSource: string | null = null;
|
||||
const discordServerCoverCache = new Map<string, string | null>();
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying } = usePlayerStore.getState();
|
||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||
const {
|
||||
discordRichPresence,
|
||||
discordCoverSource,
|
||||
discordTemplateDetails,
|
||||
discordTemplateState,
|
||||
discordTemplateLargeText,
|
||||
discordTemplateName,
|
||||
} = useAuthStore.getState();
|
||||
|
||||
if (!discordRichPresence || !currentTrack) {
|
||||
if (discordPrevTrackId !== null) {
|
||||
discordPrevTrackId = null;
|
||||
discordPrevIsPlaying = null;
|
||||
discordPrevCoverSource = null;
|
||||
discordPrevTemplateDetails = null;
|
||||
discordPrevTemplateState = null;
|
||||
discordPrevTemplateLargeText = null;
|
||||
discordPrevTemplateName = null;
|
||||
invoke('discord_clear_presence').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackChanged = currentTrack.id !== discordPrevTrackId;
|
||||
const playingChanged = isPlaying !== discordPrevIsPlaying;
|
||||
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
|
||||
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
|
||||
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
|
||||
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
|
||||
const nameTemplateChanged = discordTemplateName !== discordPrevTemplateName;
|
||||
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return;
|
||||
|
||||
discordPrevTrackId = currentTrack.id;
|
||||
discordPrevIsPlaying = isPlaying;
|
||||
discordPrevCoverSource = discordCoverSource;
|
||||
discordPrevTemplateDetails = discordTemplateDetails;
|
||||
discordPrevTemplateState = discordTemplateState;
|
||||
discordPrevTemplateLargeText = discordTemplateLargeText;
|
||||
discordPrevTemplateName = discordTemplateName;
|
||||
|
||||
const sendPresence = (coverArtUrl: string | null) => {
|
||||
invoke('discord_update_presence', {
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist ?? 'Unknown Artist',
|
||||
album: currentTrack.album ?? null,
|
||||
isPlaying,
|
||||
elapsedSecs: isPlaying ? currentTime : null,
|
||||
coverArtUrl,
|
||||
fetchItunesCovers: discordCoverSource === 'apple',
|
||||
detailsTemplate: discordTemplateDetails,
|
||||
stateTemplate: discordTemplateState,
|
||||
largeTextTemplate: discordTemplateLargeText,
|
||||
nameTemplate: discordTemplateName,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
if (discordCoverSource === 'server' && currentTrack.coverArt) {
|
||||
const cacheKey = currentTrack.coverArt;
|
||||
const cached = discordServerCoverCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
sendPresence(cached);
|
||||
} else {
|
||||
void resolveTrackCoverRefFromLibrary(
|
||||
{
|
||||
id: currentTrack.id,
|
||||
albumId: currentTrack.albumId,
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
).then(ref => {
|
||||
if (!ref) {
|
||||
sendPresence(null);
|
||||
return;
|
||||
}
|
||||
return coverArtUrlForDiscord(ref)
|
||||
.then(url => {
|
||||
discordServerCoverCache.set(cacheKey, url);
|
||||
sendPresence(url);
|
||||
})
|
||||
.catch(() => {
|
||||
discordServerCoverCache.set(cacheKey, null);
|
||||
sendPresence(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sendPresence(null);
|
||||
}
|
||||
}
|
||||
|
||||
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
|
||||
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
|
||||
|
||||
return () => {
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn(async () => null as unknown) }));
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
||||
|
||||
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
|
||||
|
||||
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
function resetEq(over: Partial<{
|
||||
gains: number[];
|
||||
enabled: boolean;
|
||||
preGain: number;
|
||||
activePreset: string | null;
|
||||
rememberPerDevice: boolean;
|
||||
byDevice: Record<string, EqSnapshot>;
|
||||
}> = {}): void {
|
||||
useEqStore.setState({
|
||||
gains: [...FLAT],
|
||||
enabled: false,
|
||||
preGain: 0,
|
||||
activePreset: 'Flat',
|
||||
customPresets: [],
|
||||
rememberPerDevice: false,
|
||||
byDevice: {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function snap(gain0: number, over: Partial<EqSnapshot> = {}): EqSnapshot {
|
||||
return { gains: [gain0, 0, 0, 0, 0, 0, 0, 0, 0, 0], enabled: false, preGain: 0, activePreset: null, ...over };
|
||||
}
|
||||
|
||||
describe('eqDeviceSync', () => {
|
||||
let cleanup: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockClear();
|
||||
resetEq();
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
cleanup = () => {};
|
||||
});
|
||||
|
||||
it('mirrors live EQ edits into the current device snapshot when enabled', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice['Speakers'].gains[0]).toBe(4);
|
||||
});
|
||||
|
||||
it('does not mirror edits when the feature is off', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ rememberPerDevice: false });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice).toEqual({});
|
||||
});
|
||||
|
||||
it('seeds the current device snapshot when the feature is switched on', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Speakers');
|
||||
resetEq({ gains: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useEqStore.getState().setRememberPerDevice(true);
|
||||
|
||||
expect(useEqStore.getState().byDevice['Speakers']?.gains[0]).toBe(2);
|
||||
});
|
||||
|
||||
it('saves the old device and restores the new device on switch', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { B: snap(7, { enabled: true, preGain: 1 }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
// Edit on A is mirrored into A's snapshot.
|
||||
useEqStore.getState().setBandGain(0, 3);
|
||||
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
|
||||
|
||||
// Switching to B restores B's saved profile to the live EQ.
|
||||
useAuthStore.getState().setAudioOutputDevice('B');
|
||||
expect(useEqStore.getState().gains[0]).toBe(7);
|
||||
expect(useEqStore.getState().enabled).toBe(true);
|
||||
|
||||
// A's saved snapshot is preserved (not overwritten by applying B).
|
||||
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
|
||||
expect(useEqStore.getState().byDevice['B'].gains[0]).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps the current EQ when the new device has no saved snapshot', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('NoProfile');
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(5);
|
||||
});
|
||||
|
||||
it('applies the saved snapshot for the current device on startup', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { A: snap(9, { enabled: true, preGain: 2, activePreset: 'X' }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
const s = useEqStore.getState();
|
||||
expect(s.gains[0]).toBe(9);
|
||||
expect(s.enabled).toBe(true);
|
||||
expect(s.activePreset).toBe('X');
|
||||
});
|
||||
|
||||
// Frank's exact scenario: Jazz on device 1, Rock on device 2 (neither
|
||||
// pre-seeded), then back to device 1 — must restore Jazz, not Rock.
|
||||
it('preset on dev1, preset on dev2, back to dev1 restores dev1 preset', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('Device1');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useEqStore.getState().applyPreset('Jazz');
|
||||
expect(useEqStore.getState().activePreset).toBe('Jazz');
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('Device2');
|
||||
useEqStore.getState().applyPreset('Rock');
|
||||
expect(useEqStore.getState().activePreset).toBe('Rock');
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice('Device1');
|
||||
expect(useEqStore.getState().activePreset).toBe('Jazz');
|
||||
});
|
||||
|
||||
it('mirrors the system default (null device) into the __default__ bucket', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useEqStore.getState().setBandGain(0, 4);
|
||||
|
||||
expect(useEqStore.getState().byDevice['__default__'].gains[0]).toBe(4);
|
||||
});
|
||||
|
||||
it('restores the __default__ profile when the device resets to null (unplug / audio:device-reset)', () => {
|
||||
// The audio:device-reset event sets audioOutputDevice = null; the sync
|
||||
// reacts to that store change like any other device switch.
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true, byDevice: { __default__: snap(8, { enabled: true }) } });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
|
||||
expect(useEqStore.getState().gains[0]).toBe(8);
|
||||
expect(useEqStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('cleanup stops mirroring further edits', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice('A');
|
||||
resetEq({ rememberPerDevice: true });
|
||||
cleanup = setupEqDeviceSync();
|
||||
|
||||
cleanup();
|
||||
useEqStore.getState().setBandGain(0, 6);
|
||||
|
||||
expect(useEqStore.getState().byDevice['A']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
|
||||
|
||||
/** Key used when no specific device is selected (system default). */
|
||||
const DEFAULT_DEVICE_KEY = '__default__';
|
||||
|
||||
function deviceKey(name: string | null): string {
|
||||
return name ?? DEFAULT_DEVICE_KEY;
|
||||
}
|
||||
|
||||
// The device key currently in effect. Updated on every device change.
|
||||
let currentKey = DEFAULT_DEVICE_KEY;
|
||||
// Suppress the mirror subscription while we programmatically apply a saved
|
||||
// snapshot (on a device switch or at startup), so applying a profile does not
|
||||
// immediately write it straight back.
|
||||
let applying = false;
|
||||
|
||||
function applySnapshot(snap: EqSnapshot): void {
|
||||
applying = true;
|
||||
try {
|
||||
useEqStore.getState().applySnapshot(snap);
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-device EQ memory. Opt-in via `eqStore.rememberPerDevice` (default off);
|
||||
* while off, every branch below returns early so behaviour is unchanged.
|
||||
*
|
||||
* Keeps the equalizer profile (bands, enabled, pre-gain, active preset) for
|
||||
* each audio output device and restores it automatically when the device
|
||||
* changes. Device identity is the canonical device-name string already held in
|
||||
* `authStore.audioOutputDevice` (null = system default → `__default__`) — the
|
||||
* same key the device-selection feature relies on. The audio backend exposes no
|
||||
* stable device UUID, so this deliberately inherits that feature's identity
|
||||
* model rather than inventing a weaker one.
|
||||
*
|
||||
* Returns a cleanup that removes both subscriptions (StrictMode-safe via
|
||||
* `initAudioListeners`).
|
||||
*/
|
||||
export function setupEqDeviceSync(): () => void {
|
||||
currentKey = deviceKey(useAuthStore.getState().audioOutputDevice);
|
||||
|
||||
// Startup: restore the saved profile for the current device, if any. A no-op
|
||||
// in the common case (the persisted global EQ already equals this device's
|
||||
// mirrored snapshot); it only matters when the resolved device differs from
|
||||
// the one active at shutdown.
|
||||
const eqAtStart = useEqStore.getState();
|
||||
if (eqAtStart.rememberPerDevice) {
|
||||
const snap = eqAtStart.byDevice[currentKey];
|
||||
if (snap) applySnapshot(snap);
|
||||
}
|
||||
|
||||
// Sub 1 — device changed. Covers both the explicit picker selection and the
|
||||
// `audio:device-reset` unplug event, since both flow through this field.
|
||||
const unsubDevice = useAuthStore.subscribe((state, prev) => {
|
||||
if (state.audioOutputDevice === prev.audioOutputDevice) return;
|
||||
currentKey = deviceKey(state.audioOutputDevice);
|
||||
const eq = useEqStore.getState();
|
||||
if (!eq.rememberPerDevice) return;
|
||||
const snap = eq.byDevice[currentKey];
|
||||
if (snap) applySnapshot(snap);
|
||||
// No saved profile for this device → keep the current EQ as-is; the next
|
||||
// edit mirrors it under this device's key.
|
||||
});
|
||||
|
||||
// Sub 2 — mirror live EQ edits into the current device's snapshot, and seed
|
||||
// the current device when the feature is switched on. Writing `byDevice` does
|
||||
// not touch the content fields, so the re-triggered listener is a no-op (no
|
||||
// feedback loop).
|
||||
const unsubEq = useEqStore.subscribe((state, prev) => {
|
||||
if (applying) return;
|
||||
if (!state.rememberPerDevice) return;
|
||||
const justEnabled = !prev.rememberPerDevice;
|
||||
const contentChanged =
|
||||
state.gains !== prev.gains ||
|
||||
state.enabled !== prev.enabled ||
|
||||
state.preGain !== prev.preGain ||
|
||||
state.activePreset !== prev.activePreset;
|
||||
if (justEnabled || contentChanged) {
|
||||
useEqStore.getState().saveSnapshotFor(currentKey);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubDevice();
|
||||
unsubEq();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import { invokeAudioSetNormalizationDeduped } from '@/features/playback/store/normalizationIpcDedupe';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
|
||||
/**
|
||||
* One-shot startup sync: pushes the persisted audio settings to the Rust engine
|
||||
* and primes waveform / loudness caches for the boot track. No cleanup needed.
|
||||
*/
|
||||
export function runInitialAudioSync(): void {
|
||||
// Sync loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncNetworkLovedTracks();
|
||||
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
||||
const { volume } = usePlayerStore.getState();
|
||||
invoke('audio_set_volume', { volume }).catch(() => {});
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||
const normCfg = useAuthStore.getState();
|
||||
usePlayerStore.setState({
|
||||
normalizationEngineLive: normCfg.normalizationEngine,
|
||||
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
|
||||
normalizationNowDb: null,
|
||||
normalizationDbgSource: 'init:set-normalization',
|
||||
});
|
||||
emitNormalizationDebug('init:set-normalization', {
|
||||
engine: normCfg.normalizationEngine,
|
||||
targetLufs: normCfg.loudnessTargetLufs,
|
||||
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
|
||||
});
|
||||
invokeAudioSetNormalizationDeduped({
|
||||
engine: normCfg.normalizationEngine,
|
||||
targetLufs: normCfg.loudnessTargetLufs,
|
||||
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
|
||||
normCfg.loudnessPreAnalysisAttenuationDb,
|
||||
normCfg.loudnessTargetLufs,
|
||||
),
|
||||
});
|
||||
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (bootTrackId) {
|
||||
void refreshWaveformForTrack(bootTrackId);
|
||||
}
|
||||
if (normCfg.normalizationEngine === 'loudness') {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (currentId) {
|
||||
void refreshLoudnessForTrack(currentId).finally(() => {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (audioOutputDevice) {
|
||||
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { resolvePlaybackCoverScope } from '@/cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
|
||||
import { coverArtUrlForMpris } from '@/cover/integrations/mpris';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
|
||||
|
||||
/**
|
||||
* MPRIS / OS media-controls sync. Whenever the current track or playback state
|
||||
* changes, pushes updates to the Rust souvlaki MediaControls so the OS media
|
||||
* overlay stays accurate. Returns a cleanup function.
|
||||
*/
|
||||
export function setupMprisSync(): () => void {
|
||||
let prevTrackId: string | null = null;
|
||||
let prevRadioId: string | null = null;
|
||||
let prevIsPlaying: boolean | null = null;
|
||||
let lastMprisPositionUpdate = 0;
|
||||
|
||||
const unsubMpris = usePlayerStore.subscribe((state) => {
|
||||
const { currentTrack, currentRadio, isPlaying } = state;
|
||||
|
||||
// Update metadata when track changes
|
||||
if (currentTrack && currentTrack.id !== prevTrackId) {
|
||||
prevTrackId = currentTrack.id;
|
||||
prevRadioId = null;
|
||||
const title = currentTrack.title;
|
||||
const artist = currentTrack.artist;
|
||||
const album = currentTrack.album;
|
||||
const durationSecs = currentTrack.duration;
|
||||
if (currentTrack.coverArt && currentTrack.albumId) {
|
||||
void resolveTrackCoverRefFromLibrary(
|
||||
{
|
||||
id: currentTrack.id,
|
||||
albumId: currentTrack.albumId,
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
).then(ref => {
|
||||
if (!ref) return;
|
||||
coverArtUrlForMpris(ref)
|
||||
.then(coverUrl => invoke('mpris_set_metadata', {
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
coverUrl: coverUrl || undefined,
|
||||
durationSecs,
|
||||
}))
|
||||
.catch(() => {});
|
||||
});
|
||||
} else {
|
||||
invoke('mpris_set_metadata', {
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
coverUrl: undefined,
|
||||
durationSecs,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata when a radio station starts (initial push — station name as title).
|
||||
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
|
||||
if (currentRadio && currentRadio.id !== prevRadioId) {
|
||||
prevRadioId = currentRadio.id;
|
||||
prevTrackId = null;
|
||||
invoke('mpris_set_metadata', {
|
||||
title: currentRadio.name,
|
||||
artist: null,
|
||||
album: null,
|
||||
coverUrl: null,
|
||||
durationSecs: null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Update playback state on play/pause change (use live snapshot — persisted
|
||||
// store currentTime is intentionally coarse between commits).
|
||||
const playbackChanged = isPlaying !== prevIsPlaying;
|
||||
if (playbackChanged) {
|
||||
prevIsPlaying = isPlaying;
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
const pos = getPlaybackProgressSnapshot().currentTime;
|
||||
invoke('mpris_set_playback', {
|
||||
playing: isPlaying,
|
||||
positionSecs: pos > 0 ? pos : null,
|
||||
}).catch(() => {});
|
||||
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
});
|
||||
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
|
||||
const { currentRadio, isPlaying } = usePlayerStore.getState();
|
||||
if (currentRadio || !isPlaying) return;
|
||||
if (Date.now() - lastMprisPositionUpdate < 1500) return;
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: true,
|
||||
positionSecs: currentTime,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMpris();
|
||||
unsubMprisProgress();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
/**
|
||||
* Radio ICY StreamTitle → MPRIS. The Rust download task emits "radio:metadata"
|
||||
* with { title, is_ad } every time an ICY metadata block changes (typically
|
||||
* every 8–32 KB of audio). Forward each update to mpris_set_metadata so the OS
|
||||
* now-playing overlay stays in sync while the stream is live. Returns a cleanup
|
||||
* function.
|
||||
*/
|
||||
export function setupRadioMprisMetadata(): () => void {
|
||||
const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => {
|
||||
const { currentRadio } = usePlayerStore.getState();
|
||||
if (!currentRadio) return; // guard: only forward during active radio session
|
||||
if (payload.is_ad) return; // skip CDN-injected ad metadata
|
||||
|
||||
// Parse "Artist - Title" convention used by most ICY streams.
|
||||
const sep = payload.title.indexOf(' - ');
|
||||
const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null;
|
||||
const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title;
|
||||
|
||||
invoke('mpris_set_metadata', {
|
||||
title: title || currentRadio.name,
|
||||
artist: artist || currentRadio.name,
|
||||
album: null,
|
||||
coverUrl: null,
|
||||
durationSecs: null,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
radioMetaUnlisten.then(unlisten => unlisten());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
import {
|
||||
armAutodjMixing,
|
||||
clearAutodjTransitionUi,
|
||||
useAutodjTransitionUi,
|
||||
} from '@/features/playback/store/autodjTransitionUi';
|
||||
|
||||
describe('autodjTransitionUi', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
setTransitionMode('autodj');
|
||||
clearAutodjTransitionUi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setTransitionMode('none');
|
||||
});
|
||||
|
||||
it('arms mixing then returns to idle after overlap', () => {
|
||||
armAutodjMixing(2);
|
||||
expect(useAutodjTransitionUi.getState().phase).toBe('mixing');
|
||||
vi.advanceTimersByTime(2250);
|
||||
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not arm outside AutoDJ mode', () => {
|
||||
setTransitionMode('crossfade');
|
||||
armAutodjMixing(2);
|
||||
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
|
||||
/** User-visible AutoDJ transition feedback on the player-bar play button. */
|
||||
export type AutodjTransitionPhase = 'idle' | 'mixing';
|
||||
|
||||
interface AutodjTransitionUiState {
|
||||
phase: AutodjTransitionPhase;
|
||||
}
|
||||
|
||||
let mixingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export const useAutodjTransitionUi = create<AutodjTransitionUiState>(() => ({
|
||||
phase: 'idle',
|
||||
}));
|
||||
|
||||
function clearMixingTimer(): void {
|
||||
if (mixingTimer) {
|
||||
clearTimeout(mixingTimer);
|
||||
mixingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop any transition indicator (stop, hard cut, new idle track). */
|
||||
export function clearAutodjTransitionUi(): void {
|
||||
clearMixingTimer();
|
||||
useAutodjTransitionUi.setState({ phase: 'idle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the mixing indicator only while a real crossfade overlap is in progress.
|
||||
* No-op outside AutoDJ mode.
|
||||
*/
|
||||
export function armAutodjMixing(overlapSec: number): void {
|
||||
if (!(overlapSec > 0)) return;
|
||||
if (getTransitionMode(useAuthStore.getState()) !== 'autodj') return;
|
||||
clearMixingTimer();
|
||||
useAutodjTransitionUi.setState({ phase: 'mixing' });
|
||||
const ms = Math.round(overlapSec * 1000) + 250;
|
||||
mixingTimer = setTimeout(() => {
|
||||
mixingTimer = null;
|
||||
if (useAutodjTransitionUi.getState().phase === 'mixing') {
|
||||
useAutodjTransitionUi.setState({ phase: 'idle' });
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* B1 regression cluster: queue thin-state server identity must be canonical
|
||||
* everywhere. Writers emit the URL-derived index key (same model as the
|
||||
* library index) so mixed-server queues with duplicate `trackId` across
|
||||
* servers stay unambiguous on every path the review flagged:
|
||||
*
|
||||
* - resolver correctness (`seedQueueResolver`, `getCachedTrack`)
|
||||
* - restore / hydrate (persist `merge`, `hydrateQueueFromIndex`)
|
||||
* - undo / redo snapshots (`applyQueueHistorySnapshot` prepend, H3)
|
||||
* - queue sync id emission (`savePlayQueue` trackIds only)
|
||||
* - write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { bindQueueServerForPlayback } from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
_resetQueueResolverForTest,
|
||||
getCachedTrack,
|
||||
seedQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
import { applyQueueHistorySnapshot } from '@/features/playback/store/applyQueueHistorySnapshot';
|
||||
import {
|
||||
pushQueueUndoSnapshot,
|
||||
type QueueUndoSnapshot,
|
||||
} from '@/features/playback/store/queueUndo';
|
||||
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
|
||||
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/features/playback/store/queueSync';
|
||||
|
||||
vi.mock('@/lib/api/subsonicPlayQueue', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
}));
|
||||
vi.mock('@/utils/network/activeServerReachability', () => ({
|
||||
isActiveServerReachable: () => true,
|
||||
}));
|
||||
|
||||
const SERVER_A = {
|
||||
id: 'uuid-a',
|
||||
name: 'A',
|
||||
url: 'http://a.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const SERVER_B = {
|
||||
id: 'uuid-b',
|
||||
name: 'B',
|
||||
url: 'http://b.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const KEY_A = 'a.test';
|
||||
const KEY_B = 'b.test';
|
||||
|
||||
function track(id: string, title: string): Track {
|
||||
return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
|
||||
}
|
||||
|
||||
function getMerge() {
|
||||
type MergeFn = (
|
||||
persisted: unknown,
|
||||
current: ReturnType<typeof usePlayerStore.getState>,
|
||||
) => ReturnType<typeof usePlayerStore.getState>;
|
||||
return (usePlayerStore as unknown as {
|
||||
persist: { getOptions(): { merge: MergeFn } };
|
||||
}).persist.getOptions().merge;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
resetPlayerStore();
|
||||
_resetQueueResolverForTest();
|
||||
_resetQueueSyncForTest();
|
||||
vi.mocked(savePlayQueue).mockClear();
|
||||
useAuthStore.setState({
|
||||
servers: [SERVER_A, SERVER_B],
|
||||
activeServerId: SERVER_A.id,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
});
|
||||
|
||||
// ── Write helpers canonicalize ────────────────────────────────────────────
|
||||
|
||||
describe('B1 — writers emit canonical server keys', () => {
|
||||
it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
|
||||
const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
|
||||
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
|
||||
});
|
||||
|
||||
it('toQueueItemRefs is idempotent on an already-canonical input', () => {
|
||||
const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
|
||||
expect(refs[0].serverId).toBe(KEY_B);
|
||||
});
|
||||
|
||||
it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
|
||||
const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
|
||||
expect(refs[0].serverId).toBe('unknown-srv');
|
||||
});
|
||||
|
||||
it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
|
||||
useAuthStore.setState({ activeServerId: SERVER_B.id });
|
||||
bindQueueServerForPlayback();
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Resolver correctness: same trackId across two servers must NOT collide ─
|
||||
|
||||
describe('B1 — resolver isolates duplicate trackId across servers', () => {
|
||||
it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
|
||||
const t = track('shared', 'Original');
|
||||
seedQueueResolver(SERVER_A.id, [t]); // UUID input
|
||||
// Read via the canonical ref (what writers now emit)
|
||||
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
|
||||
// …and via legacy UUID-bound refs (migration window compat path)
|
||||
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
|
||||
});
|
||||
|
||||
it('two servers with the same trackId resolve independently — no cross-contamination', () => {
|
||||
seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
|
||||
seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
|
||||
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
|
||||
expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
|
||||
// Legacy-form refs map back to the same canonical entry per server.
|
||||
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
|
||||
expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────
|
||||
|
||||
describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
|
||||
it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
|
||||
const merged = getMerge()(
|
||||
{
|
||||
queueServerId: SERVER_A.id,
|
||||
queueIndex: 1,
|
||||
queueItems: [
|
||||
{ serverId: SERVER_A.id, trackId: 't1' },
|
||||
{ serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
|
||||
],
|
||||
queueItemsIndex: 1,
|
||||
},
|
||||
usePlayerStore.getState(),
|
||||
);
|
||||
expect(merged.queueServerId).toBe(KEY_A);
|
||||
expect(merged.queueItems).toEqual([
|
||||
{ serverId: KEY_A, trackId: 't1' },
|
||||
{ serverId: KEY_A, trackId: 't2', radioAdded: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes the legacy queueRefs-only shape', () => {
|
||||
const merged = getMerge()(
|
||||
{
|
||||
queueServerId: SERVER_B.id,
|
||||
queueRefs: ['x', 'y'],
|
||||
queueRefsIndex: 1,
|
||||
},
|
||||
usePlayerStore.getState(),
|
||||
);
|
||||
expect(merged.queueServerId).toBe(KEY_B);
|
||||
expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
|
||||
});
|
||||
|
||||
it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
|
||||
const merged = getMerge()(
|
||||
{
|
||||
queueServerId: SERVER_A.id,
|
||||
queueItems: [
|
||||
{ serverId: SERVER_A.id, trackId: 'shared' },
|
||||
{ serverId: SERVER_B.id, trackId: 'shared' },
|
||||
],
|
||||
queueItemsIndex: 0,
|
||||
},
|
||||
usePlayerStore.getState(),
|
||||
);
|
||||
expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
|
||||
expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────
|
||||
|
||||
describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
|
||||
it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
|
||||
// Live state: playback was just rebound to server B mid-undo.
|
||||
const playingTrack = track('shared', 'Still playing');
|
||||
const prior: PlayerState = {
|
||||
...usePlayerStore.getState(),
|
||||
currentTrack: playingTrack,
|
||||
currentTime: 12,
|
||||
progress: 0.2,
|
||||
isPlaying: true,
|
||||
queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
|
||||
queueServerId: KEY_B,
|
||||
queueIndex: 0,
|
||||
};
|
||||
usePlayerStore.setState(prior);
|
||||
|
||||
// Snapshot was captured under server A — the prepend must follow A,
|
||||
// not the live B binding.
|
||||
const snap: QueueUndoSnapshot = {
|
||||
queueItems: [],
|
||||
queueIndex: 0,
|
||||
currentTrack: null,
|
||||
currentTime: 0,
|
||||
progress: 0,
|
||||
isPlaying: false,
|
||||
queueServerId: KEY_A,
|
||||
};
|
||||
|
||||
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
|
||||
|
||||
const after = usePlayerStore.getState();
|
||||
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
|
||||
expect(after.queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
|
||||
const playingTrack = track('p', 'Playing');
|
||||
const prior: PlayerState = {
|
||||
...usePlayerStore.getState(),
|
||||
currentTrack: playingTrack,
|
||||
isPlaying: true,
|
||||
queueItems: [{ serverId: KEY_B, trackId: 'p' }],
|
||||
queueServerId: KEY_B,
|
||||
queueIndex: 0,
|
||||
};
|
||||
usePlayerStore.setState(prior);
|
||||
|
||||
const snap: QueueUndoSnapshot = {
|
||||
queueItems: [{ serverId: KEY_A, trackId: 'other' }],
|
||||
queueIndex: 0,
|
||||
currentTrack: null,
|
||||
};
|
||||
|
||||
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
|
||||
|
||||
// Prepend inherits server identity from the snapshot's first ref.
|
||||
const after = usePlayerStore.getState();
|
||||
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
|
||||
});
|
||||
|
||||
it('snapshot from current state captures the canonical queueServerId', () => {
|
||||
pushQueueUndoSnapshot({
|
||||
queueItems: [{ serverId: KEY_A, trackId: 't1' }],
|
||||
queueIndex: 0,
|
||||
currentTrack: null,
|
||||
queueServerId: KEY_A,
|
||||
});
|
||||
// Sanity: snapshots transport the canonical key forward through stacks.
|
||||
// (The actual capture happens in queueUndoSnapshotFromState, which now
|
||||
// includes queueServerId from PlayerState — see queueUndo.ts.)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Queue sync emits trackIds only (server identity goes via playback API) ─
|
||||
|
||||
describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
|
||||
it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const refs: QueueItemRef[] = [
|
||||
{ serverId: KEY_A, trackId: 't1' },
|
||||
{ serverId: KEY_A, trackId: 't2' },
|
||||
];
|
||||
usePlayerStore.setState({
|
||||
queueItems: refs,
|
||||
queueIndex: 0,
|
||||
queueServerId: KEY_A,
|
||||
currentTrack: track('t1', 'One'),
|
||||
currentTime: 1.5,
|
||||
isPlaying: true,
|
||||
});
|
||||
|
||||
await flushPlayQueuePosition();
|
||||
|
||||
expect(savePlayQueue).toHaveBeenCalledTimes(1);
|
||||
const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
|
||||
expect(ids).toEqual(['t1', 't2']);
|
||||
expect(current).toBe('t1');
|
||||
expect(posMs).toBe(1500);
|
||||
// savePlayQueue's serverId arg comes from getPlaybackServerId(), which
|
||||
// resolves a canonical key OR a UUID back to a UUID — needed for the
|
||||
// Subsonic auth lookup. Either is OK here; what matters is no leakage
|
||||
// of a per-ref serverId into the request body.
|
||||
expect(typeof serverId).toBe('string');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add-to-queue mutations pin the active server when queueServerId is null ─
|
||||
//
|
||||
// Regression for the queue-blanking bug: the first user action after launch is
|
||||
// often a single-track enqueue (e.g. clicking the + button on a search result
|
||||
// row), not a queue-replacing playTrack. Before the pin, `queueServerId`
|
||||
// stayed null, `seedIncoming` was a no-op, the refs landed with empty server
|
||||
// keys, and every new queue row rendered as the resolver placeholder ("…" +
|
||||
// 0:00) until the user happened to trigger a path that did call
|
||||
// `bindQueueServerForPlayback`.
|
||||
|
||||
describe('B1+ — add-to-queue mutations pin queueServerId when it is null', () => {
|
||||
it('enqueue seeds the cache so refs resolve to the real track instead of the placeholder', () => {
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
const t = track('t1', 'Real Title');
|
||||
usePlayerStore.getState().enqueue([t], true);
|
||||
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
|
||||
const refs = usePlayerStore.getState().queueItems;
|
||||
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
|
||||
expect(getCachedTrack(refs[0])).toEqual(expect.objectContaining({
|
||||
id: 't1',
|
||||
title: 'Real Title',
|
||||
}));
|
||||
});
|
||||
|
||||
it('enqueueAt pins and seeds when queueServerId is null', () => {
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
const t = track('t1', 'Inserted');
|
||||
usePlayerStore.getState().enqueueAt([t], 0, true);
|
||||
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
|
||||
const refs = usePlayerStore.getState().queueItems;
|
||||
expect(refs[0]).toEqual({ serverId: KEY_A, trackId: 't1' });
|
||||
expect(getCachedTrack(refs[0])?.title).toBe('Inserted');
|
||||
});
|
||||
|
||||
it('enqueueRadio pins and seeds when queueServerId is null', () => {
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
const t = track('r1', 'Radio Track');
|
||||
usePlayerStore.getState().enqueueRadio([t], 'artist-x');
|
||||
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
|
||||
const refs = usePlayerStore.getState().queueItems;
|
||||
expect(refs[0]).toEqual(expect.objectContaining({ serverId: KEY_A, trackId: 'r1' }));
|
||||
expect(getCachedTrack(refs[0])?.title).toBe('Radio Track');
|
||||
});
|
||||
|
||||
it('does not crash or pin when no active server is available', () => {
|
||||
useAuthStore.setState({ activeServerId: null });
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
usePlayerStore.getState().enqueue([track('t1', 'X')], true);
|
||||
|
||||
// No active server → bindQueueServerForPlayback is a no-op. The mutation
|
||||
// still runs (matches the pre-fix baseline behaviour) — placeholder UI is
|
||||
// the expected fallback when no server can be pinned.
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
expect(usePlayerStore.getState().queueItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not overwrite an already-pinned queueServerId', () => {
|
||||
useAuthStore.setState({ activeServerId: SERVER_B.id });
|
||||
usePlayerStore.setState({ queueServerId: KEY_A });
|
||||
|
||||
usePlayerStore.getState().enqueue([track('t1', 'Y')], true);
|
||||
|
||||
// Already pinned → ensureQueueServerPinned is a no-op even though the
|
||||
// active server has since switched (mixed-server enqueue keeps the anchor).
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
|
||||
import { computeWaveformSilence, planCrossfadeTransition } from '@/utils/waveform/waveformSilence';
|
||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import {
|
||||
hasPlannedCrossfade,
|
||||
markPlannedCrossfade,
|
||||
setCrossfadeTransition,
|
||||
} from '@/features/playback/store/crossfadeTrimCache';
|
||||
import { getBytePreloadingId, setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { fetchWaveformBins } from '@/features/playback/store/waveformRefresh';
|
||||
|
||||
// Crossfade pre-buffer budget: begin downloading the next track this many
|
||||
// seconds before it needs to play (the crossfade start), so a large lossless
|
||||
// file over HTTP has time to buffer + promote to cache before the fade. Generous
|
||||
// on purpose. The trailing-silence trim widens the window further so the early
|
||||
// A-tail advance keeps the full budget.
|
||||
export const CROSSFADE_PRELOAD_BUDGET_SECS = 30;
|
||||
|
||||
/**
|
||||
* Readiness gate for the AutoDJ early, content-driven advance. A stable fade
|
||||
* needs the *next* track's audio at the overlap moment — analysis (waveform)
|
||||
* alone is not enough. B counts as ready when its full bytes are in the engine
|
||||
* RAM preload slot (`enginePreloadedTrackId`) or it is local on disk: offline
|
||||
* library, favourite auto-sync, or hot-cache ephemeral tier. When B isn't ready
|
||||
* we skip the early advance and let the plain engine crossfade handle the
|
||||
* transition (graceful degrade) instead of fading over a buffering stream.
|
||||
*/
|
||||
export function isCrossfadeNextReady(
|
||||
trackId: string,
|
||||
profileId: string | null,
|
||||
cacheKey: string | null,
|
||||
): boolean {
|
||||
if (!trackId) return false;
|
||||
if (usePlayerStore.getState().enginePreloadedTrackId === trackId) return true;
|
||||
for (const sid of [profileId, cacheKey]) {
|
||||
if (!sid) continue;
|
||||
if (
|
||||
findLocalPlaybackUrl(trackId, sid, 'library')
|
||||
|| findLocalPlaybackUrl(trackId, sid, 'favorite-auto')
|
||||
|| findLocalPlaybackUrl(trackId, sid, 'ephemeral')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Outgoing fade + preload window before an interrupt handoff (library pick, etc.). */
|
||||
export const INTERRUPT_BLEND_PREP_FADE_SEC = 1.0;
|
||||
|
||||
/** @deprecated Use {@link INTERRUPT_BLEND_PREP_FADE_SEC} — prep and wait are aligned. */
|
||||
export const INTERRUPT_BLEND_PRELOAD_WAIT_MS = Math.round(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
|
||||
|
||||
/**
|
||||
* Start an eager RAM preload for a track the user just picked (no queue lead time).
|
||||
* No-op when already ready or a preload for this id is in flight.
|
||||
*/
|
||||
export function kickEagerCrossfadePreload(
|
||||
track: Track,
|
||||
profileId: string | null,
|
||||
cacheKey: string | null,
|
||||
): void {
|
||||
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
|
||||
if (track.id === getBytePreloadingId()) return;
|
||||
const serverId = cacheKey || profileId;
|
||||
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
|
||||
setBytePreloadingId(track.id);
|
||||
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
|
||||
invoke('audio_preload', {
|
||||
url,
|
||||
durationHint: track.duration,
|
||||
analysisTrackId: track.id,
|
||||
serverId: serverId || null,
|
||||
eager: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function sleepMs(ms: number): Promise<void> {
|
||||
return new Promise(resolve => { window.setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until B is playable for a stable crossfade, or `maxWaitMs` elapses.
|
||||
* Returns false when `isStale()` reports a superseding play generation.
|
||||
*/
|
||||
export async function waitForCrossfadeNextReady(
|
||||
trackId: string,
|
||||
profileId: string | null,
|
||||
cacheKey: string | null,
|
||||
maxWaitMs: number,
|
||||
isStale: () => boolean,
|
||||
): Promise<boolean> {
|
||||
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (isStale()) return false;
|
||||
await sleepMs(50);
|
||||
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
|
||||
}
|
||||
return isCrossfadeNextReady(trackId, profileId, cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfade-only byte pre-download for the next track + (when trim is on) its
|
||||
* leading-silence probe. Self-gating and idempotent (`bytePreloadingId` /
|
||||
* `hasFetchedCrossfadeLead` guards), so it is safe to call every progress tick
|
||||
* *and* immediately after a seek lands inside the pre-buffer window. No-ops for
|
||||
* the gapless / hot-cache paths (those pre-buffer elsewhere).
|
||||
*
|
||||
* Lives in its own module so `seekAction` can call it without pulling in
|
||||
* `audioEventHandlers` (which would close a `playerStore` import cycle).
|
||||
*/
|
||||
export function maybeCrossfadeBytePreload(currentTime: number, dur: number): void {
|
||||
if (!(dur > 0)) return;
|
||||
const {
|
||||
gaplessEnabled, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, crossfadeTrimSilence,
|
||||
} = useAuthStore.getState();
|
||||
if (!crossfadeEnabled || gaplessEnabled) return;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track || store.currentRadio) return;
|
||||
const remaining = dur - currentTime;
|
||||
if (!(remaining > 0)) return;
|
||||
|
||||
const curTrailSilenceSec = crossfadeTrimSilence
|
||||
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
|
||||
: 0;
|
||||
const crossfadeWindowSecs = crossfadeSecs + curTrailSilenceSec + CROSSFADE_PRELOAD_BUDGET_SECS;
|
||||
if (remaining >= crossfadeWindowSecs) return;
|
||||
|
||||
const { queueItems, queueIndex, repeatMode } = store;
|
||||
if (repeatMode === 'one') return;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextRef = nextIdx < queueItems.length
|
||||
? queueItems[nextIdx]
|
||||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||
if (!nextRef) return;
|
||||
const nextTrack = resolveQueueTrack(nextRef);
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
const serverId = playbackCacheKeyForRef(nextRef);
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — skipped when the hot cache is on (it already keeps the
|
||||
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
|
||||
// the next track is local → seekable → starts instantly past its lead silence).
|
||||
if (!hotCacheEnabled && nextTrack.id !== getBytePreloadingId()) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
// Loudness cache only — never refreshWaveformForTrack(next): it writes the
|
||||
// global waveformBins and would replace the current track's seekbar.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: serverId || null,
|
||||
// Crossfade/AutoDJ pre-buffer: skip the 8 s throttle so the RAM slot
|
||||
// fills before the fade — without the hot cache this is the only source
|
||||
// of B's bytes, and a late slot means no fade (or an audible jump).
|
||||
eager: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// B-head + dynamic overlap: plan the whole transition once (no store write) so
|
||||
// playTrack can start the incoming track past its dead head AND fade over a
|
||||
// content-adaptive overlap. Pairs the current track's envelope (already in the
|
||||
// store) with the next track's cached waveform; the alignment maths is cheap,
|
||||
// so it runs regardless of hot cache (which otherwise skips the byte
|
||||
// pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no
|
||||
// head trim → today's behaviour.
|
||||
if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) {
|
||||
markPlannedCrossfade(nextTrack.id);
|
||||
const planTrackId = nextTrack.id;
|
||||
const planDuration = nextTrack.duration;
|
||||
const curBins = store.waveformBins;
|
||||
void fetchWaveformBins(planTrackId, serverId || null)
|
||||
.then(nextBins => {
|
||||
// Overlap is derived purely from the audio (fade-out / buildup); the
|
||||
// user's crossfadeSecs is intentionally not a factor in this mode.
|
||||
const maxOverlapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
|
||||
const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration, { maxOverlapSec });
|
||||
setCrossfadeTransition(planTrackId, plan);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
_resetCrossfadeTrimCacheForTest,
|
||||
armCrossfadeDynamicOverlap,
|
||||
consumeCrossfadeDynamicOverlap,
|
||||
peekArmedCrossfadeDynamicOverlap,
|
||||
getCrossfadeTransition,
|
||||
hasPlannedCrossfade,
|
||||
markPlannedCrossfade,
|
||||
setCrossfadeTransition,
|
||||
} from '@/features/playback/store/crossfadeTrimCache';
|
||||
|
||||
describe('crossfadeTrimCache', () => {
|
||||
beforeEach(() => _resetCrossfadeTrimCacheForTest());
|
||||
|
||||
it('returns null for unknown / empty track ids', () => {
|
||||
expect(getCrossfadeTransition('nope')).toBeNull();
|
||||
expect(getCrossfadeTransition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and reads a transition plan', () => {
|
||||
setCrossfadeTransition('t1', { bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
|
||||
expect(getCrossfadeTransition('t1')).toEqual({ bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
|
||||
});
|
||||
|
||||
it('clamps negative values to 0 and ignores empty ids', () => {
|
||||
setCrossfadeTransition('t2', { bStartSec: -1, overlapSec: -2, outgoingFadeSec: -3 });
|
||||
expect(getCrossfadeTransition('t2')).toEqual({ bStartSec: 0, overlapSec: 0, outgoingFadeSec: 0 });
|
||||
setCrossfadeTransition('', { bStartSec: 3, overlapSec: 3, outgoingFadeSec: 3 });
|
||||
expect(getCrossfadeTransition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks planned ids independently', () => {
|
||||
expect(hasPlannedCrossfade('t3')).toBe(false);
|
||||
markPlannedCrossfade('t3');
|
||||
expect(hasPlannedCrossfade('t3')).toBe(true);
|
||||
});
|
||||
|
||||
it('evicts oldest entries past the cap', () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
setCrossfadeTransition(`k${i}`, { bStartSec: i, overlapSec: 1, outgoingFadeSec: 1 });
|
||||
}
|
||||
// First entries should have been evicted (cap 32).
|
||||
expect(getCrossfadeTransition('k0')).toBeNull();
|
||||
expect(getCrossfadeTransition('k39')).toEqual({ bStartSec: 39, overlapSec: 1, outgoingFadeSec: 1 });
|
||||
});
|
||||
|
||||
it('arms and consumes the dynamic overlap once, for the matching track', () => {
|
||||
armCrossfadeDynamicOverlap('b1', 4, 0);
|
||||
// Mismatched id consumes nothing and leaves the armed value intact.
|
||||
expect(consumeCrossfadeDynamicOverlap('other')).toBeNull();
|
||||
expect(consumeCrossfadeDynamicOverlap('b1')).toEqual({ overlapSec: 4, outgoingFadeSec: 0 });
|
||||
// One-shot: a second consume returns null.
|
||||
expect(consumeCrossfadeDynamicOverlap('b1')).toBeNull();
|
||||
});
|
||||
|
||||
it('peeks armed overlap without consuming', () => {
|
||||
armCrossfadeDynamicOverlap('b3', 2, 2);
|
||||
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(true);
|
||||
expect(peekArmedCrossfadeDynamicOverlap('other')).toBe(false);
|
||||
expect(consumeCrossfadeDynamicOverlap('b3')).not.toBeNull();
|
||||
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the engine fade-out length for A (non-scenario-A swaps)', () => {
|
||||
armCrossfadeDynamicOverlap('b2', 0.5, 0.5);
|
||||
expect(consumeCrossfadeDynamicOverlap('b2')).toEqual({ overlapSec: 0.5, outgoingFadeSec: 0.5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Silence-aware crossfade — tiny module cache bridging the pre-buffer stage and
|
||||
* `playTrack`. During the crossfade pre-buffer window (`crossfadePreload`) we
|
||||
* fetch the *next* track's cached waveform and, together with the current
|
||||
* track's envelope, derive a per-transition plan: where the incoming track
|
||||
* should begin (leading silence skipped) and the adaptive overlap length.
|
||||
* `playTrackAction` then reads it to pass `audio_play(start_secs, crossfade_secs_override)`,
|
||||
* and `audioEventHandlers` reads the overlap to re-anchor the early A-tail advance.
|
||||
*
|
||||
* Kept out of the persisted Zustand store on purpose: this is ephemeral,
|
||||
* per-transition playback data, not user state.
|
||||
*/
|
||||
import type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
|
||||
|
||||
export type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
|
||||
|
||||
/** trackId → planned transition for when this track starts under crossfade. */
|
||||
const planByTrackId = new Map<string, CrossfadeTransitionPlan>();
|
||||
/** trackIds we've already attempted a plan for (avoids per-tick refetch). */
|
||||
const plannedTrackIds = new Set<string>();
|
||||
|
||||
// Bound both sets so a long session can't grow them without limit.
|
||||
const MAX_ENTRIES = 32;
|
||||
|
||||
function trim(map: { delete: (k: string) => void; size: number; keys: () => IterableIterator<string> }): void {
|
||||
while (map.size > MAX_ENTRIES) {
|
||||
const oldest = map.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the computed transition plan for `trackId`. */
|
||||
export function setCrossfadeTransition(trackId: string, plan: CrossfadeTransitionPlan): void {
|
||||
if (!trackId) return;
|
||||
planByTrackId.set(trackId, {
|
||||
bStartSec: Math.max(0, plan.bStartSec),
|
||||
overlapSec: Math.max(0, plan.overlapSec),
|
||||
outgoingFadeSec: Math.max(0, plan.outgoingFadeSec),
|
||||
});
|
||||
trim(planByTrackId);
|
||||
}
|
||||
|
||||
/** Read the cached transition plan for `trackId` (null when none/unknown). */
|
||||
export function getCrossfadeTransition(trackId: string): CrossfadeTransitionPlan | null {
|
||||
if (!trackId) return null;
|
||||
return planByTrackId.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
/** True once we've already attempted to plan a transition into `trackId`. */
|
||||
export function hasPlannedCrossfade(trackId: string): boolean {
|
||||
return plannedTrackIds.has(trackId);
|
||||
}
|
||||
|
||||
/** Mark `trackId` as planned so the pre-buffer loop doesn't refetch every tick. */
|
||||
export function markPlannedCrossfade(trackId: string): void {
|
||||
if (!trackId) return;
|
||||
plannedTrackIds.add(trackId);
|
||||
trim(plannedTrackIds);
|
||||
}
|
||||
|
||||
// ── One-shot dynamic-overlap hand-off (A-tail advance → playTrack) ──────────────
|
||||
// When the JS early-advance fires it "arms" the content-driven overlap for the
|
||||
// incoming track. `playTrack` consumes it to pass `crossfade_secs_override`, so the
|
||||
// per-transition fade length is applied *only* when JS controlled the advance
|
||||
// timing. Engine-driven advances (plain loud→loud endings) leave it unset and keep
|
||||
// the normal crossfade length — avoids muting the outgoing track's tail.
|
||||
let armedOverlapTrackId: string | null = null;
|
||||
let armedOverlapSec = 0;
|
||||
let armedOutgoingFadeSec = 0;
|
||||
|
||||
/** The fade lengths JS armed for one incoming transition. */
|
||||
export interface ArmedCrossfadeOverlap {
|
||||
/** Track B's fade-in length (the overlap). */
|
||||
overlapSec: number;
|
||||
/** Track A's engine fade-out length (0 = A rides its own recorded fade). */
|
||||
outgoingFadeSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the content-driven fade lengths JS just positioned for the incoming
|
||||
* `trackId`: B's fade-in (`overlapSec`) and A's engine fade-out
|
||||
* (`outgoingFadeSec`; 0 ⇒ let A ride its own recorded fade — scenario A).
|
||||
*/
|
||||
export function armCrossfadeDynamicOverlap(
|
||||
trackId: string,
|
||||
overlapSec: number,
|
||||
outgoingFadeSec: number,
|
||||
): void {
|
||||
if (!trackId) return;
|
||||
armedOverlapTrackId = trackId;
|
||||
armedOverlapSec = Math.max(0, overlapSec);
|
||||
armedOutgoingFadeSec = Math.max(0, outgoingFadeSec);
|
||||
}
|
||||
|
||||
/** Consume + clear the armed fades for `trackId` (null when none/mismatched). */
|
||||
export function consumeCrossfadeDynamicOverlap(trackId: string): ArmedCrossfadeOverlap | null {
|
||||
if (!trackId || armedOverlapTrackId !== trackId) return null;
|
||||
const overlapSec = armedOverlapSec;
|
||||
const outgoingFadeSec = armedOutgoingFadeSec;
|
||||
armedOverlapTrackId = null;
|
||||
armedOverlapSec = 0;
|
||||
armedOutgoingFadeSec = 0;
|
||||
return overlapSec > 0 ? { overlapSec, outgoingFadeSec } : null;
|
||||
}
|
||||
|
||||
/** True when JS A-tail advance armed a handoff for `trackId` (peek only). */
|
||||
export function peekArmedCrossfadeDynamicOverlap(trackId: string): boolean {
|
||||
return !!trackId && armedOverlapTrackId === trackId && armedOverlapSec > 0;
|
||||
}
|
||||
|
||||
/** Test/reset hook. */
|
||||
export function _resetCrossfadeTrimCacheForTest(): void {
|
||||
planByTrackId.clear();
|
||||
plannedTrackIds.clear();
|
||||
armedOverlapTrackId = null;
|
||||
armedOverlapSec = 0;
|
||||
armedOutgoingFadeSec = 0;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
|
||||
import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '@/utils/audio/hiResCrossfadeResample';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
import { isReplayGainActive, loudnessGainDbForEngineBind } from '@/features/playback/store/loudnessGainCache';
|
||||
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from '@/features/playback/store/playbackUrlRouting';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
/**
|
||||
* Load a track into the Rust engine at `atSeconds`, optionally leaving transport
|
||||
* playing or paused. Shared by queue-undo restore and cold-start paused prepare.
|
||||
*/
|
||||
export function engineLoadTrackAtPosition(opts: {
|
||||
generation: number;
|
||||
track: Track;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
atSeconds: number;
|
||||
wantPlaying: boolean;
|
||||
}): void {
|
||||
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
|
||||
const authState = useAuthStore.getState();
|
||||
const vol = usePlayerStore.getState().volume;
|
||||
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
||||
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, coldPrev, coldNext,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
||||
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
||||
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
||||
});
|
||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||
const startPaused = !wantPlaying;
|
||||
setDeferHotCachePrefetch(true);
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: vol,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual: false,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: track.id,
|
||||
serverId: playbackIndexKey || null,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
startPaused,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
const dur = track.duration && track.duration > 0 ? track.duration : null;
|
||||
const seekTo = Math.max(0, atSeconds);
|
||||
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
|
||||
const afterSeek = () => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (!wantPlaying) {
|
||||
if (!startPaused) {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
}
|
||||
setIsAudioPaused(true);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
} else {
|
||||
setIsAudioPaused(false);
|
||||
}
|
||||
};
|
||||
if (canSeek) {
|
||||
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
|
||||
} else {
|
||||
afterSeek();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
console.error('[psysonic] engineLoadTrackAtPosition failed:', err);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
})
|
||||
.finally(() => {
|
||||
setDeferHotCachePrefetch(false);
|
||||
});
|
||||
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
_resetEngineStateForTest,
|
||||
bumpPlayGeneration,
|
||||
getIsAudioPaused,
|
||||
getPlayGeneration,
|
||||
setIsAudioPaused,
|
||||
} from '@/features/playback/store/engineState';
|
||||
|
||||
afterEach(() => {
|
||||
_resetEngineStateForTest();
|
||||
});
|
||||
|
||||
describe('isAudioPaused', () => {
|
||||
it('starts false', () => {
|
||||
expect(getIsAudioPaused()).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips through get/set', () => {
|
||||
setIsAudioPaused(true);
|
||||
expect(getIsAudioPaused()).toBe(true);
|
||||
setIsAudioPaused(false);
|
||||
expect(getIsAudioPaused()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playGeneration', () => {
|
||||
it('starts at 0', () => {
|
||||
expect(getPlayGeneration()).toBe(0);
|
||||
});
|
||||
|
||||
it('bumpPlayGeneration increments + returns the new value', () => {
|
||||
expect(bumpPlayGeneration()).toBe(1);
|
||||
expect(bumpPlayGeneration()).toBe(2);
|
||||
expect(getPlayGeneration()).toBe(2);
|
||||
});
|
||||
|
||||
it('captures a snapshot that a later bump invalidates', () => {
|
||||
const snap = bumpPlayGeneration();
|
||||
bumpPlayGeneration();
|
||||
expect(getPlayGeneration()).not.toBe(snap);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resetEngineStateForTest', () => {
|
||||
it('resets both fields', () => {
|
||||
setIsAudioPaused(true);
|
||||
bumpPlayGeneration();
|
||||
bumpPlayGeneration();
|
||||
_resetEngineStateForTest();
|
||||
expect(getIsAudioPaused()).toBe(false);
|
||||
expect(getPlayGeneration()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Two pieces of state that coordinate the Rust audio engine from JS:
|
||||
*
|
||||
* - **isAudioPaused** — true when the engine has a loaded-but-paused
|
||||
* track. `resume()` reads this to decide between `audio_resume` (warm
|
||||
* path, just unpause the existing Sink) and a cold restart via
|
||||
* `audio_play + audio_seek`. Set true on every pause, false on every
|
||||
* successful play / seek.
|
||||
*
|
||||
* - **playGeneration** — monotonically increasing counter bumped at the
|
||||
* start of every play attempt. Long-running `.then`/`.finally`
|
||||
* callbacks capture their generation at start and compare against the
|
||||
* current value before applying state changes; a mismatch means the
|
||||
* user moved on and the callback should bail out without touching the
|
||||
* store. Prevents stale callbacks from snapping playback back to a
|
||||
* track the user already left.
|
||||
*/
|
||||
|
||||
let isAudioPaused = false;
|
||||
let playGeneration = 0;
|
||||
|
||||
export function getIsAudioPaused(): boolean {
|
||||
return isAudioPaused;
|
||||
}
|
||||
|
||||
export function setIsAudioPaused(value: boolean): void {
|
||||
isAudioPaused = value;
|
||||
}
|
||||
|
||||
export function getPlayGeneration(): number {
|
||||
return playGeneration;
|
||||
}
|
||||
|
||||
/** Bump + return the new generation in one call (mirrors `++playGeneration`). */
|
||||
export function bumpPlayGeneration(): number {
|
||||
return ++playGeneration;
|
||||
}
|
||||
|
||||
/** Test-only: reset both fields so each spec starts from a clean slate. */
|
||||
export function _resetEngineStateForTest(): void {
|
||||
isAudioPaused = false;
|
||||
playGeneration = 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Three mutables that coordinate the gapless preloader. Most of the surface
|
||||
* is straight get/set — the interesting bit is `clearPreloadingIds` (atomic
|
||||
* clear of both) and `markGaplessSwitch` (timestamp side effect).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
_resetGaplessPreloadStateForTest,
|
||||
clearPreloadingIds,
|
||||
getBytePreloadingId,
|
||||
getGaplessPreloadingId,
|
||||
getLastGaplessSwitchTime,
|
||||
markGaplessSwitch,
|
||||
setBytePreloadingId,
|
||||
setGaplessPreloadingId,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetGaplessPreloadStateForTest();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('is null / 0 for unread accessors', () => {
|
||||
expect(getGaplessPreloadingId()).toBeNull();
|
||||
expect(getBytePreloadingId()).toBeNull();
|
||||
expect(getLastGaplessSwitchTime()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preloading-id accessors', () => {
|
||||
it('round-trips through the gapless guard', () => {
|
||||
setGaplessPreloadingId('t1');
|
||||
expect(getGaplessPreloadingId()).toBe('t1');
|
||||
});
|
||||
|
||||
it('round-trips through the byte guard', () => {
|
||||
setBytePreloadingId('t2');
|
||||
expect(getBytePreloadingId()).toBe('t2');
|
||||
});
|
||||
|
||||
it('keeps the two guards independent', () => {
|
||||
setGaplessPreloadingId('a');
|
||||
setBytePreloadingId('b');
|
||||
expect(getGaplessPreloadingId()).toBe('a');
|
||||
expect(getBytePreloadingId()).toBe('b');
|
||||
});
|
||||
|
||||
it('accepts null to clear a guard', () => {
|
||||
setGaplessPreloadingId('a');
|
||||
setGaplessPreloadingId(null);
|
||||
expect(getGaplessPreloadingId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPreloadingIds', () => {
|
||||
it('clears both guards atomically', () => {
|
||||
setGaplessPreloadingId('a');
|
||||
setBytePreloadingId('b');
|
||||
clearPreloadingIds();
|
||||
expect(getGaplessPreloadingId()).toBeNull();
|
||||
expect(getBytePreloadingId()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not touch the gapless-switch timestamp', () => {
|
||||
markGaplessSwitch();
|
||||
const before = getLastGaplessSwitchTime();
|
||||
clearPreloadingIds();
|
||||
expect(getLastGaplessSwitchTime()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markGaplessSwitch', () => {
|
||||
it('stamps Date.now()', () => {
|
||||
markGaplessSwitch();
|
||||
expect(getLastGaplessSwitchTime()).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('overwrites on a later call', () => {
|
||||
markGaplessSwitch();
|
||||
const first = getLastGaplessSwitchTime();
|
||||
vi.advanceTimersByTime(700);
|
||||
markGaplessSwitch();
|
||||
expect(getLastGaplessSwitchTime()).toBeGreaterThan(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Coordinates the gapless-chain preloader so the runtime doesn't pre-fetch
|
||||
* the same track twice or fire a chain-switch while a previous one is
|
||||
* still settling.
|
||||
*
|
||||
* - `gaplessPreloadingId` — track id last handed to `audio_chain_preload`
|
||||
* - `bytePreloadingId` — track id last handed to `audio_preload`
|
||||
* - `lastGaplessSwitchTime` — timestamp of the last gapless track-switch
|
||||
* event from Rust. The 500–600 ms guards in `handleAudioTrackSwitched`
|
||||
* and the progress handler use this to suppress stale IPC arriving
|
||||
* right after the switch.
|
||||
*
|
||||
* `clearPreloadingIds` collapses the repeated `= null; = null;` pattern
|
||||
* that the store actions used to inline in five places.
|
||||
*/
|
||||
|
||||
let gaplessPreloadingId: string | null = null;
|
||||
let bytePreloadingId: string | null = null;
|
||||
let lastGaplessSwitchTime = 0;
|
||||
|
||||
export function getGaplessPreloadingId(): string | null {
|
||||
return gaplessPreloadingId;
|
||||
}
|
||||
|
||||
export function setGaplessPreloadingId(id: string | null): void {
|
||||
gaplessPreloadingId = id;
|
||||
}
|
||||
|
||||
export function getBytePreloadingId(): string | null {
|
||||
return bytePreloadingId;
|
||||
}
|
||||
|
||||
export function setBytePreloadingId(id: string | null): void {
|
||||
bytePreloadingId = id;
|
||||
}
|
||||
|
||||
/** Atomic: clear both preloading guards. Called on track switch + on errors. */
|
||||
export function clearPreloadingIds(): void {
|
||||
gaplessPreloadingId = null;
|
||||
bytePreloadingId = null;
|
||||
}
|
||||
|
||||
export function getLastGaplessSwitchTime(): number {
|
||||
return lastGaplessSwitchTime;
|
||||
}
|
||||
|
||||
/** Record a gapless switch event. Subsequent guards compare against this. */
|
||||
export function markGaplessSwitch(): void {
|
||||
lastGaplessSwitchTime = Date.now();
|
||||
}
|
||||
|
||||
/** Test-only: reset all three mutables. */
|
||||
export function _resetGaplessPreloadStateForTest(): void {
|
||||
gaplessPreloadingId = null;
|
||||
bytePreloadingId = null;
|
||||
lastGaplessSwitchTime = 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LocalPlaybackEntry } from '@/store/localPlaybackStore';
|
||||
import { countHotCacheTracks } from '@/features/playback/store/hotCacheStore';
|
||||
|
||||
function ephemeral(
|
||||
serverIndexKey: string,
|
||||
trackId: string,
|
||||
): LocalPlaybackEntry {
|
||||
return {
|
||||
serverIndexKey,
|
||||
trackId,
|
||||
localPath: `/media/cache/${trackId}.mp3`,
|
||||
sizeBytes: 1_000,
|
||||
layoutFingerprint: 'fp',
|
||||
tier: 'ephemeral',
|
||||
suffix: 'mp3',
|
||||
cachedAt: Date.parse('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('countHotCacheTracks', () => {
|
||||
it('counts all ephemeral rows regardless of server index key shape', () => {
|
||||
const entries = {
|
||||
'192.168.0.5:4533:t1': ephemeral('192.168.0.5:4533', 't1'),
|
||||
'srv-uuid:t2': ephemeral('srv-uuid', 't2'),
|
||||
'srv-uuid:t3': { ...ephemeral('srv-uuid', 't3'), tier: 'library' as const },
|
||||
};
|
||||
expect(countHotCacheTracks(entries)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores non-ephemeral tiers', () => {
|
||||
const entries = {
|
||||
'srv:t1': ephemeral('srv', 't1'),
|
||||
'srv:t2': { ...ephemeral('srv', 't2'), tier: 'favorite-auto' as const },
|
||||
};
|
||||
expect(countHotCacheTracks(entries)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import { create } from 'zustand';
|
||||
import type { HotCacheEntry } from '@/features/playback/store/hotCacheStoreTypes';
|
||||
import { useLocalPlaybackStore, type LocalPlaybackEntry } from '@/store/localPlaybackStore';
|
||||
import { entryBelongsToServer } from '@/store/localPlaybackResolve';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
|
||||
export type { HotCacheEntry } from '@/features/playback/store/hotCacheStoreTypes';
|
||||
/** @deprecated Use {@link LOCAL_PLAYBACK_PROTECT_AFTER_CURRENT}. */
|
||||
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
||||
|
||||
interface HotCacheState {
|
||||
getLocalUrl: (trackId: string, serverId: string) => string | null;
|
||||
setEntry: (
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
localPath: string,
|
||||
sizeBytes: number,
|
||||
debugSource?: string,
|
||||
layoutFingerprint?: string,
|
||||
suffix?: string,
|
||||
) => void;
|
||||
touchPlayed: (trackId: string, serverId: string) => void;
|
||||
removeEntry: (trackId: string, serverId: string) => Promise<void>;
|
||||
totalBytes: () => number;
|
||||
evictToFit: (
|
||||
queue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
maxBytes: number,
|
||||
activeServerId: string,
|
||||
mediaDir: string | null,
|
||||
) => Promise<void>;
|
||||
clearAllDisk: (mediaDir: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Ephemeral-tier view for UI selectors (Settings track count, prefetch helpers). */
|
||||
export function selectHotCacheEntries(
|
||||
entries: Record<string, import('@/store/localPlaybackStore').LocalPlaybackEntry>,
|
||||
): Record<string, HotCacheEntry> {
|
||||
const out: Record<string, HotCacheEntry> = {};
|
||||
for (const [key, e] of Object.entries(entries)) {
|
||||
if (e.tier !== 'ephemeral') continue;
|
||||
out[key] = {
|
||||
localPath: e.localPath,
|
||||
sizeBytes: e.sizeBytes,
|
||||
cachedAt: e.cachedAt,
|
||||
lastPlayedAt: e.lastPlayedAt,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ephemeral-tier row count for Settings (optional active-server scope). */
|
||||
export function countHotCacheTracks(
|
||||
entries: Record<string, LocalPlaybackEntry>,
|
||||
scopeServerId?: string,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const e of Object.values(entries)) {
|
||||
if (e.tier !== 'ephemeral') continue;
|
||||
if (scopeServerId && !entryBelongsToServer(e, scopeServerId)) continue;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export const useHotCacheStore = create<HotCacheState>()(() => ({
|
||||
getLocalUrl: (trackId, serverId) =>
|
||||
useLocalPlaybackStore.getState().getLocalUrl(trackId, serverId, 'ephemeral'),
|
||||
|
||||
setEntry: (trackId, serverId, localPath, sizeBytes, _debugSource, layoutFingerprint = '', suffix = 'mp3') => {
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
serverIndexKey: serverId,
|
||||
trackId,
|
||||
localPath,
|
||||
sizeBytes,
|
||||
layoutFingerprint,
|
||||
tier: 'ephemeral',
|
||||
suffix,
|
||||
});
|
||||
},
|
||||
|
||||
touchPlayed: (trackId, serverId) => {
|
||||
useLocalPlaybackStore.getState().touchPlayed(trackId, serverId);
|
||||
},
|
||||
|
||||
removeEntry: async (trackId, serverId) => {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const e = lp.getEntry(trackId, serverId);
|
||||
if (e?.tier === 'ephemeral' && e.localPath) {
|
||||
await invoke('delete_media_file', { localPath: e.localPath, mediaDir: getMediaDir() }).catch(
|
||||
() => {},
|
||||
);
|
||||
lp.removeEntry(trackId, serverId, 'hot-cache-shim');
|
||||
}
|
||||
},
|
||||
|
||||
totalBytes: () => useLocalPlaybackStore.getState().ephemeralTotalBytes(),
|
||||
|
||||
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, mediaDir) => {
|
||||
await useLocalPlaybackStore.getState().evictEphemeralToFit(
|
||||
queue,
|
||||
queueIndex,
|
||||
maxBytes,
|
||||
activeServerId,
|
||||
mediaDir,
|
||||
);
|
||||
},
|
||||
|
||||
clearAllDisk: async (mediaDir) => {
|
||||
await useLocalPlaybackStore.getState().purgeEphemeralDisk(mediaDir);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface HotCacheEntry {
|
||||
localPath: string;
|
||||
sizeBytes: number;
|
||||
cachedAt: number;
|
||||
lastPlayedAt?: number;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { touchPlayedMock } = vi.hoisted(() => ({
|
||||
touchPlayedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/hotCacheStore', () => ({
|
||||
useHotCacheStore: { getState: () => ({ touchPlayed: touchPlayedMock }) },
|
||||
}));
|
||||
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
|
||||
beforeEach(() => {
|
||||
touchPlayedMock.mockClear();
|
||||
});
|
||||
|
||||
describe('touchHotCacheOnPlayback', () => {
|
||||
it('forwards a populated id pair to the hot-cache store', () => {
|
||||
touchHotCacheOnPlayback('t1', 'srv');
|
||||
expect(touchPlayedMock).toHaveBeenCalledWith('t1', 'srv');
|
||||
});
|
||||
|
||||
it('skips when the trackId is empty', () => {
|
||||
touchHotCacheOnPlayback('', 'srv');
|
||||
expect(touchPlayedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when the serverId is empty', () => {
|
||||
touchHotCacheOnPlayback('t1', '');
|
||||
expect(touchPlayedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useHotCacheStore } from '@/features/playback/store/hotCacheStore';
|
||||
|
||||
/**
|
||||
* Mark a track as recently played for the hot-cache LRU. Called from every
|
||||
* `audio_play` entry point — cold start, gapless switch, queue rewrite,
|
||||
* radio next — so the hot cache promotes frequently-played tracks even when
|
||||
* playback bounced through different code paths. The empty-id guards keep
|
||||
* dev-time crashes (e.g. unauthenticated state, server still resolving)
|
||||
* from surfacing as cache writes against a meaningless key.
|
||||
*/
|
||||
export function touchHotCacheOnPlayback(trackId: string, serverId: string): void {
|
||||
if (!trackId || !serverId) return;
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
_resetInfiniteQueueStateForTest,
|
||||
isInfiniteQueueFetching,
|
||||
setInfiniteQueueFetching,
|
||||
} from '@/features/playback/store/infiniteQueueState';
|
||||
|
||||
afterEach(() => {
|
||||
_resetInfiniteQueueStateForTest();
|
||||
});
|
||||
|
||||
describe('infiniteQueueFetching', () => {
|
||||
it('starts false', () => {
|
||||
expect(isInfiniteQueueFetching()).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips through set/get', () => {
|
||||
setInfiniteQueueFetching(true);
|
||||
expect(isInfiniteQueueFetching()).toBe(true);
|
||||
setInfiniteQueueFetching(false);
|
||||
expect(isInfiniteQueueFetching()).toBe(false);
|
||||
});
|
||||
|
||||
it('_resetInfiniteQueueStateForTest resets to false', () => {
|
||||
setInfiniteQueueFetching(true);
|
||||
_resetInfiniteQueueStateForTest();
|
||||
expect(isInfiniteQueueFetching()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Concurrent-fetch guard for the infinite-queue feature. Stops a second
|
||||
* `buildInfiniteQueueCandidates` request from running while the first
|
||||
* is still pending — without it, switching tracks quickly while the
|
||||
* infinite tail is loading would race two enqueue actions and the
|
||||
* second would clobber the first's results.
|
||||
*/
|
||||
|
||||
let infiniteQueueFetching = false;
|
||||
|
||||
export function isInfiniteQueueFetching(): boolean {
|
||||
return infiniteQueueFetching;
|
||||
}
|
||||
|
||||
export function setInfiniteQueueFetching(value: boolean): void {
|
||||
infiniteQueueFetching = value;
|
||||
}
|
||||
|
||||
/** Test-only: reset the guard. */
|
||||
export function _resetInfiniteQueueStateForTest(): void {
|
||||
infiniteQueueFetching = false;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { setupAudioEngineListeners } from '@/features/playback/store/audioListenerSetup/audioEngineListeners';
|
||||
import { runInitialAudioSync } from '@/features/playback/store/audioListenerSetup/initialAudioSync';
|
||||
import { setupAuthSync } from '@/features/playback/store/audioListenerSetup/authSyncListener';
|
||||
import { setupMprisSync } from '@/features/playback/store/audioListenerSetup/mprisSync';
|
||||
import { setupRadioMprisMetadata } from '@/features/playback/store/audioListenerSetup/radioMprisMetadata';
|
||||
import { setupDiscordPresence } from '@/features/playback/store/audioListenerSetup/discordPresence';
|
||||
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
|
||||
|
||||
/**
|
||||
* Set up Tauri event listeners for the Rust audio engine.
|
||||
* Returns a cleanup function — pass it to useEffect's return value so that
|
||||
* React StrictMode (which double-invokes effects in dev) tears down the first
|
||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||
*
|
||||
* Each concern lives in its own module under `audioListenerSetup/`; this
|
||||
* function just composes them in the original setup / teardown order.
|
||||
*/
|
||||
export function initAudioListeners(): () => void {
|
||||
const stopEngineListeners = setupAudioEngineListeners();
|
||||
runInitialAudioSync();
|
||||
const stopAuthSync = setupAuthSync();
|
||||
const stopMprisSync = setupMprisSync();
|
||||
const stopRadioMprisMetadata = setupRadioMprisMetadata();
|
||||
const stopDiscordPresence = setupDiscordPresence();
|
||||
const stopEqDeviceSync = setupEqDeviceSync();
|
||||
|
||||
return () => {
|
||||
stopAuthSync();
|
||||
stopMprisSync();
|
||||
stopDiscordPresence();
|
||||
stopEngineListeners();
|
||||
stopRadioMprisMetadata();
|
||||
stopEqDeviceSync();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Backfill state: two parallel maps that retry the per-track loudness
|
||||
* analysis a bounded number of times. The interesting behaviours are the
|
||||
* `markBackfillInFlight` atomicity (both flag + counter bump in one call)
|
||||
* and the reseed reset that expands across the `stream:` / bare id forms
|
||||
* via `loudnessCacheStateKeysForTrackId` (re-used from loudnessGainCache).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
|
||||
_resetBackfillStateForTest,
|
||||
clearBackfillInFlight,
|
||||
getBackfillAttempts,
|
||||
isBackfillInFlight,
|
||||
markBackfillInFlight,
|
||||
resetBackfillAttempts,
|
||||
resetLoudnessBackfillStateForTrackId,
|
||||
} from '@/features/playback/store/loudnessBackfillState';
|
||||
|
||||
afterEach(() => {
|
||||
_resetBackfillStateForTest();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('reports no inflight + 0 attempts for unknown tracks', () => {
|
||||
expect(isBackfillInFlight('t1')).toBe(false);
|
||||
expect(getBackfillAttempts('t1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markBackfillInFlight', () => {
|
||||
it('atomically sets inflight flag and counter', () => {
|
||||
markBackfillInFlight('t1', 1);
|
||||
expect(isBackfillInFlight('t1')).toBe(true);
|
||||
expect(getBackfillAttempts('t1')).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps tracks independent', () => {
|
||||
markBackfillInFlight('a', 1);
|
||||
markBackfillInFlight('b', 2);
|
||||
expect(getBackfillAttempts('a')).toBe(1);
|
||||
expect(getBackfillAttempts('b')).toBe(2);
|
||||
clearBackfillInFlight('a');
|
||||
expect(isBackfillInFlight('a')).toBe(false);
|
||||
expect(isBackfillInFlight('b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearBackfillInFlight', () => {
|
||||
it('clears the flag without touching the counter', () => {
|
||||
markBackfillInFlight('t1', 1);
|
||||
clearBackfillInFlight('t1');
|
||||
expect(isBackfillInFlight('t1')).toBe(false);
|
||||
expect(getBackfillAttempts('t1')).toBe(1); // counter preserved
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetBackfillAttempts', () => {
|
||||
it('zeros the counter without touching the inflight flag', () => {
|
||||
markBackfillInFlight('t1', 2);
|
||||
resetBackfillAttempts('t1');
|
||||
expect(getBackfillAttempts('t1')).toBe(0);
|
||||
expect(isBackfillInFlight('t1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_BACKFILL_ATTEMPTS_PER_TRACK', () => {
|
||||
it('is the hard-coded threshold the runtime uses', () => {
|
||||
expect(MAX_BACKFILL_ATTEMPTS_PER_TRACK).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetLoudnessBackfillStateForTrackId', () => {
|
||||
it('clears both maps for both id forms (bare + stream:)', () => {
|
||||
markBackfillInFlight('t1', 1);
|
||||
markBackfillInFlight('stream:t1', 2);
|
||||
resetLoudnessBackfillStateForTrackId('t1');
|
||||
expect(isBackfillInFlight('t1')).toBe(false);
|
||||
expect(isBackfillInFlight('stream:t1')).toBe(false);
|
||||
expect(getBackfillAttempts('t1')).toBe(0);
|
||||
expect(getBackfillAttempts('stream:t1')).toBe(0);
|
||||
});
|
||||
|
||||
it('also works when invoked with the stream-prefixed form', () => {
|
||||
markBackfillInFlight('t1', 1);
|
||||
markBackfillInFlight('stream:t1', 2);
|
||||
resetLoudnessBackfillStateForTrackId('stream:t1');
|
||||
expect(getBackfillAttempts('t1')).toBe(0);
|
||||
expect(getBackfillAttempts('stream:t1')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { loudnessCacheStateKeysForTrackId } from '@/features/playback/store/loudnessGainCache';
|
||||
|
||||
/**
|
||||
* Bounded retry state for the per-track loudness backfill: each `refresh:miss`
|
||||
* for a track in loudness mode enqueues an `analysis_enqueue_seed_from_url`
|
||||
* job, but only if (a) no enqueue is already inflight for that id and
|
||||
* (b) the per-track attempt counter is below `MAX_BACKFILL_ATTEMPTS_PER_TRACK`.
|
||||
* A `refresh:hit` resets the counter so the next miss starts fresh.
|
||||
*
|
||||
* Both maps stay keyed by the raw track id passed by the caller — the
|
||||
* `loudnessCacheStateKeysForTrackId` expansion only matters when clearing
|
||||
* during a reseed (`resetLoudnessBackfillStateForTrackId`).
|
||||
*/
|
||||
|
||||
export const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
|
||||
|
||||
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
|
||||
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
|
||||
|
||||
export function isBackfillInFlight(trackId: string): boolean {
|
||||
return Boolean(analysisBackfillInFlightByTrackId[trackId]);
|
||||
}
|
||||
|
||||
export function getBackfillAttempts(trackId: string): number {
|
||||
return analysisBackfillAttemptsByTrackId[trackId] ?? 0;
|
||||
}
|
||||
|
||||
/** Atomic: flag the track inflight AND bump the attempt counter to `nextAttempt`. */
|
||||
export function markBackfillInFlight(trackId: string, nextAttempt: number): void {
|
||||
analysisBackfillInFlightByTrackId[trackId] = true;
|
||||
analysisBackfillAttemptsByTrackId[trackId] = nextAttempt;
|
||||
}
|
||||
|
||||
/** Clear the inflight flag (called from the `.finally` of the enqueue promise). */
|
||||
export function clearBackfillInFlight(trackId: string): void {
|
||||
delete analysisBackfillInFlightByTrackId[trackId];
|
||||
}
|
||||
|
||||
/** Reset the attempt counter to 0 — called after a `refresh:hit`. */
|
||||
export function resetBackfillAttempts(trackId: string): void {
|
||||
analysisBackfillAttemptsByTrackId[trackId] = 0;
|
||||
}
|
||||
|
||||
/** Full reset for both maps across the bare + `stream:` id forms — used during a reseed. */
|
||||
export function resetLoudnessBackfillStateForTrackId(trackId: string): void {
|
||||
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
||||
delete analysisBackfillInFlightByTrackId[k];
|
||||
analysisBackfillAttemptsByTrackId[k] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: wipe both maps so each spec starts clean. */
|
||||
export function _resetBackfillStateForTest(): void {
|
||||
for (const k of Object.keys(analysisBackfillInFlightByTrackId)) delete analysisBackfillInFlightByTrackId[k];
|
||||
for (const k of Object.keys(analysisBackfillAttemptsByTrackId)) delete analysisBackfillAttemptsByTrackId[k];
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Pure functions over a queue slice: the "is this id inside the prefetch
|
||||
* window?" check and the "give me the window's id list" collector. Window
|
||||
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
|
||||
* duplicates collapsed.
|
||||
*/
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||
collectLoudnessBackfillWindowTrackIds,
|
||||
isTrackInsideLoudnessBackfillWindow,
|
||||
} from '@/features/playback/store/loudnessBackfillWindow';
|
||||
|
||||
function track(id: string): Track {
|
||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||
}
|
||||
|
||||
// Thin-state: the window functions take queue refs; the currentTrack arg stays a Track.
|
||||
function ref(id: string): QueueItemRef {
|
||||
return { serverId: 's', trackId: id };
|
||||
}
|
||||
|
||||
const big = Array.from({ length: 12 }, (_, i) => ref(`t${i}`));
|
||||
|
||||
describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
|
||||
it('is the value the runtime expects', () => {
|
||||
expect(LOUDNESS_BACKFILL_WINDOW_AHEAD).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTrackInsideLoudnessBackfillWindow', () => {
|
||||
it('matches the current track unconditionally', () => {
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, track('t0'))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches an id inside the ahead window', () => {
|
||||
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit.
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, track('t0'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an id beyond the ahead window', () => {
|
||||
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside.
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, track('t0'))).toBe(false);
|
||||
});
|
||||
|
||||
it('window slides with queueIndex', () => {
|
||||
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not.
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, track('t4'))).toBe(true);
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty queue', () => {
|
||||
expect(isTrackInsideLoudnessBackfillWindow('t1', [], 0, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty trackId', () => {
|
||||
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, track('t0'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when currentTrack is null and id is not in the queue window', () => {
|
||||
expect(isTrackInsideLoudnessBackfillWindow('missing', big, 0, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLoudnessBackfillWindowTrackIds', () => {
|
||||
it('returns current + next 5 entries', () => {
|
||||
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, track('t0'));
|
||||
expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
|
||||
});
|
||||
|
||||
it('clamps the window to the end of the queue', () => {
|
||||
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, track('t9'));
|
||||
// queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
|
||||
expect(ids).toEqual(['t9', 't10', 't11']);
|
||||
});
|
||||
|
||||
it('omits the current track when null', () => {
|
||||
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, null);
|
||||
expect(ids).toEqual(['t1', 't2', 't3', 't4', 't5']);
|
||||
});
|
||||
|
||||
it('deduplicates when currentTrack is also in the ahead window', () => {
|
||||
const queue = [ref('a'), ref('b'), ref('a'), ref('c')];
|
||||
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a'));
|
||||
expect(ids).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns just the current track for an empty queue', () => {
|
||||
expect(collectLoudnessBackfillWindowTrackIds([], 0, track('only'))).toEqual(['only']);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing is playing and the queue is empty', () => {
|
||||
expect(collectLoudnessBackfillWindowTrackIds([], 0, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
/**
|
||||
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
|
||||
* warms the loudness cache for the current track + the next N entries so
|
||||
* the engine's `audio_chain_preload` sees a real cached gain instead of
|
||||
* the startup trim. These helpers compute that window — both as a
|
||||
* "does this id sit inside it?" predicate and as the explicit id list
|
||||
* the prefetch loop iterates over.
|
||||
*
|
||||
* Pure functions of the state slice — no store imports, no side effects.
|
||||
* The caller passes the queue + index + current track so the test surface
|
||||
* stays trivial and there's no top-level coupling back to playerStore.
|
||||
*/
|
||||
export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5;
|
||||
|
||||
export function isTrackInsideLoudnessBackfillWindow(
|
||||
trackId: string,
|
||||
queue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
currentTrack: Track | null,
|
||||
): boolean {
|
||||
if (!trackId) return false;
|
||||
if (currentTrack?.id === trackId) return true;
|
||||
if (queue.length === 0) return false;
|
||||
const start = Math.max(0, queueIndex + 1);
|
||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||
for (let i = start; i < end; i++) {
|
||||
if (queue[i]?.trackId === trackId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectLoudnessBackfillWindowTrackIds(
|
||||
queue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
currentTrack: Track | null,
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
if (currentTrack?.id) ids.add(currentTrack.id);
|
||||
const start = Math.max(0, queueIndex + 1);
|
||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||
for (let i = start; i < end; i++) {
|
||||
const tid = queue[i]?.trackId;
|
||||
if (tid) ids.add(tid);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
/** Next ~5 queue neighbours for middle-tier analysis priority hints. */
|
||||
export function collectPlaybackMiddlePriorityTrackIds(
|
||||
queue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
currentTrack: Track | null,
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
const start = Math.max(0, queueIndex + 1);
|
||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||
for (let i = start; i < end; i++) {
|
||||
const tid = queue[i]?.trackId;
|
||||
if (tid && tid !== currentTrack?.id) ids.add(tid);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function loudnessBackfillPriorityForTrack(
|
||||
trackId: string,
|
||||
queue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
currentTrack: Track | null,
|
||||
): 'high' | 'middle' | 'low' {
|
||||
if (currentTrack?.id === trackId) return 'high';
|
||||
const start = Math.max(0, queueIndex + 1);
|
||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||
for (let i = start; i < end; i++) {
|
||||
if (queue[i]?.trackId === trackId) return 'middle';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Loudness-gain cache encapsulates two parallel maps and a small API that
|
||||
* playerStore drives from the audio-event handlers + cache refresh path.
|
||||
* The interesting behaviours: (a) stable-flag gating in
|
||||
* `loudnessGainDbForEngineBind` (partial values are silently invisible to
|
||||
* engine bind), (b) `clearLoudnessCacheStateForTrackId` expands across the
|
||||
* `stream:` prefix while `forgetLoudnessGain` does NOT (preserves the
|
||||
* existing direct-delete semantics from playerStore).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { authState } = vi.hoisted(() => ({
|
||||
authState: {
|
||||
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
|
||||
replayGainEnabled: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: { getState: () => authState },
|
||||
}));
|
||||
|
||||
import {
|
||||
_resetLoudnessGainCacheForTest,
|
||||
clearLoudnessCacheStateForTrackId,
|
||||
forgetLoudnessGain,
|
||||
getCachedLoudnessGain,
|
||||
hasStableLoudness,
|
||||
isReplayGainActive,
|
||||
loudnessCacheStateKeysForTrackId,
|
||||
loudnessGainDbForEngineBind,
|
||||
markLoudnessStable,
|
||||
setCachedLoudnessGain,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
|
||||
beforeEach(() => {
|
||||
authState.normalizationEngine = 'off';
|
||||
authState.replayGainEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetLoudnessGainCacheForTest();
|
||||
});
|
||||
|
||||
describe('loudnessCacheStateKeysForTrackId', () => {
|
||||
it('returns bare + stream-prefixed form for a bare id', () => {
|
||||
expect(loudnessCacheStateKeysForTrackId('abc')).toEqual(['abc', 'stream:abc']);
|
||||
});
|
||||
|
||||
it('returns stream-prefixed + bare form for a stream id', () => {
|
||||
expect(loudnessCacheStateKeysForTrackId('stream:abc')).toEqual(['stream:abc', 'abc']);
|
||||
});
|
||||
|
||||
it('returns empty for an empty id', () => {
|
||||
expect(loudnessCacheStateKeysForTrackId('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only the stream-prefixed form when the bare portion is empty', () => {
|
||||
expect(loudnessCacheStateKeysForTrackId('stream:')).toEqual(['stream:']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCachedLoudnessGain / setCachedLoudnessGain', () => {
|
||||
it('round-trips a value through the cache', () => {
|
||||
setCachedLoudnessGain('t1', -7.2);
|
||||
expect(getCachedLoudnessGain('t1')).toBe(-7.2);
|
||||
});
|
||||
|
||||
it('returns undefined for missing entries', () => {
|
||||
expect(getCachedLoudnessGain('missing')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasStableLoudness / markLoudnessStable', () => {
|
||||
it('flags as stable only after markLoudnessStable', () => {
|
||||
setCachedLoudnessGain('t1', -7);
|
||||
expect(hasStableLoudness('t1')).toBe(false);
|
||||
markLoudnessStable('t1', -7);
|
||||
expect(hasStableLoudness('t1')).toBe(true);
|
||||
});
|
||||
|
||||
it('markLoudnessStable writes the cached value atomically', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
expect(getCachedLoudnessGain('t1')).toBe(-5);
|
||||
expect(hasStableLoudness('t1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgetLoudnessGain (single-key delete)', () => {
|
||||
it('clears both maps for the literal id only — does not touch the other form', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
markLoudnessStable('stream:t1', -6);
|
||||
forgetLoudnessGain('t1');
|
||||
expect(getCachedLoudnessGain('t1')).toBeUndefined();
|
||||
expect(hasStableLoudness('t1')).toBe(false);
|
||||
// Stream form must still be there — forget is intentionally narrow.
|
||||
expect(getCachedLoudnessGain('stream:t1')).toBe(-6);
|
||||
expect(hasStableLoudness('stream:t1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearLoudnessCacheStateForTrackId (two-form delete)', () => {
|
||||
it('clears both maps for both id forms', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
markLoudnessStable('stream:t1', -6);
|
||||
clearLoudnessCacheStateForTrackId('t1');
|
||||
expect(getCachedLoudnessGain('t1')).toBeUndefined();
|
||||
expect(getCachedLoudnessGain('stream:t1')).toBeUndefined();
|
||||
expect(hasStableLoudness('t1')).toBe(false);
|
||||
expect(hasStableLoudness('stream:t1')).toBe(false);
|
||||
});
|
||||
|
||||
it('also works when invoked with the stream-prefixed form', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
markLoudnessStable('stream:t1', -6);
|
||||
clearLoudnessCacheStateForTrackId('stream:t1');
|
||||
expect(getCachedLoudnessGain('t1')).toBeUndefined();
|
||||
expect(getCachedLoudnessGain('stream:t1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loudnessGainDbForEngineBind', () => {
|
||||
it('returns null without a stable flag (partial/placeholder values are hidden from engine bind)', () => {
|
||||
setCachedLoudnessGain('t1', -5);
|
||||
expect(loudnessGainDbForEngineBind('t1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the cached value once the entry is stable', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
expect(loudnessGainDbForEngineBind('t1')).toBe(-5);
|
||||
});
|
||||
|
||||
it('returns null when the cached value is non-finite', () => {
|
||||
markLoudnessStable('t1', Number.NaN);
|
||||
expect(loudnessGainDbForEngineBind('t1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null / empty trackId input', () => {
|
||||
expect(loudnessGainDbForEngineBind(null)).toBeNull();
|
||||
expect(loudnessGainDbForEngineBind(undefined)).toBeNull();
|
||||
expect(loudnessGainDbForEngineBind('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReplayGainActive', () => {
|
||||
it('is false when normalization engine is off', () => {
|
||||
authState.normalizationEngine = 'off';
|
||||
authState.replayGainEnabled = true;
|
||||
expect(isReplayGainActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when engine is replaygain but flag is disabled', () => {
|
||||
authState.normalizationEngine = 'replaygain';
|
||||
authState.replayGainEnabled = false;
|
||||
expect(isReplayGainActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true only when engine is replaygain AND the flag is enabled', () => {
|
||||
authState.normalizationEngine = 'replaygain';
|
||||
authState.replayGainEnabled = true;
|
||||
expect(isReplayGainActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when engine is loudness (different normalization mode)', () => {
|
||||
authState.normalizationEngine = 'loudness';
|
||||
authState.replayGainEnabled = true;
|
||||
expect(isReplayGainActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resetLoudnessGainCacheForTest', () => {
|
||||
it('wipes both maps', () => {
|
||||
markLoudnessStable('t1', -5);
|
||||
markLoudnessStable('t2', -6);
|
||||
_resetLoudnessGainCacheForTest();
|
||||
expect(getCachedLoudnessGain('t1')).toBeUndefined();
|
||||
expect(getCachedLoudnessGain('t2')).toBeUndefined();
|
||||
expect(hasStableLoudness('t1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/**
|
||||
* In-memory cache of the per-track loudness normalization gain (dB). Two
|
||||
* parallel maps:
|
||||
*
|
||||
* - `cachedLoudnessGainByTrackId` — the dB value last computed (from an
|
||||
* `analysis_get_loudness_for_track` row, a partial-loudness event, or
|
||||
* a placeholder-until-cache value).
|
||||
* - `stableLoudnessGainByTrackId` — `true` once the value has been
|
||||
* promoted to the final cached/analysis-confirmed form. Engine bind
|
||||
* only trusts entries flagged stable; partial / placeholder values
|
||||
* deliberately omit the flag so Rust uses its pre-trim default until
|
||||
* the analysis catches up.
|
||||
*
|
||||
* Keys can land in either the bare Subsonic id form or the `stream:`
|
||||
* prefixed form depending on which event surface wrote the entry —
|
||||
* `loudnessCacheStateKeysForTrackId` returns the two forms a caller may
|
||||
* need to look up or clear together.
|
||||
*/
|
||||
|
||||
const cachedLoudnessGainByTrackId: Record<string, number> = {};
|
||||
const stableLoudnessGainByTrackId: Record<string, true> = {};
|
||||
|
||||
/** Returns the two-form key list (bare id + `stream:<id>`) for paired lookups. */
|
||||
export function loudnessCacheStateKeysForTrackId(trackId: string): string[] {
|
||||
if (!trackId) return [];
|
||||
const out: string[] = [trackId];
|
||||
if (trackId.startsWith('stream:')) {
|
||||
const bare = trackId.slice('stream:'.length);
|
||||
if (bare) out.push(bare);
|
||||
} else {
|
||||
out.push(`stream:${trackId}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getCachedLoudnessGain(trackId: string): number | undefined {
|
||||
return cachedLoudnessGainByTrackId[trackId];
|
||||
}
|
||||
|
||||
export function setCachedLoudnessGain(trackId: string, gainDb: number): void {
|
||||
cachedLoudnessGainByTrackId[trackId] = gainDb;
|
||||
}
|
||||
|
||||
export function hasStableLoudness(trackId: string): boolean {
|
||||
return Boolean(stableLoudnessGainByTrackId[trackId]);
|
||||
}
|
||||
|
||||
/** Atomic: write the cached value AND mark it stable (analysis-confirmed). */
|
||||
export function markLoudnessStable(trackId: string, gainDb: number): void {
|
||||
cachedLoudnessGainByTrackId[trackId] = gainDb;
|
||||
stableLoudnessGainByTrackId[trackId] = true;
|
||||
}
|
||||
|
||||
/** Drop both maps for the literal track id (no stream-form expansion). */
|
||||
export function forgetLoudnessGain(trackId: string): void {
|
||||
delete cachedLoudnessGainByTrackId[trackId];
|
||||
delete stableLoudnessGainByTrackId[trackId];
|
||||
}
|
||||
|
||||
/** Drop both maps for each form of the track id (bare + `stream:<id>`). */
|
||||
export function clearLoudnessCacheStateForTrackId(trackId: string): void {
|
||||
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
||||
delete cachedLoudnessGainByTrackId[k];
|
||||
delete stableLoudnessGainByTrackId[k];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit
|
||||
* partial hints so Rust uses pre-trim until `analysis:loudness-partial` +
|
||||
* `audio_update_replay_gain`.
|
||||
*/
|
||||
export function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null {
|
||||
if (!trackId) return null;
|
||||
if (!stableLoudnessGainByTrackId[trackId]) return null;
|
||||
const v = cachedLoudnessGainByTrackId[trackId];
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/** True when ReplayGain is selected AND user has it enabled in Settings. */
|
||||
export function isReplayGainActive(): boolean {
|
||||
const a = useAuthStore.getState();
|
||||
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
|
||||
}
|
||||
|
||||
/** Test-only: wipe both maps so each spec starts clean. */
|
||||
export function _resetLoudnessGainCacheForTest(): void {
|
||||
for (const k of Object.keys(cachedLoudnessGainByTrackId)) delete cachedLoudnessGainByTrackId[k];
|
||||
for (const k of Object.keys(stableLoudnessGainByTrackId)) delete stableLoudnessGainByTrackId[k];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `prefetchLoudnessForEnqueuedTracks` warms the loudness cache for the
|
||||
* current + next-N tracks after a bulk enqueue. Tests pin the engine
|
||||
* guard, the window collection, and the no-sync-engine flag on each
|
||||
* refresh call.
|
||||
*/
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
|
||||
const player = { currentTrack: null as Track | null };
|
||||
return {
|
||||
auth,
|
||||
player,
|
||||
refreshMock: vi.fn(async () => undefined),
|
||||
collectMock: vi.fn((_q: QueueItemRef[], _i: number, _c: Track | null): string[] => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: { getState: () => hoisted.player },
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessRefresh', () => ({
|
||||
refreshLoudnessForTrack: hoisted.refreshMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessBackfillWindow', () => ({
|
||||
collectLoudnessBackfillWindowTrackIds: hoisted.collectMock,
|
||||
}));
|
||||
|
||||
import { prefetchLoudnessForEnqueuedTracks } from '@/features/playback/store/loudnessPrefetch';
|
||||
|
||||
function track(id: string): Track {
|
||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||
}
|
||||
|
||||
// Thin-state: prefetchLoudnessForEnqueuedTracks takes queue refs now.
|
||||
function ref(id: string): QueueItemRef {
|
||||
return { serverId: 's', trackId: id };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.auth.normalizationEngine = 'loudness';
|
||||
hoisted.player.currentTrack = null;
|
||||
hoisted.refreshMock.mockClear();
|
||||
hoisted.collectMock.mockReset();
|
||||
hoisted.collectMock.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('prefetchLoudnessForEnqueuedTracks', () => {
|
||||
it("is a no-op when engine isn't loudness", () => {
|
||||
hoisted.auth.normalizationEngine = 'off';
|
||||
hoisted.collectMock.mockReturnValueOnce(['t1']);
|
||||
prefetchLoudnessForEnqueuedTracks([ref('t1')], 0);
|
||||
expect(hoisted.refreshMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.collectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
|
||||
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']);
|
||||
prefetchLoudnessForEnqueuedTracks([ref('t1'), ref('t2'), ref('t3')], 0);
|
||||
expect(hoisted.refreshMock).toHaveBeenCalledTimes(3);
|
||||
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
|
||||
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false });
|
||||
expect(hoisted.refreshMock).toHaveBeenCalledWith('t3', { syncPlayingEngine: false });
|
||||
});
|
||||
|
||||
it('passes the queue + currentTrack through to the window collector', () => {
|
||||
hoisted.player.currentTrack = track('cur');
|
||||
const q = [ref('cur'), ref('next')];
|
||||
prefetchLoudnessForEnqueuedTracks(q, 0);
|
||||
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
|
||||
});
|
||||
|
||||
it('handles empty window list gracefully', () => {
|
||||
hoisted.collectMock.mockReturnValueOnce([]);
|
||||
prefetchLoudnessForEnqueuedTracks([], 0);
|
||||
expect(hoisted.refreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { collectLoudnessBackfillWindowTrackIds } from '@/features/playback/store/loudnessBackfillWindow';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
/**
|
||||
* After a bulk enqueue (queue replace, append-many, lucky-mix) warm the
|
||||
* loudness cache for the current track + the next N entries so the
|
||||
* gapless `audio_chain_preload` payload sees a real cached gain instead
|
||||
* of the startup trim. No-op when normalization isn't on `loudness` —
|
||||
* other engines don't need the cache populated proactively.
|
||||
*
|
||||
* Calls don't sync the playing engine (`syncPlayingEngine: false`) — the
|
||||
* already-playing track is unaffected; we're only filling the cache for
|
||||
* the upcoming ones.
|
||||
*/
|
||||
export function prefetchLoudnessForEnqueuedTracks(
|
||||
mergedQueue: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): void {
|
||||
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
|
||||
const currentTrack = usePlayerStore.getState().currentTrack;
|
||||
const ids = collectLoudnessBackfillWindowTrackIds(mergedQueue, queueIndex, currentTrack);
|
||||
for (const id of ids) {
|
||||
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* `refreshLoudnessForTrack` orchestrates the loudness analysis fetch:
|
||||
* coalesce concurrent calls, distinguish hit vs miss, enqueue backfill
|
||||
* within bounds, suppress stale-target results. The individual helpers
|
||||
* (cache, backfill state, window predicate, debug emit) are tested in
|
||||
* their own modules — this file pins the orchestration only.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const auth = {
|
||||
loudnessTargetLufs: -14,
|
||||
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
|
||||
};
|
||||
const playerState = {
|
||||
queue: [] as Array<{ id: string }>,
|
||||
queueIndex: 0,
|
||||
currentTrack: null as { id: string } | null,
|
||||
updateReplayGainForCurrentTrack: vi.fn(),
|
||||
};
|
||||
return {
|
||||
auth,
|
||||
playerState,
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
|
||||
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
redactMock: vi.fn((s: string) => s),
|
||||
playerSetStateMock: vi.fn(),
|
||||
emitDebugMock: vi.fn(),
|
||||
forgetLoudnessMock: vi.fn(),
|
||||
markLoudnessStableMock: vi.fn(),
|
||||
getBackfillAttemptsMock: vi.fn(() => 0),
|
||||
isBackfillInFlightMock: vi.fn(() => false),
|
||||
markBackfillInFlightMock: vi.fn(),
|
||||
clearBackfillInFlightMock: vi.fn(),
|
||||
resetBackfillAttemptsMock: vi.fn(),
|
||||
isTrackInsideWindowMock: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('@/lib/api/subsonicStreamUrl', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
|
||||
vi.mock('@/utils/server/redactSubsonicUrl', () => ({ redactSubsonicUrlForLog: hoisted.redactMock }));
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerState,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/features/playback/store/normalizationDebug', () => ({ emitNormalizationDebug: hoisted.emitDebugMock }));
|
||||
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
|
||||
forgetLoudnessGain: hoisted.forgetLoudnessMock,
|
||||
markLoudnessStable: hoisted.markLoudnessStableMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessBackfillState', () => ({
|
||||
MAX_BACKFILL_ATTEMPTS_PER_TRACK: 2,
|
||||
clearBackfillInFlight: hoisted.clearBackfillInFlightMock,
|
||||
getBackfillAttempts: hoisted.getBackfillAttemptsMock,
|
||||
isBackfillInFlight: hoisted.isBackfillInFlightMock,
|
||||
markBackfillInFlight: hoisted.markBackfillInFlightMock,
|
||||
resetBackfillAttempts: hoisted.resetBackfillAttemptsMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessBackfillWindow', () => ({
|
||||
LOUDNESS_BACKFILL_WINDOW_AHEAD: 5,
|
||||
isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock,
|
||||
loudnessBackfillPriorityForTrack: vi.fn(() => 'middle'),
|
||||
}));
|
||||
|
||||
import {
|
||||
_resetLoudnessRefreshInflightForTest,
|
||||
refreshLoudnessForTrack,
|
||||
} from '@/features/playback/store/loudnessRefresh';
|
||||
|
||||
beforeEach(() => {
|
||||
_resetLoudnessRefreshInflightForTest();
|
||||
hoisted.auth.loudnessTargetLufs = -14;
|
||||
hoisted.auth.normalizationEngine = 'loudness';
|
||||
hoisted.playerState.queue = [];
|
||||
hoisted.playerState.queueIndex = 0;
|
||||
hoisted.playerState.currentTrack = null;
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(null);
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.emitDebugMock.mockClear();
|
||||
hoisted.forgetLoudnessMock.mockClear();
|
||||
hoisted.markLoudnessStableMock.mockClear();
|
||||
hoisted.markBackfillInFlightMock.mockClear();
|
||||
hoisted.clearBackfillInFlightMock.mockClear();
|
||||
hoisted.resetBackfillAttemptsMock.mockClear();
|
||||
hoisted.getBackfillAttemptsMock.mockReset();
|
||||
hoisted.getBackfillAttemptsMock.mockReturnValue(0);
|
||||
hoisted.isBackfillInFlightMock.mockReset();
|
||||
hoisted.isBackfillInFlightMock.mockReturnValue(false);
|
||||
hoisted.isTrackInsideWindowMock.mockReset();
|
||||
hoisted.isTrackInsideWindowMock.mockReturnValue(true);
|
||||
hoisted.playerState.updateReplayGainForCurrentTrack = vi.fn();
|
||||
});
|
||||
|
||||
describe('refreshLoudnessForTrack', () => {
|
||||
it('is a no-op for empty trackId', async () => {
|
||||
await refreshLoudnessForTrack('');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coalesces concurrent calls for the same key into one inflight promise', async () => {
|
||||
hoisted.invokeMock.mockResolvedValue(null);
|
||||
const p1 = refreshLoudnessForTrack('t1');
|
||||
const p2 = refreshLoudnessForTrack('t1');
|
||||
await Promise.all([p1, p2]);
|
||||
// One analysis_get_loudness_for_track call shared between both awaiters.
|
||||
const getCalls = hoisted.invokeMock.mock.calls.filter(c => c[0] === 'analysis_get_loudness_for_track');
|
||||
expect(getCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks loudness stable on a hit row', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 123 });
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.markLoudnessStableMock).toHaveBeenCalledWith('t1', -7);
|
||||
expect(hoisted.resetBackfillAttemptsMock).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('forgets the cached value on a miss row', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('enqueues a backfill when conditions are met (loudness engine, not inflight, attempts < max, in window)', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.markBackfillInFlightMock).toHaveBeenCalledWith('t1', 1);
|
||||
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
|
||||
expect(enqueueCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips backfill when outside the prefetch window', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
hoisted.isTrackInsideWindowMock.mockReturnValueOnce(false);
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
|
||||
expect(enqueueCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips backfill when attempts already at max', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
hoisted.getBackfillAttemptsMock.mockReturnValueOnce(2);
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||
const throttledCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'backfill:throttled');
|
||||
expect(throttledCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skips backfill when already inflight', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
hoisted.isBackfillInFlightMock.mockReturnValueOnce(true);
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards results and retries when the LUFS target changes mid-flight', async () => {
|
||||
hoisted.invokeMock.mockImplementationOnce(async () => {
|
||||
hoisted.auth.loudnessTargetLufs = -10; // target changes during await
|
||||
return { recommendedGainDb: -5, targetLufs: -14, updatedAt: 1 };
|
||||
});
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null); // retry returns miss
|
||||
await refreshLoudnessForTrack('t1');
|
||||
// markLoudnessStable should NOT have been called from the first invocation —
|
||||
// result is discarded because target changed.
|
||||
expect(hoisted.markLoudnessStableMock).not.toHaveBeenCalled();
|
||||
const staleCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:stale-target');
|
||||
expect(staleCalls).toHaveLength(1);
|
||||
// Drain pending recursive retries spawned via `void refreshLoudnessForTrack(...)`
|
||||
// so they don't bleed into the next test's mock queue.
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
it('skips engine update when syncPlayingEngine is false', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
|
||||
await refreshLoudnessForTrack('t1', { syncPlayingEngine: false });
|
||||
expect(hoisted.playerState.updateReplayGainForCurrentTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateReplayGainForCurrentTrack by default on hit', async () => {
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.playerState.updateReplayGainForCurrentTrack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forgets cache + emits refresh:error on a thrown invoke', async () => {
|
||||
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust busy'));
|
||||
await refreshLoudnessForTrack('t1');
|
||||
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
|
||||
const errCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:error');
|
||||
expect(errCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { redactSubsonicUrlForLog } from '@/utils/server/redactSubsonicUrl';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import {
|
||||
forgetLoudnessGain,
|
||||
markLoudnessStable,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
import {
|
||||
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
|
||||
clearBackfillInFlight,
|
||||
getBackfillAttempts,
|
||||
isBackfillInFlight,
|
||||
markBackfillInFlight,
|
||||
resetBackfillAttempts,
|
||||
} from '@/features/playback/store/loudnessBackfillState';
|
||||
import {
|
||||
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||
isTrackInsideLoudnessBackfillWindow,
|
||||
loudnessBackfillPriorityForTrack,
|
||||
} from '@/features/playback/store/loudnessBackfillWindow';
|
||||
|
||||
/** Subsonic-server loudness-cache row as Rust hands it back. */
|
||||
type LoudnessCachePayload = {
|
||||
integratedLufs: number;
|
||||
truePeak: number;
|
||||
recommendedGainDb: number;
|
||||
targetLufs: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode
|
||||
* pair. The `analysis:waveform-updated` listener fires refreshWaveform +
|
||||
* refreshLoudness in parallel for every full-track analysis completion;
|
||||
* without coalescing, gapless preload + current-track completion can
|
||||
* stack two SQLite reads + two state writes.
|
||||
*/
|
||||
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Fetch the loudness gain for `trackId` from Rust and apply it to the
|
||||
* loudness-gain cache + player-store debug fields. When `syncPlayingEngine`
|
||||
* is false (default true), the engine is NOT asked to update its
|
||||
* replay-gain — used when prefetching neighbour tracks.
|
||||
*
|
||||
* Coalesces by (trackId, syncEngine, target) so concurrent calls share a
|
||||
* single inflight promise.
|
||||
*/
|
||||
export async function refreshLoudnessForTrack(
|
||||
trackId: string,
|
||||
opts?: { syncPlayingEngine?: boolean },
|
||||
): Promise<void> {
|
||||
if (!trackId) return;
|
||||
const syncEngine = opts?.syncPlayingEngine !== false;
|
||||
const target = useAuthStore.getState().loudnessTargetLufs;
|
||||
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
|
||||
const existing = loudnessRefreshInflight.get(inflightKey);
|
||||
if (existing) return existing;
|
||||
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
|
||||
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
|
||||
loudnessRefreshInflight.set(inflightKey, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
|
||||
emitNormalizationDebug('refresh:start', { trackId });
|
||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
||||
try {
|
||||
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
|
||||
const serverId = getPlaybackIndexKey() || null;
|
||||
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
|
||||
trackId,
|
||||
targetLufs: requestedTarget,
|
||||
serverId,
|
||||
});
|
||||
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
|
||||
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
|
||||
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
|
||||
return;
|
||||
}
|
||||
if (!row || !Number.isFinite(row.recommendedGainDb)) {
|
||||
forgetLoudnessGain(trackId);
|
||||
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
|
||||
const auth = useAuthStore.getState();
|
||||
const attempts = getBackfillAttempts(trackId);
|
||||
if (auth.normalizationEngine === 'loudness'
|
||||
&& !isBackfillInFlight(trackId)
|
||||
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
||||
const live = usePlayerStore.getState();
|
||||
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queueItems, live.queueIndex, live.currentTrack)) {
|
||||
emitNormalizationDebug('backfill:skipped-outside-window', {
|
||||
trackId,
|
||||
queueIndex: live.queueIndex,
|
||||
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||
});
|
||||
return;
|
||||
}
|
||||
markBackfillInFlight(trackId, attempts + 1);
|
||||
const url = buildStreamUrl(trackId);
|
||||
const priority = loudnessBackfillPriorityForTrack(
|
||||
trackId,
|
||||
live.queueItems,
|
||||
live.queueIndex,
|
||||
live.currentTrack,
|
||||
);
|
||||
emitNormalizationDebug('backfill:enqueue', {
|
||||
trackId,
|
||||
url: redactSubsonicUrlForLog(url),
|
||||
attempt: attempts + 1,
|
||||
priority,
|
||||
});
|
||||
void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId, priority })
|
||||
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
|
||||
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
|
||||
.finally(() => {
|
||||
clearBackfillInFlight(trackId);
|
||||
});
|
||||
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
||||
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
normalizationDbgSource: 'refresh:miss',
|
||||
normalizationDbgTrackId: trackId,
|
||||
normalizationDbgCacheGainDb: null,
|
||||
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
|
||||
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
markLoudnessStable(trackId, row.recommendedGainDb);
|
||||
resetBackfillAttempts(trackId);
|
||||
emitNormalizationDebug('refresh:hit', { trackId, row });
|
||||
usePlayerStore.setState({
|
||||
normalizationDbgSource: 'refresh:hit',
|
||||
normalizationDbgTrackId: trackId,
|
||||
normalizationDbgCacheGainDb: row.recommendedGainDb,
|
||||
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
|
||||
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
|
||||
});
|
||||
if (syncEngine) {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
}
|
||||
} catch {
|
||||
forgetLoudnessGain(trackId);
|
||||
emitNormalizationDebug('refresh:error', { trackId });
|
||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: drop pending refresh promises so each spec starts clean. */
|
||||
export function _resetLoudnessRefreshInflightForTest(): void {
|
||||
loudnessRefreshInflight.clear();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one
|
||||
* track: invalidate waveform gen, clear loudness + backfill state, wipe
|
||||
* server rows, kick a forced seed. The orchestration is what's worth
|
||||
* pinning — each individual helper is already tested in its own module.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const authState = {
|
||||
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
|
||||
loudnessTargetLufs: -14,
|
||||
};
|
||||
const playerSnapshot: {
|
||||
currentTrack: { id: string } | null;
|
||||
updateReplayGainForCurrentTrack: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
currentTrack: null,
|
||||
updateReplayGainForCurrentTrack: vi.fn(),
|
||||
};
|
||||
return {
|
||||
authState,
|
||||
playerSnapshot,
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
|
||||
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
bumpWaveformRefreshGenMock: vi.fn(),
|
||||
clearLoudnessCacheMock: vi.fn(),
|
||||
resetBackfillStateMock: vi.fn(),
|
||||
playerSetStateMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('@/lib/api/subsonicStreamUrl', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerSnapshot,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/features/playback/store/waveformRefreshGen', () => ({
|
||||
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
|
||||
clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/loudnessBackfillState', () => ({
|
||||
resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock,
|
||||
}));
|
||||
|
||||
import { reseedLoudnessForTrackId } from '@/features/playback/store/loudnessReseed';
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.authState.normalizationEngine = 'loudness';
|
||||
hoisted.authState.loudnessTargetLufs = -14;
|
||||
hoisted.playerSnapshot.currentTrack = null;
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(undefined);
|
||||
hoisted.buildStreamUrlMock.mockClear();
|
||||
hoisted.bumpWaveformRefreshGenMock.mockClear();
|
||||
hoisted.clearLoudnessCacheMock.mockClear();
|
||||
hoisted.resetBackfillStateMock.mockClear();
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn();
|
||||
});
|
||||
|
||||
describe('reseedLoudnessForTrackId', () => {
|
||||
it('is a no-op for empty trackId', async () => {
|
||||
await reseedLoudnessForTrackId('');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when normalization engine isn't loudness", async () => {
|
||||
hoisted.authState.normalizationEngine = 'off';
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the full reseed pipeline in order', async () => {
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1');
|
||||
expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1');
|
||||
expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1');
|
||||
const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]);
|
||||
expect(invokeCalls).toEqual([
|
||||
'analysis_delete_waveform_for_track',
|
||||
'analysis_delete_loudness_for_track',
|
||||
'analysis_enqueue_seed_from_url',
|
||||
]);
|
||||
expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({
|
||||
trackId: 't1',
|
||||
url: 'https://mock/stream/t1',
|
||||
force: true,
|
||||
serverId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('blanks the seekbar only when the reseed target is the current track', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).toContainEqual({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('does NOT blank the seekbar when reseeding a different track', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).not.toContainEqual({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('resets live normalization-state to placeholder values', async () => {
|
||||
hoisted.authState.loudnessTargetLufs = -10;
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).toContainEqual({
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: -10,
|
||||
normalizationEngineLive: 'loudness',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues past errors in delete-waveform', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockRejectedValueOnce(new Error('waveform delete failed'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('continues past errors in delete-loudness', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('loudness delete failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('swallows the final enqueue-seed error', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('enqueue failed'));
|
||||
await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
|
||||
import { clearLoudnessCacheStateForTrackId } from '@/features/playback/store/loudnessGainCache';
|
||||
import { resetLoudnessBackfillStateForTrackId } from '@/features/playback/store/loudnessBackfillState';
|
||||
|
||||
/**
|
||||
* Tear down every cached piece of analysis for a track and re-enqueue a
|
||||
* forced reseed. Used by the Settings „Re-analyse this track" action.
|
||||
*
|
||||
* Sequence:
|
||||
* 1. Skip if loudness engine isn't active (re-analysis only makes sense
|
||||
* when normalization actually consumes the result).
|
||||
* 2. Invalidate the waveform refresh generation so any in-flight read
|
||||
* for this id is discarded, and blank the seekbar bins immediately if
|
||||
* the track is current.
|
||||
* 3. Wipe both loudness-cache maps (both forms of the id) + the backfill
|
||||
* retry state.
|
||||
* 4. Reset the live normalization-state to placeholder values so the UI
|
||||
* doesn't show stale dB until the new analysis lands.
|
||||
* 5. Delete the on-disk waveform + loudness rows via Rust.
|
||||
* 6. Re-issue `updateReplayGainForCurrentTrack` so the engine drops
|
||||
* whatever gain it was holding for this track.
|
||||
* 7. Enqueue a forced seed via `analysis_enqueue_seed_from_url`.
|
||||
*
|
||||
* Best-effort throughout — Rust errors are logged but never thrown.
|
||||
*/
|
||||
export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
|
||||
if (!trackId) return;
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.normalizationEngine !== 'loudness') return;
|
||||
bumpWaveformRefreshGen(trackId);
|
||||
if (usePlayerStore.getState().currentTrack?.id === trackId) {
|
||||
usePlayerStore.setState({ waveformBins: null });
|
||||
}
|
||||
clearLoudnessCacheStateForTrackId(trackId);
|
||||
resetLoudnessBackfillStateForTrackId(trackId);
|
||||
usePlayerStore.setState({
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: auth.loudnessTargetLufs,
|
||||
normalizationEngineLive: 'loudness',
|
||||
});
|
||||
const serverId = getPlaybackIndexKey() || null;
|
||||
try {
|
||||
await invoke('analysis_delete_waveform_for_track', { trackId, serverId });
|
||||
} catch (e) {
|
||||
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
|
||||
}
|
||||
try {
|
||||
await invoke('analysis_delete_loudness_for_track', { trackId, serverId });
|
||||
} catch (e) {
|
||||
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
|
||||
}
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
const url = buildStreamUrl(trackId);
|
||||
try {
|
||||
await invoke('analysis_enqueue_seed_from_url', {
|
||||
trackId,
|
||||
url,
|
||||
force: true,
|
||||
serverId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { applyServerPlayQueue } from '@/features/playback/store/applyServerPlayQueue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
bumpPlayGeneration,
|
||||
setIsAudioPaused,
|
||||
} from '@/features/playback/store/engineState';
|
||||
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
|
||||
import { reseedLoudnessForTrackId } from '@/features/playback/store/loudnessReseed';
|
||||
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
|
||||
import { shouldRebindPlaybackToHotCache } from '@/features/playback/store/playbackUrlRouting';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
|
||||
import {
|
||||
clearRadioReconnectTimer,
|
||||
playRadioStream,
|
||||
setRadioVolume,
|
||||
} from '@/features/playback/store/radioPlayer';
|
||||
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
|
||||
import { clearSeekDebounce } from '@/features/playback/store/seekDebounce';
|
||||
import {
|
||||
clearSeekFallbackRetry,
|
||||
setSeekFallbackVisualTarget,
|
||||
} from '@/features/playback/store/seekFallbackState';
|
||||
import { clearSeekTarget } from '@/features/playback/store/seekTargetState';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
/**
|
||||
* Heterogeneous "misc" cluster — seven small-to-medium actions that
|
||||
* don't fit the more focused factories (transport / queue / Last.fm /
|
||||
* UI state):
|
||||
*
|
||||
* - `playRadio` — switches the player into HTML5 radio mode (Rust
|
||||
* engine stopped, queue cleared, ICY stream resolved + played).
|
||||
* - `previous` — Subsonic-style back: restart current track if past
|
||||
* 3 s, otherwise jump to the previous queue index.
|
||||
* - `setVolume` — clamps + propagates to Rust engine and radio sink.
|
||||
* - `setProgress` — pure UI state update used by progress polling.
|
||||
* - `initializeFromServerQueue` — startup queue restore from
|
||||
* Navidrome's `getPlayQueue` endpoint.
|
||||
* - `reanalyzeLoudnessForTrack` — toast + reseed the loudness cache
|
||||
* for a single track.
|
||||
* - `reseedQueueForInstantMix` — replaces the queue with a single
|
||||
* track when "Instant Mix" is triggered on the currently-playing
|
||||
* song.
|
||||
*/
|
||||
export function createMiscActions(set: SetState, get: GetState): Pick<
|
||||
PlayerState,
|
||||
| 'playRadio'
|
||||
| 'previous'
|
||||
| 'setVolume'
|
||||
| 'setProgress'
|
||||
| 'initializeFromServerQueue'
|
||||
| 'reanalyzeLoudnessForTrack'
|
||||
| 'reseedQueueForInstantMix'
|
||||
> {
|
||||
return {
|
||||
playRadio: async (station) => {
|
||||
const { volume } = get();
|
||||
bumpPlayGeneration();
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
setIsAudioPaused(false);
|
||||
clearRadioReconnectTimer();
|
||||
clearPreloadingIds();
|
||||
clearSeekFallbackRetry();
|
||||
clearSeekDebounce(); clearSeekTarget();
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
||||
// to HTML5 <audio> — the browser cannot play playlist files directly.
|
||||
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
|
||||
.catch(() => station.streamUrl);
|
||||
const { replayGainFallbackDb } = useAuthStore.getState();
|
||||
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
|
||||
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
|
||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
set({ isPlaying: false, currentRadio: null });
|
||||
});
|
||||
set({
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
waveformBins: null,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
currentPlaybackSource: null,
|
||||
queueItems: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: true, // no scrobbling for radio
|
||||
});
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { queueItems, queueIndex, currentTrack } = get();
|
||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||
if (currentTime > 3) {
|
||||
// Restart current track from the beginning.
|
||||
const authState = useAuthStore.getState();
|
||||
const sid = authState.activeServerId ?? '';
|
||||
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
||||
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
|
||||
// No-arg queue: keep the canonical refs, restart in place.
|
||||
get().playTrack(currentTrack, undefined, true);
|
||||
return;
|
||||
}
|
||||
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||
set({ progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const prevIdx = queueIndex - 1;
|
||||
if (prevIdx >= 0 && queueItems[prevIdx]) {
|
||||
// Resolve the previous ref (resolver cache → placeholder); pass undefined
|
||||
// for the queue arg so playTrack just moves the index.
|
||||
get().playTrack(resolveQueueTrack(queueItems[prevIdx]), undefined, true, false, prevIdx);
|
||||
}
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||
setRadioVolume(clamped);
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
setProgress: (t, duration) => {
|
||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||
},
|
||||
|
||||
initializeFromServerQueue: async () => {
|
||||
const activeId = useAuthStore.getState().activeServerId;
|
||||
if (!activeId) return;
|
||||
await applyServerPlayQueue(activeId, { mode: 'startup' });
|
||||
},
|
||||
|
||||
reanalyzeLoudnessForTrack: async (trackId: string) => {
|
||||
try {
|
||||
showToast(i18n.t('queue.recalculatingLoudnessWaveform'), 2000, 'info');
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await reseedLoudnessForTrackId(trackId);
|
||||
},
|
||||
|
||||
reseedQueueForInstantMix: (track) => {
|
||||
const s = get();
|
||||
if (s.currentTrack?.id !== track.id) {
|
||||
get().playTrack(track, [track]);
|
||||
return;
|
||||
}
|
||||
pushQueueUndoFromGetter(get);
|
||||
const wasPlaying = s.isPlaying;
|
||||
const sid = s.queueServerId ?? '';
|
||||
if (sid) seedQueueResolver(sid, [track]);
|
||||
const newItems = toQueueItemRefs(sid, [track]);
|
||||
set({
|
||||
queueItems: newItems,
|
||||
queueIndex: 0,
|
||||
currentTrack: track,
|
||||
});
|
||||
syncUserQueueMutationToServer(newItems, track, s.currentTime);
|
||||
if (!wasPlaying) get().resume();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
/**
|
||||
* Loved-track actions routed through the Music Network runtime (enrichment
|
||||
* primary). `networkLovedCache` is keyed by `${title}::${artist}` (not track id)
|
||||
* so other queue rows showing the same song update too. `syncNetworkLovedTracks`
|
||||
* merges the primary's loved list with the local cache — local likes win.
|
||||
*
|
||||
* The love write itself is best-effort on the runtime; the cache update is
|
||||
* optimistic so the UI reflects the toggle immediately.
|
||||
*/
|
||||
export function createNetworkLoveActions(set: SetState, get: GetState): Pick<
|
||||
PlayerState,
|
||||
'toggleNetworkLove' | 'setNetworkLoved' | 'setNetworkLovedForSong' | 'syncNetworkLovedTracks'
|
||||
> {
|
||||
return {
|
||||
toggleNetworkLove: () => {
|
||||
const { currentTrack, networkLoved } = get();
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
if (!currentTrack || !runtime?.getEnrichmentPrimaryId()) return;
|
||||
const newLoved = !networkLoved;
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ networkLoved: newLoved, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: newLoved } }));
|
||||
void runtime.setTrackLoved({ title: currentTrack.title, artist: currentTrack.artist }, newLoved);
|
||||
},
|
||||
|
||||
setNetworkLoved: (v) => {
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ networkLoved: v, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v } }));
|
||||
} else {
|
||||
set({ networkLoved: v });
|
||||
}
|
||||
},
|
||||
|
||||
syncNetworkLovedTracks: async () => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
if (!runtime?.getEnrichmentPrimaryId()) return;
|
||||
const newCache = await runtime.syncLovedTracks();
|
||||
// Merge with existing cache (local likes take precedence).
|
||||
set(s => ({ networkLovedCache: { ...newCache, ...s.networkLovedCache } }));
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
|
||||
set({ networkLoved: loved });
|
||||
}
|
||||
},
|
||||
|
||||
setNetworkLovedForSong: (title, artist, v) => {
|
||||
const cacheKey = `${title}::${artist}`;
|
||||
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
|
||||
set(s => ({
|
||||
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v },
|
||||
...(isCurrentTrack ? { networkLoved: v } : {}),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user