import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types'; import { apiService } from './services/api'; import { STRIPE_BASE_SPEED, STRIPE_DECEL_DURATION_MS, STRIPE_TILE_SIZE, STRIPE_BACKGROUND_SIZE, SYNC_SUCCESS_TOTAL_MS, SYNC_ERROR_RESET_MS, TOAST_AUTO_DISMISS_MS, TOAST_EXIT_DURATION_MS } from './Config'; import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config'; import ServerPanel from './components/ServerPanel'; import StrategySelector from './components/StrategySelector'; import ConnectionModal from './components/ConnectionModal'; import LoginScreen from './components/LoginScreen'; import OverflowMarquee from './components/OverflowMarquee'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut } from 'lucide-react'; import { useLanguage } from './LanguageContext'; interface Toast { id: number; message: string; exiting: boolean; entering: boolean; } // Custom hook to handle the stripe animation logic const useStripeAnimation = (syncState: SyncState) => { const leftYellowRef = useRef(null); const leftGreenRef = useRef(null); const rightYellowRef = useRef(null); const rightGreenRef = useRef(null); const requestRef = useRef(); const lastTimeRef = useRef(0); const offsetRef = useRef(0); // State tracking for deceleration const isDeceleratingRef = useRef(false); const decelStartTimeRef = useRef(0); const animate = (time: number) => { if (lastTimeRef.current === 0) lastTimeRef.current = time; const dt = (time - lastTimeRef.current) / 1000; lastTimeRef.current = time; let speed = STRIPE_BASE_SPEED; // pixels per second if (isDeceleratingRef.current) { const t = time - decelStartTimeRef.current; const duration = STRIPE_DECEL_DURATION_MS; // deceleration duration if (t >= duration) { speed = 0; } else { // Linear slow down speed = speed * (1 - (t / duration)); } } // Update offset offsetRef.current += speed * dt; const modOffset = offsetRef.current % STRIPE_TILE_SIZE; // Apply to DOM elements directly for performance const leftPos = `right ${modOffset}px top 0`; const rightPos = `left ${modOffset}px top 0`; if (leftYellowRef.current) leftYellowRef.current.style.backgroundPosition = leftPos; if (leftGreenRef.current) leftGreenRef.current.style.backgroundPosition = leftPos; if (rightYellowRef.current) rightYellowRef.current.style.backgroundPosition = rightPos; if (rightGreenRef.current) rightGreenRef.current.style.backgroundPosition = rightPos; // Continue loop if moving or if we are in the middle of decelerating if (speed > 0 || (isDeceleratingRef.current && (time - decelStartTimeRef.current) < STRIPE_DECEL_DURATION_MS)) { requestRef.current = requestAnimationFrame(animate); } }; useEffect(() => { if (syncState === SyncState.SYNCING) { isDeceleratingRef.current = false; lastTimeRef.current = 0; // Start animation loop if (!requestRef.current) { requestRef.current = requestAnimationFrame(animate); } } else if (syncState === SyncState.SUCCESS) { isDeceleratingRef.current = true; decelStartTimeRef.current = performance.now(); // Ensure loop is running to handle deceleration phase if (!requestRef.current) { requestRef.current = requestAnimationFrame(animate); } } else { // IDLE or ERROR: Stop animation if (requestRef.current) { cancelAnimationFrame(requestRef.current); requestRef.current = undefined; } offsetRef.current = 0; } return () => { if (requestRef.current) { cancelAnimationFrame(requestRef.current); requestRef.current = undefined; } }; }, [syncState]); return { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef }; }; const App: React.FC = () => { const { t, language, setLanguage } = useLanguage(); // Auth (optional; controlled by backend /api/auth/config) const [authReady, setAuthReady] = useState(false); const [authEnabled, setAuthEnabled] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); const [currentUser, setCurrentUser] = useState(''); const [localPlaylists, setLocalPlaylists] = useState([]); const [cloudPlaylists, setCloudPlaylists] = useState([]); const [cloudServerInfo, setCloudServerInfo] = useState(undefined); const [localPath, setLocalPath] = useState(''); const [connectionSettings, setConnectionSettings] = useState(null); const [loadingLocal, setLoadingLocal] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false); const [syncState, setSyncState] = useState(SyncState.IDLE); const manualSyncInProgress = useRef(false); const lastKnownSyncTimeRef = useRef(undefined); // Animation Refs const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState); // Abort Controllers for Refresh Actions const localAbortRef = useRef(null); const cloudAbortRef = useRef(null); // Connection Modal State const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); // Strategy State const [currentStrategy, setCurrentStrategy] = useState(SyncStrategy.LOCAL_OVERWRITE); // Path Mapping State (Includes Simple and Regex Rules) const [pathMappingConfig, setPathMappingConfig] = useState({ mode: PathMappingMode.SIMPLE, simple: [], regex: { localPre: [], localPost: [], remotePre: [], remotePost: [] } }); // Schedule State const [scheduleSettings, setScheduleSettings] = useState({ mode: ScheduleMode.DISABLED, cronExpression: '', dailyTime: '02:00', weeklyDays: [0], // Sunday weeklyTime: '03:00', autoWatch: false }); const [nextRunTime, setNextRunTime] = useState(undefined); // Backup State const [backupSettings, setBackupSettings] = useState({ enabled: false, retentionCount: 5 }); useEffect(() => { let cancelled = false; const initAuth = async () => { try { const cfg = await apiService.getAuthConfig(); const enabled = cfg.status === 'success' ? !!cfg.data.enabled : false; if (cancelled) return; setAuthEnabled(enabled); if (!enabled) { setIsAuthenticated(true); setAuthReady(true); return; } const savedToken = localStorage.getItem('plexsync-token'); const savedUser = localStorage.getItem('plexsync-username'); if (!savedToken || !savedUser) { setIsAuthenticated(false); setCurrentUser(''); setAuthReady(true); return; } const me = await apiService.me(); if (me.status === 'success') { setIsAuthenticated(true); setCurrentUser(me.data.username || savedUser); } else { localStorage.removeItem('plexsync-token'); localStorage.removeItem('plexsync-username'); setIsAuthenticated(false); setCurrentUser(''); } } catch { // If auth discovery fails, fall back to no-auth to keep local dev workable. setAuthEnabled(false); setIsAuthenticated(true); } finally { if (!cancelled) setAuthReady(true); } }; initAuth(); return () => { cancelled = true; }; }, []); const handleLoginSuccess = (token: string, username: string) => { localStorage.setItem('plexsync-token', token); localStorage.setItem('plexsync-username', username); setCurrentUser(username); setIsAuthenticated(true); }; const handleLogout = async () => { try { await apiService.logout(); } catch { // ignore } localStorage.removeItem('plexsync-token'); localStorage.removeItem('plexsync-username'); setIsAuthenticated(false); setCurrentUser(''); }; // Toast Notification System const [toasts, setToasts] = useState([]); const timeoutsRef = useRef<{[key: number]: ReturnType}>({}); const removeToast = (id: number) => { setToasts(prev => prev.filter(t => t.id !== id)); if (timeoutsRef.current[id]) { clearTimeout(timeoutsRef.current[id]); delete timeoutsRef.current[id]; } }; const addToast = useCallback((message: string) => { const id = Date.now(); // Start with entering: true to position it above const newToast: Toast = { id, message, exiting: false, entering: true }; setToasts(prev => { // Mark all existing toasts as exiting immediately so they slide up const exitingToasts = prev.map(t => ({ ...t, exiting: true, entering: false })); return [...exitingToasts, newToast]; }); // Auto dismiss the new toast after configured duration const dismissTimer = setTimeout(() => { setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); }, TOAST_AUTO_DISMISS_MS); timeoutsRef.current[id] = dismissTimer; }, []); // Effect to trigger the "slide down" animation useEffect(() => { const enteringIds = toasts.filter(t => t.entering).map(t => t.id); if (enteringIds.length > 0) { let raf1: number; let raf2: number; raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { setToasts(prev => prev.map(t => enteringIds.includes(t.id) ? { ...t, entering: false } : t )); }); }); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; } }, [toasts]); // Cleanup effect for exiting toasts useEffect(() => { const exitingToasts = toasts.filter(t => t.exiting); exitingToasts.forEach(t => { if (!timeoutsRef.current[`remove-${t.id}`]) { timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => { removeToast(t.id); delete timeoutsRef.current[`remove-${t.id}`]; }, TOAST_EXIT_DURATION_MS); } }); }, [toasts]); const loadSettings = useCallback(async () => { const result = await apiService.getSettings(); if (result.status === 'success') { setCurrentStrategy(result.data.strategy); setPathMappingConfig(result.data.pathMapping); setLocalPath(result.data.localPath || 'playlist'); setConnectionSettings(result.data.connection); } }, []); const loadSchedule = useCallback(async () => { const result = await apiService.getScheduleSettings(); if (result.status === 'success') { setScheduleSettings(result.data); setNextRunTime(result.data.nextRun); } }, []); const loadBackupSettings = useCallback(async () => { const result = await apiService.getBackupSettings(); if (result.status === 'success') { setBackupSettings(result.data); } }, []); // Handle Schedule Save const handleSaveSchedule = async (settings: ScheduleSettings): Promise => { const result = await apiService.saveScheduleSettings(settings); if (result.status === 'success') { setScheduleSettings(settings); // Refresh schedule info to get next run time loadSchedule(); if (settings.mode === ScheduleMode.DISABLED) { addToast(t('toasts.scheduleDisabled')); } else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') { addToast(t('toasts.scheduleEmpty')); } else { addToast(t('toasts.scheduleStarted')); } return true; } else { addToast(result.message || t('toasts.scheduleFailed')); return false; } }; // Handle Backup Settings Save const handleSaveBackupSettings = async (settings: BackupSettings) => { const result = await apiService.saveBackupSettings(settings); if (result.status === 'success') { setBackupSettings(settings); addToast(t('toasts.backupSaved')); } else { addToast(result.message || t('toasts.backupFailed')); } }; // Fetch Local Playlists const refreshLocal = useCallback(async () => { if (!authReady || !isAuthenticated) return; if (localAbortRef.current) localAbortRef.current.abort(); const abortController = new AbortController(); localAbortRef.current = abortController; setLoadingLocal(true); const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal, localPath || undefined); if (result.status === 'success') { setLocalPlaylists(result.data); } setLoadingLocal(false); localAbortRef.current = null; }, [authReady, isAuthenticated, localPath]); const cancelLocalRefresh = () => { if (localAbortRef.current) { localAbortRef.current.abort(); localAbortRef.current = null; setLoadingLocal(false); addToast(t('toasts.localRefreshCancelled')); } }; // Fetch Cloud Playlists and Info const refreshCloud = useCallback(async () => { if (!authReady || !isAuthenticated) return; if (cloudAbortRef.current) cloudAbortRef.current.abort(); const abortController = new AbortController(); cloudAbortRef.current = abortController; setLoadingCloud(true); // Fetch playlists const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal); if (!abortController.signal.aborted) { if (playlistResult.status === 'success') { setCloudPlaylists(playlistResult.data); } // Fetch server info const infoResult = await apiService.getServerStatus(abortController.signal); if (infoResult.status === 'success') { setCloudServerInfo(infoResult.data); } setLoadingCloud(false); cloudAbortRef.current = null; } }, [authReady, isAuthenticated]); const cancelCloudRefresh = () => { if (cloudAbortRef.current) { cloudAbortRef.current.abort(); cloudAbortRef.current = null; setLoadingCloud(false); addToast(t('toasts.cloudRefreshCancelled')); } }; // Load persisted configuration useEffect(() => { if (!authReady || !isAuthenticated) return; loadSettings(); loadSchedule(); loadBackupSettings(); }, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]); // Initial Load useEffect(() => { if (!authReady || !isAuthenticated) return; refreshLocal(); refreshCloud(); return () => { // Cleanup on unmount if (localAbortRef.current) localAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort(); } }, [authReady, isAuthenticated, refreshLocal, refreshCloud]); // Handle Strategy Change const handleStrategyChange = async (strategy: SyncStrategy, label: string) => { setCurrentStrategy(strategy); const result = await apiService.updateSyncStrategy(strategy); if (result.status === 'success') { addToast(t('toasts.strategySaved', { strategy: label })); } else { addToast(result.message || t('toasts.strategySaveFailed')); } }; // Handle Path Mapping Save const handleSavePathMapping = async (config: PathMappingConfig) => { setPathMappingConfig(config); const result = await apiService.savePathMapping(config); if (result.status === 'success') { addToast(t('toasts.mappingSaved')); } else { addToast(result.message || t('toasts.mappingSaveFailed')); } }; // Handle Sync Trigger const handleSyncTrigger = async () => { if (!authReady || !isAuthenticated) return; if (syncState !== SyncState.IDLE) return; setSyncState(SyncState.SYNCING); manualSyncInProgress.current = true; const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined); manualSyncInProgress.current = false; if (result.status === 'success') { setSyncState(SyncState.SUCCESS); setTimeout(() => { setSyncState(SyncState.IDLE); refreshLocal(); refreshCloud(); }, SYNC_SUCCESS_TOTAL_MS); } else { setSyncState(SyncState.ERROR); addToast(result.message || t('toasts.syncFailed')); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); } }; // SSE for sync status useEffect(() => { if (!authReady || !isAuthenticated) return; const base = `${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`; const url = new URL(base, window.location.origin); if (authEnabled) { const token = localStorage.getItem('plexsync-token'); if (!token) return; url.searchParams.set('access_token', token); } const eventSource = new EventSource(url.toString()); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); const { is_syncing, status, error, last_sync_time } = data; // Initialize lastKnownSyncTime if it's the first event if (lastKnownSyncTimeRef.current === undefined) { lastKnownSyncTimeRef.current = last_sync_time; // If we are currently syncing on load, show it if (is_syncing && !manualSyncInProgress.current) { setSyncState(SyncState.SYNCING); } return; } // If manual sync is in progress, we ignore background updates to avoid state conflict if (manualSyncInProgress.current) { if (last_sync_time !== lastKnownSyncTimeRef.current) { lastKnownSyncTimeRef.current = last_sync_time; } return; } // Handle Syncing State if (is_syncing) { if (syncState !== SyncState.SYNCING) { setSyncState(SyncState.SYNCING); } } else { // Check for completion by comparing timestamps if (last_sync_time !== lastKnownSyncTimeRef.current) { lastKnownSyncTimeRef.current = last_sync_time; // A sync has completed since our last check if (status === 'success') { setSyncState(SyncState.SUCCESS); refreshLocal(); refreshCloud(); addToast(t('toasts.backgroundSyncSuccess')); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS); } else if (status === 'error') { setSyncState(SyncState.ERROR); addToast(t('toasts.backgroundSyncFailed', { error: error || '' })); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); } } else { // Edge case: We are in SYNCING state but backend says not syncing, and time hasn't changed. if (syncState === SyncState.SYNCING) { setSyncState(SyncState.IDLE); } } } } catch (e) { console.error("Failed to parse SSE event", e); } }; eventSource.onerror = (err) => { console.error("EventSource failed:", err); eventSource.close(); }; return () => { eventSource.close(); }; }, [authReady, isAuthenticated, authEnabled, syncState, refreshLocal, refreshCloud, addToast]); if (!authReady) { return (
{t('common.loading')}
); } if (authEnabled && !isAuthenticated) { return ( addToast(msg)} /> ); } const handleConnectSuccess = async (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); if (serverInfo.libraryName) { await apiService.updateLibrary(serverInfo.libraryName); } // Reload settings to ensure we have the latest connection details (protocol, etc.) await loadSettings(); // Refresh playlists after new connection refreshCloud(); }; const getToastStyles = (toast: Toast): React.CSSProperties => { if (toast.exiting || toast.entering) { return { opacity: 0, transform: 'translateY(-40px) scale(0.95)', }; } return { opacity: 1, transform: 'translateY(0) scale(1)', }; }; const getToastClasses = () => { return "absolute top-2 flex items-center space-x-2 px-4 py-2 rounded-full shadow-lg border text-sm font-medium pointer-events-auto bg-gray-800 text-plex-orange border-plex-orange/30 transition-all duration-300 ease-out origin-top z-50 backdrop-blur-md"; }; const isConnected = cloudServerInfo?.isConnected; const getScheduleDisplayInfo = () => { const result = { label: t('dashboard.autoSync'), value: t('schedule.notConfigured'), active: false, autoWatch: scheduleSettings.autoWatch, }; if (scheduleSettings.mode === ScheduleMode.DISABLED) { result.value = t('common.disabled'); return result; } if (scheduleSettings.mode === ScheduleMode.CRON && scheduleSettings.cronExpression.trim() === '') { result.value = t('dashboard.notSet'); } else { result.value = nextRunTime ? `${nextRunTime}` : t('common.loading'); } result.active = true; return result; }; const scheduleInfo = getScheduleDisplayInfo(); // Helper: Calculate Path Mapping Info const getPathMappingDisplayInfo = (config: PathMappingConfig) => { let count = 0; let Icon = Type; if (config.mode === PathMappingMode.SIMPLE) { count = config.simple.length; Icon = Type; } else { count = config.regex.localPre.length + config.regex.localPost.length + config.regex.remotePre.length + config.regex.remotePost.length; Icon = Code2; } if (count === 0) { return { label: t('dashboard.mapping'), value: t('dashboard.notSet'), active: false, Icon, }; } const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex'); return { label: t('dashboard.mapping'), value: `${modeLabel} (${count})`, active: true, Icon, }; }; const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig); // Helper: Calculate Backup Info const getBackupDisplayInfo = (settings: BackupSettings) => { if (!settings.enabled) { return { label: t('dashboard.backup'), value: t('common.disabled'), active: false, }; } return { label: t('dashboard.backup'), value: t('dashboard.keep', { count: settings.retentionCount }), active: true, }; }; const backupInfo = getBackupDisplayInfo(backupSettings); return (
{/* App Header */}
{/* Syncing/Success Animated Background Layer */} {syncState !== SyncState.IDLE && (
{/* Left Side */}
{/* Right Side */}
)} {/* Content Container */}
{syncState === SyncState.IDLE ? ( <> {/* Normal Toolbar Left */}

PMS Playlist Sync

{/* Normal Toolbar Right */}
{/* Unified Status Dock */}
{/* Path Mapping Section */}
{pathMappingInfo.label}
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
{/* Backup Section */}
{backupInfo.label}
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
{/* Schedule Section */}
{scheduleInfo.label} {/* Watch Indicator Badge */}
{scheduleInfo.autoWatch ? : } {t('dashboard.watch')}
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
{/* Language Switcher */}
{isLangMenuOpen && ( <>
setIsLangMenuOpen(false)}>
)}
{/* Connection Status Button */} {/* Logout (rightmost) */} {authEnabled && isAuthenticated && ( )}
) : ( /* Syncing / Success Text Banner */

{syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}

)}
{/* Notification Toasts Container */}
{toasts.map((toast) => (
{toast.message}
))}
{/* Main Content Area */}
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
{/* Left Column - Local */}
{/* Strategy Selector - Positioned specifically between headers */}
{/* Right Column - Cloud */}
{/* Footer */}

{t('app.footer', { year: new Date().getFullYear() })}

{/* Modals */} setIsConnectionModalOpen(false)} onConnectSuccess={handleConnectSuccess} onShowMessage={addToast} initialSettings={connectionSettings || undefined} />
); }; export default App;