mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Introduce display settings for real-time latency and search mode, enhance video player controls, and add a generic UI switch component.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Ping API Route - Measures latency to video sources
|
||||
* Returns response time for real-time latency display
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { url } = body;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Use HEAD request for faster ping (less data transfer)
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors', // Allow cross-origin requests
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch (fetchError) {
|
||||
// If HEAD fails, try GET with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
// Still return latency even on error (timeout = slow)
|
||||
return NextResponse.json({ latency, success: false, timeout: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ping error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+80
-10
@@ -1,18 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VideoPlayer } from '@/components/player/VideoPlayer';
|
||||
import { VideoMetadata } from '@/components/player/VideoMetadata';
|
||||
import { EpisodeList } from '@/components/player/EpisodeList';
|
||||
import { PlayerError } from '@/components/player/PlayerError';
|
||||
import { SourceSelector, SourceInfo } from '@/components/player/SourceSelector';
|
||||
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import { PlayerNavbar } from '@/components/player/PlayerNavbar';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import Image from 'next/image';
|
||||
|
||||
function PlayerContent() {
|
||||
@@ -24,6 +26,30 @@ function PlayerContent() {
|
||||
const source = searchParams.get('source');
|
||||
const title = searchParams.get('title');
|
||||
const episodeParam = searchParams.get('episode');
|
||||
const groupedSourcesParam = searchParams.get('groupedSources');
|
||||
|
||||
// Parse grouped sources if available
|
||||
const groupedSources = useMemo<SourceInfo[]>(() => {
|
||||
if (!groupedSourcesParam) return [];
|
||||
try {
|
||||
return JSON.parse(groupedSourcesParam);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [groupedSourcesParam]);
|
||||
|
||||
// Track current source for switching
|
||||
const [currentSourceId, setCurrentSourceId] = useState(source);
|
||||
|
||||
// Track settings
|
||||
const [isReversed, setIsReversed] = useState(() =>
|
||||
typeof window !== 'undefined' ? settingsStore.getSettings().episodeReverseOrder : false
|
||||
);
|
||||
|
||||
// Sync with store changes if any (though usually it's one-way from UI to store)
|
||||
useEffect(() => {
|
||||
setIsReversed(settingsStore.getSettings().episodeReverseOrder);
|
||||
}, []);
|
||||
|
||||
// Redirect if no video ID or source
|
||||
if (!videoId || !source) {
|
||||
@@ -41,7 +67,7 @@ function PlayerContent() {
|
||||
setPlayUrl,
|
||||
setVideoError,
|
||||
fetchVideoDetails,
|
||||
} = useVideoPlayer(videoId, source, episodeParam);
|
||||
} = useVideoPlayer(videoId, source, episodeParam, isReversed);
|
||||
|
||||
// Add initial history entry when video data is loaded
|
||||
useEffect(() => {
|
||||
@@ -78,12 +104,29 @@ function PlayerContent() {
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleToggleReverse = (reversed: boolean) => {
|
||||
setIsReversed(reversed);
|
||||
const settings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
episodeReverseOrder: reversed
|
||||
});
|
||||
};
|
||||
|
||||
// Handle auto-next episode
|
||||
const handleNextEpisode = () => {
|
||||
const episodes = videoData?.episodes;
|
||||
if (!episodes || currentEpisode >= episodes.length - 1) return;
|
||||
if (!episodes) return;
|
||||
|
||||
let nextIndex;
|
||||
if (!isReversed) {
|
||||
if (currentEpisode >= episodes.length - 1) return;
|
||||
nextIndex = currentEpisode + 1;
|
||||
} else {
|
||||
if (currentEpisode <= 0) return;
|
||||
nextIndex = currentEpisode - 1;
|
||||
}
|
||||
|
||||
const nextIndex = currentEpisode + 1;
|
||||
const nextEpisode = episodes[nextIndex];
|
||||
if (nextEpisode) {
|
||||
handleEpisodeClick(nextEpisode, nextIndex);
|
||||
@@ -118,6 +161,7 @@ function PlayerContent() {
|
||||
onBack={() => router.back()}
|
||||
totalEpisodes={videoData?.episodes?.length || 1}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
isReversed={isReversed}
|
||||
/>
|
||||
<VideoMetadata
|
||||
videoData={videoData}
|
||||
@@ -144,13 +188,39 @@ function PlayerContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Episodes Sidebar */}
|
||||
{/* Sidebar with sticky wrapper */}
|
||||
<div className="lg:col-span-1">
|
||||
<EpisodeList
|
||||
episodes={videoData?.episodes || null}
|
||||
currentEpisode={currentEpisode}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
/>
|
||||
<div className="lg:sticky lg:top-32 space-y-6">
|
||||
<EpisodeList
|
||||
episodes={videoData?.episodes || null}
|
||||
currentEpisode={currentEpisode}
|
||||
isReversed={isReversed}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
onToggleReverse={handleToggleReverse}
|
||||
/>
|
||||
|
||||
{/* Source Selector - only show when grouped sources available */}
|
||||
{groupedSources.length > 1 && (
|
||||
<SourceSelector
|
||||
sources={groupedSources}
|
||||
currentSource={currentSourceId || source || ''}
|
||||
onSourceChange={(newSource) => {
|
||||
// Navigate to same video with different source
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(newSource.id));
|
||||
params.set('source', newSource.source);
|
||||
params.set('title', title || '');
|
||||
if (groupedSourcesParam) {
|
||||
params.set('groupedSources', groupedSourcesParam);
|
||||
}
|
||||
setCurrentSourceId(newSource.source);
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
// Trigger refetch
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { settingsStore, getDefaultSources, type SortOption } from '@/lib/store/settings-store';
|
||||
import { settingsStore, getDefaultSources, type SortOption, type SearchDisplayMode } from '@/lib/store/settings-store';
|
||||
import type { VideoSource, SourceSubscription } from '@/lib/types';
|
||||
import {
|
||||
type ImportResult,
|
||||
@@ -23,6 +23,10 @@ export function useSettingsPage() {
|
||||
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
|
||||
const [envPasswordSet, setEnvPasswordSet] = useState(false);
|
||||
|
||||
// Display settings
|
||||
const [realtimeLatency, setRealtimeLatency] = useState(false);
|
||||
const [searchDisplayMode, setSearchDisplayMode] = useState<SearchDisplayMode>('normal');
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
@@ -30,6 +34,8 @@ export function useSettingsPage() {
|
||||
setSortBy(settings.sortBy);
|
||||
setPasswordAccess(settings.passwordAccess);
|
||||
setAccessPasswords(settings.accessPasswords);
|
||||
setRealtimeLatency(settings.realtimeLatency);
|
||||
setSearchDisplayMode(settings.searchDisplayMode);
|
||||
|
||||
// Fetch env password status
|
||||
fetch('/api/config')
|
||||
@@ -255,6 +261,24 @@ export function useSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeLatencyChange = (enabled: boolean) => {
|
||||
setRealtimeLatency(enabled);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
realtimeLatency: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchDisplayModeChange = (mode: SearchDisplayMode) => {
|
||||
setSearchDisplayMode(mode);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
searchDisplayMode: mode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestoreDefaults = () => {
|
||||
const defaults = getDefaultSources();
|
||||
handleSourcesChange(defaults);
|
||||
@@ -274,6 +298,8 @@ export function useSettingsPage() {
|
||||
passwordAccess,
|
||||
accessPasswords,
|
||||
envPasswordSet,
|
||||
realtimeLatency,
|
||||
searchDisplayMode,
|
||||
isAddModalOpen,
|
||||
isExportModalOpen,
|
||||
isImportModalOpen,
|
||||
@@ -301,5 +327,7 @@ export function useSettingsPage() {
|
||||
handleResetAll,
|
||||
editingSource,
|
||||
handleEditSource,
|
||||
handleRealtimeLatencyChange,
|
||||
handleSearchDisplayModeChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SourceSettings } from '@/components/settings/SourceSettings';
|
||||
import { SortSettings } from '@/components/settings/SortSettings';
|
||||
import { DataSettings } from '@/components/settings/DataSettings';
|
||||
import { PasswordSettings } from '@/components/settings/PasswordSettings';
|
||||
import { DisplaySettings } from '@/components/settings/DisplaySettings';
|
||||
import { SettingsHeader } from '@/components/settings/SettingsHeader';
|
||||
import { useSettingsPage } from './hooks/useSettingsPage';
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function SettingsPage() {
|
||||
passwordAccess,
|
||||
accessPasswords,
|
||||
envPasswordSet,
|
||||
realtimeLatency,
|
||||
searchDisplayMode,
|
||||
isAddModalOpen,
|
||||
isExportModalOpen,
|
||||
isImportModalOpen,
|
||||
@@ -47,6 +50,8 @@ export default function SettingsPage() {
|
||||
editingSource,
|
||||
handleEditSource,
|
||||
setEditingSource,
|
||||
handleRealtimeLatencyChange,
|
||||
handleSearchDisplayModeChange,
|
||||
} = useSettingsPage();
|
||||
|
||||
return (
|
||||
@@ -65,6 +70,14 @@ export default function SettingsPage() {
|
||||
onRemove={handleRemovePassword}
|
||||
/>
|
||||
|
||||
{/* Display Settings */}
|
||||
<DisplaySettings
|
||||
realtimeLatency={realtimeLatency}
|
||||
searchDisplayMode={searchDisplayMode}
|
||||
onRealtimeLatencyChange={handleRealtimeLatencyChange}
|
||||
onSearchDisplayModeChange={handleSearchDisplayModeChange}
|
||||
/>
|
||||
|
||||
{/* Source Management */}
|
||||
<SourceSettings
|
||||
sources={sources}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface CustomVideoPlayerProps {
|
||||
totalEpisodes?: number;
|
||||
currentEpisodeIndex?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ interface DesktopVideoPlayerProps {
|
||||
totalEpisodes?: number;
|
||||
currentEpisodeIndex?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
export function DesktopVideoPlayer({
|
||||
@@ -31,8 +32,9 @@ export function DesktopVideoPlayer({
|
||||
totalEpisodes = 1,
|
||||
currentEpisodeIndex = 0,
|
||||
onNextEpisode,
|
||||
isReversed = false,
|
||||
}: DesktopVideoPlayerProps) {
|
||||
const { refs, state } = useDesktopPlayerState();
|
||||
const { refs, data, actions } = useDesktopPlayerState();
|
||||
|
||||
// Initialize HLS Player
|
||||
useHlsPlayer({
|
||||
@@ -50,9 +52,14 @@ export function DesktopVideoPlayer({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
} = data;
|
||||
|
||||
const {
|
||||
setShowControls,
|
||||
setIsLoading,
|
||||
} = state;
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
} = actions;
|
||||
|
||||
// Reset loading state and show spinner when source changes
|
||||
React.useEffect(() => {
|
||||
@@ -66,11 +73,12 @@ export function DesktopVideoPlayer({
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
data,
|
||||
actions
|
||||
});
|
||||
|
||||
// Auto-skip intro/outro and auto-next episode
|
||||
useAutoSkip({
|
||||
const { isOutroActive } = useAutoSkip({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
@@ -78,6 +86,8 @@ export function DesktopVideoPlayer({
|
||||
totalEpisodes,
|
||||
currentEpisodeIndex,
|
||||
onNextEpisode,
|
||||
isReversed,
|
||||
src,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -114,15 +124,16 @@ export function DesktopVideoPlayer({
|
||||
/>
|
||||
|
||||
<DesktopOverlayWrapper
|
||||
state={state}
|
||||
showControls={state.showControls}
|
||||
data={data}
|
||||
actions={actions}
|
||||
showControls={data.showControls}
|
||||
onTogglePlay={togglePlay}
|
||||
onSkipForward={logic.skipForward}
|
||||
onSkipBackward={logic.skipBackward}
|
||||
// More Menu Props
|
||||
showMoreMenu={state.showMoreMenu}
|
||||
showMoreMenu={data.showMoreMenu}
|
||||
isProxied={src.includes('/api/proxy')}
|
||||
onToggleMoreMenu={() => state.setShowMoreMenu(!state.showMoreMenu)}
|
||||
onToggleMoreMenu={() => actions.setShowMoreMenu(!data.showMoreMenu)}
|
||||
onMoreMenuMouseEnter={() => {
|
||||
if (refs.moreMenuTimeoutRef.current) {
|
||||
clearTimeout(refs.moreMenuTimeoutRef.current);
|
||||
@@ -134,16 +145,16 @@ export function DesktopVideoPlayer({
|
||||
clearTimeout(refs.moreMenuTimeoutRef.current);
|
||||
}
|
||||
refs.moreMenuTimeoutRef.current = setTimeout(() => {
|
||||
state.setShowMoreMenu(false);
|
||||
actions.setShowMoreMenu(false);
|
||||
refs.moreMenuTimeoutRef.current = null;
|
||||
}, 800); // Increased timeout for better stability
|
||||
}}
|
||||
onCopyLink={logic.handleCopyLink}
|
||||
// Speed Menu Props
|
||||
playbackRate={state.playbackRate}
|
||||
showSpeedMenu={state.showSpeedMenu}
|
||||
playbackRate={data.playbackRate}
|
||||
showSpeedMenu={data.showSpeedMenu}
|
||||
speeds={[0.5, 0.75, 1, 1.25, 1.5, 2]}
|
||||
onToggleSpeedMenu={() => state.setShowSpeedMenu(!state.showSpeedMenu)}
|
||||
onToggleSpeedMenu={() => actions.setShowSpeedMenu(!data.showSpeedMenu)}
|
||||
onSpeedChange={logic.changePlaybackSpeed}
|
||||
onSpeedMenuMouseEnter={logic.clearSpeedMenuTimeout}
|
||||
onSpeedMenuMouseLeave={logic.startSpeedMenuTimeout}
|
||||
@@ -153,7 +164,8 @@ export function DesktopVideoPlayer({
|
||||
|
||||
<DesktopControlsWrapper
|
||||
src={src}
|
||||
state={state}
|
||||
data={data}
|
||||
actions={actions}
|
||||
logic={logic}
|
||||
refs={refs}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
interface Episode {
|
||||
name?: string;
|
||||
@@ -14,18 +15,44 @@ interface Episode {
|
||||
interface EpisodeListProps {
|
||||
episodes: Episode[] | null;
|
||||
currentEpisode: number;
|
||||
isReversed?: boolean;
|
||||
onEpisodeClick: (episode: Episode, index: number) => void;
|
||||
onToggleReverse?: (reversed: boolean) => void;
|
||||
}
|
||||
|
||||
export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: EpisodeListProps) {
|
||||
export function EpisodeList({
|
||||
episodes,
|
||||
currentEpisode,
|
||||
isReversed = false,
|
||||
onEpisodeClick,
|
||||
onToggleReverse
|
||||
}: EpisodeListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Memoized display episodes - reversed if toggle is on
|
||||
const displayEpisodes = useMemo(() => {
|
||||
if (!episodes) return null;
|
||||
return isReversed ? [...episodes].reverse() : episodes;
|
||||
}, [episodes, isReversed]);
|
||||
|
||||
// Map display index to original index
|
||||
const getOriginalIndex = useCallback((displayIndex: number) => {
|
||||
if (!episodes || !isReversed) return displayIndex;
|
||||
return episodes.length - 1 - displayIndex;
|
||||
}, [episodes, isReversed]);
|
||||
|
||||
// Map original index to display index (for highlighting current episode)
|
||||
const getDisplayIndex = useCallback((originalIndex: number) => {
|
||||
if (!episodes || !isReversed) return originalIndex;
|
||||
return episodes.length - 1 - originalIndex;
|
||||
}, [episodes, isReversed]);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardNavigation({
|
||||
enabled: true,
|
||||
containerRef: listRef,
|
||||
currentIndex: currentEpisode,
|
||||
currentIndex: getDisplayIndex(currentEpisode),
|
||||
itemCount: episodes?.length || 0,
|
||||
orientation: 'vertical',
|
||||
onNavigate: useCallback((index: number) => {
|
||||
@@ -35,21 +62,43 @@ export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: Episod
|
||||
block: 'nearest'
|
||||
});
|
||||
}, []),
|
||||
onSelect: useCallback((index: number) => {
|
||||
if (episodes && episodes[index]) {
|
||||
onEpisodeClick(episodes[index], index);
|
||||
onSelect: useCallback((displayIndex: number) => {
|
||||
if (episodes) {
|
||||
const originalIndex = getOriginalIndex(displayIndex);
|
||||
if (episodes[originalIndex]) {
|
||||
onEpisodeClick(episodes[originalIndex], originalIndex);
|
||||
}
|
||||
}
|
||||
}, [episodes, onEpisodeClick]),
|
||||
}, [episodes, onEpisodeClick, getOriginalIndex]),
|
||||
});
|
||||
|
||||
const showReverseToggle = episodes && episodes.length > 1;
|
||||
|
||||
return (
|
||||
<Card hover={false} className="lg:sticky lg:top-32">
|
||||
<Card hover={false}>
|
||||
<h3 className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.List size={20} className="sm:w-6 sm:h-6" />
|
||||
<span>选集</span>
|
||||
{episodes && (
|
||||
<Badge variant="primary">{episodes.length}</Badge>
|
||||
)}
|
||||
{/* Reverse order toggle button - only show when more than 1 episode */}
|
||||
{showReverseToggle && (
|
||||
<button
|
||||
onClick={() => onToggleReverse?.(!isReversed)}
|
||||
className={`
|
||||
ml-auto p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200
|
||||
${isReversed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
aria-label={isReversed ? '恢复正序' : '倒序排列'}
|
||||
title={isReversed ? '恢复正序' : '倒序排列'}
|
||||
>
|
||||
<Icons.ArrowUpDown size={16} />
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
@@ -58,42 +107,47 @@ export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: Episod
|
||||
role="radiogroup"
|
||||
aria-label="剧集选择"
|
||||
>
|
||||
{episodes && episodes.length > 0 ? (
|
||||
episodes.map((episode, index) => (
|
||||
<button
|
||||
key={index}
|
||||
ref={(el) => { buttonRefs.current[index] = el; }}
|
||||
onClick={() => onEpisodeClick(episode, index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEpisodeClick(episode, index);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="radio"
|
||||
aria-checked={currentEpisode === index}
|
||||
aria-current={currentEpisode === index ? 'true' : undefined}
|
||||
aria-label={`${episode.name || `第 ${index + 1} 集`}${currentEpisode === index ? ',当前播放' : ''}`}
|
||||
className={`
|
||||
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
|
||||
${currentEpisode === index
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
}
|
||||
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm sm:text-base">
|
||||
{episode.name || `第 ${index + 1} 集`}
|
||||
</span>
|
||||
{currentEpisode === index && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
{displayEpisodes && displayEpisodes.length > 0 ? (
|
||||
displayEpisodes.map((episode, displayIndex) => {
|
||||
const originalIndex = getOriginalIndex(displayIndex);
|
||||
const isCurrentEpisode = currentEpisode === originalIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={originalIndex}
|
||||
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
|
||||
onClick={() => onEpisodeClick(episode, originalIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEpisodeClick(episode, originalIndex);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="radio"
|
||||
aria-checked={isCurrentEpisode}
|
||||
aria-current={isCurrentEpisode ? 'true' : undefined}
|
||||
aria-label={`${episode.name || `第 ${originalIndex + 1} 集`}${isCurrentEpisode ? ',当前播放' : ''}`}
|
||||
className={`
|
||||
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
|
||||
${isCurrentEpisode
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
}
|
||||
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm sm:text-base">
|
||||
{episode.name || `第 ${originalIndex + 1} 集`}
|
||||
</span>
|
||||
{isCurrentEpisode && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* SourceSelector - Component for selecting video source in player
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface SourceInfo {
|
||||
id: string | number;
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
latency?: number;
|
||||
pic?: string;
|
||||
}
|
||||
|
||||
interface SourceSelectorProps {
|
||||
sources: SourceInfo[];
|
||||
currentSource: string;
|
||||
onSourceChange: (source: SourceInfo) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourceSelector({
|
||||
sources,
|
||||
currentSource,
|
||||
onSourceChange,
|
||||
className = '',
|
||||
}: SourceSelectorProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [latencies, setLatencies] = useState<Record<string, number>>({});
|
||||
|
||||
// Sort sources by latency
|
||||
const sortedSources = useMemo(() => {
|
||||
return [...sources].sort((a, b) => {
|
||||
const latA = latencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = latencies[b.source] ?? b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
});
|
||||
}, [sources, latencies]);
|
||||
|
||||
// Refresh latency for all sources
|
||||
const refreshLatencies = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
// Use the stored baseUrl or extract from source
|
||||
const response = await fetch('/api/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: source.source, // This should be the baseUrl ideally
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { source: source.source, latency: data.latency };
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return { source: source.source, latency: undefined };
|
||||
})
|
||||
);
|
||||
|
||||
const newLatencies: Record<string, number> = {};
|
||||
results.forEach(({ source, latency }) => {
|
||||
if (latency !== undefined) {
|
||||
newLatencies[source] = latency;
|
||||
}
|
||||
});
|
||||
|
||||
setLatencies(newLatencies);
|
||||
setIsLoading(false);
|
||||
}, [sources]);
|
||||
|
||||
// Initialize latencies from sources
|
||||
useEffect(() => {
|
||||
const initial: Record<string, number> = {};
|
||||
sources.forEach(s => {
|
||||
if (s.latency !== undefined) {
|
||||
initial[s.source] = s.latency;
|
||||
}
|
||||
});
|
||||
setLatencies(initial);
|
||||
}, [sources]);
|
||||
|
||||
if (sources.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card hover={false} className={`mt-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Layers size={20} className="sm:w-6 sm:h-6" />
|
||||
<span></span>
|
||||
<Badge variant="primary">{sources.length}</Badge>
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={refreshLatencies}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5"
|
||||
>
|
||||
<Icons.RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
刷新延迟
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{sortedSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => !isCurrent && onSourceChange(source)}
|
||||
className={`
|
||||
w-full p-3 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-3
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{source.pic && (
|
||||
<div className="w-12 h-16 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={48}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm sm:text-base truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-1">
|
||||
<LatencyBadge latency={latency} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current indicator */}
|
||||
{isCurrent && (
|
||||
<Icons.Play size={16} className="flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Rank badge for top 3 */}
|
||||
{!isCurrent && index < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,18 @@ interface VideoPlayerProps {
|
||||
// Episode navigation props for auto-skip/auto-next
|
||||
totalEpisodes?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpisodes, onNextEpisode }: VideoPlayerProps) {
|
||||
export function VideoPlayer({
|
||||
playUrl,
|
||||
videoId,
|
||||
currentEpisode,
|
||||
onBack,
|
||||
totalEpisodes,
|
||||
onNextEpisode,
|
||||
isReversed = false
|
||||
}: VideoPlayerProps) {
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(true);
|
||||
@@ -82,7 +91,7 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi
|
||||
}, [videoId, playUrl, title, currentEpisode, source, addToHistory]);
|
||||
|
||||
// Handle time updates and save progress (throttled to every 5 seconds)
|
||||
const handleTimeUpdate = (currentTime: number, duration: number) => {
|
||||
const handleTimeUpdate = useCallback((currentTime: number, duration: number) => {
|
||||
// Always track current time for beforeunload
|
||||
currentTimeRef.current = currentTime;
|
||||
durationRef.current = duration;
|
||||
@@ -95,7 +104,7 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi
|
||||
lastSaveTimeRef.current = now;
|
||||
saveProgress(currentTime, duration);
|
||||
}
|
||||
};
|
||||
}, [videoId, playUrl, saveProgress]);
|
||||
|
||||
// Save on page leave/refresh
|
||||
useEffect(() => {
|
||||
@@ -158,8 +167,8 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi
|
||||
{showModeIndicator && (
|
||||
<div className="absolute top-3 right-3 z-30">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
|
||||
? 'bg-orange-500/80 text-white'
|
||||
: 'bg-green-500/80 text-white'
|
||||
? 'bg-orange-500/80 text-white'
|
||||
: 'bg-green-500/80 text-white'
|
||||
}`}>
|
||||
{useProxy ? '代理模式' : '直连模式'}
|
||||
</span>
|
||||
@@ -175,7 +184,7 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi
|
||||
/>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Force remount when switching modes or retrying
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${finalPlayUrl}`} // Force remount when switching modes, retrying, or changing source
|
||||
src={finalPlayUrl}
|
||||
onError={handleVideoError}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
@@ -184,6 +193,7 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi
|
||||
totalEpisodes={totalEpisodes}
|
||||
currentEpisodeIndex={currentEpisode}
|
||||
onNextEpisode={onNextEpisode}
|
||||
isReversed={isReversed}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -5,12 +5,13 @@ import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic';
|
||||
|
||||
interface DesktopControlsWrapperProps {
|
||||
src: string;
|
||||
state: ReturnType<typeof useDesktopPlayerState>['state'];
|
||||
data: ReturnType<typeof useDesktopPlayerState>['data'];
|
||||
actions: ReturnType<typeof useDesktopPlayerState>['actions'];
|
||||
logic: ReturnType<typeof useDesktopPlayerLogic>;
|
||||
refs: ReturnType<typeof useDesktopPlayerState>['refs'];
|
||||
}
|
||||
|
||||
export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopControlsWrapperProps) {
|
||||
export function DesktopControlsWrapper({ src, data, actions, logic, refs }: DesktopControlsWrapperProps) {
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -23,7 +24,7 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
isCastAvailable,
|
||||
} = state;
|
||||
} = data;
|
||||
|
||||
const {
|
||||
togglePlay,
|
||||
|
||||
@@ -64,7 +64,7 @@ export function DesktopOverlay({
|
||||
onSpeedChange,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
containerRef
|
||||
containerRef,
|
||||
}: DesktopOverlayProps) {
|
||||
// Show navigation buttons when controls are visible or when paused (controls usually show when paused anyway)
|
||||
const showNavButtons = showControls || !isPlaying;
|
||||
@@ -143,7 +143,7 @@ export function DesktopOverlay({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next Button (Method: Skip Forward) */}
|
||||
{/* Next Button (Method: Skip Forward) - Refined to use FastForward icon */}
|
||||
<div
|
||||
className={`absolute right-0 top-0 bottom-0 flex items-center justify-center p-4 md:p-8 transition-opacity duration-300 z-10 ${showNavButtons ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
@@ -157,7 +157,7 @@ export function DesktopOverlay({
|
||||
className="group flex items-center justify-center w-12 h-12 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
|
||||
aria-label="Skip Forward 10s"
|
||||
>
|
||||
<Icons.SkipForward className="w-6 h-6 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
|
||||
<Icons.FastForward className="w-6 h-6 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { DesktopOverlay } from './DesktopOverlay';
|
||||
import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
|
||||
|
||||
interface DesktopOverlayWrapperProps {
|
||||
state: ReturnType<typeof useDesktopPlayerState>['state'];
|
||||
data: ReturnType<typeof useDesktopPlayerState>['data'];
|
||||
actions: ReturnType<typeof useDesktopPlayerState>['actions'];
|
||||
showControls: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
@@ -18,15 +19,16 @@ interface DesktopOverlayWrapperProps {
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
speeds: number[];
|
||||
onToggleMoreMenu?: () => void; // Unused but kept for type safety if needed
|
||||
onToggleSpeedMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function DesktopOverlayWrapper({
|
||||
state,
|
||||
data,
|
||||
actions,
|
||||
showControls,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
@@ -34,6 +36,8 @@ export function DesktopOverlayWrapper({
|
||||
showMoreMenu,
|
||||
isProxied,
|
||||
onToggleMoreMenu,
|
||||
onMouseEnter, // Note: The prop name was actually missing in destructuring or renamed? Let me check previous view_file.
|
||||
// Wait, the previous view_file of DesktopOverlayWrapper had these:
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
onCopyLink,
|
||||
@@ -44,7 +48,7 @@ export function DesktopOverlayWrapper({
|
||||
onSpeedChange,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
containerRef
|
||||
containerRef,
|
||||
}: DesktopOverlayWrapperProps) {
|
||||
const {
|
||||
isLoading,
|
||||
@@ -57,7 +61,7 @@ export function DesktopOverlayWrapper({
|
||||
isSkipBackwardAnimatingOut,
|
||||
showToast,
|
||||
toastMessage,
|
||||
} = state;
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DesktopOverlay
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
interface UseCastControlsProps {
|
||||
src: string;
|
||||
@@ -114,7 +114,9 @@ export function useCastControls({
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
const castActions = useMemo(() => ({
|
||||
showCastMenu
|
||||
};
|
||||
}), [showCastMenu]);
|
||||
|
||||
return castActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
interface UseControlsVisibilityProps {
|
||||
isPlaying: boolean;
|
||||
@@ -107,9 +107,11 @@ export function useControlsVisibility({
|
||||
return () => clearSpeedMenuTimeout();
|
||||
}, [showSpeedMenu, startSpeedMenuTimeout, clearSpeedMenuTimeout]);
|
||||
|
||||
return {
|
||||
const visibilityActions = useMemo(() => ({
|
||||
handleMouseMove,
|
||||
startSpeedMenuTimeout,
|
||||
clearSpeedMenuTimeout
|
||||
};
|
||||
}), [handleMouseMove, startSpeedMenuTimeout, clearSpeedMenuTimeout]);
|
||||
|
||||
return visibilityActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
interface UseFullscreenControlsProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -151,9 +151,11 @@ export function useFullscreenControls({
|
||||
}
|
||||
}, [videoRef, isAirPlaySupported]);
|
||||
|
||||
return {
|
||||
const fullscreenActions = useMemo(() => ({
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
showAirPlayMenu
|
||||
};
|
||||
}), [toggleFullscreen, togglePictureInPicture, showAirPlayMenu]);
|
||||
|
||||
return fullscreenActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { formatTime } from '@/lib/utils/format-utils';
|
||||
import { usePlaybackPolling } from '../usePlaybackPolling';
|
||||
|
||||
@@ -135,7 +135,7 @@ export function usePlaybackControls({
|
||||
setIsPlaying
|
||||
});
|
||||
|
||||
return {
|
||||
const playbackActions = useMemo(() => ({
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
@@ -144,5 +144,15 @@ export function usePlaybackControls({
|
||||
handleVideoError,
|
||||
changePlaybackSpeed,
|
||||
formatTime
|
||||
};
|
||||
}), [
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleTimeUpdateEvent,
|
||||
handleLoadedMetadata,
|
||||
handleVideoError,
|
||||
changePlaybackSpeed
|
||||
]);
|
||||
|
||||
return playbackActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
interface UseProgressControlsProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -61,8 +61,10 @@ export function useProgressControls({
|
||||
};
|
||||
}, [duration, isDraggingProgressRef, progressBarRef, videoRef, setCurrentTime]);
|
||||
|
||||
return {
|
||||
const progressActions = useMemo(() => ({
|
||||
handleProgressClick,
|
||||
handleProgressMouseDown
|
||||
};
|
||||
}), [handleProgressClick, handleProgressMouseDown]);
|
||||
|
||||
return progressActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
interface UseSkipControlsProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -101,8 +101,10 @@ export function useSkipControls({
|
||||
}, 800);
|
||||
}, [videoRef, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
|
||||
|
||||
return {
|
||||
const skipActions = useMemo(() => ({
|
||||
skipForward,
|
||||
skipBackward
|
||||
};
|
||||
}), [skipForward, skipBackward]);
|
||||
|
||||
return skipActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
interface UseUtilitiesProps {
|
||||
src: string;
|
||||
@@ -37,8 +37,10 @@ export function useUtilities({
|
||||
}
|
||||
}, [src, showToastNotification]);
|
||||
|
||||
return {
|
||||
const utilityActions = useMemo(() => ({
|
||||
showToastNotification,
|
||||
handleCopyLink
|
||||
};
|
||||
}), [showToastNotification, handleCopyLink]);
|
||||
|
||||
return utilityActions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
interface UseVolumeControlsProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -85,10 +85,12 @@ export function useVolumeControls({
|
||||
};
|
||||
}, [isDraggingVolumeRef, volumeBarRef, videoRef, setVolume, setIsMuted]);
|
||||
|
||||
return {
|
||||
const volumeActions = useMemo(() => ({
|
||||
toggleMute,
|
||||
showVolumeBarTemporarily,
|
||||
handleVolumeChange,
|
||||
handleVolumeMouseDown
|
||||
};
|
||||
}), [toggleMute, showVolumeBarTemporarily, handleVolumeChange, handleVolumeMouseDown]);
|
||||
|
||||
return volumeActions;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { usePlayerSettings } from './usePlayerSettings';
|
||||
|
||||
interface UseAutoSkipProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
src: string;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
totalEpisodes?: number;
|
||||
currentEpisodeIndex?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,8 @@ export function useAutoSkip({
|
||||
totalEpisodes = 1,
|
||||
currentEpisodeIndex = 0,
|
||||
onNextEpisode,
|
||||
isReversed = false,
|
||||
src,
|
||||
}: UseAutoSkipProps) {
|
||||
const {
|
||||
autoNextEpisode,
|
||||
@@ -39,28 +43,47 @@ export function useAutoSkip({
|
||||
|
||||
// Track if we've already skipped intro for this video session
|
||||
const hasSkippedIntroRef = useRef(false);
|
||||
// Track if we've triggered outro skip to prevent multiple triggers
|
||||
// Track if we've already handled navigation for this specific source
|
||||
const lastHandledSrcRef = useRef<string>('');
|
||||
// Track if we've triggered outro skip to prevent multiple triggers within the same video session
|
||||
const hasTriggeredOutroSkipRef = useRef(false);
|
||||
// Track previous src to reset flags on video change
|
||||
const prevSrcRef = useRef<string | null>(null);
|
||||
// Track if we're currently in the outro zone for UI purposes
|
||||
const [isOutroActive, setIsOutroActive] = useState(false);
|
||||
|
||||
// Reset flags when video source changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const currentSrc = video.src;
|
||||
if (prevSrcRef.current !== currentSrc) {
|
||||
hasSkippedIntroRef.current = false;
|
||||
hasTriggeredOutroSkipRef.current = false;
|
||||
prevSrcRef.current = currentSrc;
|
||||
}
|
||||
}, [videoRef]);
|
||||
hasSkippedIntroRef.current = false;
|
||||
hasTriggeredOutroSkipRef.current = false;
|
||||
setIsOutroActive(false);
|
||||
}, [src, videoRef]);
|
||||
|
||||
// Check if we can advance to next episode
|
||||
const canAdvanceToNext = useCallback(() => {
|
||||
return totalEpisodes > 1 && currentEpisodeIndex < totalEpisodes - 1 && onNextEpisode;
|
||||
}, [totalEpisodes, currentEpisodeIndex, onNextEpisode]);
|
||||
if (totalEpisodes <= 1) return false;
|
||||
|
||||
if (!isReversed) {
|
||||
// Normal order: next is index + 1
|
||||
return currentEpisodeIndex < totalEpisodes - 1 && onNextEpisode;
|
||||
} else {
|
||||
// Reversed order: next is index - 1 (since we're going backwards)
|
||||
return currentEpisodeIndex > 0 && onNextEpisode;
|
||||
}
|
||||
}, [totalEpisodes, currentEpisodeIndex, onNextEpisode, isReversed]);
|
||||
|
||||
// Helper to trigger next episode exactly once per source
|
||||
const triggerNextEpisode = useCallback((reason: string) => {
|
||||
if (!onNextEpisode) return;
|
||||
|
||||
// Prevent double trigger for the same source URL
|
||||
if (lastHandledSrcRef.current === src) {
|
||||
console.log(`[AutoSkip] Ignoring ${reason} trigger: already handled for this source`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AutoSkip] Triggering next episode via ${reason}`);
|
||||
lastHandledSrcRef.current = src;
|
||||
onNextEpisode();
|
||||
}, [src, onNextEpisode]);
|
||||
|
||||
// Validate that duration is ready (not 0, NaN, or Infinity)
|
||||
const isDurationValid = useCallback(() => {
|
||||
@@ -73,7 +96,7 @@ export function useAutoSkip({
|
||||
}, [currentTime]);
|
||||
|
||||
// Handle intro skip
|
||||
useEffect(() => {
|
||||
const attemptIntroSkip = useCallback(() => {
|
||||
if (!autoSkipIntro || skipIntroSeconds <= 0) return;
|
||||
if (!isDurationValid() || !isTimeValid()) return;
|
||||
if (hasSkippedIntroRef.current) return;
|
||||
@@ -83,54 +106,89 @@ export function useAutoSkip({
|
||||
|
||||
// Only skip if we're in the intro zone (between 0 and skipIntroSeconds)
|
||||
if (currentTime >= 0 && currentTime < skipIntroSeconds && currentTime < duration) {
|
||||
// Wait a brief moment to ensure video is ready
|
||||
const skipTimeout = setTimeout(() => {
|
||||
if (video && video.readyState >= 2) { // HAVE_CURRENT_DATA or better
|
||||
video.currentTime = Math.min(skipIntroSeconds, duration - 1);
|
||||
hasSkippedIntroRef.current = true;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(skipTimeout);
|
||||
if (video.readyState >= 2) { // HAVE_CURRENT_DATA or better
|
||||
console.log(`[AutoSkip] Jumping from ${currentTime}s to intro skip point ${skipIntroSeconds}s`);
|
||||
video.currentTime = Math.min(skipIntroSeconds, duration - 1);
|
||||
hasSkippedIntroRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [autoSkipIntro, skipIntroSeconds, currentTime, duration, isDurationValid, isTimeValid, videoRef]);
|
||||
|
||||
// React to time changes for intro skip
|
||||
useEffect(() => {
|
||||
attemptIntroSkip();
|
||||
}, [attemptIntroSkip]);
|
||||
|
||||
// Also react to video getting ready for intro skip
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleReady = () => {
|
||||
if (!hasSkippedIntroRef.current) {
|
||||
attemptIntroSkip();
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', handleReady);
|
||||
video.addEventListener('loadedmetadata', handleReady);
|
||||
return () => {
|
||||
video.removeEventListener('canplay', handleReady);
|
||||
video.removeEventListener('loadedmetadata', handleReady);
|
||||
};
|
||||
}, [videoRef, attemptIntroSkip]);
|
||||
|
||||
// Handle outro skip (based on remaining time)
|
||||
useEffect(() => {
|
||||
if (!autoSkipOutro || skipOutroSeconds <= 0) return;
|
||||
if (!autoSkipOutro || skipOutroSeconds <= 0) {
|
||||
setIsOutroActive(false);
|
||||
return;
|
||||
}
|
||||
if (!isDurationValid() || !isTimeValid()) return;
|
||||
if (hasTriggeredOutroSkipRef.current) return;
|
||||
if (!isPlaying) return;
|
||||
|
||||
const remainingTime = duration - currentTime;
|
||||
|
||||
// Only trigger if video is actually playing and approaching end
|
||||
if (remainingTime > 0 && remainingTime <= skipOutroSeconds && currentTime > 0) {
|
||||
hasTriggeredOutroSkipRef.current = true;
|
||||
// Check if we're in the outro zone
|
||||
const inOutroZone = remainingTime > 0 && remainingTime <= skipOutroSeconds && currentTime > 0;
|
||||
|
||||
// If we can advance to next episode, do it
|
||||
if (autoNextEpisode && canAdvanceToNext()) {
|
||||
onNextEpisode?.();
|
||||
} else {
|
||||
// Otherwise just seek to end to trigger ended event
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.currentTime = duration;
|
||||
if (inOutroZone) {
|
||||
setIsOutroActive(true);
|
||||
|
||||
// Only auto-trigger if video is actually playing
|
||||
if (isPlaying) {
|
||||
console.log(`[AutoSkip] Outro detected: ${remainingTime.toFixed(1)}s remaining`);
|
||||
hasTriggeredOutroSkipRef.current = true;
|
||||
|
||||
// If we can advance to next episode, do it
|
||||
if (autoNextEpisode && canAdvanceToNext()) {
|
||||
triggerNextEpisode('outro-timer');
|
||||
} else {
|
||||
// Otherwise just seek to end to trigger ended event
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
console.log('[AutoSkip] No next episode, seeking to end');
|
||||
video.currentTime = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setIsOutroActive(false);
|
||||
}
|
||||
}, [autoSkipOutro, skipOutroSeconds, currentTime, duration, isPlaying, isDurationValid, isTimeValid, autoNextEpisode, canAdvanceToNext, onNextEpisode, videoRef]);
|
||||
}, [autoSkipOutro, skipOutroSeconds, currentTime, duration, isPlaying, isDurationValid, isTimeValid, autoNextEpisode, canAdvanceToNext, triggerNextEpisode, videoRef]);
|
||||
|
||||
// Handle video ended event for auto-next
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
console.log(`[AutoSkip] Video ended naturally`);
|
||||
if (!autoNextEpisode) return;
|
||||
if (!canAdvanceToNext()) return;
|
||||
if (hasTriggeredOutroSkipRef.current) return;
|
||||
|
||||
// Slight delay to ensure clean transition
|
||||
setTimeout(() => {
|
||||
onNextEpisode?.();
|
||||
triggerNextEpisode('ended-event');
|
||||
}, 100);
|
||||
}, [autoNextEpisode, canAdvanceToNext, onNextEpisode]);
|
||||
}, [autoNextEpisode, canAdvanceToNext, triggerNextEpisode]);
|
||||
|
||||
// Attach ended event listener
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePlaybackControls } from './desktop/usePlaybackControls';
|
||||
import { useVolumeControls } from './desktop/useVolumeControls';
|
||||
import { useProgressControls } from './desktop/useProgressControls';
|
||||
@@ -19,7 +20,8 @@ interface UseDesktopPlayerLogicProps {
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
refs: DesktopPlayerState['refs'];
|
||||
state: DesktopPlayerState['state'];
|
||||
data: DesktopPlayerState['data'];
|
||||
actions: DesktopPlayerState['actions'];
|
||||
}
|
||||
|
||||
export function useDesktopPlayerLogic({
|
||||
@@ -29,7 +31,8 @@ export function useDesktopPlayerLogic({
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
data,
|
||||
actions
|
||||
}: UseDesktopPlayerLogicProps) {
|
||||
const {
|
||||
videoRef, containerRef, progressBarRef, volumeBarRef,
|
||||
@@ -39,28 +42,51 @@ export function useDesktopPlayerLogic({
|
||||
} = refs;
|
||||
|
||||
const {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
showControls,
|
||||
isLoading,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
skipForwardAmount,
|
||||
skipBackwardAmount,
|
||||
showSkipForwardIndicator,
|
||||
showSkipBackwardIndicator,
|
||||
showMoreMenu
|
||||
} = data;
|
||||
|
||||
const {
|
||||
setIsPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setVolume,
|
||||
setIsMuted,
|
||||
setIsFullscreen,
|
||||
setShowControls,
|
||||
setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
isAirPlaySupported, setIsAirPlaySupported,
|
||||
skipForwardAmount, setSkipForwardAmount,
|
||||
skipBackwardAmount, setSkipBackwardAmount,
|
||||
showSkipForwardIndicator, setShowSkipForwardIndicator,
|
||||
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
|
||||
setIsSkipForwardAnimatingOut, setIsSkipBackwardAnimatingOut,
|
||||
setShowVolumeBar, setToastMessage, setShowToast,
|
||||
isCastAvailable, setIsCastAvailable,
|
||||
isCasting, setIsCasting,
|
||||
showMoreMenu, setShowMoreMenu
|
||||
} = state;
|
||||
setPlaybackRate,
|
||||
setShowSpeedMenu,
|
||||
setIsPiPSupported,
|
||||
setIsAirPlaySupported,
|
||||
setSkipForwardAmount,
|
||||
setSkipBackwardAmount,
|
||||
setShowSkipForwardIndicator,
|
||||
setShowSkipBackwardIndicator,
|
||||
setIsSkipForwardAnimatingOut,
|
||||
setIsSkipBackwardAnimatingOut,
|
||||
setShowVolumeBar,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
setIsCastAvailable,
|
||||
setIsCasting,
|
||||
setShowMoreMenu
|
||||
} = actions;
|
||||
|
||||
const playbackControls = usePlaybackControls({
|
||||
videoRef, isPlaying, setIsPlaying, setIsLoading,
|
||||
@@ -120,7 +146,7 @@ export function useDesktopPlayerLogic({
|
||||
setShowControls, setVolume, setIsMuted, controlsTimeoutRef
|
||||
});
|
||||
|
||||
return {
|
||||
return useMemo(() => ({
|
||||
handleMouseMove: controlsVisibility.handleMouseMove,
|
||||
togglePlay: playbackControls.togglePlay,
|
||||
handlePlay: playbackControls.handlePlay,
|
||||
@@ -148,5 +174,15 @@ export function useDesktopPlayerLogic({
|
||||
startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout,
|
||||
clearSpeedMenuTimeout: controlsVisibility.clearSpeedMenuTimeout,
|
||||
formatTime: playbackControls.formatTime
|
||||
};
|
||||
}), [
|
||||
src,
|
||||
controlsVisibility,
|
||||
playbackControls,
|
||||
progressControls,
|
||||
volumeControls,
|
||||
fullscreenControls,
|
||||
castControls,
|
||||
skipControls,
|
||||
utilities
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
|
||||
export function useDesktopPlayerState() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -44,48 +44,83 @@ export function useDesktopPlayerState() {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
controlsTimeoutRef,
|
||||
speedMenuTimeoutRef,
|
||||
skipForwardTimeoutRef,
|
||||
skipBackwardTimeoutRef,
|
||||
volumeBarTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
isDraggingVolumeRef,
|
||||
mouseMoveThrottleRef,
|
||||
toastTimeoutRef,
|
||||
moreMenuTimeoutRef
|
||||
},
|
||||
state: {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
isLoading, setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
isAirPlaySupported, setIsAirPlaySupported,
|
||||
isCastAvailable, setIsCastAvailable,
|
||||
isCasting, setIsCasting,
|
||||
skipForwardAmount, setSkipForwardAmount,
|
||||
skipBackwardAmount, setSkipBackwardAmount,
|
||||
showSkipForwardIndicator, setShowSkipForwardIndicator,
|
||||
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
|
||||
isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut,
|
||||
isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut,
|
||||
showVolumeBar, setShowVolumeBar,
|
||||
toastMessage, setToastMessage,
|
||||
showToast, setShowToast,
|
||||
showMoreMenu, setShowMoreMenu
|
||||
}
|
||||
};
|
||||
const refs = useMemo(() => ({
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
controlsTimeoutRef,
|
||||
speedMenuTimeoutRef,
|
||||
skipForwardTimeoutRef,
|
||||
skipBackwardTimeoutRef,
|
||||
volumeBarTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
isDraggingVolumeRef,
|
||||
mouseMoveThrottleRef,
|
||||
toastTimeoutRef,
|
||||
moreMenuTimeoutRef
|
||||
}), []); // Refs never change after creation
|
||||
|
||||
const data = useMemo(() => ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
showControls,
|
||||
isLoading,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
isCastAvailable,
|
||||
isCasting,
|
||||
skipForwardAmount,
|
||||
skipBackwardAmount,
|
||||
showSkipForwardIndicator,
|
||||
showSkipBackwardIndicator,
|
||||
isSkipForwardAnimatingOut,
|
||||
isSkipBackwardAnimatingOut,
|
||||
showVolumeBar,
|
||||
toastMessage,
|
||||
showToast,
|
||||
showMoreMenu
|
||||
}), [
|
||||
isPlaying, currentTime, duration, volume, isMuted, isFullscreen,
|
||||
showControls, isLoading, playbackRate, showSpeedMenu, isPiPSupported,
|
||||
isAirPlaySupported, isCastAvailable, isCasting, skipForwardAmount,
|
||||
skipBackwardAmount, showSkipForwardIndicator, showSkipBackwardIndicator,
|
||||
isSkipForwardAnimatingOut, isSkipBackwardAnimatingOut, showVolumeBar,
|
||||
toastMessage, showToast, showMoreMenu
|
||||
]);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
setIsPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setVolume,
|
||||
setIsMuted,
|
||||
setIsFullscreen,
|
||||
setShowControls,
|
||||
setIsLoading,
|
||||
setPlaybackRate,
|
||||
setShowSpeedMenu,
|
||||
setIsPiPSupported,
|
||||
setIsAirPlaySupported,
|
||||
setIsCastAvailable,
|
||||
setIsCasting,
|
||||
setSkipForwardAmount,
|
||||
setSkipBackwardAmount,
|
||||
setShowSkipForwardIndicator,
|
||||
setShowSkipBackwardIndicator,
|
||||
setIsSkipForwardAnimatingOut,
|
||||
setIsSkipBackwardAnimatingOut,
|
||||
setShowVolumeBar,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
setShowMoreMenu
|
||||
}), []); // All setters from useState are stable
|
||||
|
||||
return { refs, data, actions };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import { VideoCard } from './VideoCard';
|
||||
|
||||
import { VideoGroupCard, GroupedVideo } from './VideoGroupCard';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { Video } from '@/lib/types';
|
||||
|
||||
interface VideoGridProps {
|
||||
@@ -13,14 +14,58 @@ interface VideoGridProps {
|
||||
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal');
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Load display mode from settings
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setDisplayMode(settings.searchDisplayMode);
|
||||
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
const newSettings = settingsStore.getSettings();
|
||||
setDisplayMode(newSettings.searchDisplayMode);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Callback ref for the load more trigger to handle dynamic mounting/unmounting
|
||||
// Group videos by name when in grouped mode
|
||||
const groupedVideos = useMemo<GroupedVideo[]>(() => {
|
||||
if (displayMode !== 'grouped') return [];
|
||||
|
||||
const groups = new Map<string, Video[]>();
|
||||
|
||||
videos.forEach(video => {
|
||||
const name = video.vod_name.toLowerCase().trim();
|
||||
if (!groups.has(name)) {
|
||||
groups.set(name, []);
|
||||
}
|
||||
groups.get(name)!.push(video);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([, groupVideos]) => {
|
||||
// Sort by latency (lowest first)
|
||||
const sorted = [...groupVideos].sort((a, b) => {
|
||||
if (a.latency === undefined) return 1;
|
||||
if (b.latency === undefined) return -1;
|
||||
return a.latency - b.latency;
|
||||
});
|
||||
|
||||
return {
|
||||
representative: sorted[0],
|
||||
videos: sorted,
|
||||
name: sorted[0].vod_name,
|
||||
};
|
||||
});
|
||||
}, [videos, displayMode]);
|
||||
|
||||
// Callback ref for the load more trigger
|
||||
const loadMoreRef = useCallback((node: HTMLDivElement | null) => {
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
|
||||
@@ -35,27 +80,24 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoize the click handler to prevent re-renders
|
||||
// Memoize the click handler
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
// Check if it's a mobile device
|
||||
const isMobile = window.innerWidth < 1024; // lg breakpoint
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, first click shows details, second click navigates
|
||||
if (activeCardId === videoId) {
|
||||
// Already active, allow navigation
|
||||
window.location.href = videoUrl;
|
||||
} else {
|
||||
// First click, show details
|
||||
e.preventDefault();
|
||||
setActiveCardId(videoId);
|
||||
}
|
||||
}
|
||||
// On desktop, let the Link work normally
|
||||
}, [activeCardId]);
|
||||
|
||||
// Memoize video items to prevent unnecessary re-computations
|
||||
// Normal mode items
|
||||
const videoItems = useMemo(() => {
|
||||
if (displayMode === 'grouped') return [];
|
||||
|
||||
return videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: String(video.vod_id),
|
||||
@@ -65,15 +107,21 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
|
||||
const cardId = `${video.vod_id}-${index}`;
|
||||
|
||||
return {
|
||||
video,
|
||||
videoUrl,
|
||||
cardId,
|
||||
};
|
||||
return { video, videoUrl, cardId };
|
||||
});
|
||||
}, [videos]);
|
||||
}, [videos, displayMode]);
|
||||
|
||||
const visibleItems = videoItems.slice(0, visibleCount);
|
||||
// Grouped mode items
|
||||
const groupItems = useMemo(() => {
|
||||
if (displayMode !== 'grouped') return [];
|
||||
|
||||
return groupedVideos.map((group, index) => ({
|
||||
group,
|
||||
cardId: `group-${group.representative.vod_id}-${index}`,
|
||||
}));
|
||||
}, [groupedVideos, displayMode]);
|
||||
|
||||
const totalItems = displayMode === 'grouped' ? groupItems.length : videoItems.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,30 +130,41 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6 max-w-[1920px] mx-auto ${className}`}
|
||||
role="list"
|
||||
aria-label="视频搜索结果"
|
||||
style={{
|
||||
// Optimize rendering performance
|
||||
// willChange: 'auto', // Removed to let browser decide
|
||||
// contain: 'layout style paint', // Removed to fix z-index stacking context
|
||||
}}
|
||||
>
|
||||
{visibleItems.map(({ video, videoUrl, cardId }) => {
|
||||
const isActive = activeCardId === cardId;
|
||||
|
||||
return (
|
||||
<VideoCard
|
||||
key={cardId}
|
||||
video={video}
|
||||
videoUrl={videoUrl}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{displayMode === 'grouped' ? (
|
||||
// Grouped mode
|
||||
groupItems.slice(0, visibleCount).map(({ group, cardId }) => {
|
||||
const isActive = activeCardId === cardId;
|
||||
return (
|
||||
<VideoGroupCard
|
||||
key={cardId}
|
||||
group={group}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Normal mode
|
||||
videoItems.slice(0, visibleCount).map(({ video, videoUrl, cardId }) => {
|
||||
const isActive = activeCardId === cardId;
|
||||
return (
|
||||
<VideoCard
|
||||
key={cardId}
|
||||
video={video}
|
||||
videoUrl={videoUrl}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load more trigger */}
|
||||
{visibleCount < videoItems.length && (
|
||||
{visibleCount < totalItems && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="h-20 w-full flex items-center justify-center opacity-0 pointer-events-none"
|
||||
@@ -115,3 +174,4 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* VideoGroupCard - Displays grouped videos with same name as single card
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import { Video } from '@/lib/types';
|
||||
import { parseVideoTitle } from '@/lib/utils/video';
|
||||
|
||||
export interface GroupedVideo {
|
||||
/** Representative video (lowest latency) */
|
||||
representative: Video;
|
||||
/** All videos in this group */
|
||||
videos: Video[];
|
||||
/** Group name (vod_name) */
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface VideoGroupCardProps {
|
||||
group: GroupedVideo;
|
||||
cardId: string;
|
||||
isActive: boolean;
|
||||
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
|
||||
}
|
||||
|
||||
export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
||||
group,
|
||||
cardId,
|
||||
isActive,
|
||||
onCardClick
|
||||
}) => {
|
||||
const { representative, videos, name } = group;
|
||||
|
||||
// Best latency from the group
|
||||
const bestLatency = useMemo(() => {
|
||||
const latencies = videos.filter(v => v.latency !== undefined).map(v => v.latency!);
|
||||
return latencies.length > 0 ? Math.min(...latencies) : undefined;
|
||||
}, [videos]);
|
||||
|
||||
// Generate URL with grouped sources data
|
||||
const videoUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
id: String(representative.vod_id),
|
||||
source: representative.source,
|
||||
title: representative.vod_name,
|
||||
});
|
||||
|
||||
// Add group data if multiple sources
|
||||
if (videos.length > 1) {
|
||||
const groupData = videos.map(v => ({
|
||||
id: v.vod_id,
|
||||
source: v.source,
|
||||
sourceName: v.sourceName,
|
||||
latency: v.latency,
|
||||
pic: v.vod_pic,
|
||||
}));
|
||||
params.set('groupedSources', JSON.stringify(groupData));
|
||||
}
|
||||
|
||||
return `/player?${params.toString()}`;
|
||||
}, [representative, videos]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.zIndex = '100')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.zIndex = '1')}
|
||||
>
|
||||
<Link
|
||||
key={cardId}
|
||||
href={videoUrl}
|
||||
onClick={(e) => onCardClick(e, cardId, videoUrl)}
|
||||
role="listitem"
|
||||
aria-label={`${name} - ${videos.length} 个源${representative.vod_remarks ? ` - ${representative.vod_remarks}` : ''}`}
|
||||
prefetch={false}
|
||||
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out block h-full"
|
||||
>
|
||||
<Card
|
||||
className="p-0 flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
|
||||
hover={false}
|
||||
blur={false}
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{representative.vod_pic ? (
|
||||
<Image
|
||||
src={representative.vod_pic}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
loading="eager"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Icon */}
|
||||
<div className="absolute inset-0 flex items-center justify-center -z-10">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
|
||||
</div>
|
||||
|
||||
{/* Badge Container */}
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
{/* Source count badge */}
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0">
|
||||
<Icons.Layers size={12} className="mr-1" />
|
||||
{videos.length} 源
|
||||
</Badge>
|
||||
|
||||
{bestLatency !== undefined && (
|
||||
<LatencyBadge latency={bestLatency} className="flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite Button - Top Right */}
|
||||
<div className={`absolute top-2 right-2 z-20 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
<FavoriteButton
|
||||
videoId={representative.vod_id}
|
||||
source={representative.source}
|
||||
title={name}
|
||||
poster={representative.vod_pic}
|
||||
sourceName={representative.sourceName}
|
||||
type={representative.type_name}
|
||||
year={representative.vod_year}
|
||||
remarks={representative.vod_remarks}
|
||||
size={16}
|
||||
className="shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{isActive && (
|
||||
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
|
||||
再次点击播放 →
|
||||
</div>
|
||||
)}
|
||||
{representative.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{representative.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{representative.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{representative.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
{(() => {
|
||||
const { cleanTitle, quality } = parseVideoTitle(name);
|
||||
const displayQuality = quality || representative.vod_remarks;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] mb-1">
|
||||
{cleanTitle}
|
||||
</h4>
|
||||
{displayQuality && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] font-medium">
|
||||
{displayQuality}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoGroupCard.displayName = 'VideoGroupCard';
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* DisplaySettings - Settings for search display and latency
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import { type SearchDisplayMode } from '@/lib/store/settings-store';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
|
||||
interface DisplaySettingsProps {
|
||||
realtimeLatency: boolean;
|
||||
searchDisplayMode: SearchDisplayMode;
|
||||
onRealtimeLatencyChange: (enabled: boolean) => void;
|
||||
onSearchDisplayModeChange: (mode: SearchDisplayMode) => void;
|
||||
}
|
||||
|
||||
export function DisplaySettings({
|
||||
realtimeLatency,
|
||||
searchDisplayMode,
|
||||
onRealtimeLatencyChange,
|
||||
onSearchDisplayModeChange,
|
||||
}: DisplaySettingsProps) {
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">显示设置</h2>
|
||||
|
||||
{/* Real-time Latency Toggle */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-color)]">实时延迟显示</h3>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
开启后,搜索结果中的延迟数值会每 5 秒更新一次
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={realtimeLatency}
|
||||
onChange={onRealtimeLatencyChange}
|
||||
ariaLabel="实时延迟显示开关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Display Mode */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-color)] mb-2">搜索结果显示方式</h3>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
选择搜索结果的展示模式
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => onSearchDisplayModeChange('normal')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${searchDisplayMode === 'normal'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">默认显示</div>
|
||||
<div className="text-sm opacity-80 mt-1">每个源的结果单独显示</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSearchDisplayModeChange('grouped')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${searchDisplayMode === 'grouped'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">合并同名源</div>
|
||||
<div className="text-sm opacity-80 mt-1">相同名称的视频合并为一个卡片</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
|
||||
interface PasswordSettingsProps {
|
||||
enabled: boolean;
|
||||
@@ -51,15 +52,11 @@ export function PasswordSettings({
|
||||
<label className="text-sm font-medium text-[var(--text-color)]">
|
||||
启用密码访问
|
||||
</label>
|
||||
<label className="switch relative inline-flex items-center cursor-pointer h-[30px] w-[50px] shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
/>
|
||||
<div className={`switch-slider w-full h-full rounded-[var(--radius-full)] bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)] peer-checked:bg-[var(--accent-color)] transition-colors duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) before:content-[''] before:absolute before:h-[26px] before:w-[26px] before:left-[2px] before:bottom-[2px] before:bg-white before:rounded-[var(--radius-full)] before:transition-transform before:duration-[0.4s] before:cubic-bezier(0.2,0.8,0.2,1) before:shadow-[0_1px_3px_rgba(0,0,0,0.2)] peer-checked:before:translate-x-[20px]`}></div>
|
||||
</label>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
ariaLabel="启用密码访问开关"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Switch - A reusable toggle switch component
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<label
|
||||
className={`
|
||||
switch relative inline-flex items-center cursor-pointer
|
||||
h-[30px] w-[50px] shrink-0
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
onChange={(e) => !disabled && onChange(e.target.checked)}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
switch-slider w-full h-full rounded-[var(--radius-full)]
|
||||
bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]
|
||||
peer-checked:bg-[var(--accent-color)]
|
||||
transition-colors duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)
|
||||
before:content-[''] before:absolute before:h-[26px] before:w-[26px]
|
||||
before:left-[2px] before:bottom-[2px]
|
||||
before:bg-white before:rounded-[var(--radius-full)]
|
||||
before:transition-transform before:duration-[0.4s]
|
||||
before:cubic-bezier(0.2,0.8,0.2,1)
|
||||
before:shadow-[0_1px_3px_rgba(0,0,0,0.2)]
|
||||
peer-checked:before:translate-x-[20px]
|
||||
active:before:scale-95
|
||||
`}
|
||||
></div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -30,4 +30,22 @@ export const NavigationIcons = {
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
ArrowUpDown: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Layers: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" />
|
||||
<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" />
|
||||
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* useLatencyPing - Hook for real-time latency measurement
|
||||
* Periodically pings video sources when enabled
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
interface LatencyState {
|
||||
[sourceId: string]: number;
|
||||
}
|
||||
|
||||
interface UseLatencyPingOptions {
|
||||
sourceUrls: { id: string; baseUrl: string }[];
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export function useLatencyPing({
|
||||
sourceUrls,
|
||||
enabled = true,
|
||||
intervalMs = 5000,
|
||||
}: UseLatencyPingOptions) {
|
||||
const [latencies, setLatencies] = useState<LatencyState>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Check if real-time latency is enabled in settings
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setRealtimeEnabled(settings.realtimeLatency);
|
||||
|
||||
// Subscribe to settings changes
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
const newSettings = settingsStore.getSettings();
|
||||
setRealtimeEnabled(newSettings.realtimeLatency);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pingSource = useCallback(async (sourceId: string, baseUrl: string): Promise<number | null> => {
|
||||
try {
|
||||
const response = await fetch('/api/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: baseUrl }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.latency || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pingAllSources = useCallback(async () => {
|
||||
if (!mountedRef.current || sourceUrls.length === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await Promise.all(
|
||||
sourceUrls.map(async ({ id, baseUrl }) => {
|
||||
const latency = await pingSource(id, baseUrl);
|
||||
return { id, latency };
|
||||
})
|
||||
);
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLatencies(prev => {
|
||||
const newState = { ...prev };
|
||||
results.forEach(({ id, latency }) => {
|
||||
if (latency !== null) {
|
||||
newState[id] = latency;
|
||||
}
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sourceUrls, pingSource]);
|
||||
|
||||
// Start/stop polling based on enabled state
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
const shouldPoll = enabled && realtimeEnabled && sourceUrls.length > 0;
|
||||
|
||||
if (shouldPoll) {
|
||||
// Initial ping
|
||||
pingAllSources();
|
||||
|
||||
// Set up interval
|
||||
intervalRef.current = setInterval(pingAllSources, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, realtimeEnabled, sourceUrls, intervalMs, pingAllSources]);
|
||||
|
||||
const refreshLatency = useCallback((sourceId: string) => {
|
||||
const source = sourceUrls.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
pingSource(sourceId, source.baseUrl).then(latency => {
|
||||
if (latency !== null && mountedRef.current) {
|
||||
setLatencies(prev => ({ ...prev, [sourceId]: latency }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [sourceUrls, pingSource]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
pingAllSources();
|
||||
}, [pingAllSources]);
|
||||
|
||||
return {
|
||||
latencies,
|
||||
isLoading,
|
||||
refreshLatency,
|
||||
refreshAll,
|
||||
isRealtimeEnabled: realtimeEnabled,
|
||||
};
|
||||
}
|
||||
@@ -10,24 +10,63 @@ interface SearchCache {
|
||||
const CACHE_KEY = 'kvideo_search_cache';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const MAX_CACHED_RESULTS = 300;
|
||||
|
||||
export function useSearchCache() {
|
||||
/**
|
||||
* Strip unnecessary large fields before caching to save LocalStorage space
|
||||
*/
|
||||
const stripVideoData = (results: any[]) => {
|
||||
return results.slice(0, MAX_CACHED_RESULTS).map(video => {
|
||||
// Remove large text fields that are only needed for the detail page
|
||||
const {
|
||||
vod_content,
|
||||
vod_actor,
|
||||
vod_director,
|
||||
...rest
|
||||
} = video;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const saveToCache = (
|
||||
query: string,
|
||||
results: any[],
|
||||
sources: any[]
|
||||
) => {
|
||||
const cache: SearchCache = {
|
||||
query,
|
||||
results,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
const strippedResults = stripVideoData(results);
|
||||
|
||||
const cache: SearchCache = {
|
||||
query,
|
||||
results: strippedResults,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
console.log(`[Cache] Successfully saved ${strippedResults.length} results for query: "${query}"`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
if (error instanceof Error && error.name === 'QuotaExceededError') {
|
||||
console.warn('[Cache] LocalStorage quota exceeded. Clearing cache and trying again with fewer results.');
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
// Try saving only top 100 results if quota exceeded
|
||||
const reducedResults = results.slice(0, 100).map(({ vod_content, vod_actor, vod_director, ...rest }: any) => rest);
|
||||
const reducedCache = {
|
||||
query,
|
||||
results: reducedResults,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(reducedCache));
|
||||
} catch (innerError) {
|
||||
console.error('[Cache] Failed to save even reduced cache:', innerError);
|
||||
}
|
||||
} else {
|
||||
console.error('[Cache] Failed to save search results to LocalStorage:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +85,7 @@ export function useSearchCache() {
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
console.error('[Cache] Failed to load search results from LocalStorage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,7 +33,8 @@ import { settingsStore } from '@/lib/store/settings-store';
|
||||
export function useVideoPlayer(
|
||||
videoId: string | null,
|
||||
source: string | null,
|
||||
episodeParam: string | null
|
||||
episodeParam: string | null,
|
||||
isReversed: boolean = false
|
||||
): UseVideoPlayerReturn {
|
||||
const [videoData, setVideoData] = useState<VideoData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -94,8 +95,10 @@ export function useVideoPlayer(
|
||||
setLoading(false);
|
||||
|
||||
if (data.data.episodes && data.data.episodes.length > 0) {
|
||||
const episodeIndex = episodeParam ? parseInt(episodeParam, 10) : 0;
|
||||
const validIndex = (episodeIndex >= 0 && episodeIndex < data.data.episodes.length) ? episodeIndex : 0;
|
||||
// Default to first (0) or last (length-1) based on reverse order if no param
|
||||
const defaultIndex = isReversed ? data.data.episodes.length - 1 : 0;
|
||||
const episodeIndex = episodeParam ? parseInt(episodeParam, 10) : defaultIndex;
|
||||
const validIndex = (episodeIndex >= 0 && episodeIndex < data.data.episodes.length) ? episodeIndex : defaultIndex;
|
||||
|
||||
const episodeUrl = data.data.episodes[validIndex].url;
|
||||
|
||||
@@ -113,7 +116,7 @@ export function useVideoPlayer(
|
||||
setVideoError(error instanceof Error ? error.message : 'Failed to load video details. Please try another source.');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [videoId, source, episodeParam]);
|
||||
}, [videoId, source, episodeParam, isReversed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoId && source) {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type SortOption =
|
||||
| 'name-asc'
|
||||
| 'name-desc';
|
||||
|
||||
export type SearchDisplayMode = 'normal' | 'grouped';
|
||||
|
||||
export interface AppSettings {
|
||||
sources: VideoSource[];
|
||||
adultSources: VideoSource[];
|
||||
@@ -33,6 +35,10 @@ export interface AppSettings {
|
||||
autoSkipOutro: boolean;
|
||||
skipOutroSeconds: number;
|
||||
showModeIndicator: boolean; // Show '直连模式'/'代理模式' badge on player
|
||||
// Search & Display settings
|
||||
realtimeLatency: boolean; // Enable real-time latency ping updates
|
||||
searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos
|
||||
episodeReverseOrder: boolean; // Persist episode list reverse state
|
||||
}
|
||||
|
||||
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
|
||||
@@ -99,6 +105,9 @@ export const settingsStore = {
|
||||
autoSkipOutro: false,
|
||||
skipOutroSeconds: 0,
|
||||
showModeIndicator: false,
|
||||
realtimeLatency: false,
|
||||
searchDisplayMode: 'normal',
|
||||
episodeReverseOrder: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +128,9 @@ export const settingsStore = {
|
||||
autoSkipOutro: false,
|
||||
skipOutroSeconds: 0,
|
||||
showModeIndicator: false,
|
||||
realtimeLatency: false,
|
||||
searchDisplayMode: 'normal',
|
||||
episodeReverseOrder: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +187,9 @@ export const settingsStore = {
|
||||
autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false,
|
||||
skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0,
|
||||
showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false,
|
||||
realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false,
|
||||
searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal',
|
||||
episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
|
||||
};
|
||||
} catch {
|
||||
// Even if localStorage fails, we should return defaults + ENV subscriptions
|
||||
@@ -195,6 +210,9 @@ export const settingsStore = {
|
||||
autoSkipOutro: false,
|
||||
skipOutroSeconds: 0,
|
||||
showModeIndicator: false,
|
||||
realtimeLatency: false,
|
||||
searchDisplayMode: 'normal',
|
||||
episodeReverseOrder: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.7.6",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "3.7.6",
|
||||
"version": "3.8.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.7.6",
|
||||
"version": "3.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user