refactor(lib,playback): fold utils/cache+themes into lib; playbackScheduleFormat→playback (drains utils/format)

This commit is contained in:
Psychotoxical
2026-06-30 20:33:25 +02:00
parent 31d4a87401
commit 6becd57798
47 changed files with 69 additions and 69 deletions
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import { makeCache } from '@/utils/cache/nowPlayingCache';
import { makeCache } from '@/lib/cache/nowPlayingCache';
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
@@ -3,7 +3,7 @@ import { Moon, Pause, Play, Sunrise } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
@@ -26,7 +26,7 @@ import { LyricsPane } from '@/features/lyrics';
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
// ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
@@ -5,7 +5,7 @@ import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSo
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
import type { ArtistStats, TrackStats } from '@/music-network';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { makeCache } from '@/utils/cache/nowPlayingCache';
import { makeCache } from '@/lib/cache/nowPlayingCache';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/store/authStore';
import { useShallow } from 'zustand/react/shallow';
import type { TFunction } from 'i18next';
import { formatPlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { formatPlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
import { formatClockTime } from '@/lib/format/formatClockTime';
import {
isValidPlaybackSchedulePreviewTimestamp,
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { formatPlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps {
@@ -19,7 +19,7 @@ import { useRadioMetadata } from '@/features/radio';
import { useRadioMprisSync } from '@/features/radio';
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { coerceOpenArtistRefs } from '@/lib/api/openArtistRefs';
@@ -7,7 +7,7 @@ import { useAutodjTransitionUi } from '@/features/playback/store/autodjTransitio
import { usePreviewStore } from '@/features/playback/store/previewStore';
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
type RepeatMode = PlayerState['repeatMode'];
type PlayPauseBind = ReturnType<typeof usePlaybackDelayPress>['playPauseBind'];
@@ -8,7 +8,7 @@ import {
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from '@/features/playback/store/libraryPlaybackHint';
import {
playListenSessionFinalize,
@@ -1,6 +1,6 @@
import type { Track } from '@/lib/media/trackTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
@@ -1,7 +1,7 @@
import { playbackReportStart, playbackReportStopped } from '@/features/playback/store/playbackReportSession';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
import { orbitBulkGuard, orbitSnapshot } from '@/store/orbitRuntime';
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
import {
@@ -38,7 +38,7 @@ const hoisted = vi.hoisted(() => {
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/utils/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
vi.mock('@/lib/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({ resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock }));
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
+1 -1
View File
@@ -1,7 +1,7 @@
import { getSong } from '@/lib/api/subsonicLibrary';
import { invoke } from '@tauri-apps/api/core';
import { estimateLivePosition, orbitSnapshot } from '@/store/orbitRuntime';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
if (deadlineMs == null) return '';
const sec = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1000));
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}:${rm.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
export interface PlaybackScheduleInfo {
/** Formatted countdown, e.g. "4:32". */
remaining: string;
/** Which timer is running: `pause` = sleep-timer, `start` = delayed-start. */
mode: 'pause' | 'start';
}
/**
* Hook: returns the active playback-schedule countdown + its mode, or null
* when no timer is armed. Ticks every 500 ms so the caller never needs to
* set up its own interval. Used by the play/pause button to swap the
* Play/Pause icon for a mode-icon + countdown text while a timer runs.
*/
export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
const { isPlaying, scheduledPauseAtMs, scheduledResumeAtMs } = usePlayerStore(
useShallow(s => ({
isPlaying: s.isPlaying,
scheduledPauseAtMs: s.scheduledPauseAtMs,
scheduledResumeAtMs: s.scheduledResumeAtMs,
})),
);
const mode: 'pause' | 'start' | null =
isPlaying && scheduledPauseAtMs != null ? 'pause'
: !isPlaying && scheduledResumeAtMs != null ? 'start'
: null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now());
const windowHidden = useWindowVisibility();
useEffect(() => {
if (deadlineMs == null) return;
// On a deadline change, snap the wall-clock to now so the countdown is fresh
// before the 500 ms interval first fires; nowMs is real time, not derivable.
// eslint-disable-next-line react-hooks/set-state-in-effect
setNowMs(Date.now());
}, [deadlineMs]);
useEffect(() => {
if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id);
}, [deadlineMs, windowHidden]);
if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
}
@@ -3,13 +3,13 @@ import { Check, RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { uninstallTheme } from '@/utils/themes/uninstallTheme';
import { installThemeFromRegistry } from '@/utils/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
import { useThemeUpdates } from '@/features/settings/hooks/useThemeUpdates';
import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk';
import { showToast } from '@/utils/ui/toast';
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
import { FIXED_THEMES } from '@/lib/themes/fixedThemes';
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
function swatch(css: string): { bg: string; card: string; accent: string } {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { validateThemePackage, type ValidatedTheme } from '@/utils/themes/validateThemePackage';
import { validateThemePackage, type ValidatedTheme } from '@/lib/themes/validateThemePackage';
import { showToast } from '@/utils/ui/toast';
import ConfirmModal from '@/components/ConfirmModal';
@@ -3,16 +3,16 @@ import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/themes/themeRegistry';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/lib/themes/themeRegistry';
// Control the registry the store browses so pagination/refresh are deterministic.
vi.mock('@/utils/themes/themeRegistry', () => ({
vi.mock('@/lib/themes/themeRegistry', () => ({
fetchRegistry: vi.fn(),
fetchThemeCss: vi.fn(async () => 'css'),
assetUrl: (p: string) => `https://raw.example/${p}`,
}));
import { fetchRegistry } from '@/utils/themes/themeRegistry';
import { fetchRegistry } from '@/lib/themes/themeRegistry';
const fetchRegistryMock = vi.mocked(fetchRegistry);
@@ -13,9 +13,9 @@ import {
assetUrl,
fetchRegistry,
type RegistryTheme,
} from '@/utils/themes/themeRegistry';
import { installThemeFromRegistry } from '@/utils/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/utils/themes/uninstallTheme';
} from '@/lib/themes/themeRegistry';
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
@@ -5,7 +5,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import CustomSelect from '@/ui/CustomSelect';
import BackToTopButton from '@/ui/BackToTopButton';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
import { FIXED_THEMES } from '@/lib/themes/fixedThemes';
import { InstalledThemes } from '@/features/settings/components/InstalledThemes';
import { ThemeImportSection } from '@/features/settings/components/ThemeImportSection';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
vi.mock('@/utils/themes/themeRegistry', () => ({
vi.mock('@/lib/themes/themeRegistry', () => ({
fetchRegistry: vi.fn(),
getCachedRegistry: vi.fn(() => null),
}));
import { fetchRegistry, type Registry } from '@/utils/themes/themeRegistry';
import { fetchRegistry, type Registry } from '@/lib/themes/themeRegistry';
import { useInstalledThemesStore, type InstalledTheme } from '@/store/installedThemesStore';
import { useThemeUpdates, themeUpdateSignature } from '@/features/settings/hooks/useThemeUpdates';
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/utils/themes/themeRegistry';
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/lib/themes/themeRegistry';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
// Refresh the registry from source once per app launch (not just from the