Files
Psychotoxical-psysonic/src/features/deviceSync/pages/DeviceSync.tsx
T

276 lines
12 KiB
TypeScript

import type { SubsonicSong } from '@/api/subsonicTypes';
import React, { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDeviceSyncStore, DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
import {
type SourceTab,
} from '@/features/deviceSync/utils/deviceSyncHelpers';
import { useDeviceSyncDrives } from '@/features/deviceSync/hooks/useDeviceSyncDrives';
import { useDeviceSyncSourceStatuses } from '@/features/deviceSync/hooks/useDeviceSyncSourceStatuses';
import { useDeviceSyncBrowser } from '@/features/deviceSync/hooks/useDeviceSyncBrowser';
import { useDeviceSyncDeviceScan } from '@/features/deviceSync/hooks/useDeviceSyncDeviceScan';
import { useDeviceSyncJobEvents } from '@/features/deviceSync/hooks/useDeviceSyncJobEvents';
import {
runDeviceSyncMigrationPreview,
runDeviceSyncMigrationExecute,
type MigrationPhase, type MigrationPair, type MigrationResult,
} from '@/features/deviceSync/utils/runDeviceSyncMigration';
import {
runDeviceSyncSummaryPrompt,
runDeviceSyncExecute,
type SyncDelta,
} from '@/features/deviceSync/utils/runDeviceSyncExecution';
import { runDeviceSyncChooseFolder } from '@/features/deviceSync/utils/runDeviceSyncChooseFolder';
import DeviceSyncHeader from '@/features/deviceSync/components/DeviceSyncHeader';
import DeviceSyncPreSyncModal from '@/features/deviceSync/components/DeviceSyncPreSyncModal';
import DeviceSyncMigrationModal from '@/features/deviceSync/components/DeviceSyncMigrationModal';
import DeviceSyncBrowserPanel from '@/features/deviceSync/components/DeviceSyncBrowserPanel';
import DeviceSyncDevicePanel from '@/features/deviceSync/components/DeviceSyncDevicePanel';
// ─── component ───────────────────────────────────────────────────────────────
export default function DeviceSync() {
const { t } = useTranslation();
const targetDir = useDeviceSyncStore(s => s.targetDir);
const sources = useDeviceSyncStore(s => s.sources);
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion);
const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths);
const scanning = useDeviceSyncStore(s => s.scanning);
const {
setTargetDir, addSource, removeSource,
toggleChecked, setCheckedIds, markForDeletion,
unmarkDeletion, removeSources,
} = useDeviceSyncStore.getState();
const jobStatus = useDeviceSyncJobStore(s => s.status);
const jobDone = useDeviceSyncJobStore(s => s.done);
const jobSkip = useDeviceSyncJobStore(s => s.skipped);
const jobFail = useDeviceSyncJobStore(s => s.failed);
const jobTotal = useDeviceSyncJobStore(s => s.total);
const [activeTab, setActiveTab] = useState<SourceTab>('albums');
const [search, setSearch] = useState('');
// ─── Removable drive detection ──────────────────────────────────────────
const { drives, drivesLoading, activeDrive, driveDetected, refreshDrives } =
useDeviceSyncDrives(targetDir);
const [preSyncOpen, setPreSyncOpen] = useState(false);
const [preSyncLoading, setPreSyncLoading] = useState(false);
const [syncDelta, setSyncDelta] = useState<SyncDelta>({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
// ─── Migration (rename existing files into the fixed scheme) ────────────
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
const [migrationPairs, setMigrationPairs] = useState<MigrationPair[]>([]);
const [migrationCollisions, setMigrationCollisions] = useState<MigrationPair[]>([]);
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
const [migrationResult, setMigrationResult] = useState<MigrationResult | null>(null);
const isRunning = jobStatus === 'running';
// ─── Device scan + manifest auto-import ─────────────────────────────────
const { scanDevice } = useDeviceSyncDeviceScan(targetDir, sources.length, driveDetected, t);
// Source status (path map + derived synced/pending/deletion)
const { sourcePathsMap, sourceStatuses } = useDeviceSyncSourceStatuses(
targetDir, sources, pendingDeletion, deviceFilePaths,
);
// ─── Desired State / Diff Logic ─────────────────────────────────────────
const handleToggleSource = useCallback((source: DeviceSyncSource) => {
const isSelected = sources.some(s => s.id === source.id);
const isPendingDeletion = pendingDeletion.includes(source.id);
const isActuallySelected = isSelected && !isPendingDeletion;
if (isActuallySelected) {
// User initiated a DE-SELECTION. Diff check against target device
const isSynced = sourceStatuses.get(source.id) === 'synced';
const pathsOnDisk = sourcePathsMap.get(source.id)?.filter(p => deviceFilePaths.includes(p)).length || 0;
if (pathsOnDisk > 0 || isSynced) {
// Source currently has physical footprint. Stage for deletion.
markForDeletion([source.id]);
} else {
// Zero physical footprint. Strip safely.
removeSource(source.id);
}
} else {
// User initiated a SELECTION.
if (isPendingDeletion) {
unmarkDeletion(source.id); // Cancel queued red/strikethrough state
} else if (!isSelected) {
addSource(source); // Trigger clean pending install state
}
}
}, [sources, pendingDeletion, sourceStatuses, sourcePathsMap, deviceFilePaths, markForDeletion, removeSource, unmarkDeletion, addSource]);
// ─── Listen for background sync events ──────────────────────────────────
useDeviceSyncJobEvents(t, scanDevice);
// Browser (playlists / albums / artists tabs + their loaders + debounced search)
const {
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
artists, loadingBrowser,
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
toggleArtistExpand,
} = useDeviceSyncBrowser(activeTab, search, () => setSearch(''));
// ─── Migration handlers ─────────────────────────────────────────────────
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
targetDir, sources,
setMigrationPhase, setMigrationResult, setMigrationOldTemplate,
setMigrationPairs, setMigrationCollisions, setMigrationUnchanged,
});
const executeMigration = () => runDeviceSyncMigrationExecute({
targetDir, sources, migrationPairs,
setMigrationPhase, setMigrationResult, scanDevice,
});
const closeMigration = () => {
setMigrationPhase('closed');
setMigrationPairs([]);
setMigrationCollisions([]);
setMigrationResult(null);
setMigrationOldTemplate('');
};
const handleChooseFolder = () => runDeviceSyncChooseFolder({ t, setTargetDir, scanDevice });
// ─── Sync (non-blocking) ────────────────────────────────────────────────
const promptSyncSummary = () => runDeviceSyncSummaryPrompt({
targetDir, sources, pendingDeletion, t,
setPreSyncLoading, setPreSyncOpen, setSyncDelta,
});
const handleSyncExecution = () => runDeviceSyncExecute({
targetDir, sources, pendingDeletion, syncDelta, t,
setPreSyncOpen, removeSources, scanDevice,
});
// ─── Actions ────────────────────────────────────────────────────────────
const handleMarkCheckedForDeletion = () => {
if (checkedIds.length === 0) return;
markForDeletion(checkedIds);
};
const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id));
const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id));
const pendingCount = Array.from(sourceStatuses.values()).filter(s => s === 'pending').length;
const syncedCount = Array.from(sourceStatuses.values()).filter(s => s === 'synced').length;
const deletionCount = pendingDeletion.length;
// ─── Dynamic action button label ────────────────────────────────────────
const actionButtonLabel = useMemo(() => {
if (deletionCount > 0 && pendingCount === 0) return t('deviceSync.actionDelete');
if (pendingCount > 0 && deletionCount === 0) return t('deviceSync.actionTransfer');
if (pendingCount > 0 && deletionCount > 0) return t('deviceSync.actionApplyAll');
return t('deviceSync.syncButton'); // both zero — button will be disabled
}, [pendingCount, deletionCount, t]);
const actionButtonDisabled =
!targetDir ||
sources.length === 0 ||
isRunning ||
(!driveDetected && !!targetDir) ||
(pendingCount === 0 && deletionCount === 0);
return (
<div className="device-sync-page">
<DeviceSyncHeader
targetDir={targetDir}
setTargetDir={setTargetDir}
sources={sources}
drives={drives}
drivesLoading={drivesLoading}
activeDrive={activeDrive}
refreshDrives={refreshDrives}
scanDevice={scanDevice}
handleChooseFolder={handleChooseFolder}
startMigrationPreview={startMigrationPreview}
/>
{/* ── Main ── */}
<div className="device-sync-main">
<DeviceSyncBrowserPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
search={search}
setSearch={setSearch}
playlists={playlists}
randomAlbums={randomAlbums}
albumSearchResults={albumSearchResults}
albumSearchLoading={albumSearchLoading}
artists={artists}
loadingBrowser={loadingBrowser}
expandedArtistIds={expandedArtistIds}
artistAlbumsMap={artistAlbumsMap}
loadingArtistIds={loadingArtistIds}
toggleArtistExpand={toggleArtistExpand}
sources={sources}
pendingDeletion={pendingDeletion}
handleToggleSource={handleToggleSource}
/>
<DeviceSyncDevicePanel
sources={sources}
sourceStatuses={sourceStatuses}
driveDetected={driveDetected}
scanning={scanning}
checkedIds={checkedIds}
toggleChecked={toggleChecked}
allChecked={allChecked}
toggleAll={toggleAll}
syncedCount={syncedCount}
pendingCount={pendingCount}
deletionCount={deletionCount}
isRunning={isRunning}
actionButtonLabel={actionButtonLabel}
actionButtonDisabled={actionButtonDisabled}
promptSyncSummary={promptSyncSummary}
handleMarkCheckedForDeletion={handleMarkCheckedForDeletion}
handleToggleSource={handleToggleSource}
markForDeletion={markForDeletion}
unmarkDeletion={unmarkDeletion}
jobStatus={jobStatus}
jobDone={jobDone}
jobSkip={jobSkip}
jobFail={jobFail}
jobTotal={jobTotal}
/>
</div>
<DeviceSyncPreSyncModal
preSyncOpen={preSyncOpen}
preSyncLoading={preSyncLoading}
syncDelta={syncDelta}
onCancel={() => setPreSyncOpen(false)}
onProceed={handleSyncExecution}
/>
<DeviceSyncMigrationModal
migrationPhase={migrationPhase}
migrationOldTemplate={migrationOldTemplate}
migrationPairs={migrationPairs}
migrationCollisions={migrationCollisions}
migrationUnchanged={migrationUnchanged}
migrationResult={migrationResult}
executeMigration={executeMigration}
closeMigration={closeMigration}
/>
</div>
);
}