import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, 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 { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut, User } 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(undefined); 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 State const [isAuthenticated, setIsAuthenticated] = useState(false); const [currentUser, setCurrentUser] = useState(''); // App Data State const [localPlaylists, setLocalPlaylists] = useState([]); const [cloudPlaylists, setCloudPlaylists] = useState([]); const [cloudServerInfo, setCloudServerInfo] = useState(undefined); const [loadingLocal, setLoadingLocal] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false); // Sync State const [syncState, setSyncState] = useState(SyncState.IDLE); // 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 }); // Backup State const [backupSettings, setBackupSettings] = useState({ enabled: false, retentionCount: 5 }); // 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 = (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 3 seconds const dismissTimer = setTimeout(() => { setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); }, TOAST_AUTO_DISMISS_MS); timeoutsRef.current[id] = dismissTimer; }; // Check auth on mount useEffect(() => { const savedToken = localStorage.getItem('plexsync-token'); const savedUser = localStorage.getItem('plexsync-username'); if (savedToken && savedUser) { setIsAuthenticated(true); setCurrentUser(savedUser); } }, []); // 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]); // Fetch Local Playlists const refreshLocal = useCallback(async () => { if (!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); if (result.status === 'success') { setLocalPlaylists(result.data); } setLoadingLocal(false); localAbortRef.current = null; }, [isAuthenticated]); 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 (!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; } }, [isAuthenticated]); const cancelCloudRefresh = () => { if (cloudAbortRef.current) { cloudAbortRef.current.abort(); cloudAbortRef.current = null; setLoadingCloud(false); addToast(t('toasts.cloudRefreshCancelled')); } }; // Initial Load (Only if Authenticated) useEffect(() => { if (isAuthenticated) { refreshLocal(); refreshCloud(); } return () => { // Cleanup on unmount if (localAbortRef.current) localAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort(); } }, [isAuthenticated, refreshLocal, refreshCloud]); // Handle Strategy Change const handleStrategyChange = (strategy: SyncStrategy, label: string) => { setCurrentStrategy(strategy); addToast(t('toasts.strategySaved', { strategy: label })); }; // Handle Path Mapping Save const handleSavePathMapping = (config: PathMappingConfig) => { setPathMappingConfig(config); addToast(t('toasts.mappingSaved')); }; // 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(t('toasts.backupFailed')); } }; // Handle Schedule Save const handleSaveSchedule = async (settings: ScheduleSettings): Promise => { // Call API (validation happens in Mock) const result = await apiService.saveScheduleSettings(settings); if (result.status === 'success') { // Only update local state if successful setScheduleSettings(settings); 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 Sync Trigger const handleSyncTrigger = async () => { if (syncState !== SyncState.IDLE) return; setSyncState(SyncState.SYNCING); // Note: We deliberately do not clear playlists here to keep UI populated during sync const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig); if (result.status === 'success') { // Transition to Success state setSyncState(SyncState.SUCCESS); // Timing Breakdown: // T+0.0s: State is SUCCESS. setTimeout(() => { setSyncState(SyncState.IDLE); refreshLocal(); refreshCloud(); }, SYNC_SUCCESS_TOTAL_MS); } else { setSyncState(SyncState.ERROR); addToast(t('toasts.syncFailed')); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); } }; const handleConnectSuccess = (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); // Refresh playlists after new connection refreshCloud(); }; const handleLoginSuccess = (token: string, username: string) => { localStorage.setItem('plexsync-token', token); localStorage.setItem('plexsync-username', username); setIsAuthenticated(true); setCurrentUser(username); addToast(t('auth.welcome', { user: username })); }; const handleLoginError = (msg: string) => { // Toast handles error display, or LoginScreen internal state handles UI addToast(msg); }; const handleLogout = () => { localStorage.removeItem('plexsync-token'); localStorage.removeItem('plexsync-username'); setIsAuthenticated(false); setCurrentUser(''); addToast(t('toasts.loggedOut')); }; 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; // Helper: Calculate Next Run Info const getScheduleDisplayInfo = (settings: ScheduleSettings) => { const result = { label: t('schedule.schedule'), value: t('schedule.notConfigured'), active: false, autoWatch: settings.autoWatch }; if (settings.mode === ScheduleMode.DISABLED) { result.label = t('dashboard.autoSync'); result.value = t('common.disabled'); return result; } if (settings.mode === ScheduleMode.CRON) { result.label = t('schedule.cron'); result.value = settings.cronExpression || t('server.waiting'); result.active = true; return result; } const now = new Date(); const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; let nextRun: Date | null = null; let timeStr = ''; if (settings.mode === ScheduleMode.DAILY) { const [h, m] = settings.dailyTime.split(':').map(Number); const target = new Date(); target.setHours(h, m, 0, 0); timeStr = settings.dailyTime; if (now < target) { nextRun = target; } else { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(h, m, 0, 0); nextRun = tomorrow; } } else if (settings.mode === ScheduleMode.WEEKLY) { timeStr = settings.weeklyTime; const [h, m] = settings.weeklyTime.split(':').map(Number); const activeDays = [...settings.weeklyDays].sort(); if (activeDays.length === 0) { result.label = t('schedule.weekly'); result.value = t('common.none'); return result; } // Check rest of today if (activeDays.includes(now.getDay())) { const todayTarget = new Date(); todayTarget.setHours(h, m, 0, 0); if (todayTarget > now) { nextRun = todayTarget; } } // Check future days if (!nextRun) { for (let i = 1; i <= 7; i++) { const nextDayIndex = (now.getDay() + i) % 7; if (activeDays.includes(nextDayIndex)) { const d = new Date(); d.setDate(now.getDate() + i); d.setHours(h, m, 0, 0); nextRun = d; break; } } } } if (nextRun) { // Format logic const isToday = nextRun.getDate() === now.getDate() && nextRun.getMonth() === now.getMonth(); const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate(); let dateStr = ''; if (isToday) dateStr = t('schedule.today'); else if (isTomorrow) dateStr = t('schedule.tomorrow'); else dateStr = days[nextRun.getDay()]; result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly'); result.value = `${dateStr} @ ${timeStr}`; result.active = true; return result; } return result; }; const scheduleInfo = getScheduleDisplayInfo(scheduleSettings); // Helper: Calculate Path Mapping Info const getPathMappingDisplayInfo = (config: PathMappingConfig) => { let count = 0; let modeLabel = ''; let Icon = Type; if (config.mode === PathMappingMode.SIMPLE) { modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping modeLabel = 'Simple'; count = config.simple.length; Icon = Type; } else { modeLabel = 'Regex'; 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: Icon }; } return { label: t('dashboard.mapping'), value: `${modeLabel} (${count})`, active: true, Icon: 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); // If not authenticated, show Login Screen if (!isAuthenticated) { return ( <> {/* Render toasts over login screen if needed (e.g. login errors pushed to global toast) */}
{toasts.map((toast) => (
{toast.message}
))}
); } return (
{/* App Header */}
{/* Syncing/Success Animated Background Layer */} {syncState !== SyncState.IDLE && (
{/* Left Side: Gradient 135deg (TR -> BL /), Anchored RIGHT (Center). Moves LEFT (Right offset increases). */}
{/* Layer 1: Yellow (Syncing) */}
{/* Layer 2: Green (Success) - Fade In */}
{/* Right Side: Gradient 225deg (TL -> BR \), Anchored LEFT (Center). Moves RIGHT (Left offset increases). */}
{/* Layer 1: Yellow (Syncing) */}
{/* Layer 2: Green (Success) - Fade In */}
)} {/* Content Container */}
{syncState === SyncState.IDLE ? ( <> {/* Normal Toolbar Left */}

PMS Playlist Sync

{/* Normal Toolbar Right */}
{/* Unified Status Dock */}
{/* Path Mapping Section */}
{pathMappingInfo.label}
{pathMappingInfo.value}
{/* Backup Section */}
{backupInfo.label}
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
{/* Schedule Section */}
{t('dashboard.autoSync')} {/* Watch Indicator Badge */}
{scheduleInfo.autoWatch ? : } {t('dashboard.watch')}
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
{/* Language Switcher */}
{isLangMenuOpen && ( <>
setIsLangMenuOpen(false)}>
)}
{/* Connection Status Button */} {/* Logout Button */}
) : ( /* 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} />
); }; export default App;