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('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({ 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('closed'); const [migrationOldTemplate, setMigrationOldTemplate] = useState(''); const [migrationPairs, setMigrationPairs] = useState([]); const [migrationCollisions, setMigrationCollisions] = useState([]); const [migrationUnchanged, setMigrationUnchanged] = useState(0); const [migrationResult, setMigrationResult] = useState(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 (
{/* ── Main ── */}
setPreSyncOpen(false)} onProceed={handleSyncExecution} />
); }