./kaisetsu-app/src/components/VideoPlayer.tsx
'use client';
import { useState, useRef, forwardRef, useImperativeHandle, useEffect, useCallback } from 'react';
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize2,
SkipBack,
SkipForward,
Mic,
ChevronUp,
ChevronDown
} from 'lucide-react';
import { TimelineBlockData } from './TimelineBlock';
interface VideoPlayerProps {
videoUrl?: string;
onTimeUpdate?: (currentTime: number) => void;
blocks?: TimelineBlockData[];
tcOffsetSeconds?: number;
}
export interface VideoPlayerHandle {
seekTo: (time: number) => void;
play: () => void;
}
interface CachedAudio {
blockId: string;
audio: HTMLAudioElement;
startTime: number;
endTime: number;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(({
videoUrl,
onTimeUpdate,
blocks = [],
tcOffsetSeconds = 36000
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(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 [includeNarration, setIncludeNarration] = useState(true);
const [playbackRate, setPlaybackRate] = useState(1);
// Cache for narration audio
const audioCache = useRef<Map<string, CachedAudio>>(new Map());
const currentlyPlayingNarration = useRef<HTMLAudioElement | null>(null);
const playedNarrations = useRef<Set<string>>(new Set());
// Get narration blocks that have audio
const narrationBlocks = blocks.filter(b => b.type === 'narration' && b.text && b.audioUrl);
// Load pre-generated audio from blocks
useEffect(() => {
const loadAudioFromBlocks = async () => {
for (const block of narrationBlocks) {
if (!block.audioUrl || audioCache.current.has(block.id)) continue;
const audio = new Audio(block.audioUrl);
// Wait for audio to load to get duration
await new Promise<void>((resolve) => {
audio.onloadedmetadata = () => resolve();
audio.onerror = () => resolve();
});
audioCache.current.set(block.id, {
blockId: block.id,
audio,
startTime: block.startTime,
endTime: block.startTime + (audio.duration || 0),
});
}
};
loadAudioFromBlocks();
}, [narrationBlocks]);
// Check and play narrations at current time
const checkAndPlayNarration = useCallback((time: number) => {
if (!includeNarration) return;
for (const [blockId, cached] of audioCache.current) {
// Check if we should start playing this narration
if (
time >= cached.startTime &&
time < cached.startTime + 0.5 && // Within 0.5s of start
!playedNarrations.current.has(blockId) &&
currentlyPlayingNarration.current !== cached.audio
) {
// Stop any currently playing narration
if (currentlyPlayingNarration.current) {
currentlyPlayingNarration.current.pause();
currentlyPlayingNarration.current.currentTime = 0;
}
playedNarrations.current.add(blockId);
currentlyPlayingNarration.current = cached.audio;
cached.audio.currentTime = 0;
cached.audio.play().catch(console.error);
}
}
}, [includeNarration]);
// Stop all narrations when video pauses
const stopAllNarrations = useCallback(() => {
if (currentlyPlayingNarration.current) {
currentlyPlayingNarration.current.pause();
currentlyPlayingNarration.current.currentTime = 0;
currentlyPlayingNarration.current = null;
}
}, []);
// Reset played narrations when seeking
const resetPlayedNarrations = useCallback((seekTime: number) => {
playedNarrations.current.clear();
// Mark narrations before seek time as played
for (const [blockId, cached] of audioCache.current) {
if (cached.endTime < seekTime) {
playedNarrations.current.add(blockId);
}
}
}, []);
useImperativeHandle(ref, () => ({
seekTo: (time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
stopAllNarrations();
resetPlayedNarrations(time);
}
},
play: () => {
if (videoRef.current) {
videoRef.current.play();
setIsPlaying(true);
}
}
}));
// Cleanup audio cache on unmount
useEffect(() => {
return () => {
audioCache.current.clear();
};
}, []);
// Format time as absolute timecode (adds TC offset)
const formatTime = (seconds: number): string => {
const absoluteSeconds = seconds + tcOffsetSeconds;
const hrs = Math.floor(absoluteSeconds / 3600);
const mins = Math.floor((absoluteSeconds % 3600) / 60);
const secs = Math.floor(absoluteSeconds % 60);
const frames = Math.floor((seconds % 1) * 30); // Use original seconds for frame accuracy
return `${hrs.toString().padStart(2, '0')};${mins.toString().padStart(2, '0')};${secs.toString().padStart(2, '0')};${frames.toString().padStart(2, '0')}`;
};
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
stopAllNarrations();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
const time = videoRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
// Check and play narrations if video is playing
if (isPlaying && includeNarration) {
checkAndPlayNarration(time);
}
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
stopAllNarrations();
resetPlayedNarrations(time);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const vol = parseFloat(e.target.value);
setVolume(vol);
if (videoRef.current) {
videoRef.current.volume = vol;
}
setIsMuted(vol === 0);
};
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const skip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(0, Math.min(duration, videoRef.current.currentTime + seconds));
}
};
const handlePlaybackRateChange = (rate: number) => {
const clampedRate = Math.max(0.1, Math.min(4, rate));
setPlaybackRate(clampedRate);
if (videoRef.current) {
videoRef.current.playbackRate = clampedRate;
}
};
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{/* Video container */}
<div className="relative bg-gray-900 aspect-video">
{videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500">
<div className="w-20 h-20 rounded-full bg-gray-800 flex items-center justify-center mb-4">
<Play className="w-10 h-10 text-gray-600 ml-1" />
</div>
<p className="text-sm">動画ファイルを選択してください</p>
</div>
)}
{/* Timecode overlay */}
<div className="absolute top-4 left-4 bg-black/70 backdrop-blur-sm px-3 py-1.5 rounded-lg">
<span className="font-mono text-white text-sm">{formatTime(currentTime)}</span>
</div>
</div>
{/* Controls */}
<div className="p-4 space-y-3">
{/* Progress bar */}
<div className="relative">
<input
type="range"
min="0"
max={duration || 100}
step="0.01"
value={currentTime}
onChange={handleSeek}
className="w-full h-2 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #111827 ${(currentTime / (duration || 1)) * 100}%, #e5e7eb ${(currentTime / (duration || 1)) * 100}%)`
}}
/>
</div>
{/* Time display and controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Skip back */}
<button
onClick={() => skip(-5)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-600"
title="5秒戻る"
>
<SkipBack className="w-5 h-5" />
</button>
{/* Play/Pause */}
<button
onClick={togglePlay}
className="w-12 h-12 rounded-full bg-gray-900 hover:bg-gray-800 flex items-center justify-center text-white transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
{/* Skip forward */}
<button
onClick={() => skip(5)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-600"
title="5秒進む"
>
<SkipForward className="w-5 h-5" />
</button>
{/* Time display */}
<div className="font-mono text-sm text-gray-500 ml-2">
<span className="text-gray-900">{formatTime(currentTime)}</span>
<span className="mx-1">/</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center gap-3">
{/* Playback speed control */}
<div className="flex items-center gap-1">
<button
onClick={() => handlePlaybackRateChange(Math.round((playbackRate - 0.1) * 10) / 10)}
className="p-1 rounded hover:bg-gray-100 transition-colors text-gray-600"
title="速度を下げる"
>
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={() => handlePlaybackRateChange(1)}
className="px-2 py-1 text-xs font-mono text-gray-700 hover:bg-gray-100 rounded transition-colors min-w-[3rem] text-center"
title="速度をリセット"
>
{playbackRate.toFixed(1)}x
</button>
<button
onClick={() => handlePlaybackRateChange(Math.round((playbackRate + 0.1) * 10) / 10)}
className="p-1 rounded hover:bg-gray-100 transition-colors text-gray-600"
title="速度を上げる"
>
<ChevronUp className="w-4 h-4" />
</button>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<button
onClick={toggleMute}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-600"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-20"
/>
</div>
{/* Fullscreen */}
<button
onClick={() => videoRef.current?.requestFullscreen()}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-600"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
</div>
{/* Narration toggle */}
{narrationBlocks.length > 0 && (
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeNarration}
onChange={(e) => setIncludeNarration(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
/>
<Mic className="w-4 h-4 text-purple-500" />
<span className="text-sm text-gray-700">解説音声を含める</span>
</label>
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded">
{narrationBlocks.length}件の解説音声
</span>
</div>
)}
</div>
</div>
);