lyrics-transcriber 0.66.0__py3-none-any.whl → 0.69.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.
Files changed (27) hide show
  1. lyrics_transcriber/core/config.py +1 -1
  2. lyrics_transcriber/core/controller.py +22 -0
  3. lyrics_transcriber/correction/anchor_sequence.py +16 -3
  4. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  5. lyrics_transcriber/frontend/package.json +1 -1
  6. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +4 -2
  7. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +257 -134
  8. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +35 -237
  9. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +27 -3
  10. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
  11. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -18
  12. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +198 -30
  13. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  14. lyrics_transcriber/frontend/web_assets/assets/{index-BMWgZ3MR.js → index-izP9z1oB.js} +985 -327
  15. lyrics_transcriber/frontend/web_assets/assets/index-izP9z1oB.js.map +1 -0
  16. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  17. lyrics_transcriber/lyrics/base_lyrics_provider.py +1 -1
  18. lyrics_transcriber/output/ass/config.py +37 -0
  19. lyrics_transcriber/output/ass/lyrics_line.py +1 -1
  20. lyrics_transcriber/output/generator.py +21 -5
  21. lyrics_transcriber/output/subtitles.py +2 -1
  22. {lyrics_transcriber-0.66.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/METADATA +1 -1
  23. {lyrics_transcriber-0.66.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/RECORD +26 -24
  24. lyrics_transcriber/frontend/web_assets/assets/index-BMWgZ3MR.js.map +0 -1
  25. {lyrics_transcriber-0.66.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/LICENSE +0 -0
  26. {lyrics_transcriber-0.66.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/WHEEL +0 -0
  27. {lyrics_transcriber-0.66.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,688 @@
1
+ import {
2
+ Dialog,
3
+ DialogTitle,
4
+ DialogContent,
5
+ DialogActions,
6
+ IconButton,
7
+ Box,
8
+ Button,
9
+ Typography,
10
+ TextField,
11
+ Paper,
12
+ Divider
13
+ } from '@mui/material'
14
+ import CloseIcon from '@mui/icons-material/Close'
15
+ import ContentPasteIcon from '@mui/icons-material/ContentPaste'
16
+ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
17
+ import { LyricsSegment, Word } from '../types'
18
+ import { useState, useEffect, useCallback, useMemo, useRef, memo } from 'react'
19
+ import { nanoid } from 'nanoid'
20
+ import useManualSync from '../hooks/useManualSync'
21
+ import EditTimelineSection from './EditTimelineSection'
22
+ import EditActionBar from './EditActionBar'
23
+
24
+ // Augment window type for audio functions
25
+ declare global {
26
+ interface Window {
27
+ getAudioDuration?: () => number;
28
+ toggleAudioPlayback?: () => void;
29
+ isAudioPlaying?: boolean;
30
+ }
31
+ }
32
+
33
+ interface ReplaceAllLyricsModalProps {
34
+ open: boolean
35
+ onClose: () => void
36
+ onSave: (newSegments: LyricsSegment[]) => void
37
+ onPlaySegment?: (startTime: number) => void
38
+ currentTime?: number
39
+ setModalSpacebarHandler: (handler: (() => (e: KeyboardEvent) => void) | undefined) => void
40
+ }
41
+
42
+ export default function ReplaceAllLyricsModal({
43
+ open,
44
+ onClose,
45
+ onSave,
46
+ onPlaySegment,
47
+ currentTime = 0,
48
+ setModalSpacebarHandler
49
+ }: ReplaceAllLyricsModalProps) {
50
+ const [inputText, setInputText] = useState('')
51
+ const [isReplaced, setIsReplaced] = useState(false)
52
+ const [globalSegment, setGlobalSegment] = useState<LyricsSegment | null>(null)
53
+ const [originalSegments, setOriginalSegments] = useState<LyricsSegment[]>([])
54
+ const [currentSegments, setCurrentSegments] = useState<LyricsSegment[]>([])
55
+
56
+ // Get the real audio duration, with fallback
57
+ const getAudioDuration = useCallback(() => {
58
+ if (window.getAudioDuration) {
59
+ const duration = window.getAudioDuration()
60
+ return duration > 0 ? duration : 600 // Use real duration or 10 min fallback
61
+ }
62
+ return 600 // 10 minute fallback if audio not loaded
63
+ }, [])
64
+
65
+ // Parse the input text to get line and word counts
66
+ const parseInfo = useMemo(() => {
67
+ if (!inputText.trim()) return { lines: 0, words: 0 }
68
+
69
+ const lines = inputText.trim().split('\n').filter(line => line.trim().length > 0)
70
+ const totalWords = lines.reduce((count, line) => {
71
+ return count + line.trim().split(/\s+/).length
72
+ }, 0)
73
+
74
+ return { lines: lines.length, words: totalWords }
75
+ }, [inputText])
76
+
77
+ // Process the input text into segments and words
78
+ const processLyrics = useCallback(() => {
79
+ if (!inputText.trim()) return
80
+
81
+ const lines = inputText.trim().split('\n').filter(line => line.trim().length > 0)
82
+ const newSegments: LyricsSegment[] = []
83
+ const allWords: Word[] = []
84
+
85
+ lines.forEach((line) => {
86
+ const words = line.trim().split(/\s+/).filter(word => word.length > 0)
87
+ const segmentWords: Word[] = []
88
+
89
+ words.forEach((wordText) => {
90
+ const word: Word = {
91
+ id: nanoid(),
92
+ text: wordText,
93
+ start_time: null,
94
+ end_time: null,
95
+ confidence: 1.0,
96
+ created_during_correction: true
97
+ }
98
+ segmentWords.push(word)
99
+ allWords.push(word)
100
+ })
101
+
102
+ const segment: LyricsSegment = {
103
+ id: nanoid(),
104
+ text: line.trim(),
105
+ words: segmentWords,
106
+ start_time: null,
107
+ end_time: null
108
+ }
109
+
110
+ newSegments.push(segment)
111
+ })
112
+
113
+ // Create a global segment with all words for manual sync
114
+ // Set a very large end time to ensure manual sync doesn't stop prematurely
115
+ const audioDuration = getAudioDuration()
116
+ const endTime = Math.max(audioDuration, 3600) // At least 1 hour to prevent auto-stop
117
+
118
+ console.log('ReplaceAllLyricsModal - Creating global segment', {
119
+ audioDuration,
120
+ endTime,
121
+ wordCount: allWords.length
122
+ })
123
+
124
+ const globalSegment: LyricsSegment = {
125
+ id: 'global-replacement',
126
+ text: allWords.map(w => w.text).join(' '),
127
+ words: allWords,
128
+ start_time: 0,
129
+ end_time: endTime
130
+ }
131
+
132
+ setCurrentSegments(newSegments)
133
+ setOriginalSegments(JSON.parse(JSON.stringify(newSegments)))
134
+ setGlobalSegment(globalSegment)
135
+ setIsReplaced(true)
136
+ }, [inputText, getAudioDuration])
137
+
138
+ // Handle paste from clipboard
139
+ const handlePasteFromClipboard = useCallback(async () => {
140
+ try {
141
+ const text = await navigator.clipboard.readText()
142
+ setInputText(text)
143
+ } catch (error) {
144
+ console.error('Failed to read from clipboard:', error)
145
+ alert('Failed to read from clipboard. Please paste manually.')
146
+ }
147
+ }, [])
148
+
149
+ // Update segment when words change during manual sync
150
+ const updateSegment = useCallback((newWords: Word[]) => {
151
+ if (!globalSegment) return
152
+
153
+ const validStartTimes = newWords.map(w => w.start_time).filter((t): t is number => t !== null)
154
+ const validEndTimes = newWords.map(w => w.end_time).filter((t): t is number => t !== null)
155
+
156
+ const segmentStartTime = validStartTimes.length > 0 ? Math.min(...validStartTimes) : null
157
+ const segmentEndTime = validEndTimes.length > 0 ? Math.max(...validEndTimes) : null
158
+
159
+ const updatedGlobalSegment = {
160
+ ...globalSegment,
161
+ words: newWords,
162
+ text: newWords.map(w => w.text).join(' '),
163
+ start_time: segmentStartTime,
164
+ end_time: segmentEndTime
165
+ }
166
+
167
+ // Batch state updates to prevent multiple re-renders
168
+ setGlobalSegment(updatedGlobalSegment)
169
+
170
+ // Update individual segment timing as words get synced - but only calculate when needed
171
+ const updatedSegments = currentSegments.map(segment => {
172
+ // Find words that belong to this segment and have been timed
173
+ const segmentWordsWithTiming = segment.words.map(segmentWord => {
174
+ const globalWord = newWords.find(w => w.id === segmentWord.id)
175
+ return globalWord || segmentWord
176
+ })
177
+
178
+ // Calculate segment timing if all words have timing
179
+ const wordsWithTiming = segmentWordsWithTiming.filter(w =>
180
+ w.start_time !== null && w.end_time !== null
181
+ )
182
+
183
+ if (wordsWithTiming.length === segmentWordsWithTiming.length && wordsWithTiming.length > 0) {
184
+ // All words in this segment have timing - update segment timing
185
+ const segmentStart = Math.min(...wordsWithTiming.map(w => w.start_time!))
186
+ const segmentEnd = Math.max(...wordsWithTiming.map(w => w.end_time!))
187
+
188
+ return {
189
+ ...segment,
190
+ words: segmentWordsWithTiming,
191
+ start_time: segmentStart,
192
+ end_time: segmentEnd
193
+ }
194
+ } else {
195
+ // Some words still don't have timing - just update the words
196
+ return {
197
+ ...segment,
198
+ words: segmentWordsWithTiming
199
+ }
200
+ }
201
+ })
202
+
203
+ setCurrentSegments(updatedSegments)
204
+ }, [globalSegment, currentSegments])
205
+
206
+ // Use the manual sync hook
207
+ const {
208
+ isManualSyncing,
209
+ isPaused,
210
+ syncWordIndex,
211
+ startManualSync,
212
+ pauseManualSync,
213
+ resumeManualSync,
214
+ cleanupManualSync,
215
+ handleSpacebar,
216
+ isSpacebarPressed
217
+ } = useManualSync({
218
+ editedSegment: globalSegment,
219
+ currentTime,
220
+ onPlaySegment,
221
+ updateSegment
222
+ })
223
+
224
+ // Handle manual word updates (drag/resize in timeline)
225
+ const handleWordUpdate = useCallback((wordIndex: number, updates: Partial<Word>) => {
226
+ if (!globalSegment) return
227
+
228
+ // Only allow manual adjustments when manual sync is paused or not active
229
+ if (isManualSyncing && !isPaused) {
230
+ console.log('ReplaceAllLyricsModal - Ignoring word update during active manual sync')
231
+ return
232
+ }
233
+
234
+ console.log('ReplaceAllLyricsModal - Manual word update', {
235
+ wordIndex,
236
+ wordText: globalSegment.words[wordIndex]?.text,
237
+ updates,
238
+ isManualSyncing,
239
+ isPaused
240
+ })
241
+
242
+ // Update the word in the global segment
243
+ const newWords = [...globalSegment.words]
244
+ newWords[wordIndex] = {
245
+ ...newWords[wordIndex],
246
+ ...updates
247
+ }
248
+
249
+ // Update the global segment through the existing updateSegment function
250
+ updateSegment(newWords)
251
+ }, [globalSegment, updateSegment, isManualSyncing, isPaused])
252
+
253
+ // Handle un-syncing a word (right-click context menu)
254
+ const handleUnsyncWord = useCallback((wordIndex: number) => {
255
+ if (!globalSegment) return
256
+
257
+ console.log('ReplaceAllLyricsModal - Un-syncing word', {
258
+ wordIndex,
259
+ wordText: globalSegment.words[wordIndex]?.text
260
+ })
261
+
262
+ // Update the word to remove timing
263
+ const newWords = [...globalSegment.words]
264
+ newWords[wordIndex] = {
265
+ ...newWords[wordIndex],
266
+ start_time: null,
267
+ end_time: null
268
+ }
269
+
270
+ // Update the global segment through the existing updateSegment function
271
+ updateSegment(newWords)
272
+ }, [globalSegment, updateSegment])
273
+
274
+ // Handle modal close
275
+ const handleClose = useCallback(() => {
276
+ cleanupManualSync()
277
+ setInputText('')
278
+ setIsReplaced(false)
279
+ setGlobalSegment(null)
280
+ setOriginalSegments([])
281
+ setCurrentSegments([])
282
+ onClose()
283
+ }, [onClose, cleanupManualSync])
284
+
285
+ // Handle save
286
+ const handleSave = useCallback(() => {
287
+ if (!globalSegment || !currentSegments.length) return
288
+
289
+ // Distribute the timed words back to their original segments
290
+ const finalSegments: LyricsSegment[] = []
291
+ let wordIndex = 0
292
+
293
+ currentSegments.forEach((segment) => {
294
+ const originalWordCount = segment.words.length
295
+ const segmentWords = globalSegment.words.slice(wordIndex, wordIndex + originalWordCount)
296
+ wordIndex += originalWordCount
297
+
298
+ if (segmentWords.length > 0) {
299
+ // Recalculate segment start and end times
300
+ const validStartTimes = segmentWords.map(w => w.start_time).filter((t): t is number => t !== null)
301
+ const validEndTimes = segmentWords.map(w => w.end_time).filter((t): t is number => t !== null)
302
+
303
+ const segmentStartTime = validStartTimes.length > 0 ? Math.min(...validStartTimes) : null
304
+ const segmentEndTime = validEndTimes.length > 0 ? Math.max(...validEndTimes) : null
305
+
306
+ finalSegments.push({
307
+ ...segment,
308
+ words: segmentWords,
309
+ text: segmentWords.map(w => w.text).join(' '),
310
+ start_time: segmentStartTime,
311
+ end_time: segmentEndTime
312
+ })
313
+ }
314
+ })
315
+
316
+ console.log('ReplaceAllLyricsModal - Saving new segments:', {
317
+ originalSegmentCount: currentSegments.length,
318
+ finalSegmentCount: finalSegments.length,
319
+ totalWords: finalSegments.reduce((count, seg) => count + seg.words.length, 0)
320
+ })
321
+
322
+ onSave(finalSegments)
323
+ handleClose()
324
+ }, [globalSegment, currentSegments, onSave, handleClose])
325
+
326
+ // Handle reset
327
+ const handleReset = useCallback(() => {
328
+ if (!originalSegments.length) return
329
+
330
+ console.log('ReplaceAllLyricsModal - Resetting to original state')
331
+
332
+ // Reset all words to have null timing (ready for fresh manual sync)
333
+ const resetWords = originalSegments.flatMap(segment =>
334
+ segment.words.map(word => ({
335
+ ...word,
336
+ start_time: null,
337
+ end_time: null
338
+ }))
339
+ )
340
+
341
+ const audioDuration = getAudioDuration()
342
+ const resetGlobalSegment: LyricsSegment = {
343
+ id: 'global-replacement',
344
+ text: resetWords.map(w => w.text).join(' '),
345
+ words: resetWords,
346
+ start_time: 0,
347
+ end_time: Math.max(audioDuration, 3600) // At least 1 hour to prevent auto-stop
348
+ }
349
+
350
+ // Also reset the current segments to have null timing
351
+ const resetCurrentSegments = originalSegments.map(segment => ({
352
+ ...segment,
353
+ words: segment.words.map(word => ({
354
+ ...word,
355
+ start_time: null,
356
+ end_time: null
357
+ })),
358
+ start_time: null,
359
+ end_time: null
360
+ }))
361
+
362
+ setGlobalSegment(resetGlobalSegment)
363
+ setCurrentSegments(resetCurrentSegments)
364
+ }, [originalSegments, getAudioDuration])
365
+
366
+ // Keep a ref to the current spacebar handler to avoid closure issues
367
+ const spacebarHandlerRef = useRef(handleSpacebar)
368
+ spacebarHandlerRef.current = handleSpacebar
369
+
370
+ // Update the spacebar handler when modal state changes
371
+ useEffect(() => {
372
+ if (open && isReplaced) {
373
+ console.log('ReplaceAllLyricsModal - Setting up spacebar handler')
374
+
375
+ const handleKeyEvent = (e: KeyboardEvent) => {
376
+ if (e.code === 'Space') {
377
+ console.log('ReplaceAllLyricsModal - Spacebar captured in modal')
378
+ e.preventDefault()
379
+ e.stopPropagation()
380
+ // Use the ref to get the current handler
381
+ spacebarHandlerRef.current(e)
382
+ }
383
+ }
384
+
385
+ setModalSpacebarHandler(() => handleKeyEvent)
386
+
387
+ return () => {
388
+ if (!open) {
389
+ console.log('ReplaceAllLyricsModal - Clearing spacebar handler')
390
+ setModalSpacebarHandler(undefined)
391
+ }
392
+ }
393
+ } else if (open) {
394
+ // Clear handler when not in replaced state
395
+ setModalSpacebarHandler(undefined)
396
+ }
397
+ }, [open, isReplaced, setModalSpacebarHandler])
398
+
399
+ // Memoize timeline range to prevent recalculation
400
+ const timeRange = useMemo(() => {
401
+ const audioDuration = getAudioDuration()
402
+ // Always use full song duration for replace-all mode
403
+ return { start: 0, end: audioDuration }
404
+ }, [getAudioDuration])
405
+
406
+ // Memoize the segment progress props to prevent unnecessary re-renders
407
+ const segmentProgressProps = useMemo(() => ({
408
+ currentSegments,
409
+ globalSegment,
410
+ syncWordIndex
411
+ }), [currentSegments, globalSegment, syncWordIndex])
412
+
413
+ return (
414
+ <Dialog
415
+ open={open}
416
+ onClose={handleClose}
417
+ maxWidth={false}
418
+ fullWidth={true}
419
+ onKeyDown={(e) => {
420
+ if (e.key === 'Enter' && !e.shiftKey && isReplaced) {
421
+ e.preventDefault()
422
+ handleSave()
423
+ }
424
+ }}
425
+ PaperProps={{
426
+ sx: {
427
+ height: '90vh',
428
+ margin: '5vh 2vh',
429
+ maxWidth: 'calc(100vw - 4vh)',
430
+ width: 'calc(100vw - 4vh)'
431
+ }
432
+ }}
433
+ >
434
+ <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
435
+ <Box sx={{ flex: 1 }}>
436
+ Replace All Lyrics
437
+ </Box>
438
+ <IconButton onClick={handleClose} sx={{ ml: 'auto' }}>
439
+ <CloseIcon />
440
+ </IconButton>
441
+ </DialogTitle>
442
+
443
+ <DialogContent
444
+ dividers
445
+ sx={{
446
+ display: 'flex',
447
+ flexDirection: 'column',
448
+ flexGrow: 1,
449
+ overflow: 'hidden'
450
+ }}
451
+ >
452
+ {!isReplaced ? (
453
+ // Step 1: Input new lyrics
454
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}>
455
+ <Typography variant="h6" gutterBottom>
456
+ Paste your new lyrics below:
457
+ </Typography>
458
+
459
+ <Typography variant="body2" color="text.secondary" gutterBottom>
460
+ Each line will become a separate segment. Words will be separated by spaces.
461
+ </Typography>
462
+
463
+ <Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
464
+ <Button
465
+ variant="outlined"
466
+ onClick={handlePasteFromClipboard}
467
+ startIcon={<ContentPasteIcon />}
468
+ size="small"
469
+ >
470
+ Paste from Clipboard
471
+ </Button>
472
+ <Typography variant="body2" sx={{
473
+ alignSelf: 'center',
474
+ color: 'text.secondary',
475
+ fontWeight: 'medium'
476
+ }}>
477
+ {parseInfo.lines} lines, {parseInfo.words} words
478
+ </Typography>
479
+ </Box>
480
+
481
+ <TextField
482
+ multiline
483
+ rows={15}
484
+ value={inputText}
485
+ onChange={(e) => setInputText(e.target.value)}
486
+ placeholder="Paste your lyrics here...&#10;Each line will become a segment&#10;Words will be separated by spaces"
487
+ sx={{
488
+ flexGrow: 1,
489
+ '& .MuiInputBase-root': {
490
+ height: '100%',
491
+ alignItems: 'flex-start'
492
+ }
493
+ }}
494
+ />
495
+
496
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
497
+ <Button
498
+ variant="contained"
499
+ onClick={processLyrics}
500
+ disabled={!inputText.trim()}
501
+ startIcon={<AutoFixHighIcon />}
502
+ >
503
+ Replace All Lyrics
504
+ </Button>
505
+ </Box>
506
+ </Box>
507
+ ) : (
508
+ // Step 2: Manual sync interface
509
+ <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
510
+ <Paper sx={{ p: 2, bgcolor: 'background.paper' }}>
511
+ <Typography variant="h6" gutterBottom>
512
+ Lyrics Replaced Successfully
513
+ </Typography>
514
+ <Typography variant="body2" color="text.secondary">
515
+ Created {currentSegments.length} segments with {globalSegment?.words.length} words total.
516
+ Use Manual Sync to set timing for all words.
517
+ </Typography>
518
+ </Paper>
519
+
520
+ <Divider />
521
+
522
+ {globalSegment && (
523
+ <Box sx={{ display: 'flex', gap: 2, flexGrow: 1, minHeight: 0 }}>
524
+ {/* Timeline Section */}
525
+ <Box sx={{ flex: 2, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
526
+ <EditTimelineSection
527
+ words={globalSegment.words}
528
+ startTime={timeRange.start}
529
+ endTime={timeRange.end}
530
+ originalStartTime={0}
531
+ originalEndTime={getAudioDuration()}
532
+ currentStartTime={globalSegment.start_time}
533
+ currentEndTime={globalSegment.end_time}
534
+ currentTime={currentTime}
535
+ isManualSyncing={isManualSyncing}
536
+ syncWordIndex={syncWordIndex}
537
+ isSpacebarPressed={isSpacebarPressed}
538
+ onWordUpdate={handleWordUpdate}
539
+ onUnsyncWord={handleUnsyncWord}
540
+ onPlaySegment={onPlaySegment}
541
+ onStopAudio={() => {
542
+ // Stop audio playback using global function
543
+ if (window.toggleAudioPlayback && window.isAudioPlaying) {
544
+ window.toggleAudioPlayback()
545
+ }
546
+ }}
547
+ startManualSync={startManualSync}
548
+ pauseManualSync={pauseManualSync}
549
+ resumeManualSync={resumeManualSync}
550
+ isPaused={isPaused}
551
+ isGlobal={true}
552
+ defaultZoomLevel={10} // Show 10 seconds by default
553
+ isReplaceAllMode={true} // Prevent zoom changes during sync
554
+ />
555
+ </Box>
556
+
557
+ {/* Segment Progress Section */}
558
+ <SegmentProgressPanel
559
+ currentSegments={segmentProgressProps.currentSegments}
560
+ globalSegment={segmentProgressProps.globalSegment}
561
+ syncWordIndex={segmentProgressProps.syncWordIndex}
562
+ />
563
+ </Box>
564
+ )}
565
+ </Box>
566
+ )}
567
+ </DialogContent>
568
+
569
+ <DialogActions>
570
+ {isReplaced && (
571
+ <EditActionBar
572
+ onReset={handleReset}
573
+ onClose={handleClose}
574
+ onSave={handleSave}
575
+ editedSegment={globalSegment}
576
+ isGlobal={true}
577
+ />
578
+ )}
579
+ </DialogActions>
580
+ </Dialog>
581
+ )
582
+ }
583
+
584
+ // Memoized Segment Progress Item to prevent unnecessary re-renders
585
+ const SegmentProgressItem = memo(({
586
+ segment,
587
+ index,
588
+ isActive
589
+ }: {
590
+ segment: LyricsSegment
591
+ index: number
592
+ isActive: boolean
593
+ }) => {
594
+ const wordsWithTiming = segment.words.filter(w =>
595
+ w.start_time !== null && w.end_time !== null
596
+ ).length
597
+ const totalWords = segment.words.length
598
+ const isComplete = wordsWithTiming === totalWords
599
+
600
+ return (
601
+ <Paper
602
+ key={segment.id}
603
+ ref={isActive ? (el) => {
604
+ // Auto-scroll to active segment
605
+ if (el) {
606
+ el.scrollIntoView({
607
+ behavior: 'smooth',
608
+ block: 'center'
609
+ })
610
+ }
611
+ } : undefined}
612
+ sx={{
613
+ p: 1,
614
+ mb: 1,
615
+ bgcolor: isActive ? 'primary.light' :
616
+ isComplete ? 'success.light' : 'background.paper',
617
+ border: isActive ? 2 : 1,
618
+ borderColor: isActive ? 'primary.main' : 'divider'
619
+ }}
620
+ >
621
+ <Typography
622
+ variant="body2"
623
+ sx={{
624
+ fontWeight: isActive ? 'bold' : 'normal',
625
+ mb: 0.5
626
+ }}
627
+ >
628
+ Segment {index + 1}: {segment.text.slice(0, 50)}
629
+ {segment.text.length > 50 ? '...' : ''}
630
+ </Typography>
631
+ <Typography variant="caption" color="text.secondary">
632
+ {wordsWithTiming}/{totalWords} words synced
633
+ {isComplete && segment.start_time !== null && segment.end_time !== null && (
634
+ <>
635
+ <br />
636
+ {segment.start_time.toFixed(2)}s - {segment.end_time.toFixed(2)}s
637
+ </>
638
+ )}
639
+ </Typography>
640
+ </Paper>
641
+ )
642
+ })
643
+
644
+ // Memoized Segment Progress Panel
645
+ const SegmentProgressPanel = memo(({
646
+ currentSegments,
647
+ globalSegment,
648
+ syncWordIndex
649
+ }: {
650
+ currentSegments: LyricsSegment[]
651
+ globalSegment: LyricsSegment | null
652
+ syncWordIndex: number
653
+ }) => {
654
+ return (
655
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
656
+ <Typography variant="h6" gutterBottom>
657
+ Segment Progress
658
+ </Typography>
659
+ <Box sx={{
660
+ overflow: 'auto',
661
+ flexGrow: 1,
662
+ border: 1,
663
+ borderColor: 'divider',
664
+ borderRadius: 1,
665
+ p: 1
666
+ }}>
667
+ {currentSegments.map((segment, index) => {
668
+ const isActive = Boolean(
669
+ globalSegment &&
670
+ syncWordIndex >= 0 &&
671
+ syncWordIndex < globalSegment.words.length &&
672
+ globalSegment.words[syncWordIndex] &&
673
+ segment.words.some(w => w.id === globalSegment.words[syncWordIndex].id)
674
+ )
675
+
676
+ return (
677
+ <SegmentProgressItem
678
+ key={segment.id}
679
+ segment={segment}
680
+ index={index}
681
+ isActive={isActive}
682
+ />
683
+ )
684
+ })}
685
+ </Box>
686
+ </Box>
687
+ )
688
+ })