From 4158d999dec87b73f59b3ee5d51e2f3296ee3ad7 Mon Sep 17 00:00:00 2001 From: Koha9 Date: Sat, 29 Nov 2025 03:53:38 +0900 Subject: [PATCH] Squashed 'sample-front-end/' changes from 601ffe4..552f9c4 552f9c4 feat: Centralize animation and timing constants cc962c2 feat: Adjust sync animation gradient e623426 feat: Add playlist sync functionality and animations git-subtree-dir: sample-front-end git-subtree-split: 552f9c471324793b85af14534e81d45d319036a2 --- App.tsx | 270 +++++++++++++++++++++++--- Config.ts | 23 +++ components/StrategySelector.tsx | 323 +++++++++++++++++++------------- index.html | 21 +++ services/api.ts | 22 ++- types.ts | 9 +- 6 files changed, 506 insertions(+), 162 deletions(-) create mode 100644 Config.ts diff --git a/App.tsx b/App.tsx index a0e94d2..cdf6911 100644 --- a/App.tsx +++ b/App.tsx @@ -1,7 +1,17 @@ - import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement } from './types'; +import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, 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 +} 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'; @@ -14,6 +24,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([]); @@ -22,6 +119,12 @@ const App: React.FC = () => { 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); @@ -59,9 +162,9 @@ const App: React.FC = () => { }); // Auto dismiss the new toast after 3 seconds - const dismissTimer = setTimeout(() => { + const dismissTimer = setTimeout(() => { setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); - }, 3000); + }, TOAST_AUTO_DISMISS_MS); timeoutsRef.current[id] = dismissTimer; }; @@ -97,7 +200,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]); @@ -183,6 +286,41 @@ const App: React.FC = () => { addToast('Regex preprocessing rules have been saved.'); }; + // 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 @@ -212,29 +350,107 @@ const App: React.FC = () => {
{/* 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 */} +
-

- PlexSync -

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

+ PlexSync +

+
+ + {/* Connection Status Button */} + + + ) : ( + /* Syncing / Success Text Banner */ +
+
+

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

+
+
+ )} - {/* Connection Status Button */} -
@@ -288,6 +504,8 @@ const App: React.FC = () => { onSelect={handleStrategyChange} savedRegexReplacements={regexReplacements} onSaveRegex={handleSaveRegex} + syncState={syncState} + onSync={handleSyncTrigger} />
@@ -322,4 +540,4 @@ const App: React.FC = () => { ); }; -export default App; +export default App; \ No newline at end of file diff --git a/Config.ts b/Config.ts new file mode 100644 index 0000000..c332e3a --- /dev/null +++ b/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) \ No newline at end of file diff --git a/components/StrategySelector.tsx b/components/StrategySelector.tsx index 12c80f9..3de2ae4 100644 --- a/components/StrategySelector.tsx +++ b/components/StrategySelector.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; -import { SyncStrategy, RegexReplacement } from '../types'; +import { SyncStrategy, RegexReplacement, SyncState } from '../types'; import { ArrowRightCircle, ArrowLeftCircle, @@ -11,7 +11,9 @@ import { Plus, Trash2, Save, - RotateCcw + RotateCcw, + Zap, + Loader2 } 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 + 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,35 +107,49 @@ const StrategySelector: React.FC = ({ }, []); const handleSelect = (strategy: StrategyOption) => { + if (isLocked) return; onSelect(strategy.value, strategy.label); }; // Regex Handlers const handleAddRegex = () => { + if (isLocked) return; const newId = Date.now().toString(); setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]); }; const handleDeleteRegex = (id: string) => { + if (isLocked) return; setLocalReplacements(prev => prev.filter(r => r.id !== id)); }; const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => { + if (isLocked) return; setLocalReplacements(prev => prev.map(r => r.id === id ? { ...r, [field]: value } : r )); }; const handleReset = () => { + if (isLocked) return; setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); }; const handleSave = () => { + if (isLocked) return; const validReplacements = localReplacements.filter(r => r.pattern.trim() !== ''); setLocalReplacements(validReplacements); onSaveRegex(validReplacements); }; + const handleSyncClick = () => { + if (isLocked) return; + onSync(); + }; + + // If syncing or locked, apply grayscale filter to content sections + const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all"; + return (
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */} @@ -154,140 +177,174 @@ const StrategySelector: React.FC = ({ transition-all duration-200 ease-out ${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`} > - {/* Section 1: Sync Strategy */} -
-

Sync Strategy

-
- {STRATEGIES.map((strategy) => ( -
handleSelect(strategy)} - className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${ - currentStrategy === strategy.value - ? 'bg-white/10 border-white/10 shadow-sm' - : 'hover:bg-white/5 border-transparent' - }`} - > -
- - - {strategy.label} - +
+ {/* Section 1: Sync Strategy */} +
+

Sync Strategy

+
+ {STRATEGIES.map((strategy) => ( +
handleSelect(strategy)} + className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${ + currentStrategy === strategy.value + ? 'bg-white/10 border-white/10 shadow-sm' + : 'hover:bg-white/5 border-transparent' + }`} + > +
+ + + {strategy.label} + +
+ +
+
+ +
+ {strategy.description} +
+
+ + {currentStrategy === strategy.value && ( + + )} +
- -
-
- -
- {strategy.description} -
-
- - {currentStrategy === strategy.value && ( - - )} + ))} +
+
+ + {/* Section 2: Regex Preprocessing */} +
+
+

Regex Rules

+ {localReplacements.length === 0 && ( + + )}
-
- ))} -
+ +
+ {localReplacements.length === 0 ? ( +
+ No regex replacements configured. +
+ ) : ( + localReplacements.map((regex) => ( +
+
+ handleUpdateRegex(regex.id, 'pattern', e.target.value)} + className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600 + ${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`} + /> +
+
+ +
+
+ handleUpdateRegex(regex.id, 'replacement', e.target.value)} + className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600" + /> +
+ +
+ )) + )} +
+ + {/* Actions */} +
+ {localReplacements.length > 0 && ( +
+ +
+ )} + +
+ + +
+
+
- {/* Section 2: Regex Preprocessing */} -
-
-

Regex Rules

- {localReplacements.length === 0 && ( - - )} -
- -
- {localReplacements.length === 0 ? ( -
- No regex replacements configured. -
- ) : ( - localReplacements.map((regex) => ( -
-
- handleUpdateRegex(regex.id, 'pattern', e.target.value)} - className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600 - ${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`} - /> -
-
- -
-
- handleUpdateRegex(regex.id, 'replacement', e.target.value)} - className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600" - /> -
- -
- )) - )} -
- - {/* Actions */} -
- {localReplacements.length > 0 && ( -
- -
- )} - -
- - -
-
+ {/* Section 3: Sync Now Button */} +
+ + {isDirty && ( +

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

+ )}
diff --git a/index.html b/index.html index 313a4b5..f33b125 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,10 @@ darker: '#111827', card: '#374151' } + }, + animation: { + 'scroll-out-left': 'scroll-out-left 1s linear infinite', + 'scroll-out-right': 'scroll-out-right 1s linear infinite', } } } @@ -36,6 +40,23 @@ ::-webkit-scrollbar-thumb:hover { background: #6b7280; } + + /* + Symmetrical Diagonal Scroll Animations + Pattern width: 40px (20px color + 20px transparent). + Diagonal length: 40 * sqrt(2) ≈ 56.57px. + + Left Side: Anchored to Right (Center). Moves Left (increases right offset). + Right Side: Anchored to Left (Center). Moves Right (increases left offset). + */ + @keyframes scroll-out-left { + 0% { background-position: right 0 top 0; } + 100% { background-position: right 56.57px top 0; } + } + @keyframes scroll-out-right { + 0% { background-position: left 0 top 0; } + 100% { background-position: left 56.57px top 0; } + }