diff --git a/app/styles/video-player.css b/app/styles/video-player.css index 7bfef3a..0e3b886 100644 --- a/app/styles/video-player.css +++ b/app/styles/video-player.css @@ -174,6 +174,41 @@ box-shadow: 0 0 20px rgba(0, 122, 255, 0.3); } +/* Next Episode Loading Indicator */ +.next-episode-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; +} + +.next-episode-loading .spinner-glass { + width: clamp(36px, 6vw, 48px); + height: clamp(36px, 6vw, 48px); +} + +.next-episode-text { + color: white; + font-size: clamp(1rem, 3vw, 1.25rem); + /* Increased size */ + font-weight: 600; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8); + letter-spacing: 0.05em; + animation: pulse-text 2s ease-in-out infinite; +} + +@keyframes pulse-text { + + 0%, + 100% { + opacity: 0.9; + } + + 50% { + opacity: 0.6; + } +} + @keyframes spin { to { transform: rotate(360deg); diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index cb00f15..e4c0198 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -5,6 +5,7 @@ import { useDesktopPlayerState } from './hooks/useDesktopPlayerState'; import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic'; import { useHlsPlayer } from './hooks/useHlsPlayer'; import { useAutoSkip } from './hooks/useAutoSkip'; +import { useStallDetection } from './hooks/useStallDetection'; import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper'; import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper'; @@ -78,7 +79,7 @@ export function DesktopVideoPlayer({ }); // Auto-skip intro/outro and auto-next episode - const { isOutroActive } = useAutoSkip({ + const { isOutroActive, isTransitioningToNextEpisode } = useAutoSkip({ videoRef, currentTime, duration, @@ -90,6 +91,15 @@ export function DesktopVideoPlayer({ src, }); + // Sensitive stalling detection (e.g. video stuck but HTML5 state says playing) + useStallDetection({ + videoRef, + isPlaying: data.isPlaying, + isDraggingProgressRef: refs.isDraggingProgressRef, + setIsLoading: actions.setIsLoading, + isTransitioningToNextEpisode + }); + const { handleMouseMove, togglePlay, @@ -130,6 +140,7 @@ export function DesktopVideoPlayer({ onTogglePlay={togglePlay} onSkipForward={logic.skipForward} onSkipBackward={logic.skipBackward} + isTransitioningToNextEpisode={isTransitioningToNextEpisode} // More Menu Props showMoreMenu={data.showMoreMenu} isProxied={src.includes('/api/proxy')} diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx index e045af6..b5c43a3 100644 --- a/components/player/VideoPlayer.tsx +++ b/components/player/VideoPlayer.tsx @@ -184,7 +184,7 @@ export function VideoPlayer({ /> ) : ( -
+ {isTransitioningToNextEpisode ? ( +
+
+ 正在自动播放下一集... +
+ ) : ( +
+ )} )} diff --git a/components/player/desktop/DesktopOverlayWrapper.tsx b/components/player/desktop/DesktopOverlayWrapper.tsx index f41aec7..c7e7a29 100644 --- a/components/player/desktop/DesktopOverlayWrapper.tsx +++ b/components/player/desktop/DesktopOverlayWrapper.tsx @@ -9,6 +9,7 @@ interface DesktopOverlayWrapperProps { onTogglePlay: () => void; onSkipForward: () => void; onSkipBackward: () => void; + isTransitioningToNextEpisode?: boolean; showMoreMenu: boolean; isProxied: boolean; onToggleMoreMenu: () => void; @@ -33,6 +34,7 @@ export function DesktopOverlayWrapper({ onTogglePlay, onSkipForward, onSkipBackward, + isTransitioningToNextEpisode = false, showMoreMenu, isProxied, onToggleMoreMenu, @@ -64,6 +66,7 @@ export function DesktopOverlayWrapper({ return ( void; isDraggingProgressRef: React.MutableRefObject; speedMenuTimeoutRef: React.MutableRefObject; + playbackRate: number; setPlaybackRate: (rate: number) => void; setShowSpeedMenu: (show: boolean) => void; } @@ -32,6 +33,7 @@ export function usePlaybackControls({ onError, isDraggingProgressRef, speedMenuTimeoutRef, + playbackRate, setPlaybackRate, setShowSpeedMenu }: UsePlaybackControlsProps) { @@ -69,7 +71,8 @@ export function usePlaybackControls({ const handleLoadedMetadata = useCallback(() => { if (!videoRef.current) return; setDuration(videoRef.current.duration); - setIsLoading(false); + // Removed setIsLoading(false) because metadata loading is too early. + // We wait for onCanPlay to set isLoading to false. // Fix for stuck at 00:00:00: // Only seek if we are at the very start (to avoid overwriting a previous seek) @@ -79,10 +82,15 @@ export function usePlaybackControls({ videoRef.current.currentTime = startPosition; } + // Apply saved playback rate when new source loads (for episode changes) + if (playbackRate !== 1 && videoRef.current.playbackRate !== playbackRate) { + videoRef.current.playbackRate = playbackRate; + } + videoRef.current.play().catch((err: Error) => { console.warn('Autoplay was prevented:', err); }); - }, [videoRef, setDuration, setIsLoading, initialTime]); + }, [videoRef, setDuration, setIsLoading, initialTime, playbackRate]); // Handle late initialization of initialTime (e.g. from async storage hydration) useEffect(() => { @@ -119,6 +127,8 @@ export function usePlaybackControls({ if (!videoRef.current) return; videoRef.current.playbackRate = speed; setPlaybackRate(speed); + // Persist playback rate to localStorage + localStorage.setItem('kvideo-playback-rate', speed.toString()); setShowSpeedMenu(false); if (speedMenuTimeoutRef.current) { clearTimeout(speedMenuTimeoutRef.current); diff --git a/components/player/hooks/useAutoSkip.ts b/components/player/hooks/useAutoSkip.ts index eb9e184..56538a3 100644 --- a/components/player/hooks/useAutoSkip.ts +++ b/components/player/hooks/useAutoSkip.ts @@ -49,14 +49,36 @@ export function useAutoSkip({ const hasTriggeredOutroSkipRef = useRef(false); // Track if we're currently in the outro zone for UI purposes const [isOutroActive, setIsOutroActive] = useState(false); + // Track if we're transitioning to next episode (for custom loading indicator) + const [isTransitioningToNextEpisode, setIsTransitioningToNextEpisode] = useState(false); // Reset flags when video source changes useEffect(() => { hasSkippedIntroRef.current = false; hasTriggeredOutroSkipRef.current = false; setIsOutroActive(false); + // Note: isTransitioningToNextEpisode is NOT reset here immediately + // because we want it to persist while the next episode is loading. + // It will be reset via the 'canplay' event below. }, [src, videoRef]); + // Handle resetting transition state when video is ready + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleReady = () => { + setIsTransitioningToNextEpisode(false); + }; + + video.addEventListener('canplay', handleReady); + video.addEventListener('playing', handleReady); + return () => { + video.removeEventListener('canplay', handleReady); + video.removeEventListener('playing', handleReady); + }; + }, [videoRef]); + // Check if we can advance to next episode const canAdvanceToNext = useCallback(() => { if (totalEpisodes <= 1) return false; @@ -82,6 +104,8 @@ export function useAutoSkip({ console.log(`[AutoSkip] Triggering next episode via ${reason}`); lastHandledSrcRef.current = src; + // Set transitioning state for custom loading indicator + setIsTransitioningToNextEpisode(true); onNextEpisode(); }, [src, onNextEpisode]); @@ -203,5 +227,6 @@ export function useAutoSkip({ hasSkippedIntro: hasSkippedIntroRef.current, hasTriggeredOutroSkip: hasTriggeredOutroSkipRef.current, isOutroActive, + isTransitioningToNextEpisode, }; } diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index dcf5c27..03753d4 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -91,7 +91,7 @@ export function useDesktopPlayerLogic({ const playbackControls = usePlaybackControls({ videoRef, isPlaying, setIsPlaying, setIsLoading, initialTime, shouldAutoPlay, setDuration, setCurrentTime, onTimeUpdate, onError, - isDraggingProgressRef, speedMenuTimeoutRef, setPlaybackRate, setShowSpeedMenu + isDraggingProgressRef, speedMenuTimeoutRef, playbackRate, setPlaybackRate, setShowSpeedMenu }); const volumeControls = useVolumeControls({ diff --git a/components/player/hooks/useDesktopPlayerState.ts b/components/player/hooks/useDesktopPlayerState.ts index c7c6be5..23d3a3c 100644 --- a/components/player/hooks/useDesktopPlayerState.ts +++ b/components/player/hooks/useDesktopPlayerState.ts @@ -27,7 +27,13 @@ export function useDesktopPlayerState() { const [isFullscreen, setIsFullscreen] = useState(false); const [showControls, setShowControls] = useState(true); const [isLoading, setIsLoading] = useState(true); - const [playbackRate, setPlaybackRate] = useState(1); + const [playbackRate, setPlaybackRate] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('kvideo-playback-rate'); + return saved ? parseFloat(saved) : 1; + } + return 1; + }); const [showSpeedMenu, setShowSpeedMenu] = useState(false); const [isPiPSupported, setIsPiPSupported] = useState(false); const [isAirPlaySupported, setIsAirPlaySupported] = useState(false); diff --git a/components/player/hooks/useStallDetection.ts b/components/player/hooks/useStallDetection.ts new file mode 100644 index 0000000..6d0f6af --- /dev/null +++ b/components/player/hooks/useStallDetection.ts @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface UseStallDetectionProps { + videoRef: React.RefObject; + isPlaying: boolean; + isDraggingProgressRef: React.MutableRefObject; + setIsLoading: (loading: boolean) => void; + isTransitioningToNextEpisode: boolean; +} + +/** + * Hook to detect if the video is stalled (supposed to be playing but currentTime isn't moving) + * threshold: 200ms as requested by the user + */ +export function useStallDetection({ + videoRef, + isPlaying, + isDraggingProgressRef, + setIsLoading, + isTransitioningToNextEpisode +}: UseStallDetectionProps) { + const lastTimeRef = useRef(0); + const lastUpdateTimeRef = useRef(Date.now()); + const isStalledByMeRef = useRef(false); + + useEffect(() => { + if (!videoRef.current) return; + + const checkStall = () => { + if (!videoRef.current) return; + + const isVideoPaused = videoRef.current.paused; + const currentTime = videoRef.current.currentTime; + const now = Date.now(); + + if (isPlaying && !isVideoPaused && !isDraggingProgressRef.current && !isTransitioningToNextEpisode) { + if (currentTime !== lastTimeRef.current) { + // Time is moving! + if (isStalledByMeRef.current) { + setIsLoading(false); + isStalledByMeRef.current = false; + } + lastTimeRef.current = currentTime; + lastUpdateTimeRef.current = now; + } else { + // Time hasn't moved. Check how long it's been. + const stallDuration = now - lastUpdateTimeRef.current; + if (stallDuration > 200) { + setIsLoading(true); + isStalledByMeRef.current = true; + } + } + } else { + // If paused, dragging, or transitioning, reset trackers + lastTimeRef.current = currentTime; + lastUpdateTimeRef.current = now; + if (isStalledByMeRef.current) { + setIsLoading(false); + isStalledByMeRef.current = false; + } + } + }; + + const interval = setInterval(checkStall, 100); + return () => { + clearInterval(interval); + if (isStalledByMeRef.current) { + setIsLoading(false); + isStalledByMeRef.current = false; + } + }; + }, [isPlaying, videoRef, isDraggingProgressRef, setIsLoading, isTransitioningToNextEpisode]); +} diff --git a/lib/hooks/useVideoPlayer.ts b/lib/hooks/useVideoPlayer.ts index 4cb796f..827d5c1 100644 --- a/lib/hooks/useVideoPlayer.ts +++ b/lib/hooks/useVideoPlayer.ts @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { settingsStore } from '@/lib/store/settings-store'; interface VideoData { vod_id: string; @@ -27,9 +28,6 @@ interface UseVideoPlayerReturn { fetchVideoDetails: () => Promise; } - -import { settingsStore } from '@/lib/store/settings-store'; - export function useVideoPlayer( videoId: string | null, source: string | null, @@ -37,11 +35,25 @@ export function useVideoPlayer( isReversed: boolean = false ): UseVideoPlayerReturn { const [videoData, setVideoData] = useState(null); - const [loading, setLoading] = useState(false); + // Initialize loading to true if we have the necessary params to start fetching + const [loading, setLoading] = useState(!!(videoId && source)); const [currentEpisode, setCurrentEpisode] = useState(0); const [playUrl, setPlayUrl] = useState(''); const [videoError, setVideoError] = useState(''); + // Refs to keep track of latest values for the fetch function without re-triggering it + // This solves the stale closure problem while keeping fetchVideoDetails stable for the player + const episodeParamRef = useRef(episodeParam); + const isReversedRef = useRef(isReversed); + + useEffect(() => { + episodeParamRef.current = episodeParam; + }, [episodeParam]); + + useEffect(() => { + isReversedRef.current = isReversed; + }, [isReversed]); + const fetchVideoDetails = useCallback(async () => { if (!videoId || !source) return; @@ -49,36 +61,28 @@ export function useVideoPlayer( setVideoError(''); setLoading(true); - // Resolve source object from settings const settings = settingsStore.getSettings(); const allSources = [ ...settings.sources, ...settings.adultSources, ...settings.subscriptions, - // Fallback to checking subscriptions expanded sources if managed there? - // For now, assume id matches one of the top level sources ]; const sourceConfig = allSources.find(s => s.id === source); - let response; if (sourceConfig) { - // use POST with full config if we found it (custom sources) response = await fetch('/api/detail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: videoId, source: sourceConfig }) }); } else { - // Fallback to GET if we can't find config locally (maybe server knows it? unlikely now) response = await fetch(`/api/detail?id=${videoId}&source=${source}`); } const data = await response.json(); - - if (!response.ok) { if (response.status === 404) { setVideoError(data.error || 'This video source is not available. Please go back and try another source.'); @@ -89,34 +93,46 @@ export function useVideoPlayer( } if (data.success && data.data) { - - setVideoData(data.data); setLoading(false); if (data.data.episodes && data.data.episodes.length > 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 latestIsReversed = isReversedRef.current; + const latestEpisodeParam = episodeParamRef.current; + + const defaultIndex = latestIsReversed ? data.data.episodes.length - 1 : 0; + const episodeIndex = latestEpisodeParam ? parseInt(latestEpisodeParam, 10) : defaultIndex; const validIndex = (episodeIndex >= 0 && episodeIndex < data.data.episodes.length) ? episodeIndex : defaultIndex; const episodeUrl = data.data.episodes[validIndex].url; - setCurrentEpisode(validIndex); setPlayUrl(episodeUrl); } else { - console.warn('No episodes found in video data'); setVideoError('No playable episodes available for this video from this source'); + setLoading(false); } } else { throw new Error(data.error || 'Invalid response from API'); } } catch (error) { console.error('Failed to fetch video details:', error); - setVideoError(error instanceof Error ? error.message : 'Failed to load video details. Please try another source.'); + setVideoError(error instanceof Error ? error.message : 'Failed to load video details.'); setLoading(false); } - }, [videoId, source, episodeParam, isReversed]); + }, [videoId, source]); + + // Sync state from params if they change externally (e.g. back/forward navigation) + useEffect(() => { + if (videoData?.episodes && episodeParam !== null) { + const index = parseInt(episodeParam, 10); + if (!isNaN(index) && index >= 0 && index < videoData.episodes.length) { + if (index !== currentEpisode) { + setCurrentEpisode(index); + setPlayUrl(videoData.episodes[index].url); + } + } + } + }, [episodeParam, videoData, currentEpisode]); useEffect(() => { if (videoId && source) { diff --git a/package-lock.json b/package-lock.json index 521d0bb..347555b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "3.8.6", + "version": "3.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "3.8.6", + "version": "3.8.7", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index a952814..19f3f8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "3.8.6", + "version": "3.8.7", "private": true, "scripts": { "dev": "next dev",