Files
mxivideo/src/components/timeline/TrackTimeline.tsx
2025-07-11 00:00:12 +08:00

271 lines
9.1 KiB
TypeScript

import React from 'react'
import { SegmentContextMenu } from './SegmentContextMenu'
import { SegmentTooltip } from './SegmentTooltip'
interface TrackSegment {
id: string
type: 'video' | 'audio' | 'image' | 'text' | 'effect'
name: string
start_time: number
end_time: number
duration: number
resource_path?: string
properties?: any
effects?: any[]
}
interface Track {
id: string
name: string
type: 'video' | 'audio' | 'subtitle'
index: number
segments: TrackSegment[]
properties?: any
}
interface TrackTimelineProps {
track: Track
totalDuration: number
currentTime: number
onSegmentClick?: (segment: TrackSegment) => void
onSegmentHover?: (segment: TrackSegment | null) => void
onSegmentNameChange?: (segmentId: string, newName: string) => void
}
export const TrackTimeline: React.FC<TrackTimelineProps> = ({
track,
totalDuration,
currentTime,
onSegmentClick,
onSegmentHover,
onSegmentNameChange
}) => {
const [editingSegmentId, setEditingSegmentId] = React.useState<string | null>(null)
const [editingName, setEditingName] = React.useState('')
const [contextMenu, setContextMenu] = React.useState<{
isOpen: boolean
position: { x: number; y: number }
segment: TrackSegment | null
}>({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
const [hoveredSegment, setHoveredSegment] = React.useState<TrackSegment | null>(null)
const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 })
const getSegmentColor = (type: string) => {
switch (type) {
case 'video': return 'bg-blue-500 hover:bg-blue-600'
case 'audio': return 'bg-green-500 hover:bg-green-600'
case 'image': return 'bg-yellow-500 hover:bg-yellow-600'
case 'text': return 'bg-purple-500 hover:bg-purple-600'
case 'effect': return 'bg-gray-500 hover:bg-gray-600'
default: return 'bg-gray-400 hover:bg-gray-500'
}
}
const getSegmentTextColor = (type: string) => {
switch (type) {
case 'video': return 'text-blue-100'
case 'audio': return 'text-green-100'
case 'image': return 'text-yellow-100'
case 'text': return 'text-purple-100'
case 'effect': return 'text-gray-100'
default: return 'text-gray-100'
}
}
const getTrackTypeColor = (type: string) => {
switch (type) {
case 'video': return 'bg-blue-100 text-blue-800 border-blue-200'
case 'audio': return 'bg-green-100 text-green-800 border-green-200'
case 'subtitle': return 'bg-purple-100 text-purple-800 border-purple-200'
default: return 'bg-gray-100 text-gray-800 border-gray-200'
}
}
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(2)
return `${minutes}:${secs.padStart(5, '0')}`
}
const handleSegmentDoubleClick = (segment: TrackSegment) => {
setEditingSegmentId(segment.id)
setEditingName(segment.name)
}
const handleSegmentRightClick = (e: React.MouseEvent, segment: TrackSegment) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
segment
})
}
const handleNameSubmit = (segmentId: string) => {
if (onSegmentNameChange && editingName.trim()) {
onSegmentNameChange(segmentId, editingName.trim())
}
setEditingSegmentId(null)
setEditingName('')
}
const handleNameCancel = () => {
setEditingSegmentId(null)
setEditingName('')
}
const handleKeyDown = (e: React.KeyboardEvent, segmentId: string) => {
if (e.key === 'Enter') {
handleNameSubmit(segmentId)
} else if (e.key === 'Escape') {
handleNameCancel()
}
}
const handleSegmentMouseEnter = (e: React.MouseEvent, segment: TrackSegment) => {
setHoveredSegment(segment)
setMousePosition({ x: e.clientX, y: e.clientY })
onSegmentHover?.(segment)
}
const handleSegmentMouseLeave = () => {
setHoveredSegment(null)
onSegmentHover?.(null)
}
const handleSegmentMouseMove = (e: React.MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY })
}
const handleContextMenuClose = () => {
setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, segment: null })
}
const handleContextMenuEdit = () => {
if (contextMenu.segment) {
setEditingSegmentId(contextMenu.segment.id)
setEditingName(contextMenu.segment.name)
}
}
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Track Header */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded text-xs font-medium border ${getTrackTypeColor(track.type)}`}>
{track.type === 'video' ? '视频' : track.type === 'audio' ? '音频' : '字幕'}
</span>
<h3 className="font-medium text-gray-900">
{track.index + 1}: {track.name}
</h3>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>{track.segments.length} </span>
</div>
</div>
</div>
{/* Timeline */}
<div className="relative h-16 bg-white">
{/* Background grid */}
<div className="absolute inset-0 opacity-10">
{Array.from({ length: Math.floor(totalDuration) + 1 }, (_, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-gray-300"
style={{ left: `${(i / totalDuration) * 100}%` }}
/>
))}
</div>
{/* Segments */}
{track.segments.map((segment) => {
const startPercent = (segment.start_time / totalDuration) * 100
const widthPercent = (segment.duration / totalDuration) * 100
return (
<div
key={segment.id}
className={`absolute top-2 bottom-2 rounded-md ${getSegmentColor(segment.type)} ${getSegmentTextColor(segment.type)}
flex items-center px-3 text-sm font-medium cursor-pointer transition-all duration-200
shadow-sm hover:shadow-md transform`}
style={{
left: `${startPercent}%`,
width: `${Math.max(widthPercent, 8)}%`, // Minimum width for visibility
minWidth: '80px'
}}
onClick={() => onSegmentClick?.(segment)}
onDoubleClick={() => handleSegmentDoubleClick(segment)}
onContextMenu={(e) => handleSegmentRightClick(e, segment)}
onMouseEnter={(e) => handleSegmentMouseEnter(e, segment)}
onMouseLeave={handleSegmentMouseLeave}
onMouseMove={handleSegmentMouseMove}
title={`${segment.name}\n类型: ${segment.type}\n开始: ${formatTime(segment.start_time)}\n结束: ${formatTime(segment.end_time)}\n时长: ${formatTime(segment.duration)}${segment.resource_path ? `\n资源: ${segment.resource_path}` : ''}`}
>
{editingSegmentId === segment.id ? (
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={() => handleNameSubmit(segment.id)}
onKeyDown={(e) => handleKeyDown(e, segment.id)}
className="w-full bg-transparent text-inherit border-none outline-none"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className="truncate flex-1">
{segment.name}
</div>
)}
</div>
)
})}
{/* Current time indicator */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-20 pointer-events-none"
style={{ left: `${(currentTime / totalDuration) * 100}%` }}
/>
{/* Empty track indicator */}
{track.segments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm italic">
</div>
)}
</div>
{/* Context Menu */}
<SegmentContextMenu
isOpen={contextMenu.isOpen}
position={contextMenu.position}
onClose={handleContextMenuClose}
onEdit={handleContextMenuEdit}
onCopy={() => {
// TODO: Implement copy functionality
console.log('Copy segment:', contextMenu.segment?.name)
}}
onDelete={() => {
// TODO: Implement delete functionality
console.log('Delete segment:', contextMenu.segment?.name)
}}
onInfo={() => {
// TODO: Implement info functionality
console.log('Show info for segment:', contextMenu.segment?.name)
}}
/>
{/* Segment Tooltip */}
<SegmentTooltip
segment={hoveredSegment}
position={mousePosition}
isVisible={!!hoveredSegment && !editingSegmentId && !contextMenu.isOpen}
/>
</div>
)
}