2 Commits

Author SHA1 Message Date
Koha9 da6056c1ae Merge commit 'b6408bf12076b250b5b760d8ba513c0998e48a21' 2025-11-29 07:43:17 +09:00
Koha9 b6408bf120 Add sync controls and status header animations 2025-11-29 04:55:41 +09:00
5 changed files with 353 additions and 57 deletions
+229 -26
View File
@@ -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} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
</h1>
</div>
<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>
</div>
)}
{/* 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>
)}
{/* 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>
</header>
@@ -319,6 +520,8 @@ const App: React.FC = () => {
onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex}
syncState={syncState}
onSync={handleSyncTrigger}
/>
</div>
+23
View File
@@ -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)
+64 -13
View File
@@ -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,
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<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedRegexReplacements,
onSaveRegex
onSaveRegex,
syncState,
onSync
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -73,6 +79,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
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<StrategySelectorProps> = ({
}, []);
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
};
@@ -127,8 +137,13 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSaveRegex(validReplacements);
};
const handleSyncClick = () => {
if (isLocked || isDirty) return;
onSync();
};
return (
<div className="relative group" ref={dropdownRef}>
<div className={`relative group ${isLocked ? 'opacity-80' : ''}`} ref={dropdownRef}>
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
<button
onClick={() => setIsOpen(!isOpen)}
@@ -221,7 +236,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
placeholder="Regex Pattern"
value={regex.pattern}
onChange={(e) => 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
disabled={isLocked}
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 disabled:opacity-60 disabled:cursor-not-allowed
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
/>
</div>
@@ -234,12 +250,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => 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"
disabled={isLocked}
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 disabled:opacity-60 disabled:cursor-not-allowed"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
disabled={isLocked}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Delete Rule"
>
<Trash2 size={14} />
@@ -255,7 +273,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex justify-center">
<button
onClick={handleAddRegex}
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
disabled={isLocked}
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus size={12} />
<span className="font-medium">Add Rule</span>
@@ -266,9 +285,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleReset}
disabled={!isDirty}
disabled={!isDirty || isLocked}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all
${isDirty
${isDirty && !isLocked
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
@@ -277,18 +296,50 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</button>
<button
onClick={handleSave}
disabled={!isDirty}
disabled={!isDirty || isLocked}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all
${isDirty
${isDirty && !isLocked
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
<Save size={14} />
<span>Save Changes</span>
</button>
</div>
</div>
</div>
</div>
{/* Section 3: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5">
<button
onClick={handleSyncClick}
disabled={isLocked || isDirty}
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`}
>
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
</>
)}
</button>
{isDirty && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save or revert regex rules changes before syncing.
</p>
)}
</div>
</div>
</div>
);
+12
View File
@@ -155,4 +155,16 @@ export const apiService = {
}
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
},
async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise<ApiResponse<null>> {
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);
},
};
+7
View File
@@ -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;