mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -13,9 +13,9 @@ import {
|
||||
startInstantMix as startInstantMixAction,
|
||||
startRadio as startRadioAction,
|
||||
} from '@/utils/componentHelpers/contextMenuActions';
|
||||
import { useContextMenuKeyboardNav } from '@/hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '@/hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
interface KeyboardRating {
|
||||
kind: RatingKind;
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isOpen: boolean;
|
||||
closeContextMenu: () => void;
|
||||
keyboardRating: KeyboardRating | null;
|
||||
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||
getRatingValueByKind: (kind: RatingKind, id: string) => number;
|
||||
commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void;
|
||||
playlistSubmenuOpen: boolean;
|
||||
setPlaylistSubmenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setPlaylistSongIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
pendingSubmenuKeyboardFocus: boolean;
|
||||
setPendingSubmenuKeyboardFocus: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
onMenuKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
getMenuNavItems: (scope?: 'main' | 'submenu') => HTMLElement[];
|
||||
focusMenuItemAt: (scope: 'main' | 'submenu', index: number) => void;
|
||||
}
|
||||
|
||||
export function useContextMenuKeyboardNav({
|
||||
menuRef, isOpen, closeContextMenu,
|
||||
keyboardRating, setKeyboardRating, getRatingValueByKind, commitRatingByKind,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, setPlaylistSongIds,
|
||||
pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus,
|
||||
}: Args): Result {
|
||||
const getMenuNavItems = useCallback(
|
||||
(scope: 'main' | 'submenu' = 'main') => {
|
||||
if (!menuRef.current) return [];
|
||||
if (scope === 'submenu') {
|
||||
const sub = menuRef.current.querySelector<HTMLElement>('.context-submenu');
|
||||
if (!sub || sub.offsetParent === null) return [];
|
||||
return Array.from(
|
||||
sub.querySelectorAll<HTMLElement>('.context-menu-item, .context-submenu-create-btn'),
|
||||
).filter(el => el.offsetParent !== null);
|
||||
}
|
||||
return Array.from(menuRef.current.children)
|
||||
.filter((el): el is HTMLElement =>
|
||||
el instanceof HTMLElement &&
|
||||
(el.classList.contains('context-menu-item') || el.classList.contains('context-menu-rating-row')) &&
|
||||
el.offsetParent !== null,
|
||||
);
|
||||
},
|
||||
[menuRef],
|
||||
);
|
||||
|
||||
const focusMenuItemAt = useCallback((scope: 'main' | 'submenu', index: number) => {
|
||||
const items = getMenuNavItems(scope);
|
||||
if (items.length === 0) return;
|
||||
menuRef.current
|
||||
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
|
||||
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
|
||||
const safeIdx = ((index % items.length) + items.length) % items.length;
|
||||
const target = items[safeIdx];
|
||||
target.classList.add('context-menu-keyboard-active');
|
||||
target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
}, [getMenuNavItems, menuRef]);
|
||||
|
||||
// Focus the menu container when it opens (no row pre-highlight; keyboard outline
|
||||
// only appears after explicit arrow navigation).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
requestAnimationFrame(() => {
|
||||
menuRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
}, [isOpen, menuRef]);
|
||||
|
||||
// Outside-click closes the menu without occluding the underlying UI.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isOpen, closeContextMenu, menuRef]);
|
||||
|
||||
// After opening a submenu via keyboard, wait for it to render then focus first item.
|
||||
useEffect(() => {
|
||||
if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return;
|
||||
let cancelled = false;
|
||||
const tryFocus = (attemptsLeft: number) => {
|
||||
if (cancelled) return;
|
||||
const items = getMenuNavItems('submenu');
|
||||
if (items.length > 0) {
|
||||
focusMenuItemAt('submenu', 0);
|
||||
setPendingSubmenuKeyboardFocus(false);
|
||||
return;
|
||||
}
|
||||
if (attemptsLeft <= 0) {
|
||||
setPendingSubmenuKeyboardFocus(false);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => tryFocus(attemptsLeft - 1));
|
||||
};
|
||||
requestAnimationFrame(() => tryFocus(8));
|
||||
return () => { cancelled = true; };
|
||||
}, [pendingSubmenuKeyboardFocus, playlistSubmenuOpen, getMenuNavItems, focusMenuItemAt, setPendingSubmenuKeyboardFocus]);
|
||||
|
||||
const onMenuKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const ratingRow = active?.closest('.context-menu-rating-row') as HTMLElement | null;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ratingRow) {
|
||||
const kind = ratingRow.dataset.ratingKind as RatingKind | undefined;
|
||||
const id = ratingRow.dataset.ratingId;
|
||||
if (!kind || !id) return;
|
||||
if (ratingRow.dataset.ratingDisabled === 'true') return;
|
||||
const value = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id
|
||||
? keyboardRating.value
|
||||
: getRatingValueByKind(kind, id);
|
||||
commitRatingByKind(kind, id, value);
|
||||
setKeyboardRating({ kind, id, value });
|
||||
return;
|
||||
}
|
||||
active?.click();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (ratingRow) {
|
||||
const kind = ratingRow.dataset.ratingKind as RatingKind | undefined;
|
||||
const id = ratingRow.dataset.ratingId;
|
||||
if (!kind || !id) return;
|
||||
if (ratingRow.dataset.ratingDisabled === 'true') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const currentValue = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id
|
||||
? keyboardRating.value
|
||||
: getRatingValueByKind(kind, id);
|
||||
const delta = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const nextValue = Math.max(0, Math.min(5, currentValue + delta));
|
||||
setKeyboardRating({ kind, id, value: nextValue });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
const trigger = active?.closest('.context-menu-item--submenu') as HTMLElement | null;
|
||||
const triggerId = trigger?.dataset.playlistTriggerId;
|
||||
if (!trigger || !triggerId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlaylistSongIds([triggerId]);
|
||||
setPlaylistSubmenuOpen(true);
|
||||
setPendingSubmenuKeyboardFocus(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
const sub = active?.closest('.context-submenu') as HTMLElement | null;
|
||||
if (!sub) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const triggerId = sub.dataset.parentTriggerId;
|
||||
setPlaylistSubmenuOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const trigger = triggerId
|
||||
? Array.from(menuRef.current?.querySelectorAll<HTMLElement>('.context-menu-item--submenu') ?? [])
|
||||
.find(el => el.dataset.playlistTriggerId === triggerId) ?? null
|
||||
: null;
|
||||
if (trigger) {
|
||||
menuRef.current
|
||||
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
|
||||
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
|
||||
trigger.classList.add('context-menu-keyboard-active');
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const scope: 'main' | 'submenu' = active?.closest('.context-submenu') ? 'submenu' : 'main';
|
||||
const items = getMenuNavItems(scope);
|
||||
if (items.length === 0) return;
|
||||
const activeIdx = items.findIndex(el => el === document.activeElement);
|
||||
const nextIdx =
|
||||
activeIdx >= 0
|
||||
? (e.key === 'ArrowDown' ? activeIdx + 1 : activeIdx - 1)
|
||||
: (e.key === 'ArrowDown' ? 0 : items.length - 1);
|
||||
focusMenuItemAt(scope, nextIdx);
|
||||
}, [
|
||||
closeContextMenu, keyboardRating, getRatingValueByKind, commitRatingByKind,
|
||||
getMenuNavItems, focusMenuItemAt, menuRef,
|
||||
setKeyboardRating, setPlaylistSubmenuOpen, setPlaylistSongIds, setPendingSubmenuKeyboardFocus,
|
||||
]);
|
||||
|
||||
return { onMenuKeyDown, getMenuNavItems, focusMenuItemAt };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback } from 'react';
|
||||
import { setRating } from '@/lib/api/subsonicStarRating';
|
||||
import { queueSongRating } from '@/features/playback/store/pendingStarSync';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
interface Args {
|
||||
type: string | null;
|
||||
item: unknown;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setUserRatingOverride: (id: string, rating: number) => void;
|
||||
entityRatingSupport: 'full' | 'track_only' | 'unknown';
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
applySongRating: (songId: string, rating: number) => void;
|
||||
applyAlbumRating: (album: SubsonicAlbum, rating: number) => void;
|
||||
applyArtistRating: (artist: SubsonicArtist, rating: number) => void;
|
||||
getRatingValueByKind: (kind: RatingKind, id: string) => number;
|
||||
commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void;
|
||||
}
|
||||
|
||||
export function useContextMenuRating({
|
||||
type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t,
|
||||
}: Args): Result {
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
|
||||
const applySongRating = useCallback((songId: string, rating: number) => {
|
||||
// F4: optimistic override + retry-with-backoff sync via the central helper.
|
||||
queueSongRating(songId, rating);
|
||||
}, []);
|
||||
|
||||
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
|
||||
setUserRatingOverride(album.id, rating);
|
||||
if (entityRatingSupport !== 'full') return;
|
||||
setRating(album.id, rating).catch(err => {
|
||||
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
|
||||
|
||||
const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => {
|
||||
setUserRatingOverride(artist.id, rating);
|
||||
if (entityRatingSupport !== 'full') return;
|
||||
setRating(artist.id, rating).catch(err => {
|
||||
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
|
||||
|
||||
const getRatingValueByKind = useCallback((kind: RatingKind, id: string): number => {
|
||||
if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) {
|
||||
const song = item as Track;
|
||||
if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'album' && type === 'album') {
|
||||
const album = item as SubsonicAlbum;
|
||||
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'album' && type === 'multi-album') {
|
||||
const albums = item as SubsonicAlbum[];
|
||||
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||
if (albums.length === 0) return 0;
|
||||
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||
const first = vals[0];
|
||||
return vals.every(v => v === first) ? first : 0;
|
||||
}
|
||||
if (kind === 'artist' && type === 'artist') {
|
||||
const artist = item as SubsonicArtist;
|
||||
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'artist' && type === 'multi-artist') {
|
||||
const artists = item as SubsonicArtist[];
|
||||
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||
if (artists.length === 0) return 0;
|
||||
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||
const first = vals[0];
|
||||
return vals.every(v => v === first) ? first : 0;
|
||||
}
|
||||
return userRatingOverrides[id] ?? 0;
|
||||
}, [type, item, userRatingOverrides]);
|
||||
|
||||
const commitRatingByKind = useCallback((kind: RatingKind, id: string, rating: number) => {
|
||||
if (kind === 'song') {
|
||||
applySongRating(id, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' && type === 'album') {
|
||||
applyAlbumRating(item as SubsonicAlbum, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' && type === 'multi-album') {
|
||||
const albums = item as SubsonicAlbum[];
|
||||
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return;
|
||||
for (const a of albums) applyAlbumRating(a, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'artist' && type === 'artist') {
|
||||
applyArtistRating(item as SubsonicArtist, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'artist' && type === 'multi-artist') {
|
||||
const artists = item as SubsonicArtist[];
|
||||
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return;
|
||||
for (const a of artists) applyArtistRating(a, rating);
|
||||
}
|
||||
}, [applySongRating, applyAlbumRating, applyArtistRating, type, item]);
|
||||
|
||||
return { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind };
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Track } from '@/lib/media/trackTypes';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
|
||||
import React, { useState, useCallback, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Info } from 'lucide-react';
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServerId';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
|
||||
import CachedImage from '@/ui/CachedImage';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { CoverArtRef } from '@/cover/types';
|
||||
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServerId';
|
||||
import { primaryTrackArtistRef } from '@/features/playback/utils/playback/trackArtistRefs';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
import { coverArtIdFromRadio } from '@/cover/ids';
|
||||
import type { SubsonicArtistInfo, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServerId';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { Equalizer } from '@/features/equalizer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useRadioMetadata } from '@/features/radio';
|
||||
import { useRadioMprisSync } from '@/features/radio';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import type { PendingSmartPlaylist } from '@/features/playlist';
|
||||
|
||||
/**
|
||||
* Poll Navidrome every 10 s for each pending smart playlist until its
|
||||
* rules finish processing on the server. We stop polling for an item when
|
||||
* (a) it has at least one song AND (b) its cover-art id has changed from
|
||||
* the placeholder we first saw — or after ~3 minutes as a fallback.
|
||||
*
|
||||
* Side-effects:
|
||||
* - rehydrates the playlist store with fresh detail-endpoint metadata
|
||||
* (cover, song count) as soon as it's available
|
||||
* - shrinks `pendingSmart` as items finish
|
||||
*/
|
||||
export function usePendingSmartPolling(
|
||||
pendingSmart: PendingSmartPlaylist[],
|
||||
setPendingSmart: React.Dispatch<React.SetStateAction<PendingSmartPlaylist[]>>,
|
||||
fetchPlaylists: () => Promise<void>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (pendingSmart.length === 0) return;
|
||||
const interval = window.setInterval(async () => {
|
||||
await fetchPlaylists();
|
||||
const listNow = usePlaylistStore.getState().playlists;
|
||||
const hydrated = pendingSmart.map(item => {
|
||||
if (item.id) return item;
|
||||
const found = listNow.find(p => p.name === item.name);
|
||||
return found ? { ...item, id: found.id } : item;
|
||||
});
|
||||
// Detail endpoint tends to reflect fresh metadata earlier than list endpoint.
|
||||
const ids = hydrated.map(p => p.id).filter((v): v is string => Boolean(v));
|
||||
const details = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
const { playlist } = await getPlaylist(id);
|
||||
return playlist;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const freshById = new Map(
|
||||
details.filter((p): p is SubsonicPlaylist => p !== null).map(p => [p.id, p]),
|
||||
);
|
||||
if (freshById.size > 0) {
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.map((p) => {
|
||||
const fresh = freshById.get(p.id);
|
||||
return fresh ? { ...p, ...fresh } : p;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
const current = usePlaylistStore.getState().playlists;
|
||||
setPendingSmart(() => {
|
||||
const next: PendingSmartPlaylist[] = [];
|
||||
for (const item of hydrated) {
|
||||
const pl = item.id
|
||||
? current.find(p => p.id === item.id)
|
||||
: current.find(p => p.name === item.name);
|
||||
if (!pl) {
|
||||
next.push({ ...item, attempts: item.attempts + 1 });
|
||||
continue;
|
||||
}
|
||||
const songCount = pl.songCount ?? 0;
|
||||
const currentCover = pl.coverArt;
|
||||
const firstCover = item.firstSeenCoverArt ?? currentCover;
|
||||
const placeholderStillThere = Boolean(firstCover) && currentCover === firstCover;
|
||||
// Wait until we see actual content and cover changed from the first placeholder-ish cover.
|
||||
// Fallback timeout keeps UI from waiting forever on servers that never update cover id.
|
||||
const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s)
|
||||
const emptySettled = songCount === 0 && item.attempts >= 3; // ~30s — valid empty result
|
||||
const ready =
|
||||
hardTimeoutReached
|
||||
|| emptySettled
|
||||
|| (songCount > 0 && (!placeholderStillThere || hardTimeoutReached));
|
||||
if (!ready) {
|
||||
next.push({
|
||||
...item,
|
||||
id: pl.id,
|
||||
firstSeenCoverArt: firstCover,
|
||||
attempts: item.attempts + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 10000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [pendingSmart, fetchPlaylists, setPendingSmart]);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
|
||||
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||
import { isSmartPlaylistName } from '@/features/playlist';
|
||||
|
||||
/**
|
||||
* Build the 2×2 cover collage for each smart playlist. Pulls each smart
|
||||
* playlist's tracks (filtered to the active library scope) and collects up
|
||||
* to four unique cover-art IDs. Re-runs when the playlist list changes or
|
||||
* when the active library filter version bumps.
|
||||
*/
|
||||
export function useSmartCoverCollage(
|
||||
playlists: SubsonicPlaylist[],
|
||||
musicLibraryFilterVersion: number,
|
||||
): Record<string, string[]> {
|
||||
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const smart = playlists.filter(pl => isSmartPlaylistName(pl.name));
|
||||
if (smart.length === 0) {
|
||||
if (!cancelled) setSmartCoverIdsByPlaylist({});
|
||||
return;
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
smart.map(async (pl) => {
|
||||
try {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of filtered) {
|
||||
const cid = s.coverArt;
|
||||
if (!cid || seen.has(cid)) continue;
|
||||
seen.add(cid);
|
||||
ids.push(cid);
|
||||
if (ids.length >= 4) break;
|
||||
}
|
||||
return [pl.id, ids] as const;
|
||||
} catch {
|
||||
return [pl.id, [] as string[]] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const [id, ids] of rows) next[id] = ids;
|
||||
setSmartCoverIdsByPlaylist(next);
|
||||
};
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [playlists, musicLibraryFilterVersion]);
|
||||
|
||||
return smartCoverIdsByPlaylist;
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
defaultSmartFilters,
|
||||
type SmartFilters, type PendingSmartPlaylist,
|
||||
} from '@/features/playlist/utils/playlistsSmart';
|
||||
import { useSmartCoverCollage } from '@/hooks/useSmartCoverCollage';
|
||||
import { useSmartCoverCollage } from '@/features/playlist/hooks/useSmartCoverCollage';
|
||||
import { usePlaylistsLibraryScopeCounts } from '@/features/playlist/hooks/usePlaylistsLibraryScopeCounts';
|
||||
import { usePendingSmartPolling } from '@/hooks/usePendingSmartPolling';
|
||||
import { usePendingSmartPolling } from '@/features/playlist/hooks/usePendingSmartPolling';
|
||||
import { runPlaylistsOpenSmartEditor } from '@/features/playlist/utils/runPlaylistsOpenSmartEditor';
|
||||
import { runPlaylistsSaveSmart } from '@/features/playlist/utils/runPlaylistsSaveSmart';
|
||||
import {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { OrbitGuestQueue, OrbitQueueHead } from '@/features/orbit';
|
||||
import HostApprovalQueue from '@/features/orbit/components/HostApprovalQueue';
|
||||
import { usePlaylistStore } from '@/features/playlist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { encodeSharePayload } from '@/utils/share/shareLink';
|
||||
import { serverShareBaseUrl } from '@/utils/server/serverEndpoint';
|
||||
@@ -32,7 +32,7 @@ import { QueueToolbar } from '@/features/queue/components/QueueToolbar';
|
||||
import { QueueList } from '@/features/queue/components/QueueList';
|
||||
import { QueueTabBar } from '@/features/queue/components/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
|
||||
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/hooks/useTimelinePlayHistory';
|
||||
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/features/playback/hooks/useTimelinePlayHistory';
|
||||
import { buildTimelineDisplayRows } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
import { activeServerQueueTrackIds } from '@/features/playback/utils/playback/trackServerScope';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { libraryGetFacts, libraryGetTrack } from '@/lib/api/library';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServerId';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import {
|
||||
enrichmentDisplayComplete,
|
||||
|
||||
@@ -7,7 +7,7 @@ import SettingsSubSection from '@/features/settings/components/SettingsSubSectio
|
||||
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
|
||||
import { useAudioDevicesProbe } from '@/hooks/useAudioDevicesProbe';
|
||||
import { useAudioDevicesProbe } from '@/features/playback/hooks/useAudioDevicesProbe';
|
||||
import { IS_MACOS } from '@/lib/util/platform';
|
||||
import { AudioOutputDeviceSection } from '@/features/settings/components/audio/AudioOutputDeviceSection';
|
||||
import { NormalizationBlock } from '@/features/settings/components/audio/NormalizationBlock';
|
||||
|
||||
Reference in New Issue
Block a user