mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
c7946f26b6
commit
6fcf2259f6
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useDeviceSyncStore, type DeviceSyncSource } from '../store/deviceSyncStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
export interface DeviceSyncDeviceScanResult {
|
||||
scanDevice: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDeviceSyncDeviceScan(
|
||||
targetDir: string | null,
|
||||
sourcesLength: number,
|
||||
driveDetected: boolean,
|
||||
t: TFunction,
|
||||
): DeviceSyncDeviceScanResult {
|
||||
const setDeviceFilePaths = useDeviceSyncStore.getState().setDeviceFilePaths;
|
||||
const setScanning = useDeviceSyncStore.getState().setScanning;
|
||||
|
||||
const scanDevice = useCallback(async () => {
|
||||
if (!targetDir || sourcesLength === 0) {
|
||||
setDeviceFilePaths([]);
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const files = await invoke<string[]>('list_device_dir_files', { dir: targetDir });
|
||||
setDeviceFilePaths(files);
|
||||
} catch {
|
||||
setDeviceFilePaths([]);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [targetDir, sourcesLength, setDeviceFilePaths, setScanning]);
|
||||
|
||||
// Scan device on mount and when targetDir changes
|
||||
useEffect(() => { scanDevice(); }, [scanDevice]);
|
||||
|
||||
// Auto-import manifest when page loads and drive is already connected
|
||||
const manifestImportedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!targetDir || !driveDetected || manifestImportedRef.current) return;
|
||||
manifestImportedRef.current = true;
|
||||
invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
).then(manifest => {
|
||||
if (manifest?.sources?.length) {
|
||||
useDeviceSyncStore.getState().clearSources();
|
||||
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
|
||||
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [targetDir, driveDetected, t]);
|
||||
|
||||
// Clear device file list and reset import flag when stick is unplugged
|
||||
useEffect(() => {
|
||||
if (!driveDetected) {
|
||||
setDeviceFilePaths([]);
|
||||
manifestImportedRef.current = false;
|
||||
}
|
||||
}, [driveDetected, setDeviceFilePaths]);
|
||||
|
||||
return { scanDevice };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useDeviceSyncStore } from '../store/deviceSyncStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { trackToSyncInfo } from '../utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '../utils/fetchTracksForSource';
|
||||
|
||||
export function useDeviceSyncJobEvents(
|
||||
t: TFunction,
|
||||
scanDevice: () => Promise<void>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const jobStore = useDeviceSyncJobStore.getState;
|
||||
const unlistenProgress = listen<{
|
||||
jobId: string; done: number; skipped: number; failed: number; total: number;
|
||||
}>('device:sync:progress', ({ payload }) => {
|
||||
const current = jobStore();
|
||||
if (current.jobId && payload.jobId === current.jobId) {
|
||||
useDeviceSyncJobStore.getState().updateProgress(
|
||||
payload.done, payload.skipped, payload.failed
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenComplete = listen<{
|
||||
jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean;
|
||||
}>('device:sync:complete', ({ payload }) => {
|
||||
const current = jobStore();
|
||||
if (current.jobId && payload.jobId === current.jobId) {
|
||||
if (payload.cancelled) {
|
||||
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
|
||||
// status is already 'cancelled' from the button click; complete() would overwrite it — restore it
|
||||
useDeviceSyncJobStore.getState().cancel();
|
||||
} else {
|
||||
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
|
||||
showToast(
|
||||
t('deviceSync.syncResult', {
|
||||
done: payload.done, skipped: payload.skipped, total: payload.total
|
||||
}),
|
||||
5000, 'info'
|
||||
);
|
||||
// Write manifest so another machine can read the synced sources from the stick
|
||||
const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState();
|
||||
if (dir) {
|
||||
invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {});
|
||||
// For every playlist source, write an Extended-M3U next to the
|
||||
// playlist-folder tracks. Context carries the playlist name +
|
||||
// per-track index so the filenames match the files we just synced.
|
||||
const playlistSources = srcs.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: dir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* m3u8 failure is non-fatal — skip silently */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-scan the device after sync completes (cancelled or not)
|
||||
scanDevice();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
};
|
||||
}, [t, scanDevice]);
|
||||
}
|
||||
Reference in New Issue
Block a user