mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user