lyrics-transcriber 0.43.1__py3-none-any.whl → 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyrics_transcriber/core/controller.py +58 -24
- lyrics_transcriber/correction/anchor_sequence.py +22 -8
- lyrics_transcriber/correction/corrector.py +47 -3
- lyrics_transcriber/correction/handlers/llm.py +15 -12
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
- lyrics_transcriber/frontend/dist/assets/{index-D0Gr3Ep7.js → index-ZCT0s9MG.js} +10174 -6197
- lyrics_transcriber/frontend/dist/assets/index-ZCT0s9MG.js.map +1 -0
- lyrics_transcriber/frontend/dist/index.html +1 -1
- lyrics_transcriber/frontend/src/App.tsx +5 -5
- lyrics_transcriber/frontend/src/api.ts +37 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +14 -10
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +62 -56
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +467 -399
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +373 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +308 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +141 -101
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +569 -107
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +22 -13
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +1 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +29 -12
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +21 -4
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -15
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +36 -18
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +89 -41
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +9 -2
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +27 -3
- lyrics_transcriber/frontend/src/components/shared/types.ts +17 -2
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +90 -19
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +192 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +267 -0
- lyrics_transcriber/frontend/src/main.tsx +7 -1
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types.ts +1 -1
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/lyrics/base_lyrics_provider.py +2 -2
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/generator.py +40 -12
- lyrics_transcriber/review/server.py +238 -8
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/METADATA +3 -2
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/RECORD +48 -40
- lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +0 -1
- lyrics_transcriber/frontend/src/components/DetailsModal.tsx +0 -252
- lyrics_transcriber/frontend/src/components/WordEditControls.tsx +0 -110
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/WHEEL +0 -0
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,373 @@
|
|
1
|
+
import {
|
2
|
+
Box,
|
3
|
+
Button,
|
4
|
+
Typography,
|
5
|
+
IconButton,
|
6
|
+
Tooltip,
|
7
|
+
Stack
|
8
|
+
} from '@mui/material'
|
9
|
+
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
|
10
|
+
import CancelIcon from '@mui/icons-material/Cancel'
|
11
|
+
import ZoomInIcon from '@mui/icons-material/ZoomIn'
|
12
|
+
import ZoomOutIcon from '@mui/icons-material/ZoomOut'
|
13
|
+
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
14
|
+
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
|
15
|
+
import AutorenewIcon from '@mui/icons-material/Autorenew'
|
16
|
+
import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline'
|
17
|
+
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong'
|
18
|
+
import TimelineEditor from './TimelineEditor'
|
19
|
+
import { Word } from '../types'
|
20
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
21
|
+
|
22
|
+
interface EditTimelineSectionProps {
|
23
|
+
words: Word[]
|
24
|
+
startTime: number
|
25
|
+
endTime: number
|
26
|
+
originalStartTime: number | null
|
27
|
+
originalEndTime: number | null
|
28
|
+
currentStartTime: number | null
|
29
|
+
currentEndTime: number | null
|
30
|
+
currentTime?: number
|
31
|
+
isManualSyncing: boolean
|
32
|
+
syncWordIndex: number
|
33
|
+
isSpacebarPressed: boolean
|
34
|
+
onWordUpdate: (index: number, updates: Partial<Word>) => void
|
35
|
+
onPlaySegment?: (time: number) => void
|
36
|
+
startManualSync: () => void
|
37
|
+
isGlobal?: boolean
|
38
|
+
}
|
39
|
+
|
40
|
+
export default function EditTimelineSection({
|
41
|
+
words,
|
42
|
+
startTime,
|
43
|
+
endTime,
|
44
|
+
originalStartTime,
|
45
|
+
originalEndTime,
|
46
|
+
currentStartTime,
|
47
|
+
currentEndTime,
|
48
|
+
currentTime,
|
49
|
+
isManualSyncing,
|
50
|
+
syncWordIndex,
|
51
|
+
isSpacebarPressed,
|
52
|
+
onWordUpdate,
|
53
|
+
onPlaySegment,
|
54
|
+
startManualSync,
|
55
|
+
isGlobal = false
|
56
|
+
}: EditTimelineSectionProps) {
|
57
|
+
// Add state for zoom level
|
58
|
+
const [zoomLevel, setZoomLevel] = useState(10) // Default 10 seconds visible
|
59
|
+
const [visibleStartTime, setVisibleStartTime] = useState(startTime)
|
60
|
+
const [visibleEndTime, setVisibleEndTime] = useState(Math.min(startTime + zoomLevel, endTime))
|
61
|
+
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true) // Default to enabled
|
62
|
+
const timelineRef = useRef<HTMLDivElement>(null)
|
63
|
+
|
64
|
+
// Initial setup of visible time range
|
65
|
+
useEffect(() => {
|
66
|
+
if (isGlobal) {
|
67
|
+
// For global mode, start at the beginning
|
68
|
+
setVisibleStartTime(startTime)
|
69
|
+
setVisibleEndTime(Math.min(startTime + zoomLevel, endTime))
|
70
|
+
} else {
|
71
|
+
// For segment mode, always show the full segment
|
72
|
+
setVisibleStartTime(startTime)
|
73
|
+
setVisibleEndTime(endTime)
|
74
|
+
}
|
75
|
+
}, [startTime, endTime, zoomLevel, isGlobal])
|
76
|
+
|
77
|
+
// Handle playback scrolling with "page turning" approach
|
78
|
+
useEffect(() => {
|
79
|
+
// Skip if not in global mode, no current time, or auto-scroll is disabled
|
80
|
+
if (!isGlobal || !currentTime || !autoScrollEnabled) return
|
81
|
+
|
82
|
+
// Only scroll when current time is outside or near the edge of the visible window
|
83
|
+
if (currentTime < visibleStartTime) {
|
84
|
+
// If current time is before visible window, jump to show it at the start
|
85
|
+
const newStart = Math.max(startTime, currentTime)
|
86
|
+
const newEnd = Math.min(endTime, newStart + zoomLevel)
|
87
|
+
setVisibleStartTime(newStart)
|
88
|
+
setVisibleEndTime(newEnd)
|
89
|
+
} else if (currentTime > visibleEndTime - (zoomLevel * 0.05)) {
|
90
|
+
// If current time is near the end of visible window (within 5% of zoom level from the end),
|
91
|
+
// jump to the next "page" with current time at 5% from the left
|
92
|
+
const pageOffset = zoomLevel * 0.05 // Position current time 5% from the left edge
|
93
|
+
const newStart = Math.max(startTime, currentTime - pageOffset)
|
94
|
+
const newEnd = Math.min(endTime, newStart + zoomLevel)
|
95
|
+
|
96
|
+
// Only update if we're actually moving forward
|
97
|
+
if (newStart > visibleStartTime) {
|
98
|
+
setVisibleStartTime(newStart)
|
99
|
+
setVisibleEndTime(newEnd)
|
100
|
+
}
|
101
|
+
}
|
102
|
+
}, [currentTime, visibleStartTime, visibleEndTime, startTime, endTime, zoomLevel, isGlobal, autoScrollEnabled])
|
103
|
+
|
104
|
+
// Update visible time range when zoom level changes - but don't auto-center on current time
|
105
|
+
useEffect(() => {
|
106
|
+
if (isGlobal) {
|
107
|
+
// Don't auto-center on current time, just adjust the visible window based on zoom level
|
108
|
+
// while keeping the left edge fixed (unless it would go out of bounds)
|
109
|
+
const newEnd = Math.min(endTime, visibleStartTime + zoomLevel)
|
110
|
+
|
111
|
+
// If the new end would exceed the total range, adjust the start time
|
112
|
+
if (newEnd === endTime) {
|
113
|
+
const newStart = Math.max(startTime, endTime - zoomLevel)
|
114
|
+
setVisibleStartTime(newStart)
|
115
|
+
}
|
116
|
+
|
117
|
+
setVisibleEndTime(newEnd)
|
118
|
+
} else {
|
119
|
+
// For segment mode, always show the full segment
|
120
|
+
setVisibleStartTime(startTime)
|
121
|
+
setVisibleEndTime(endTime)
|
122
|
+
}
|
123
|
+
}, [zoomLevel, startTime, endTime, isGlobal, visibleStartTime])
|
124
|
+
|
125
|
+
// Toggle auto-scroll
|
126
|
+
const toggleAutoScroll = () => {
|
127
|
+
setAutoScrollEnabled(!autoScrollEnabled)
|
128
|
+
}
|
129
|
+
|
130
|
+
// Jump to current playback position
|
131
|
+
const jumpToCurrentTime = useCallback(() => {
|
132
|
+
if (!isGlobal || !currentTime) return
|
133
|
+
|
134
|
+
// Center the view around the current time
|
135
|
+
const halfZoom = zoomLevel / 2
|
136
|
+
let newStart = Math.max(startTime, currentTime - halfZoom)
|
137
|
+
const newEnd = Math.min(endTime, newStart + zoomLevel)
|
138
|
+
|
139
|
+
// Adjust start time if end time hits the boundary
|
140
|
+
if (newEnd === endTime) {
|
141
|
+
newStart = Math.max(startTime, endTime - zoomLevel)
|
142
|
+
}
|
143
|
+
|
144
|
+
setVisibleStartTime(newStart)
|
145
|
+
setVisibleEndTime(newEnd)
|
146
|
+
}, [currentTime, zoomLevel, startTime, endTime, isGlobal])
|
147
|
+
|
148
|
+
// Add keyboard shortcut for toggling auto-scroll
|
149
|
+
useEffect(() => {
|
150
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
151
|
+
if (isGlobal) {
|
152
|
+
// Alt+A to toggle auto-scroll
|
153
|
+
if (e.altKey && e.key === 'a') {
|
154
|
+
e.preventDefault()
|
155
|
+
toggleAutoScroll()
|
156
|
+
}
|
157
|
+
|
158
|
+
// Alt+J to jump to current time
|
159
|
+
if (e.altKey && e.key === 'j') {
|
160
|
+
e.preventDefault()
|
161
|
+
jumpToCurrentTime()
|
162
|
+
}
|
163
|
+
}
|
164
|
+
}
|
165
|
+
|
166
|
+
window.addEventListener('keydown', handleKeyDown)
|
167
|
+
return () => {
|
168
|
+
window.removeEventListener('keydown', handleKeyDown)
|
169
|
+
}
|
170
|
+
}, [isGlobal, toggleAutoScroll, jumpToCurrentTime])
|
171
|
+
|
172
|
+
// Handle zoom in
|
173
|
+
const handleZoomIn = () => {
|
174
|
+
if (zoomLevel > 2) { // Minimum zoom level of 2 seconds
|
175
|
+
setZoomLevel(zoomLevel - 2)
|
176
|
+
}
|
177
|
+
}
|
178
|
+
|
179
|
+
// Handle zoom out
|
180
|
+
const handleZoomOut = () => {
|
181
|
+
if (zoomLevel < (endTime - startTime)) { // Maximum zoom is the full range
|
182
|
+
setZoomLevel(zoomLevel + 2)
|
183
|
+
}
|
184
|
+
}
|
185
|
+
|
186
|
+
// Handle horizontal scrolling
|
187
|
+
const handleScroll = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
188
|
+
if (isGlobal && event.deltaX !== 0) {
|
189
|
+
event.preventDefault()
|
190
|
+
|
191
|
+
// Disable auto-scroll when user manually scrolls
|
192
|
+
setAutoScrollEnabled(false)
|
193
|
+
|
194
|
+
// Calculate scroll amount in seconds (scale based on zoom level)
|
195
|
+
const scrollAmount = (event.deltaX / 100) * (zoomLevel / 10)
|
196
|
+
|
197
|
+
// Update visible time range
|
198
|
+
let newStart = visibleStartTime + scrollAmount
|
199
|
+
let newEnd = visibleEndTime + scrollAmount
|
200
|
+
|
201
|
+
// Ensure we don't scroll beyond the boundaries
|
202
|
+
if (newStart < startTime) {
|
203
|
+
newStart = startTime
|
204
|
+
newEnd = newStart + zoomLevel
|
205
|
+
}
|
206
|
+
|
207
|
+
if (newEnd > endTime) {
|
208
|
+
newEnd = endTime
|
209
|
+
newStart = Math.max(startTime, newEnd - zoomLevel)
|
210
|
+
}
|
211
|
+
|
212
|
+
setVisibleStartTime(newStart)
|
213
|
+
setVisibleEndTime(newEnd)
|
214
|
+
}
|
215
|
+
}, [isGlobal, visibleStartTime, visibleEndTime, startTime, endTime, zoomLevel])
|
216
|
+
|
217
|
+
// Handle scroll left button
|
218
|
+
const handleScrollLeft = () => {
|
219
|
+
if (!isGlobal) return
|
220
|
+
|
221
|
+
// Disable auto-scroll when user manually scrolls
|
222
|
+
setAutoScrollEnabled(false)
|
223
|
+
|
224
|
+
// Scroll left by 25% of the current visible range
|
225
|
+
const scrollAmount = zoomLevel * 0.25
|
226
|
+
const newStart = Math.max(startTime, visibleStartTime - scrollAmount)
|
227
|
+
const newEnd = newStart + zoomLevel
|
228
|
+
|
229
|
+
setVisibleStartTime(newStart)
|
230
|
+
setVisibleEndTime(newEnd)
|
231
|
+
}
|
232
|
+
|
233
|
+
// Handle scroll right button
|
234
|
+
const handleScrollRight = () => {
|
235
|
+
if (!isGlobal) return
|
236
|
+
|
237
|
+
// Disable auto-scroll when user manually scrolls
|
238
|
+
setAutoScrollEnabled(false)
|
239
|
+
|
240
|
+
// Scroll right by 25% of the current visible range
|
241
|
+
const scrollAmount = zoomLevel * 0.25
|
242
|
+
const newEnd = Math.min(endTime, visibleEndTime + scrollAmount)
|
243
|
+
let newStart = newEnd - zoomLevel
|
244
|
+
|
245
|
+
// Ensure we don't scroll beyond the start boundary
|
246
|
+
if (newStart < startTime) {
|
247
|
+
newStart = startTime
|
248
|
+
const adjustedNewEnd = Math.min(endTime, newStart + zoomLevel)
|
249
|
+
setVisibleEndTime(adjustedNewEnd)
|
250
|
+
} else {
|
251
|
+
setVisibleEndTime(newEnd)
|
252
|
+
}
|
253
|
+
|
254
|
+
setVisibleStartTime(newStart)
|
255
|
+
}
|
256
|
+
|
257
|
+
// Get the effective time range to display
|
258
|
+
const effectiveStartTime = isGlobal ? visibleStartTime : startTime
|
259
|
+
const effectiveEndTime = isGlobal ? visibleEndTime : endTime
|
260
|
+
|
261
|
+
return (
|
262
|
+
<>
|
263
|
+
<Box
|
264
|
+
sx={{ height: '120px', mb: 2 }}
|
265
|
+
ref={timelineRef}
|
266
|
+
onWheel={handleScroll}
|
267
|
+
>
|
268
|
+
<TimelineEditor
|
269
|
+
words={words}
|
270
|
+
startTime={effectiveStartTime}
|
271
|
+
endTime={effectiveEndTime}
|
272
|
+
onWordUpdate={onWordUpdate}
|
273
|
+
currentTime={currentTime}
|
274
|
+
onPlaySegment={onPlaySegment}
|
275
|
+
/>
|
276
|
+
</Box>
|
277
|
+
|
278
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
279
|
+
<Typography variant="body2" color="text.secondary">
|
280
|
+
Original Time Range: {originalStartTime?.toFixed(2) ?? 'N/A'} - {originalEndTime?.toFixed(2) ?? 'N/A'}
|
281
|
+
<br />
|
282
|
+
Current Time Range: {currentStartTime?.toFixed(2) ?? 'N/A'} - {currentEndTime?.toFixed(2) ?? 'N/A'}
|
283
|
+
</Typography>
|
284
|
+
|
285
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
286
|
+
{isGlobal && (
|
287
|
+
<>
|
288
|
+
<Tooltip title="Scroll Left">
|
289
|
+
<IconButton
|
290
|
+
onClick={handleScrollLeft}
|
291
|
+
disabled={visibleStartTime <= startTime}
|
292
|
+
size="small"
|
293
|
+
>
|
294
|
+
<ArrowBackIcon />
|
295
|
+
</IconButton>
|
296
|
+
</Tooltip>
|
297
|
+
<Tooltip title="Zoom Out (Show More Time)">
|
298
|
+
<IconButton
|
299
|
+
onClick={handleZoomOut}
|
300
|
+
disabled={zoomLevel >= (endTime - startTime)}
|
301
|
+
size="small"
|
302
|
+
>
|
303
|
+
<ZoomOutIcon />
|
304
|
+
</IconButton>
|
305
|
+
</Tooltip>
|
306
|
+
<Tooltip title="Zoom In (Show Less Time)">
|
307
|
+
<IconButton
|
308
|
+
onClick={handleZoomIn}
|
309
|
+
disabled={zoomLevel <= 2}
|
310
|
+
size="small"
|
311
|
+
>
|
312
|
+
<ZoomInIcon />
|
313
|
+
</IconButton>
|
314
|
+
</Tooltip>
|
315
|
+
<Tooltip title="Scroll Right">
|
316
|
+
<IconButton
|
317
|
+
onClick={handleScrollRight}
|
318
|
+
disabled={visibleEndTime >= endTime}
|
319
|
+
size="small"
|
320
|
+
>
|
321
|
+
<ArrowForwardIcon />
|
322
|
+
</IconButton>
|
323
|
+
</Tooltip>
|
324
|
+
<Tooltip
|
325
|
+
title={autoScrollEnabled ?
|
326
|
+
"Disable Auto-Page Turn During Playback (Alt+A)" :
|
327
|
+
"Enable Auto-Page Turn During Playback (Alt+A)"}
|
328
|
+
>
|
329
|
+
<IconButton
|
330
|
+
onClick={toggleAutoScroll}
|
331
|
+
color={autoScrollEnabled ? "primary" : "default"}
|
332
|
+
size="small"
|
333
|
+
>
|
334
|
+
{autoScrollEnabled ? <AutorenewIcon /> : <PauseCircleOutlineIcon />}
|
335
|
+
</IconButton>
|
336
|
+
</Tooltip>
|
337
|
+
<Tooltip title="Jump to Current Playback Position (Alt+J)">
|
338
|
+
<IconButton
|
339
|
+
onClick={jumpToCurrentTime}
|
340
|
+
disabled={!currentTime}
|
341
|
+
size="small"
|
342
|
+
>
|
343
|
+
<CenterFocusStrongIcon />
|
344
|
+
</IconButton>
|
345
|
+
</Tooltip>
|
346
|
+
</>
|
347
|
+
)}
|
348
|
+
<Button
|
349
|
+
variant={isManualSyncing ? "outlined" : "contained"}
|
350
|
+
onClick={startManualSync}
|
351
|
+
disabled={!onPlaySegment}
|
352
|
+
startIcon={isManualSyncing ? <CancelIcon /> : <PlayCircleOutlineIcon />}
|
353
|
+
color={isManualSyncing ? "error" : "primary"}
|
354
|
+
>
|
355
|
+
{isManualSyncing ? "Cancel Sync" : "Manual Sync"}
|
356
|
+
</Button>
|
357
|
+
{isManualSyncing && (
|
358
|
+
<Box>
|
359
|
+
<Typography variant="body2">
|
360
|
+
Word {syncWordIndex + 1} of {words.length}: <strong>{words[syncWordIndex]?.text || ''}</strong>
|
361
|
+
</Typography>
|
362
|
+
<Typography variant="caption" color="text.secondary">
|
363
|
+
{isSpacebarPressed ?
|
364
|
+
"Holding spacebar... Release when word ends" :
|
365
|
+
"Press spacebar when word starts (tap for short words, hold for long words)"}
|
366
|
+
</Typography>
|
367
|
+
</Box>
|
368
|
+
)}
|
369
|
+
</Stack>
|
370
|
+
</Box>
|
371
|
+
</>
|
372
|
+
)
|
373
|
+
}
|
@@ -0,0 +1,308 @@
|
|
1
|
+
import {
|
2
|
+
Box,
|
3
|
+
TextField,
|
4
|
+
IconButton,
|
5
|
+
Button,
|
6
|
+
Pagination,
|
7
|
+
Typography
|
8
|
+
} from '@mui/material'
|
9
|
+
import DeleteIcon from '@mui/icons-material/Delete'
|
10
|
+
import SplitIcon from '@mui/icons-material/CallSplit'
|
11
|
+
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
12
|
+
import { Word } from '../types'
|
13
|
+
import { useState, memo, useMemo } from 'react'
|
14
|
+
import WordDivider from './WordDivider'
|
15
|
+
|
16
|
+
interface EditWordListProps {
|
17
|
+
words: Word[]
|
18
|
+
onWordUpdate: (index: number, updates: Partial<Word>) => void
|
19
|
+
onSplitWord: (index: number) => void
|
20
|
+
onMergeWords: (index: number) => void
|
21
|
+
onAddWord: (index?: number) => void
|
22
|
+
onRemoveWord: (index: number) => void
|
23
|
+
onSplitSegment?: (wordIndex: number) => void
|
24
|
+
onAddSegment?: (beforeIndex: number) => void
|
25
|
+
onMergeSegment?: (mergeWithNext: boolean) => void
|
26
|
+
isGlobal?: boolean
|
27
|
+
}
|
28
|
+
|
29
|
+
// Create a memoized word row component to prevent re-renders
|
30
|
+
const WordRow = memo(function WordRow({
|
31
|
+
word,
|
32
|
+
index,
|
33
|
+
onWordUpdate,
|
34
|
+
onSplitWord,
|
35
|
+
onRemoveWord,
|
36
|
+
wordsLength
|
37
|
+
}: {
|
38
|
+
word: Word
|
39
|
+
index: number
|
40
|
+
onWordUpdate: (index: number, updates: Partial<Word>) => void
|
41
|
+
onSplitWord: (index: number) => void
|
42
|
+
onRemoveWord: (index: number) => void
|
43
|
+
wordsLength: number
|
44
|
+
}) {
|
45
|
+
return (
|
46
|
+
<Box sx={{
|
47
|
+
display: 'flex',
|
48
|
+
gap: 2,
|
49
|
+
alignItems: 'center',
|
50
|
+
padding: '4px 0',
|
51
|
+
}}>
|
52
|
+
<TextField
|
53
|
+
label={`Word ${index}`}
|
54
|
+
value={word.text}
|
55
|
+
onChange={(e) => onWordUpdate(index, { text: e.target.value })}
|
56
|
+
fullWidth
|
57
|
+
size="small"
|
58
|
+
/>
|
59
|
+
<TextField
|
60
|
+
label="Start Time"
|
61
|
+
value={word.start_time?.toFixed(2) ?? ''}
|
62
|
+
onChange={(e) => onWordUpdate(index, { start_time: parseFloat(e.target.value) })}
|
63
|
+
type="number"
|
64
|
+
inputProps={{ step: 0.01 }}
|
65
|
+
sx={{ width: '150px' }}
|
66
|
+
size="small"
|
67
|
+
/>
|
68
|
+
<TextField
|
69
|
+
label="End Time"
|
70
|
+
value={word.end_time?.toFixed(2) ?? ''}
|
71
|
+
onChange={(e) => onWordUpdate(index, { end_time: parseFloat(e.target.value) })}
|
72
|
+
type="number"
|
73
|
+
inputProps={{ step: 0.01 }}
|
74
|
+
sx={{ width: '150px' }}
|
75
|
+
size="small"
|
76
|
+
/>
|
77
|
+
<IconButton
|
78
|
+
onClick={() => onSplitWord(index)}
|
79
|
+
title="Split Word"
|
80
|
+
sx={{ color: 'primary.main' }}
|
81
|
+
size="small"
|
82
|
+
>
|
83
|
+
<SplitIcon fontSize="small" />
|
84
|
+
</IconButton>
|
85
|
+
<IconButton
|
86
|
+
onClick={() => onRemoveWord(index)}
|
87
|
+
disabled={wordsLength <= 1}
|
88
|
+
title="Remove Word"
|
89
|
+
sx={{ color: 'error.main' }}
|
90
|
+
size="small"
|
91
|
+
>
|
92
|
+
<DeleteIcon fontSize="small" />
|
93
|
+
</IconButton>
|
94
|
+
</Box>
|
95
|
+
);
|
96
|
+
});
|
97
|
+
|
98
|
+
// Memoized word item component that includes the word row and divider
|
99
|
+
const WordItem = memo(function WordItem({
|
100
|
+
word,
|
101
|
+
index,
|
102
|
+
onWordUpdate,
|
103
|
+
onSplitWord,
|
104
|
+
onRemoveWord,
|
105
|
+
onAddWord,
|
106
|
+
onMergeWords,
|
107
|
+
onSplitSegment,
|
108
|
+
onAddSegment,
|
109
|
+
onMergeSegment,
|
110
|
+
wordsLength,
|
111
|
+
isGlobal
|
112
|
+
}: {
|
113
|
+
word: Word
|
114
|
+
index: number
|
115
|
+
onWordUpdate: (index: number, updates: Partial<Word>) => void
|
116
|
+
onSplitWord: (index: number) => void
|
117
|
+
onRemoveWord: (index: number) => void
|
118
|
+
onAddWord: (index: number) => void
|
119
|
+
onMergeWords: (index: number) => void
|
120
|
+
onSplitSegment?: (index: number) => void
|
121
|
+
onAddSegment?: (index: number) => void
|
122
|
+
onMergeSegment?: (mergeWithNext: boolean) => void
|
123
|
+
wordsLength: number
|
124
|
+
isGlobal: boolean
|
125
|
+
}) {
|
126
|
+
return (
|
127
|
+
<Box key={word.id}>
|
128
|
+
<WordRow
|
129
|
+
word={word}
|
130
|
+
index={index}
|
131
|
+
onWordUpdate={onWordUpdate}
|
132
|
+
onSplitWord={onSplitWord}
|
133
|
+
onRemoveWord={onRemoveWord}
|
134
|
+
wordsLength={wordsLength}
|
135
|
+
/>
|
136
|
+
|
137
|
+
{/* Word divider with merge/split functionality */}
|
138
|
+
{!isGlobal && (
|
139
|
+
<WordDivider
|
140
|
+
onAddWord={() => onAddWord(index)}
|
141
|
+
onMergeWords={() => onMergeWords(index)}
|
142
|
+
onSplitSegment={() => onSplitSegment?.(index)}
|
143
|
+
onAddSegmentAfter={
|
144
|
+
index === wordsLength - 1
|
145
|
+
? () => onAddSegment?.(index + 1)
|
146
|
+
: undefined
|
147
|
+
}
|
148
|
+
onMergeSegment={
|
149
|
+
index === wordsLength - 1
|
150
|
+
? () => onMergeSegment?.(true)
|
151
|
+
: undefined
|
152
|
+
}
|
153
|
+
canMerge={index < wordsLength - 1}
|
154
|
+
isLast={index === wordsLength - 1}
|
155
|
+
sx={{ ml: 15 }}
|
156
|
+
/>
|
157
|
+
)}
|
158
|
+
{isGlobal && (
|
159
|
+
<WordDivider
|
160
|
+
onAddWord={() => onAddWord(index)}
|
161
|
+
onMergeWords={index < wordsLength - 1 ? () => onMergeWords(index) : undefined}
|
162
|
+
canMerge={index < wordsLength - 1}
|
163
|
+
sx={{ ml: 15 }}
|
164
|
+
/>
|
165
|
+
)}
|
166
|
+
</Box>
|
167
|
+
);
|
168
|
+
});
|
169
|
+
|
170
|
+
export default function EditWordList({
|
171
|
+
words,
|
172
|
+
onWordUpdate,
|
173
|
+
onSplitWord,
|
174
|
+
onMergeWords,
|
175
|
+
onAddWord,
|
176
|
+
onRemoveWord,
|
177
|
+
onSplitSegment,
|
178
|
+
onAddSegment,
|
179
|
+
onMergeSegment,
|
180
|
+
isGlobal = false
|
181
|
+
}: EditWordListProps) {
|
182
|
+
const [replacementText, setReplacementText] = useState('')
|
183
|
+
const [page, setPage] = useState(1)
|
184
|
+
const pageSize = isGlobal ? 50 : words.length // Use pagination only in global mode
|
185
|
+
|
186
|
+
const handleReplaceAllWords = () => {
|
187
|
+
const newWords = replacementText.trim().split(/\s+/)
|
188
|
+
newWords.forEach((text, index) => {
|
189
|
+
if (index < words.length) {
|
190
|
+
onWordUpdate(index, { text })
|
191
|
+
}
|
192
|
+
})
|
193
|
+
setReplacementText('')
|
194
|
+
}
|
195
|
+
|
196
|
+
// Calculate pagination values
|
197
|
+
const pageCount = Math.ceil(words.length / pageSize)
|
198
|
+
const startIndex = (page - 1) * pageSize
|
199
|
+
const endIndex = Math.min(startIndex + pageSize, words.length)
|
200
|
+
|
201
|
+
// Get the words for the current page
|
202
|
+
const visibleWords = useMemo(() => {
|
203
|
+
return isGlobal
|
204
|
+
? words.slice(startIndex, endIndex)
|
205
|
+
: words;
|
206
|
+
}, [words, isGlobal, startIndex, endIndex]);
|
207
|
+
|
208
|
+
// Handle page change
|
209
|
+
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
|
210
|
+
setPage(value);
|
211
|
+
};
|
212
|
+
|
213
|
+
return (
|
214
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flexGrow: 1, minHeight: 0 }}>
|
215
|
+
{/* Initial divider with Add Segment Before button */}
|
216
|
+
{!isGlobal && (
|
217
|
+
<WordDivider
|
218
|
+
onAddWord={() => onAddWord(-1)}
|
219
|
+
onAddSegmentBefore={() => onAddSegment?.(0)}
|
220
|
+
onMergeSegment={() => onMergeSegment?.(false)}
|
221
|
+
isFirst={true}
|
222
|
+
sx={{ ml: 15 }}
|
223
|
+
/>
|
224
|
+
)}
|
225
|
+
{isGlobal && (
|
226
|
+
<WordDivider
|
227
|
+
onAddWord={() => onAddWord(-1)}
|
228
|
+
sx={{ ml: 15 }}
|
229
|
+
/>
|
230
|
+
)}
|
231
|
+
|
232
|
+
{/* Word list with scrolling */}
|
233
|
+
<Box sx={{
|
234
|
+
display: 'flex',
|
235
|
+
flexDirection: 'column',
|
236
|
+
gap: 0.5,
|
237
|
+
flexGrow: 1,
|
238
|
+
overflowY: 'auto',
|
239
|
+
mb: 0,
|
240
|
+
pt: 1,
|
241
|
+
'&::-webkit-scrollbar': {
|
242
|
+
width: '8px',
|
243
|
+
},
|
244
|
+
'&::-webkit-scrollbar-thumb': {
|
245
|
+
backgroundColor: 'rgba(0,0,0,0.2)',
|
246
|
+
borderRadius: '4px',
|
247
|
+
},
|
248
|
+
scrollbarWidth: 'thin',
|
249
|
+
msOverflowStyle: 'autohiding-scrollbar',
|
250
|
+
}}>
|
251
|
+
{visibleWords.map((word, visibleIndex) => {
|
252
|
+
const actualIndex = isGlobal ? startIndex + visibleIndex : visibleIndex;
|
253
|
+
return (
|
254
|
+
<WordItem
|
255
|
+
key={word.id}
|
256
|
+
word={word}
|
257
|
+
index={actualIndex}
|
258
|
+
onWordUpdate={onWordUpdate}
|
259
|
+
onSplitWord={onSplitWord}
|
260
|
+
onRemoveWord={onRemoveWord}
|
261
|
+
onAddWord={onAddWord}
|
262
|
+
onMergeWords={onMergeWords}
|
263
|
+
onSplitSegment={onSplitSegment}
|
264
|
+
onAddSegment={onAddSegment}
|
265
|
+
onMergeSegment={onMergeSegment}
|
266
|
+
wordsLength={words.length}
|
267
|
+
isGlobal={isGlobal}
|
268
|
+
/>
|
269
|
+
);
|
270
|
+
})}
|
271
|
+
</Box>
|
272
|
+
|
273
|
+
{/* Pagination controls (only in global mode) */}
|
274
|
+
{isGlobal && pageCount > 1 && (
|
275
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mb: 1 }}>
|
276
|
+
<Pagination
|
277
|
+
count={pageCount}
|
278
|
+
page={page}
|
279
|
+
onChange={handlePageChange}
|
280
|
+
color="primary"
|
281
|
+
size="small"
|
282
|
+
/>
|
283
|
+
<Typography variant="body2" sx={{ ml: 2 }}>
|
284
|
+
Showing words {startIndex + 1}-{endIndex} of {words.length}
|
285
|
+
</Typography>
|
286
|
+
</Box>
|
287
|
+
)}
|
288
|
+
|
289
|
+
<Box sx={{ display: 'flex', gap: 2, mb: 0.6 }}>
|
290
|
+
<TextField
|
291
|
+
value={replacementText}
|
292
|
+
onChange={(e) => setReplacementText(e.target.value)}
|
293
|
+
placeholder="Replace all words"
|
294
|
+
size="small"
|
295
|
+
sx={{ flexGrow: 1, maxWidth: 'calc(100% - 140px)' }}
|
296
|
+
/>
|
297
|
+
<Button
|
298
|
+
onClick={handleReplaceAllWords}
|
299
|
+
startIcon={<AutoFixHighIcon />}
|
300
|
+
size="small"
|
301
|
+
sx={{ whiteSpace: 'nowrap' }}
|
302
|
+
>
|
303
|
+
Replace All
|
304
|
+
</Button>
|
305
|
+
</Box>
|
306
|
+
</Box>
|
307
|
+
)
|
308
|
+
}
|