Add sync controls and status header animations
This commit is contained in:
+229
-26
@@ -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<HTMLDivElement>(null);
|
||||
const leftGreenRef = useRef<HTMLDivElement>(null);
|
||||
const rightYellowRef = useRef<HTMLDivElement>(null);
|
||||
const rightGreenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const requestRef = useRef<number>();
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const offsetRef = useRef<number>(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<Playlist[]>([]);
|
||||
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
||||
@@ -24,6 +124,11 @@ const App: React.FC = () => {
|
||||
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
||||
|
||||
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
|
||||
|
||||
// Animation Refs
|
||||
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
|
||||
|
||||
// Abort Controllers for Refresh Actions
|
||||
const localAbortRef = useRef<AbortController | null>(null);
|
||||
const cloudAbortRef = useRef<AbortController | null>(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 = () => {
|
||||
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
|
||||
|
||||
{/* App Header */}
|
||||
<header className="flex-none bg-gray-800/80 border-b border-white/5 shadow-md z-20 relative backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gradient-to-br from-plex-orange to-yellow-600 p-1.5 rounded-lg text-gray-900 shadow-lg shadow-plex-orange/20">
|
||||
<ArrowLeftRight size={24} strokeWidth={2.5} />
|
||||
<header className={`flex-none shadow-md z-20 relative backdrop-blur-md transition-all duration-500 ease-in-out h-16 ${syncState === SyncState.IDLE ? 'bg-gray-800/80 border-b border-white/5' : 'bg-black border-none'}`}>
|
||||
|
||||
{/* Syncing/Success Animated Background Layer */}
|
||||
{syncState !== SyncState.IDLE && (
|
||||
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black">
|
||||
|
||||
{/* Left Side */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
||||
<div
|
||||
ref={leftYellowRef}
|
||||
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{
|
||||
backgroundPosition: 'right 0 top 0',
|
||||
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
|
||||
backgroundSize: STRIPE_BACKGROUND_SIZE,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={leftGreenRef}
|
||||
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{
|
||||
backgroundPosition: 'right 0 top 0',
|
||||
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
|
||||
backgroundSize: STRIPE_BACKGROUND_SIZE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
||||
<div
|
||||
ref={rightYellowRef}
|
||||
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{
|
||||
backgroundPosition: 'left 0 top 0',
|
||||
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
|
||||
backgroundSize: STRIPE_BACKGROUND_SIZE,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={rightGreenRef}
|
||||
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{
|
||||
backgroundPosition: 'left 0 top 0',
|
||||
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
|
||||
backgroundSize: STRIPE_BACKGROUND_SIZE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-white">
|
||||
Plex<span className="text-plex-orange">Sync</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Button */}
|
||||
<button
|
||||
onClick={() => setIsConnectionModalOpen(true)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${
|
||||
isConnected
|
||||
? 'bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20'
|
||||
: 'bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20'
|
||||
}`}
|
||||
title={isConnected ? "Connected to Plex" : "Disconnected"}
|
||||
>
|
||||
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="relative max-w-7xl mx-auto px-4 md:px-6 h-full flex items-center justify-between">
|
||||
|
||||
{syncState === SyncState.IDLE ? (
|
||||
<>
|
||||
{/* Normal Toolbar */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
|
||||
<ArrowLeftRight size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-white">
|
||||
Plex<span className="text-plex-orange">Sync</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Button */}
|
||||
<button
|
||||
onClick={() => setIsConnectionModalOpen(true)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${
|
||||
isConnected
|
||||
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
|
||||
}`}
|
||||
title={isConnected ? "Connected to Plex" : "Disconnected"}
|
||||
>
|
||||
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div
|
||||
className="bg-black shadow-none rounded-none border-none"
|
||||
style={{
|
||||
padding: `${SYNC_BANNER_PADDING_Y}px ${SYNC_BANNER_PADDING_X}px`,
|
||||
minWidth: `${SYNC_BANNER_MIN_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
|
||||
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<StrategySelector
|
||||
<StrategySelector
|
||||
currentStrategy={currentStrategy}
|
||||
onSelect={handleStrategyChange}
|
||||
savedRegexReplacements={regexReplacements}
|
||||
onSaveRegex={handleSaveRegex}
|
||||
syncState={syncState}
|
||||
onSync={handleSyncTrigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user