lyrics-transcriber 0.68.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.
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +4 -2
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +257 -134
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +35 -237
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +27 -3
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -18
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +198 -30
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/web_assets/assets/{index-D7BQUJXK.js → index-izP9z1oB.js} +985 -327
- lyrics_transcriber/frontend/web_assets/assets/index-izP9z1oB.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- {lyrics_transcriber-0.68.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/METADATA +1 -1
- {lyrics_transcriber-0.68.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/RECORD +18 -16
- lyrics_transcriber/frontend/web_assets/assets/index-D7BQUJXK.js.map +0 -1
- {lyrics_transcriber-0.68.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.68.0.dist-info → lyrics_transcriber-0.69.0.dist-info}/WHEEL +0 -0
- {lyrics_transcriber-0.68.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... Each line will become a segment 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
|
+
})
|