import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } 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 { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock } from 'lucide-react'; 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 [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); // Strategy State const [currentStrategy, setCurrentStrategy] = useState(SyncStrategy.LOCAL_OVERWRITE); // Regex State const [regexReplacements, setRegexReplacements] = useState([]); // Schedule State const [scheduleSettings, setScheduleSettings] = useState({ mode: ScheduleMode.DISABLED, cronExpression: '', dailyTime: '02:00', weeklyDays: [0], // Sunday weeklyTime: '03:00', autoWatch: false }); // 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; }; // 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 (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; }, []); const cancelLocalRefresh = () => { if (localAbortRef.current) { localAbortRef.current.abort(); localAbortRef.current = null; setLoadingLocal(false); addToast("Local refresh cancelled."); } }; // Fetch Cloud Playlists and Info const refreshCloud = useCallback(async () => { 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; } }, []); const cancelCloudRefresh = () => { if (cloudAbortRef.current) { cloudAbortRef.current.abort(); cloudAbortRef.current = null; setLoadingCloud(false); addToast("Cloud refresh cancelled."); } }; // Initial Load useEffect(() => { refreshLocal(); refreshCloud(); return () => { // Cleanup on unmount if (localAbortRef.current) localAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort(); } }, [refreshLocal, refreshCloud]); // Handle Strategy Change const handleStrategyChange = (strategy: SyncStrategy, label: string) => { setCurrentStrategy(strategy); addToast(`Selected strategy "${label}" has been saved.`); }; // Handle Regex Save const handleSaveRegex = (replacements: RegexReplacement[]) => { setRegexReplacements(replacements); addToast('Regex preprocessing rules have been saved.'); }; // 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("Scheduled tasks disabled."); } else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') { addToast("Scheduled tasks disabled (Empty Cron)."); } else { addToast("Scheduled task started successfully."); } return true; } else { addToast(result.message || "Failed to update schedule."); 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, regexReplacements); if (result.status === 'success') { // Transition to Success state setSyncState(SyncState.SUCCESS); // Timing Breakdown: // T+0.0s: State is SUCCESS. // - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s. // - CSS opacity transitions Yellow -> Green over 0.3s. // T+0.5s: Deceleration complete. Speed is 0. Background is static. // We hold this static state for another 0.5s. // T+1.0s: Total success duration complete. Disappear. setTimeout(() => { setSyncState(SyncState.IDLE); refreshLocal(); refreshCloud(); }, SYNC_SUCCESS_TOTAL_MS); } else { setSyncState(SyncState.ERROR); addToast("Sync failed. Please check connection."); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); } }; const handleConnectSuccess = (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); // 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; // Helper: Calculate Next Run Info const getScheduleDisplayInfo = (settings: ScheduleSettings) => { if (settings.mode === ScheduleMode.DISABLED) { return { label: 'Auto-Sync', value: 'Disabled', active: false }; } if (settings.mode === ScheduleMode.CRON) { return { label: 'Cron Schedule', value: settings.cronExpression || 'Pending...', active: true }; } 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) return { label: 'Weekly Schedule', value: 'No days selected', active: false }; // 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 = 'Today'; else if (isTomorrow) dateStr = 'Tomorrow'; else dateStr = days[nextRun.getDay()]; return { label: `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`, value: `${dateStr} at ${timeStr}`, active: true }; } return { label: 'Schedule', value: 'Not configured', active: false }; }; const scheduleInfo = getScheduleDisplayInfo(scheduleSettings); 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 */}

PlexSync

{/* Normal Toolbar Right */}
{/* Schedule Info */}
{scheduleInfo.label}
{scheduleInfo.active && } {scheduleInfo.value}
{/* Connection Status Button */}
) : ( /* Syncing / Success Text Banner */

{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}

)}
{/* 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 */}

© {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.

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