refactor(device-sync): G.76 — extract browser + device-scan + job-events hooks + choose-folder util (cluster) (#643)

Four-cut cluster pulling the remaining lifecycle code out of
DeviceSync.tsx. 928 → 638 LOC (−290).

useDeviceSyncBrowser — playlists / randomAlbums / artists state +
their three loaders + the tab-switch useEffect that lazy-loads on
first visit + the 300 ms debounced album-search useEffect + the
expandedArtistIds / artistAlbumsMap / loadingArtistIds state with
toggleArtistExpand. Takes activeTab + search + a resetSearch
callback (so the tab-switch effect can clear the search input the
page still owns).

useDeviceSyncDeviceScan — scanDevice useCallback + the on-mount
useEffect + the auto-import-manifest useEffect (with the
manifestImportedRef gate so it only fires once per drive plug-in)
+ the clean-on-unplug useEffect that clears deviceFilePaths and
resets the import flag.

useDeviceSyncJobEvents — the device:sync:progress and
device:sync:complete event listeners. Complete handler dispatches
the toast, writes the manifest, generates per-playlist m3u8 files
(through fetchTracksForSource + trackToSyncInfo), and triggers
scanDevice. Cancelled state is preserved by re-calling
useDeviceSyncJobStore.cancel() after complete().

runDeviceSyncChooseFolder — the openDialog → setTargetDir →
optional manifest auto-import → scanDevice timer flow.

DeviceSync drops every direct import that those hooks now own
(getPlaylists, getArtists, getArtist, getAlbumList, searchSubsonic,
listen, openDialog, useEffect, useRef, SubsonicPlaylist /
SubsonicArtist / SubsonicAlbum type imports). Pure code move
otherwise — no behaviour change.
This commit is contained in:
Frank Stellmacher
2026-05-13 15:17:19 +02:00
committed by GitHub
parent c7946f26b6
commit 6fcf2259f6
5 changed files with 297 additions and 207 deletions
+106
View File
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useState } from 'react';
import { getPlaylists } from '../api/subsonicPlaylists';
import { getArtists, getArtist } from '../api/subsonicArtists';
import { getAlbumList } from '../api/subsonicLibrary';
import { search as searchSubsonic } from '../api/subsonicSearch';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicPlaylist,
} from '../api/subsonicTypes';
import type { SourceTab } from '../utils/deviceSyncHelpers';
export interface DeviceSyncBrowserResult {
playlists: SubsonicPlaylist[];
randomAlbums: SubsonicAlbum[];
albumSearchResults: SubsonicAlbum[];
albumSearchLoading: boolean;
artists: SubsonicArtist[];
loadingBrowser: boolean;
expandedArtistIds: Set<string>;
artistAlbumsMap: Map<string, SubsonicAlbum[]>;
loadingArtistIds: Set<string>;
toggleArtistExpand: (artistId: string) => Promise<void>;
}
export function useDeviceSyncBrowser(
activeTab: SourceTab,
search: string,
resetSearch: () => void,
): DeviceSyncBrowserResult {
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [randomAlbums, setRandomAlbums] = useState<SubsonicAlbum[]>([]);
const [albumSearchResults, setAlbumSearchResults] = useState<SubsonicAlbum[]>([]);
const [albumSearchLoading, setAlbumSearchLoading] = useState(false);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [loadingBrowser, setLoadingBrowser] = useState(false);
const [expandedArtistIds, setExpandedArtistIds] = useState<Set<string>>(new Set());
const [artistAlbumsMap, setArtistAlbumsMap] = useState<Map<string, SubsonicAlbum[]>>(new Map());
const [loadingArtistIds, setLoadingArtistIds] = useState<Set<string>>(new Set());
const loadPlaylists = useCallback(async () => {
setLoadingBrowser(true);
try { setPlaylists(await getPlaylists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadRandomAlbums = useCallback(async () => {
setLoadingBrowser(true);
try { setRandomAlbums(await getAlbumList('random', 10)); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadArtists = useCallback(async () => {
setLoadingBrowser(true);
try { setArtists(await getArtists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
useEffect(() => {
resetSearch();
if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists();
if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums();
if (activeTab === 'artists' && artists.length === 0) loadArtists();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
// Live album search with 300ms debounce
useEffect(() => {
if (activeTab !== 'albums') return;
const q = search.trim();
if (!q) { setAlbumSearchResults([]); return; }
setAlbumSearchLoading(true);
const timer = setTimeout(async () => {
try {
const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 });
setAlbumSearchResults(albums);
} catch {
setAlbumSearchResults([]);
} finally {
setAlbumSearchLoading(false);
}
}, 300);
return () => { clearTimeout(timer); setAlbumSearchLoading(false); };
}, [search, activeTab]);
const toggleArtistExpand = useCallback(async (artistId: string) => {
setExpandedArtistIds(prev => {
const next = new Set(prev);
if (next.has(artistId)) { next.delete(artistId); return next; }
next.add(artistId);
return next;
});
if (!artistAlbumsMap.has(artistId)) {
setLoadingArtistIds(prev => new Set(prev).add(artistId));
try {
const { albums } = await getArtist(artistId);
setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums));
} finally {
setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; });
}
}
}, [artistAlbumsMap]);
return {
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
artists, loadingBrowser,
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
toggleArtistExpand,
};
}