feat: Implement video stall detection, next episode loading UI, and persist playback rate across sessions.

This commit is contained in:
kuekhaoyang
2026-01-02 13:17:10 +08:00
parent 1604b85b8a
commit f379c52e76
13 changed files with 222 additions and 32 deletions
+35
View File
@@ -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);
+12 -1
View File
@@ -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')}
+1 -1
View File
@@ -184,7 +184,7 @@ export function VideoPlayer({
/>
) : (
<CustomVideoPlayer
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${finalPlayUrl}`} // Force remount when switching modes, retrying, or changing source
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Only remount when switching modes or retrying, NOT when changing episodes
src={finalPlayUrl}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
+10 -1
View File
@@ -6,6 +6,7 @@ import { DesktopSpeedMenu } from './DesktopSpeedMenu';
interface DesktopOverlayProps {
isLoading: boolean;
isTransitioningToNextEpisode?: boolean;
isPlaying: boolean;
showSkipForwardIndicator: boolean;
showSkipBackwardIndicator: boolean;
@@ -38,6 +39,7 @@ interface DesktopOverlayProps {
export function DesktopOverlay({
isLoading,
isTransitioningToNextEpisode = false,
isPlaying,
showSkipForwardIndicator,
showSkipBackwardIndicator,
@@ -101,7 +103,14 @@ export function DesktopOverlay({
{/* Loading Spinner - Glass Effect */}
{isLoading && (
<div className="loading-overlay-glass">
<div className="spinner-glass"></div>
{isTransitioningToNextEpisode ? (
<div className="next-episode-loading">
<div className="spinner-glass"></div>
<span className="next-episode-text">...</span>
</div>
) : (
<div className="spinner-glass"></div>
)}
</div>
)}
@@ -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 (
<DesktopOverlay
isLoading={isLoading}
isTransitioningToNextEpisode={isTransitioningToNextEpisode}
isPlaying={isPlaying}
showSkipForwardIndicator={showSkipForwardIndicator}
showSkipBackwardIndicator={showSkipBackwardIndicator}
@@ -15,6 +15,7 @@ interface UsePlaybackControlsProps {
onError?: (error: string) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
speedMenuTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
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);
+25
View File
@@ -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,
};
}
@@ -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({
@@ -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);
@@ -0,0 +1,75 @@
'use client';
import { useEffect, useRef } from 'react';
interface UseStallDetectionProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
isPlaying: boolean;
isDraggingProgressRef: React.MutableRefObject<boolean>;
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<number>(0);
const lastUpdateTimeRef = useRef<number>(Date.now());
const isStalledByMeRef = useRef<boolean>(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]);
}
+38 -22
View File
@@ -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<void>;
}
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<VideoData | null>(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<string>('');
// 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) {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "3.8.6",
"version": "3.8.7",
"private": true,
"scripts": {
"dev": "next dev",