refactor(hooks): co-locate feature-coupled hooks (playback server/navigate/timeline/audio-devices->playback, smart-collage/pending-polling->playlist, contextmenu rating/keyboard->contextMenu)

This commit is contained in:
Psychotoxical
2026-06-30 19:30:41 +02:00
parent e0d6623bf1
commit 31bc24a178
20 changed files with 25 additions and 25 deletions
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { TFunction } from 'i18next';
import { useAuthStore } from '@/store/authStore';
import { IS_MACOS } from '@/lib/util/platform';
import { sortAudioDeviceIds } from '@/features/playback/utils/audio/audioDeviceLabels';
import { showToast } from '@/utils/ui/toast';
interface UseAudioDevicesProbeResult {
audioDevices: string[];
osDefaultAudioDeviceId: string | null;
deviceSwitching: boolean;
devicesLoading: boolean;
setDeviceSwitching: (v: boolean) => void;
refreshAudioDevices: (opts?: { silent?: boolean }) => void;
}
/**
* Drive the audio-output-device picker in the audio settings tab. Calls
* the Rust side to list devices + the current OS default, canonicalises
* the persisted selection (filenames may have shifted between runs), and
* re-runs whenever the backend reopens the stream
* (`audio:device-changed` / `audio:device-reset`).
*
* macOS short-circuits — the audio stream is pinned to the system default
* there, and the whole output-device category is gated out in settings.
*/
export function useAudioDevicesProbe(t: TFunction): UseAudioDevicesProbeResult {
const [audioDevices, setAudioDevices] = useState<string[]>([]);
const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
const [deviceSwitching, setDeviceSwitching] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => {
const silent = !!opts?.silent;
if (!silent) setDevicesLoading(true);
const listP = invoke<string[]>('audio_list_devices').catch((e) => {
console.error(e);
showToast(t('settings.audioOutputDeviceListError'), 5000, 'error');
return [] as string[];
});
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
Promise.all([listP, defP])
.then(async ([devices, osDefault]) => {
let canon: string | null = null;
try {
canon = await invoke<string | null>('audio_canonicalize_selected_device');
if (canon) useAuthStore.getState().setAudioOutputDevice(canon);
} catch {
/* ignore */
}
const finalList = canon
? await invoke<string[]>('audio_list_devices').catch(() => devices)
: devices;
const defId = osDefault ?? null;
setAudioDevices(sortAudioDeviceIds(finalList, defId));
setOsDefaultAudioDeviceId(defId);
})
.finally(() => {
if (!silent) setDevicesLoading(false);
});
}, [t]);
useEffect(() => {
if (IS_MACOS) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshAudioDevices();
}, [refreshAudioDevices]);
useEffect(() => {
if (IS_MACOS) return;
let cancelled = false;
const unlisteners: Array<() => void> = [];
(async () => {
for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) {
const u = await listen(ev, () => {
if (!cancelled) refreshAudioDevices({ silent: true });
});
if (cancelled) {
u();
return;
}
unlisteners.push(u);
}
})();
return () => {
cancelled = true;
for (const u of unlisteners) u();
};
}, [refreshAudioDevices]);
return {
audioDevices,
osDefaultAudioDeviceId,
deviceSwitching,
devicesLoading,
setDeviceSwitching,
refreshAudioDevices,
};
}
@@ -0,0 +1,14 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ensurePlaybackServerActive } from '@/features/playback/utils/playback/playbackServer';
import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation';
/** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */
export function usePlaybackLibraryNavigate() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(async (path: string) => {
await ensurePlaybackServerActive();
navigatePathWithAlbumReturnTo(navigate, location, path);
}, [navigate, location]);
}
@@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
/**
* Subsonic server that owns the current queue / stream (may differ from the browsed
* server). Use for Now Playing metadata without calling `ensurePlaybackServerActive`.
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playingServerId = usePlayerStore(
s => s.queueItems[s.queueIndex]?.serverId ?? '',
);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
// getPlaybackServerId() reads global queue/auth state; the listed values
// are intentional recompute triggers, not direct inputs to the body.
// eslint-disable-next-line react-hooks/exhaustive-deps
[queueServerId, queueIndex, playingServerId, activeServerId],
);
}
@@ -0,0 +1,112 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import { libraryGetRecentPlaySessions, type PlaySessionRecentTrack } from '@/lib/api/library';
import { seedQueueResolver, resolveBatch } from '@/features/playback/store/queueTrackResolver';
import {
applyTimelineBootstrap,
getTimelineSessionHistorySnapshot,
isTimelineBootstrapAttempted,
markTimelineBootstrapAttempted,
subscribeTimelineSessionHistory,
TIMELINE_HISTORY_BOOTSTRAP_LIMIT,
type TimelinePlayedRef,
} from '@/features/playback/store/timelineSessionHistory';
import {
bootstrapTrackFromPlaySession,
timelineHistoryToQueueRefs,
} from '@/utils/queue/timelineHistoryRefs';
import { timelineBootstrapIndexReady } from '@/utils/queue/timelineBootstrapReady';
const BOOTSTRAP_RETRY_MS = 2_000;
let bootstrapInFlight = false;
function bootstrapRowToRef(row: PlaySessionRecentTrack): TimelinePlayedRef {
return {
serverId: row.serverId,
trackId: row.trackId,
playedAtMs: row.startedAtMs,
};
}
function seedResolverFromBootstrap(rows: PlaySessionRecentTrack[]): void {
const byServer = new Map<string, ReturnType<typeof bootstrapTrackFromPlaySession>[]>();
for (const row of rows) {
const track = bootstrapTrackFromPlaySession(row);
const arr = byServer.get(row.serverId) ?? [];
arr.push(track);
byServer.set(row.serverId, arr);
}
for (const [serverId, tracks] of byServer) {
seedQueueResolver(serverId, tracks);
}
}
/** Test-only: reset in-flight bootstrap guard. */
export function _resetTimelineBootstrapInFlightForTest(): void {
bootstrapInFlight = false;
}
export async function ensureTimelineBootstrap(): Promise<void> {
if (isTimelineBootstrapAttempted() || bootstrapInFlight) return;
if (!(await timelineBootstrapIndexReady())) return;
bootstrapInFlight = true;
if (!markTimelineBootstrapAttempted()) {
bootstrapInFlight = false;
return;
}
try {
const rows = await libraryGetRecentPlaySessions({ limit: TIMELINE_HISTORY_BOOTSTRAP_LIMIT });
seedResolverFromBootstrap(rows);
const oldestFirst = [...rows].reverse().map(bootstrapRowToRef);
applyTimelineBootstrap(oldestFirst);
} catch {
/* bootstrapAttempted stays true — no retry until next app launch */
} finally {
bootstrapInFlight = false;
}
}
export function useTimelinePlayHistory(): TimelinePlayedRef[] {
return useSyncExternalStore(subscribeTimelineSessionHistory, getTimelineSessionHistorySnapshot);
}
export function useTimelineBootstrapOnMode(isTimeline: boolean): void {
useEffect(() => {
if (!isTimeline) return;
let cancelled = false;
const run = async () => {
while (!cancelled && !isTimelineBootstrapAttempted()) {
await ensureTimelineBootstrap();
if (cancelled || isTimelineBootstrapAttempted()) break;
await new Promise<void>(resolve => {
window.setTimeout(resolve, BOOTSTRAP_RETRY_MS);
});
}
};
void run();
return () => {
cancelled = true;
};
}, [isTimeline]);
}
/** Prefetch full track metadata (incl. cover ids) for cross-server history rows. */
export function useTimelineHistoryResolver(
historyRefs: TimelinePlayedRef[],
enabled: boolean,
): void {
const refsKey = useMemo(
() => historyRefs.map(r => `${r.serverId}:${r.trackId}:${r.playedAtMs}`).join('\u0001'),
[historyRefs],
);
useEffect(() => {
if (!enabled || historyRefs.length === 0) return;
void resolveBatch(timelineHistoryToQueueRefs(historyRefs));
}, [enabled, refsKey, historyRefs]);
}