mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement video stall detection, next episode loading UI, and persist playback rate across sessions.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.8.6",
|
||||
"version": "3.8.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user