Files
Psychotoxical-psysonic/src/features/playback/store/hotCacheStore.ts
T
Psychotoxical cb1a110afb 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.
2026-06-30 15:00:20 +02:00

115 lines
3.7 KiB
TypeScript

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);
},
}));