From b6408bf12076b250b5b760d8ba513c0998e48a21 Mon Sep 17 00:00:00 2001 From: Koha9 <36852125+Koha9@users.noreply.github.com> Date: Sat, 29 Nov 2025 04:55:41 +0900 Subject: [PATCH] Add sync controls and status header animations --- frontend/App.tsx | 255 ++++++++++++++++++++--- frontend/Config.ts | 23 ++ frontend/components/StrategySelector.tsx | 113 +++++++--- frontend/services/api.ts | 12 ++ frontend/types.ts | 7 + 5 files changed, 353 insertions(+), 57 deletions(-) create mode 100644 frontend/Config.ts diff --git a/frontend/App.tsx b/frontend/App.tsx index c51c46f..8fc9353 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,7 +1,20 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings } from './types'; +import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState } 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, + 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'; @@ -14,6 +27,93 @@ interface Toast { 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 [localPlaylists, setLocalPlaylists] = useState([]); const [cloudPlaylists, setCloudPlaylists] = useState([]); @@ -24,6 +124,11 @@ const App: React.FC = () => { const [loadingLocal, setLoadingLocal] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false); + 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); @@ -60,10 +165,10 @@ const App: React.FC = () => { return [...exitingToasts, newToast]; }); - // Auto dismiss the new toast after 3 seconds + // Auto dismiss the new toast after configured duration const dismissTimer = setTimeout(() => { setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); - }, 3000); + }, TOAST_AUTO_DISMISS_MS); timeoutsRef.current[id] = dismissTimer; }; @@ -99,7 +204,7 @@ const App: React.FC = () => { timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => { removeToast(t.id); delete timeoutsRef.current[`remove-${t.id}`]; - }, 300); + }, TOAST_EXIT_DURATION_MS); } }); }, [toasts]); @@ -210,6 +315,29 @@ const App: React.FC = () => { } }; + // Handle Sync Trigger + const handleSyncTrigger = async () => { + if (syncState !== SyncState.IDLE) return; + + setSyncState(SyncState.SYNCING); + + const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined); + + if (result.status === 'success') { + setSyncState(SyncState.SUCCESS); + + setTimeout(() => { + setSyncState(SyncState.IDLE); + refreshLocal(); + refreshCloud(); + }, SYNC_SUCCESS_TOTAL_MS); + } else { + setSyncState(SyncState.ERROR); + addToast(result.message || 'Sync failed. Please check connection.'); + setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); + } + }; + const handleConnectSuccess = async (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); if (serverInfo.libraryName) { @@ -243,29 +371,102 @@ const App: React.FC = () => {
{/* App Header */} -
-
-
-
- +
+ + {/* Syncing/Success Animated Background Layer */} + {syncState !== SyncState.IDLE && ( +
+ + {/* Left Side */} +
+
+
+
+ + {/* Right Side */} +
+
+
-

- PlexSync -

- - {/* Connection Status Button */} - + )} + + {/* Content Container */} +
+ + {syncState === SyncState.IDLE ? ( + <> + {/* Normal Toolbar */} +
+
+ +
+

+ PlexSync +

+
+ + {/* Connection Status Button */} + + + ) : ( +
+
+

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

+
+
+ )} +
@@ -314,11 +515,13 @@ const App: React.FC = () => { /* Desktop Positioning: Center Horizontally, Anchored Top */ md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2" > -
diff --git a/frontend/Config.ts b/frontend/Config.ts new file mode 100644 index 0000000..1bfb3bd --- /dev/null +++ b/frontend/Config.ts @@ -0,0 +1,23 @@ +// Animation and timing configuration centralization. +// Adjust these values for debugging or tuning animation behavior. +export const STRIPE_TILE_SIZE = 56.57; // px size for repeating background pattern +export const STRIPE_BASE_SPEED = 56.57; // px per second initial scroll speed +export const STRIPE_DECEL_DURATION_MS = 500; // ms duration of deceleration phase + +export const SYNC_SUCCESS_TOTAL_MS = 1000; // ms until header returns to idle after success +export const SYNC_ERROR_RESET_MS = 2000; // ms until reset after error state + +export const TOAST_AUTO_DISMISS_MS = 3000; // ms before toast begins exit +export const TOAST_EXIT_DURATION_MS = 300; // ms exit animation duration + +// If needed later for entrance timing tweaks +export const TOAST_ENTER_FRAME_DELAY_MS = 0; // logical placeholder (double rAF currently) + +// Helper: derive CSS backgroundSize string +export const STRIPE_BACKGROUND_SIZE = `${STRIPE_TILE_SIZE}px ${STRIPE_TILE_SIZE}px`; + +// Sync banner sizing (background behind SYNCHRONIZING / SYNC COMPLETE text) +// Adjust these to change the black rectangle size. +export const SYNC_BANNER_PADDING_X = 32; // horizontal padding in px +export const SYNC_BANNER_PADDING_Y = 6; // vertical padding in px +export const SYNC_BANNER_MIN_WIDTH = 260; // optional minimum width (px) diff --git a/frontend/components/StrategySelector.tsx b/frontend/components/StrategySelector.tsx index 12c80f9..3090b7a 100644 --- a/frontend/components/StrategySelector.tsx +++ b/frontend/components/StrategySelector.tsx @@ -1,17 +1,19 @@ import React, { useState, useRef, useEffect } from 'react'; -import { SyncStrategy, RegexReplacement } from '../types'; -import { - ArrowRightCircle, - ArrowLeftCircle, - GitMerge, +import { SyncStrategy, RegexReplacement, SyncState } from '../types'; +import { + ArrowRightCircle, + ArrowLeftCircle, + GitMerge, ChevronDown, Check, HelpCircle, Plus, Trash2, Save, - RotateCcw + RotateCcw, + Loader2, + Zap } from 'lucide-react'; interface StrategyOption { @@ -58,13 +60,17 @@ interface StrategySelectorProps { onSelect: (strategy: SyncStrategy, label: string) => void; savedRegexReplacements: RegexReplacement[]; onSaveRegex: (replacements: RegexReplacement[]) => void; + syncState: SyncState; + onSync: () => void; } -const StrategySelector: React.FC = ({ - currentStrategy, - onSelect, - savedRegexReplacements, - onSaveRegex +const StrategySelector: React.FC = ({ + currentStrategy, + onSelect, + savedRegexReplacements, + onSaveRegex, + syncState, + onSync }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -73,6 +79,9 @@ const StrategySelector: React.FC = ({ const [localReplacements, setLocalReplacements] = useState([]); const [isDirty, setIsDirty] = useState(false); + const isSyncing = syncState === SyncState.SYNCING; + const isLocked = isSyncing || syncState === SyncState.SUCCESS; + // Initialize local state when prop updates (only if not dirty, or initially) useEffect(() => { setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); @@ -98,6 +107,7 @@ const StrategySelector: React.FC = ({ }, []); const handleSelect = (strategy: StrategyOption) => { + if (isLocked) return; onSelect(strategy.value, strategy.label); }; @@ -127,8 +137,13 @@ const StrategySelector: React.FC = ({ onSaveRegex(validReplacements); }; + const handleSyncClick = () => { + if (isLocked || isDirty) return; + onSync(); + }; + return ( -
+
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */} -
+
+ + {/* Section 3: Sync Now Button */} +
+ + {isDirty && ( +

+ Please save or revert regex rules changes before syncing. +

+ )} +
); diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 396354b..d8acf59 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -155,4 +155,16 @@ export const apiService = { } return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>; }, + + async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise> { + const response = await fetch(`${API_BASE}/api/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: STRATEGY_TO_MODE[strategy], + local_path: localPath, + }), + }); + return handleResponse(response); + }, }; diff --git a/frontend/types.ts b/frontend/types.ts index dbe956b..27e8920 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -27,6 +27,13 @@ export enum SyncStrategy { MERGE_CLOUD = 'MERGE_CLOUD' } +export enum SyncState { + IDLE = 'IDLE', + SYNCING = 'SYNCING', + SUCCESS = 'SUCCESS', + ERROR = 'ERROR' +} + export interface RegexReplacement { id: string; pattern: string;