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:
kuekhaoyang
2025-12-29 10:48:25 +08:00
parent 592129c42f
commit c920a3160a
35 changed files with 1515 additions and 284 deletions
+75
View File
@@ -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
View File
@@ -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>
)}
+29 -1
View File
@@ -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,
};
}
+13
View File
@@ -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}
+1
View File
@@ -14,6 +14,7 @@ interface CustomVideoPlayerProps {
totalEpisodes?: number;
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
isReversed?: boolean;
}
/**
+25 -13
View File
@@ -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}
/>
+98 -44
View File
@@ -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" />
+193
View File
@@ -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>
);
}
+16 -6
View File
@@ -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,
+3 -3
View File
@@ -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;
}
+100 -42
View File
@@ -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 };
}
+98 -38
View File
@@ -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
</>
);
});
+210
View File
@@ -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';
+76
View File
@@ -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>
);
}
+6 -9
View File
@@ -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>
)}
+60
View File
@@ -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>
);
}
+18
View File
@@ -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>
),
};
+136
View File
@@ -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,
};
}
+48 -9
View File
@@ -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;
}
};
+7 -4
View File
@@ -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) {
+18
View File
@@ -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,
};
}
},
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "3.7.6",
"version": "3.8.0",
"private": true,
"scripts": {
"dev": "next dev",