mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Add custom video player component with controls, progress tracking, and volume management; enhance icon set with new playback and volume icons
This commit is contained in:
@@ -344,3 +344,92 @@ body.dark,
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Video Player Styles */
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-2xl);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: var(--radius-full);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 .slider-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.slider-track:hover .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.slider-thumb:active,
|
||||
.slider-track:active .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface CustomVideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
initialTime?: number;
|
||||
}
|
||||
|
||||
export function CustomVideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
initialTime = 0
|
||||
}: CustomVideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const volumeBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const isDraggingVolumeRef = useRef(false);
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const hideControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying && !showSpeedMenu) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
hideControls();
|
||||
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, showSpeedMenu]);
|
||||
|
||||
// Handle mouse movement to show controls
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
if (isPlaying && !showSpeedMenu) {
|
||||
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Play/Pause toggle
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video events
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
const handleTimeUpdateEvent = () => {
|
||||
if (!videoRef.current || isDraggingProgressRef.current) return;
|
||||
|
||||
const current = videoRef.current.currentTime;
|
||||
const total = videoRef.current.duration;
|
||||
|
||||
setCurrentTime(current);
|
||||
setDuration(total);
|
||||
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(current, total);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
setDuration(videoRef.current.duration);
|
||||
setIsLoading(false);
|
||||
|
||||
// Set initial time if provided
|
||||
if (initialTime > 0) {
|
||||
videoRef.current.currentTime = initialTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoError = () => {
|
||||
setIsLoading(false);
|
||||
if (onError) {
|
||||
onError('Video failed to load');
|
||||
}
|
||||
};
|
||||
|
||||
// Progress bar seeking
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressBarRef.current) return;
|
||||
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingProgressRef.current = true;
|
||||
handleProgressClick(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleProgressMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingProgressRef.current || !progressBarRef.current || !videoRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
isDraggingProgressRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleProgressMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleProgressMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [duration]);
|
||||
|
||||
// Volume control
|
||||
const toggleMute = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (isMuted) {
|
||||
videoRef.current.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
videoRef.current.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !volumeBarRef.current) return;
|
||||
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
setIsMuted(pos === 0);
|
||||
};
|
||||
|
||||
const handleVolumeMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingVolumeRef.current = true;
|
||||
handleVolumeChange(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleVolumeMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingVolumeRef.current || !volumeBarRef.current || !videoRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
setIsMuted(pos === 0);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingVolumeRef.current) {
|
||||
isDraggingVolumeRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleVolumeMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleVolumeMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fullscreen
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// Playback speed
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const changePlaybackSpeed = (speed: number) => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.playbackRate = speed;
|
||||
setPlaybackRate(speed);
|
||||
setShowSpeedMenu(false);
|
||||
};
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number) => {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden group"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
src={src}
|
||||
poster={poster}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onTimeUpdate={handleTimeUpdateEvent}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={handleVideoError}
|
||||
onWaiting={() => setIsLoading(true)}
|
||||
onCanPlay={() => setIsLoading(false)}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* Loading Spinner */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center Play Button (when paused) */}
|
||||
{!isPlaying && !isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)]"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Icons.Play size={32} className="text-white ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Controls */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${
|
||||
showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
}`}
|
||||
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4 pb-2">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="slider-track cursor-pointer"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className="slider-range"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="slider-thumb"
|
||||
style={{ left: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="btn-icon"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 group/volume">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="btn-icon"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<Icons.VolumeX size={20} />
|
||||
) : volume < 0.5 ? (
|
||||
<Icons.Volume1 size={20} />
|
||||
) : (
|
||||
<Icons.Volume2 size={20} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Volume Bar */}
|
||||
<div className="flex items-center gap-2 opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32 overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
ref={volumeBarRef}
|
||||
className="slider-track h-1 cursor-pointer flex-1"
|
||||
onClick={handleVolumeChange}
|
||||
onMouseDown={handleVolumeMouseDown}
|
||||
>
|
||||
<div
|
||||
className="slider-range h-full"
|
||||
style={{ width: `${isMuted ? 0 : volume * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="slider-thumb"
|
||||
style={{ left: `${isMuted ? 0 : volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-white text-sm font-medium tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Playback Speed */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
|
||||
{/* Speed Menu */}
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => changePlaybackSpeed(speed)}
|
||||
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${
|
||||
playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="btn-icon"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
@@ -15,9 +16,7 @@ interface VideoPlayerProps {
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const { addToHistory } = useHistoryStore();
|
||||
|
||||
@@ -25,116 +24,37 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
|
||||
const source = searchParams.get('source') || '';
|
||||
const title = searchParams.get('title') || '未知视频';
|
||||
|
||||
// Save progress to history periodically
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !videoId || !playUrl) return;
|
||||
// Get saved progress for this video
|
||||
const getSavedProgress = () => {
|
||||
if (!videoId) return 0;
|
||||
const savedTime = localStorage.getItem(`video_progress_${videoId}_${currentEpisode}`);
|
||||
return savedTime ? parseFloat(savedTime) : 0;
|
||||
};
|
||||
|
||||
const video = videoRef.current;
|
||||
let lastSavedTime = 0;
|
||||
// Handle time updates and save progress
|
||||
const handleTimeUpdate = (currentTime: number, duration: number) => {
|
||||
if (!videoId || !playUrl || duration === 0) return;
|
||||
|
||||
const updateProgress = () => {
|
||||
if (video && video.duration > 0) {
|
||||
const position = video.currentTime;
|
||||
const duration = video.duration;
|
||||
|
||||
// Only save if we have meaningful progress (more than 1 second)
|
||||
// and if at least 5 seconds have passed since last save
|
||||
if (position > 1 && Math.abs(position - lastSavedTime) >= 5) {
|
||||
lastSavedTime = position;
|
||||
console.log(`[Watch History] Saving progress: ${position.toFixed(1)}s / ${duration.toFixed(1)}s`);
|
||||
|
||||
addToHistory(
|
||||
videoId,
|
||||
title,
|
||||
playUrl,
|
||||
currentEpisode,
|
||||
source,
|
||||
position,
|
||||
duration,
|
||||
undefined, // poster - updated from player page
|
||||
[] // episodes - updated from player page
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update progress on time update (throttled by the 5 second check)
|
||||
const handleTimeUpdate = () => updateProgress();
|
||||
|
||||
// Also update on pause
|
||||
const handlePause = () => {
|
||||
if (video && video.duration > 0) {
|
||||
console.log('[Watch History] Saving on pause');
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// Update when leaving the page
|
||||
const handleBeforeUnload = () => {
|
||||
if (video && video.duration > 0) {
|
||||
console.log('[Watch History] Saving before unload');
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('pause', handlePause);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
// Save progress one last time on unmount
|
||||
if (video && video.duration > 0) {
|
||||
console.log('[Watch History] Saving on unmount');
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
}, [videoId, playUrl, currentEpisode, source, title, addToHistory]);
|
||||
|
||||
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
let errorMessage = 'Video playback failed';
|
||||
|
||||
if (video.error) {
|
||||
switch (video.error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = 'Video loading was aborted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = 'Network error occurred while loading video';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = 'Video format is not supported or corrupted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = 'Video source not supported or unavailable';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Video error: ${video.error.message || 'Unknown error'}`;
|
||||
}
|
||||
// Save progress every few seconds
|
||||
if (currentTime > 1) {
|
||||
addToHistory(
|
||||
videoId,
|
||||
title,
|
||||
playUrl,
|
||||
currentEpisode,
|
||||
source,
|
||||
currentTime,
|
||||
duration,
|
||||
undefined,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Video playback error:', errorMessage, video.error);
|
||||
setVideoError(errorMessage);
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoadStart = () => {
|
||||
setIsVideoLoading(true);
|
||||
setVideoError('');
|
||||
};
|
||||
|
||||
const handleVideoCanPlay = () => {
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setVideoError('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
// Handle video errors
|
||||
const handleVideoError = (error: string) => {
|
||||
console.error('Video playback error:', error);
|
||||
setVideoError(error);
|
||||
};
|
||||
|
||||
if (!playUrl) {
|
||||
@@ -152,63 +72,40 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
|
||||
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
</div>
|
||||
{videoError ? (
|
||||
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
|
||||
<div className="text-center text-white max-w-md px-4">
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setVideoError('')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideoLoading && !videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
|
||||
<div className="text-center text-white">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full"
|
||||
controls
|
||||
autoPlay
|
||||
</div>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
src={playUrl}
|
||||
onError={handleVideoError}
|
||||
onLoadStart={handleVideoLoadStart}
|
||||
onCanPlay={handleVideoCanPlay}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current && videoId) {
|
||||
const savedTime = localStorage.getItem(`video_progress_${videoId}_${currentEpisode}`);
|
||||
if (savedTime) {
|
||||
videoRef.current.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
initialTime={getSavedProgress()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,23 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
|
||||
Pause: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="6" y="4" width="4" height="16"/>
|
||||
<rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
TV: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -261,6 +278,91 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume2: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume1: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
VolumeX: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<line x1="23" y1="9" x2="17" y2="15"/>
|
||||
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Maximize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Minimize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Check: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
|
||||
Reference in New Issue
Block a user