new features
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import { LoaderCircle, 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 = ({
|
||||
videoUrl,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
videoUrl: string
|
||||
start: number
|
||||
end: number
|
||||
}) => {
|
||||
const currentTime = useMediaState("currentTime")
|
||||
const playing = useMediaState("playing")
|
||||
const hasPausedRef = useRef(false)
|
||||
const playerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
hasPausedRef.current = false
|
||||
}, [start, end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return
|
||||
if (currentTime >= end && !hasPausedRef.current) {
|
||||
hasPausedRef.current = true
|
||||
const player = playerRef.current as HTMLElement & {
|
||||
pause?: () => void
|
||||
}
|
||||
player?.pause?.()
|
||||
}
|
||||
}, [currentTime, end, playing])
|
||||
|
||||
return (
|
||||
<div className={styles.playerWrapper}>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout
|
||||
icons={defaultLayoutIcons}
|
||||
slots={{
|
||||
settingsMenu: null,
|
||||
pipButton: null,
|
||||
fullscreenButton: null,
|
||||
airPlayButton: null,
|
||||
googleCastButton: null,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.timeRange}>
|
||||
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
||||
</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 && (
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
currentTime={segment.start}
|
||||
className={styles.player}
|
||||
autoPlay
|
||||
>
|
||||
<SegmentPlayer
|
||||
videoUrl={videoUrl}
|
||||
start={segment.start}
|
||||
end={segment.end}
|
||||
/>
|
||||
</MediaPlayer>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user