Files
main_frontend/src/features/project/SegmentEditModal/SegmentEditModal.tsx
T
2026-04-04 14:51:40 +03:00

303 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}