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:
Psychotoxical
2026-06-30 15:00:20 +02:00
parent 6651abbc6f
commit cb1a110afb
393 changed files with 1127 additions and 1127 deletions
+2 -2
View File
@@ -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 -4
View File
@@ -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[];
+4 -4
View File
@@ -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';
+2 -2
View File
@@ -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';
+2 -2
View File
@@ -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';
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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';
+3 -3
View File
@@ -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';
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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() {
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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';
+2 -2
View File
@@ -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 12 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 832 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 500600 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);
}
}
+180
View File
@@ -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