diff --git a/app/api/ping/route.ts b/app/api/ping/route.ts new file mode 100644 index 0000000..95d1176 --- /dev/null +++ b/app/api/ping/route.ts @@ -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 } + ); + } +} diff --git a/app/player/page.tsx b/app/player/page.tsx index 24d17ad..80080be 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -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(() => { + 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} /> - {/* Episodes Sidebar */} + {/* Sidebar with sticky wrapper */}
- +
+ + + {/* Source Selector - only show when grouped sources available */} + {groupedSources.length > 1 && ( + { + // 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(); + }} + /> + )} +
)} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 5bb7854..00dfb2c 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -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([]); const [envPasswordSet, setEnvPasswordSet] = useState(false); + // Display settings + const [realtimeLatency, setRealtimeLatency] = useState(false); + const [searchDisplayMode, setSearchDisplayMode] = useState('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, }; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 3df7ca6..353da4c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 */} + + {/* Source Management */} void; + isReversed?: boolean; } /** diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 0f502d7..cb00f15 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -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({ /> 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({ diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 82d04eb..991ff16 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -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(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 ( - +

选集 {episodes && ( {episodes.length} )} + {/* Reverse order toggle button - only show when more than 1 episode */} + {showReverseToggle && ( + + )}

- {episodes && episodes.length > 0 ? ( - episodes.map((episode, index) => ( - - )) + {displayEpisodes && displayEpisodes.length > 0 ? ( + displayEpisodes.map((episode, displayIndex) => { + const originalIndex = getOriginalIndex(displayIndex); + const isCurrentEpisode = currentEpisode === originalIndex; + + return ( + + ); + }) ) : (
diff --git a/components/player/SourceSelector.tsx b/components/player/SourceSelector.tsx new file mode 100644 index 0000000..1c48aa5 --- /dev/null +++ b/components/player/SourceSelector.tsx @@ -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>({}); + + // 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 = {}; + results.forEach(({ source, latency }) => { + if (latency !== undefined) { + newLatencies[source] = latency; + } + }); + + setLatencies(newLatencies); + setIsLoading(false); + }, [sources]); + + // Initialize latencies from sources + useEffect(() => { + const initial: Record = {}; + sources.forEach(s => { + if (s.latency !== undefined) { + initial[s.source] = s.latency; + } + }); + setLatencies(initial); + }, [sources]); + + if (sources.length <= 1) { + return null; + } + + return ( + +
+

+ + + {sources.length} +

+ +
+ +
+ {sortedSources.map((source, index) => { + const isCurrent = source.source === currentSource; + const latency = latencies[source.source] ?? source.latency; + + return ( + + ); + })} +
+
+ ); +} diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx index 2dddc42..e045af6 100644 --- a/components/player/VideoPlayer.tsx +++ b/components/player/VideoPlayer.tsx @@ -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(''); 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 && (
{useProxy ? '代理模式' : '直连模式'} @@ -175,7 +184,7 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpi /> ) : ( )} diff --git a/components/player/desktop/DesktopControlsWrapper.tsx b/components/player/desktop/DesktopControlsWrapper.tsx index 85facc7..875ad30 100644 --- a/components/player/desktop/DesktopControlsWrapper.tsx +++ b/components/player/desktop/DesktopControlsWrapper.tsx @@ -5,12 +5,13 @@ import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic'; interface DesktopControlsWrapperProps { src: string; - state: ReturnType['state']; + data: ReturnType['data']; + actions: ReturnType['actions']; logic: ReturnType; refs: ReturnType['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, diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx index 930eecc..4015098 100644 --- a/components/player/desktop/DesktopOverlay.tsx +++ b/components/player/desktop/DesktopOverlay.tsx @@ -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({
- {/* Next Button (Method: Skip Forward) */} + {/* Next Button (Method: Skip Forward) - Refined to use FastForward icon */}
- +
diff --git a/components/player/desktop/DesktopOverlayWrapper.tsx b/components/player/desktop/DesktopOverlayWrapper.tsx index 02664ef..2af0dfb 100644 --- a/components/player/desktop/DesktopOverlayWrapper.tsx +++ b/components/player/desktop/DesktopOverlayWrapper.tsx @@ -3,7 +3,8 @@ import { DesktopOverlay } from './DesktopOverlay'; import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState'; interface DesktopOverlayWrapperProps { - state: ReturnType['state']; + data: ReturnType['data']; + actions: ReturnType['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; } 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 ( ({ showCastMenu - }; + }), [showCastMenu]); + + return castActions; } diff --git a/components/player/hooks/desktop/useControlsVisibility.ts b/components/player/hooks/desktop/useControlsVisibility.ts index d54a130..40a754e 100644 --- a/components/player/hooks/desktop/useControlsVisibility.ts +++ b/components/player/hooks/desktop/useControlsVisibility.ts @@ -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; } diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts index 7d28ce1..a834a91 100644 --- a/components/player/hooks/desktop/useFullscreenControls.ts +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; interface UseFullscreenControlsProps { containerRef: React.RefObject; @@ -151,9 +151,11 @@ export function useFullscreenControls({ } }, [videoRef, isAirPlaySupported]); - return { + const fullscreenActions = useMemo(() => ({ toggleFullscreen, togglePictureInPicture, showAirPlayMenu - }; + }), [toggleFullscreen, togglePictureInPicture, showAirPlayMenu]); + + return fullscreenActions; } diff --git a/components/player/hooks/desktop/usePlaybackControls.ts b/components/player/hooks/desktop/usePlaybackControls.ts index 8697c3d..8aa25ff 100644 --- a/components/player/hooks/desktop/usePlaybackControls.ts +++ b/components/player/hooks/desktop/usePlaybackControls.ts @@ -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; } diff --git a/components/player/hooks/desktop/useProgressControls.ts b/components/player/hooks/desktop/useProgressControls.ts index c044bff..2f2f174 100644 --- a/components/player/hooks/desktop/useProgressControls.ts +++ b/components/player/hooks/desktop/useProgressControls.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useMemo } from 'react'; interface UseProgressControlsProps { videoRef: React.RefObject; @@ -61,8 +61,10 @@ export function useProgressControls({ }; }, [duration, isDraggingProgressRef, progressBarRef, videoRef, setCurrentTime]); - return { + const progressActions = useMemo(() => ({ handleProgressClick, handleProgressMouseDown - }; + }), [handleProgressClick, handleProgressMouseDown]); + + return progressActions; } diff --git a/components/player/hooks/desktop/useSkipControls.ts b/components/player/hooks/desktop/useSkipControls.ts index bbf46cd..514a24b 100644 --- a/components/player/hooks/desktop/useSkipControls.ts +++ b/components/player/hooks/desktop/useSkipControls.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; interface UseSkipControlsProps { videoRef: React.RefObject; @@ -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; } diff --git a/components/player/hooks/desktop/useUtilities.ts b/components/player/hooks/desktop/useUtilities.ts index d445507..cf90d6c 100644 --- a/components/player/hooks/desktop/useUtilities.ts +++ b/components/player/hooks/desktop/useUtilities.ts @@ -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; } diff --git a/components/player/hooks/desktop/useVolumeControls.ts b/components/player/hooks/desktop/useVolumeControls.ts index 31c11ee..4996819 100644 --- a/components/player/hooks/desktop/useVolumeControls.ts +++ b/components/player/hooks/desktop/useVolumeControls.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; interface UseVolumeControlsProps { videoRef: React.RefObject; @@ -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; } diff --git a/components/player/hooks/useAutoSkip.ts b/components/player/hooks/useAutoSkip.ts index 8fd788d..a358977 100644 --- a/components/player/hooks/useAutoSkip.ts +++ b/components/player/hooks/useAutoSkip.ts @@ -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; + 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(''); + // 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(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(() => { diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index 0987b12..dcf5c27 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -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 + ]); } diff --git a/components/player/hooks/useDesktopPlayerState.ts b/components/player/hooks/useDesktopPlayerState.ts index 3ac3cc4..c7c6be5 100644 --- a/components/player/hooks/useDesktopPlayerState.ts +++ b/components/player/hooks/useDesktopPlayerState.ts @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useMemo } from 'react'; export function useDesktopPlayerState() { const videoRef = useRef(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 }; } diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx index 9ac7e39..1451726 100644 --- a/components/search/VideoGrid.tsx +++ b/components/search/VideoGrid.tsx @@ -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(null); const [visibleCount, setVisibleCount] = useState(24); + const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal'); const gridRef = useRef(null); const observerRef = useRef(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(() => { + if (displayMode !== 'grouped') return []; + + const groups = new Map(); + + 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 ( - - ); - })} + {displayMode === 'grouped' ? ( + // Grouped mode + groupItems.slice(0, visibleCount).map(({ group, cardId }) => { + const isActive = activeCardId === cardId; + return ( + + ); + }) + ) : ( + // Normal mode + videoItems.slice(0, visibleCount).map(({ video, videoUrl, cardId }) => { + const isActive = activeCardId === cardId; + return ( + + ); + }) + )}
{/* Load more trigger */} - {visibleCount < videoItems.length && ( + {visibleCount < totalItems && (
); }); + diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx new file mode 100644 index 0000000..83c9e87 --- /dev/null +++ b/components/search/VideoGroupCard.tsx @@ -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(({ + 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 ( +
(e.currentTarget.style.zIndex = '100')} + onMouseLeave={(e) => (e.currentTarget.style.zIndex = '1')} + > + 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" + > + + {/* Poster */} +
+ {representative.vod_pic ? ( + {name} { + const target = e.currentTarget as HTMLImageElement; + target.style.opacity = '0'; + }} + /> + ) : ( +
+ +
+ )} + + {/* Fallback Icon */} +
+ +
+ + {/* Badge Container */} +
+ {/* Source count badge */} + + + {videos.length} 源 + + + {bestLatency !== undefined && ( + + )} +
+ + {/* Favorite Button - Top Right */} +
+ +
+ + {/* Overlay */} +
+
+ {isActive && ( +
+ 再次点击播放 → +
+ )} + {representative.type_name && ( + + {representative.type_name} + + )} + {representative.vod_year && ( +
+ + {representative.vod_year} +
+ )} +
+
+
+ + {/* Info */} +
+ {(() => { + const { cleanTitle, quality } = parseVideoTitle(name); + const displayQuality = quality || representative.vod_remarks; + + return ( + <> +

+ {cleanTitle} +

+ {displayQuality && ( +

+ {displayQuality} +

+ )} + + ); + })()} +
+
+ +
+ ); +}); + +VideoGroupCard.displayName = 'VideoGroupCard'; diff --git a/components/settings/DisplaySettings.tsx b/components/settings/DisplaySettings.tsx new file mode 100644 index 0000000..08d1732 --- /dev/null +++ b/components/settings/DisplaySettings.tsx @@ -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 ( +
+

显示设置

+ + {/* Real-time Latency Toggle */} +
+
+
+

实时延迟显示

+

+ 开启后,搜索结果中的延迟数值会每 5 秒更新一次 +

+
+ +
+
+ + {/* Search Display Mode */} +
+

搜索结果显示方式

+

+ 选择搜索结果的展示模式 +

+
+ + +
+
+
+ ); +} diff --git a/components/settings/PasswordSettings.tsx b/components/settings/PasswordSettings.tsx index d18b7fb..7072652 100644 --- a/components/settings/PasswordSettings.tsx +++ b/components/settings/PasswordSettings.tsx @@ -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({ - +
)} diff --git a/components/ui/Switch.tsx b/components/ui/Switch.tsx new file mode 100644 index 0000000..eddbf92 --- /dev/null +++ b/components/ui/Switch.tsx @@ -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 ( + + ); +} diff --git a/components/ui/icons/navigation-icons.tsx b/components/ui/icons/navigation-icons.tsx index b811946..e46c537 100644 --- a/components/ui/icons/navigation-icons.tsx +++ b/components/ui/icons/navigation-icons.tsx @@ -30,4 +30,22 @@ export const NavigationIcons = { ), + + ArrowUpDown: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + ), + + Layers: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), }; + diff --git a/lib/hooks/useLatencyPing.ts b/lib/hooks/useLatencyPing.ts new file mode 100644 index 0000000..7ca4462 --- /dev/null +++ b/lib/hooks/useLatencyPing.ts @@ -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({}); + const [isLoading, setIsLoading] = useState(false); + const intervalRef = useRef(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 => { + 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, + }; +} diff --git a/lib/hooks/useSearchCache.ts b/lib/hooks/useSearchCache.ts index 831bbc1..fe2686a 100644 --- a/lib/hooks/useSearchCache.ts +++ b/lib/hooks/useSearchCache.ts @@ -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; } }; diff --git a/lib/hooks/useVideoPlayer.ts b/lib/hooks/useVideoPlayer.ts index d0c2cd3..4cb796f 100644 --- a/lib/hooks/useVideoPlayer.ts +++ b/lib/hooks/useVideoPlayer.ts @@ -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(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) { diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index 531b433..1881ce6 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -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, }; } }, diff --git a/package-lock.json b/package-lock.json index bb08232..64b005c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1f838b5..d1191d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "3.7.6", + "version": "3.8.0", "private": true, "scripts": { "dev": "next dev",