303 lines
7.4 KiB
TypeScript
303 lines
7.4 KiB
TypeScript
"use client"
|
||
|
||
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
|
||
import type { JSX } from "react"
|
||
|
||
import { LoaderCircle, Pause, Play, Scissors } from "lucide-react"
|
||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
|
||
import { Button, Modal } from "@shared/ui"
|
||
import {
|
||
type EditorSegment,
|
||
secondsToTimecode,
|
||
splitSegmentAtMarkers,
|
||
} from "@shared/lib/transcriptionDocument"
|
||
import { SegmentSplitter } from "@features/project/SegmentSplitter"
|
||
|
||
import styles from "./SegmentEditModal.module.scss"
|
||
|
||
const SegmentPlayer: FunctionComponent<{
|
||
videoUrl: string
|
||
start: number
|
||
end: number
|
||
}> = ({ videoUrl, start, end }) => {
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const trackRef = useRef<HTMLDivElement>(null)
|
||
const rafRef = useRef<number>(0)
|
||
const [currentTime, setCurrentTime] = useState(start)
|
||
const [playing, setPlaying] = useState(false)
|
||
const [dragging, setDragging] = useState(false)
|
||
|
||
const duration = end - start
|
||
const progress =
|
||
duration > 0
|
||
? Math.min(Math.max((currentTime - start) / duration, 0), 1)
|
||
: 0
|
||
|
||
/* Time tracking via rAF — only runs while playing or dragging */
|
||
useEffect(() => {
|
||
if (!playing && !dragging) return
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
const tick = () => {
|
||
setCurrentTime(video.currentTime)
|
||
if (video.currentTime >= end && !video.paused) {
|
||
video.pause()
|
||
setPlaying(false)
|
||
}
|
||
rafRef.current = requestAnimationFrame(tick)
|
||
}
|
||
rafRef.current = requestAnimationFrame(tick)
|
||
return () => cancelAnimationFrame(rafRef.current)
|
||
}, [playing, dragging, end])
|
||
|
||
/* Set initial time once video is ready */
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
const onLoaded = () => {
|
||
video.currentTime = start
|
||
}
|
||
video.addEventListener("loadedmetadata", onLoaded)
|
||
if (video.readyState >= 1) onLoaded()
|
||
return () => video.removeEventListener("loadedmetadata", onLoaded)
|
||
}, [start])
|
||
|
||
const togglePlay = useCallback(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
if (video.paused) {
|
||
if (video.currentTime >= end) video.currentTime = start
|
||
video.play()
|
||
setPlaying(true)
|
||
} else {
|
||
video.pause()
|
||
setPlaying(false)
|
||
}
|
||
}, [start, end])
|
||
|
||
const seekToPosition = useCallback(
|
||
(clientX: number) => {
|
||
const track = trackRef.current
|
||
const video = videoRef.current
|
||
if (!track || !video || duration <= 0) return
|
||
const rect = track.getBoundingClientRect()
|
||
const fraction = Math.min(
|
||
Math.max((clientX - rect.left) / rect.width, 0),
|
||
1,
|
||
)
|
||
video.currentTime = start + fraction * duration
|
||
},
|
||
[start, duration],
|
||
)
|
||
|
||
const handleTrackMouseDown = useCallback(
|
||
(e: React.MouseEvent) => {
|
||
e.preventDefault()
|
||
setDragging(true)
|
||
seekToPosition(e.clientX)
|
||
},
|
||
[seekToPosition],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!dragging) return
|
||
const handleMouseMove = (e: MouseEvent) => seekToPosition(e.clientX)
|
||
const handleMouseUp = () => setDragging(false)
|
||
window.addEventListener("mousemove", handleMouseMove)
|
||
window.addEventListener("mouseup", handleMouseUp)
|
||
return () => {
|
||
window.removeEventListener("mousemove", handleMouseMove)
|
||
window.removeEventListener("mouseup", handleMouseUp)
|
||
}
|
||
}, [dragging, seekToPosition])
|
||
|
||
return (
|
||
<div className={styles.playerWrapper}>
|
||
<div className={styles.videoArea}>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl}
|
||
crossOrigin="anonymous"
|
||
playsInline
|
||
preload="auto"
|
||
className={styles.video}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.playButton}
|
||
onClick={togglePlay}
|
||
>
|
||
{playing ? <Pause size={24} /> : <Play size={24} />}
|
||
</button>
|
||
<div className={styles.timeRange}>
|
||
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
||
</div>
|
||
</div>
|
||
<div className={styles.segmentControls}>
|
||
<span className={styles.segmentTime}>
|
||
{secondsToTimecode(Math.max(currentTime, start))}
|
||
</span>
|
||
<div
|
||
className={styles.segmentTrack}
|
||
ref={trackRef}
|
||
onMouseDown={handleTrackMouseDown}
|
||
>
|
||
<div
|
||
className={styles.segmentTrackFill}
|
||
style={{ width: `${progress * 100}%` }}
|
||
/>
|
||
<div
|
||
className={styles.segmentTrackThumb}
|
||
style={{ left: `${progress * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className={styles.segmentTime}>
|
||
{secondsToTimecode(end)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const SegmentEditModal: FunctionComponent<
|
||
ISegmentEditModalProps
|
||
> = ({ open, onOpenChange, videoUrl, segment, onSave, onSplit }): JSX.Element => {
|
||
const [text, setText] = useState(segment.text)
|
||
const [saving, setSaving] = useState(false)
|
||
const [splitMode, setSplitMode] = useState(false)
|
||
|
||
const canSplit = !!onSplit && !!segment.words && segment.words.length >= 2
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setText(segment.text)
|
||
setSplitMode(false)
|
||
}
|
||
}, [open, segment.text])
|
||
|
||
const editorSegment: EditorSegment = useMemo(
|
||
() => ({
|
||
startTime: secondsToTimecode(segment.start),
|
||
endTime: secondsToTimecode(segment.end),
|
||
text: segment.text,
|
||
words: segment.words,
|
||
}),
|
||
[segment],
|
||
)
|
||
|
||
const handleSave = useCallback(async () => {
|
||
setSaving(true)
|
||
try {
|
||
await onSave(text)
|
||
onOpenChange(false)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}, [text, onSave, onOpenChange])
|
||
|
||
const handleSplit = useCallback(
|
||
async (newSegments: EditorSegment[]) => {
|
||
if (!onSplit) return
|
||
setSaving(true)
|
||
try {
|
||
await onSplit(
|
||
newSegments.map((s) => ({
|
||
start: s.words?.[0]?.start ?? segment.start,
|
||
end: s.words?.[s.words.length - 1]?.end ?? segment.end,
|
||
text: s.text,
|
||
words: s.words,
|
||
})),
|
||
)
|
||
onOpenChange(false)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
},
|
||
[onSplit, onOpenChange, segment],
|
||
)
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault()
|
||
handleSave()
|
||
}
|
||
},
|
||
[handleSave],
|
||
)
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onOpenChange={onOpenChange}
|
||
title="Редактировать субтитр"
|
||
>
|
||
<div className={styles.root} data-testid="SegmentEditModal">
|
||
{videoUrl && (
|
||
<SegmentPlayer
|
||
videoUrl={videoUrl}
|
||
start={segment.start}
|
||
end={segment.end}
|
||
/>
|
||
)}
|
||
|
||
{splitMode ? (
|
||
<SegmentSplitter
|
||
segment={editorSegment}
|
||
onSplit={handleSplit}
|
||
onCancel={() => setSplitMode(false)}
|
||
/>
|
||
) : (
|
||
<>
|
||
<textarea
|
||
className={styles.textArea}
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
rows={3}
|
||
placeholder="Текст субтитра..."
|
||
autoFocus
|
||
/>
|
||
|
||
<div className={styles.actions}>
|
||
{canSplit && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setSplitMode(true)}
|
||
className={styles.splitAction}
|
||
>
|
||
<Scissors size={14} />
|
||
Разделить
|
||
</Button>
|
||
)}
|
||
<div className={styles.actionsSpacer} />
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={saving}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
{saving ? (
|
||
<LoaderCircle size={16} className={styles.spinner} />
|
||
) : null}
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|