./kaisetsu-app/src/components/TimelineBlock.tsx
'use client';
import { useState, useRef, useEffect } from 'react';
import {
MessageSquare,
Mic,
Pause,
Clock,
Volume2,
Trash2,
ChevronDown,
ChevronUp,
Plus,
Loader2
} from 'lucide-react';
export const VOICE_TYPES = [
{ id: '7nO7lVCISGqz9Dhm3AAx', name: '男性1(落ち着いている、ニュース風)' },
{ id: 'hbgab64cBnhmszNvMx0a', name: '女性1(落ち着いている、ニュース風)' },
{ id: 'W4ihLGvcGWLGK4wB0Awj', name: '女性2(明るめ、スポーツニュース風)' },
{ id: 'P8wFRdNgC0J0v6Z08giL', name: '男性ナレーター1(超明るい、バラエティ風)' },
{ id: 'Rgib2k564qSdhNBylX7C', name: '男性ナレーター2(低い、重厚、ホラー風)' },
{ id: 'jhbUCjxMYhkEB9E3kSL0', name: '男性ナレーター3(落ち着いている、ドキュメンタリー風)' },
{ id: 'Nit9fPqVBSNo8FxQiLFE', name: '女性ナレーター1(優しい、ゆっくりめ、癒し系)' },
{ id: 'tzo3dQGeYH7eRPgDLKA4', name: '女性ナレーター2(高め、情報番組風)' },
] as const;
export type VoiceTypeId = typeof VOICE_TYPES[number]['id'];
export interface TimelineBlockData {
id: string;
type: 'dialogue' | 'narration' | 'silence';
startTime: number;
endTime?: number;
speaker?: string;
text?: string;
voiceType?: VoiceTypeId;
isPlaying?: boolean;
timecode?: string; // Absolute TC for display (e.g., "10;00;05;14")
endTimecode?: string; // Absolute end TC for display
bgmLevel?: string; // BGM level (High/Low)
audioUrl?: string; // Pre-synthesized audio URL
}
interface TimelineBlockProps {
block: TimelineBlockData;
isHighlighted?: boolean;
tcOffsetSeconds?: number;
onPlay?: (id: string, startTime: number) => void;
onTextChange?: (id: string, text: string) => void;
onVoiceTypeChange?: (id: string, voiceType: VoiceTypeId) => void;
onSpeakerChange?: (id: string, speaker: string) => void;
onDelete?: (id: string) => void;
onTimestampClick?: (time: number, blockType: 'dialogue' | 'narration' | 'silence') => void;
onAddNarration?: (afterTime: number) => void;
}
export default function TimelineBlock({
block,
isHighlighted,
onPlay,
onTextChange,
onVoiceTypeChange,
onSpeakerChange,
onDelete,
onTimestampClick,
onAddNarration,
tcOffsetSeconds = 35985
}: TimelineBlockProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Convert relative seconds to absolute TC string using offset
const secondsToTC = (seconds: number): string => {
const abs = seconds + tcOffsetSeconds;
const hrs = Math.floor(abs / 3600);
const mins = Math.floor((abs % 3600) / 60);
const secs = Math.floor(abs % 60);
const frames = Math.floor((abs % 1) * 30);
return `${hrs.toString().padStart(2, '0')};${mins.toString().padStart(2, '0')};${secs.toString().padStart(2, '0')};${frames.toString().padStart(2, '0')}`;
};
const getTimecodeDisplay = (): string => {
const startTC = secondsToTC(block.startTime);
if (block.endTime !== undefined) {
return `${startTC} - ${secondsToTC(block.endTime)}`;
}
return startTC;
};
const getDuration = (): number => {
if (block.endTime !== undefined) {
return block.endTime - block.startTime;
}
return 0;
};
const handleTimestampClick = () => {
onTimestampClick?.(block.startTime, block.type);
};
const handlePlayPreview = async () => {
// If already playing, stop
if (isPlayingAudio && audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlayingAudio(false);
return;
}
if (!block.text) return;
// Use pre-synthesized audio if available
if (block.audioUrl) {
if (audioRef.current) {
audioRef.current.pause();
}
const audio = new Audio(block.audioUrl);
audio.playbackRate = audioPlaybackRate;
audioRef.current = audio;
audio.onended = () => {
setIsPlayingAudio(false);
};
audio.onerror = () => {
setIsPlayingAudio(false);
console.error('Audio playback error');
};
setIsPlayingAudio(true);
await audio.play();
return;
}
// Otherwise, generate audio on-demand
setIsLoadingAudio(true);
try {
// Get dictionary from localStorage
const savedDict = localStorage.getItem('kaisetsu-user-dictionary');
const dictionary = savedDict ? JSON.parse(savedDict) : [];
const response = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: block.text,
voiceType: block.voiceType || '7nO7lVCISGqz9Dhm3AAx',
dictionary,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'TTS failed');
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
// Create and play audio
if (audioRef.current) {
audioRef.current.pause();
URL.revokeObjectURL(audioRef.current.src);
}
const audio = new Audio(audioUrl);
audio.playbackRate = audioPlaybackRate;
audioRef.current = audio;
audio.onended = () => {
setIsPlayingAudio(false);
URL.revokeObjectURL(audioUrl);
};
audio.onerror = () => {
setIsPlayingAudio(false);
URL.revokeObjectURL(audioUrl);
console.error('Audio playback error');
};
setIsPlayingAudio(true);
await audio.play();
} catch (error) {
console.error('TTS error:', error);
setIsPlayingAudio(false);
} finally {
setIsLoadingAudio(false);
}
};
const handleAddNarration = () => {
const afterTime = block.endTime || block.startTime;
onAddNarration?.(afterTime);
};
// Silence block
if (block.type === 'silence') {
return (
<div className="relative pl-8 pb-4">
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="absolute left-1.5 top-2 w-4 h-4 rounded-full bg-gray-300 border-2 border-white" />
<div className="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-3 ml-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-500 text-sm">
<Clock className="w-4 h-4" />
<span>セリフなし(無音)</span>
<span className="font-mono bg-gray-200 px-2 py-0.5 rounded text-gray-700">
{getDuration().toFixed(1)}秒
</span>
</div>
<button
onClick={handleAddNarration}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 rounded transition-colors"
title="この後に解説音声を追加"
>
<Plus className="w-3 h-3" />
追加
</button>
</div>
</div>
</div>
);
}
// Dialogue block
if (block.type === 'dialogue') {
return (
<div className={`relative pl-8 pb-4 transition-all ${isHighlighted ? 'scale-[1.01]' : ''}`}>
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200" />
<div className={`absolute left-1.5 top-2 w-4 h-4 rounded-full border-2 border-white ${
isHighlighted ? 'bg-gray-900 animate-pulse' : 'bg-gray-600'
}`} />
<div className={`ml-4 rounded-xl overflow-hidden border transition-all ${
isHighlighted
? 'border-gray-400 bg-gray-50 shadow-md'
: 'border-gray-200 bg-white'
}`}>
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-3">
<button
onClick={handleTimestampClick}
className="flex items-center gap-1 font-mono text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded hover:bg-gray-300 transition-colors"
>
<Clock className="w-3 h-3" />
{getTimecodeDisplay()}
</button>
<div className="flex items-center gap-1.5">
<MessageSquare className="w-4 h-4 text-gray-600" />
<input
type="text"
value={block.speaker || ''}
onChange={(e) => onSpeakerChange?.(block.id, e.target.value)}
placeholder="話者"
className="text-sm font-medium text-gray-700 bg-transparent border-b border-transparent hover:border-gray-300 focus:border-gray-500 focus:outline-none px-1 py-0 w-24"
/>
</div>
{isHighlighted && (
<span className="text-xs bg-gray-900 text-white px-2 py-0.5 rounded-full">
再生中
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={handleAddNarration}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 rounded transition-colors"
title="この後に解説音声を追加"
>
<Plus className="w-3 h-3" />
追加
</button>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-200 rounded transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-500" />
) : (
<ChevronDown className="w-4 h-4 text-gray-500" />
)}
</button>
</div>
</div>
{isExpanded && (
<div className="p-4">
<p className="text-gray-700 leading-relaxed">
{block.text}
</p>
</div>
)}
</div>
</div>
);
}
// Narration block
return (
<div className={`relative pl-8 pb-4 transition-all ${isHighlighted ? 'scale-[1.01]' : ''}`}>
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-purple-200" />
<div className={`absolute left-1.5 top-2 w-4 h-4 rounded-full border-2 border-white ${
isHighlighted ? 'bg-purple-600 animate-pulse' : 'bg-purple-500'
}`} />
<div className={`ml-4 rounded-xl overflow-hidden border-2 transition-all ${
isHighlighted
? 'border-purple-400 bg-purple-50 shadow-md'
: 'border-purple-300 bg-purple-50'
}`}>
<div className="flex items-center justify-between px-4 py-2 bg-purple-100 border-b border-purple-200">
<div className="flex items-center gap-3">
<button
onClick={handleTimestampClick}
className="flex items-center gap-1 font-mono text-xs bg-purple-200 text-purple-700 px-2 py-1 rounded hover:bg-purple-300 transition-colors"
>
<Clock className="w-3 h-3" />
{getTimecodeDisplay()}
</button>
<div className="flex items-center gap-1.5">
<Mic className="w-4 h-4 text-purple-600" />
<input
type="text"
value={block.speaker || '解説音声'}
onChange={(e) => onSpeakerChange?.(block.id, e.target.value)}
className="text-sm font-medium text-purple-700 bg-transparent border-b border-transparent hover:border-purple-300 focus:border-purple-500 focus:outline-none px-1 py-0 w-28"
/>
</div>
{/* Voice type selector */}
<select
value={block.voiceType || '7nO7lVCISGqz9Dhm3AAx'}
onChange={(e) => onVoiceTypeChange?.(block.id, e.target.value as VoiceTypeId)}
className="text-xs bg-white border border-gray-300 rounded px-2 py-1 text-gray-700 focus:outline-none focus:ring-1 focus:ring-gray-400"
>
{VOICE_TYPES.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onDelete?.(block.id)}
className="p-1.5 hover:bg-red-100 rounded transition-colors text-red-500"
title="削除"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={handleAddNarration}
className="flex items-center gap-1 px-2 py-1 text-xs text-purple-600 hover:bg-purple-200 rounded transition-colors"
title="この後に解説音声を追加"
>
<Plus className="w-3 h-3" />
追加
</button>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-purple-200 rounded transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-purple-500" />
) : (
<ChevronDown className="w-4 h-4 text-purple-500" />
)}
</button>
</div>
</div>
{isExpanded && (
<div className="p-4 bg-white space-y-3">
<textarea
ref={textareaRef}
value={block.text || ''}
onChange={(e) => onTextChange?.(block.id, e.target.value)}
className="w-full min-h-[80px] p-3 bg-white border border-purple-200 rounded-lg text-gray-700 focus:outline-none focus:ring-2 focus:ring-purple-400 resize-none text-sm leading-relaxed"
placeholder="解説音声テキストを入力..."
/>
<div className="flex items-center gap-3">
<button
onClick={handlePlayPreview}
disabled={isLoadingAudio}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isLoadingAudio
? 'bg-purple-200 text-purple-500 cursor-wait'
: isPlayingAudio
? 'bg-red-100 text-red-600'
: 'bg-purple-100 text-purple-700 hover:bg-purple-200'
}`}
>