lyrics-transcriber 0.43.0__py3-none-any.whl → 0.44.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-DVoI6Z16.js} +10799 -7490
- lyrics_transcriber/frontend/dist/assets/index-DVoI6Z16.js.map +1 -0
- lyrics_transcriber/frontend/dist/index.html +1 -1
- lyrics_transcriber/frontend/src/App.tsx +4 -4
- 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/EditModal.tsx +232 -237
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/GlobalSyncEditor.tsx +675 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +141 -101
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +146 -80
- 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 +34 -16
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +186 -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 +28 -3
- lyrics_transcriber/frontend/src/components/shared/types.ts +17 -2
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +63 -14
- 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/output/video.py +18 -8
- lyrics_transcriber/review/server.py +238 -8
- {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/METADATA +3 -2
- {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/RECORD +47 -41
- 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.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/WHEEL +0 -0
- {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,675 @@
|
|
1
|
+
import {
|
2
|
+
Dialog,
|
3
|
+
DialogTitle,
|
4
|
+
DialogContent,
|
5
|
+
DialogActions,
|
6
|
+
IconButton,
|
7
|
+
Box,
|
8
|
+
Button,
|
9
|
+
Typography,
|
10
|
+
Slider,
|
11
|
+
ButtonGroup
|
12
|
+
} from '@mui/material'
|
13
|
+
import CloseIcon from '@mui/icons-material/Close'
|
14
|
+
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
|
15
|
+
import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline'
|
16
|
+
import StopCircleIcon from '@mui/icons-material/StopCircle'
|
17
|
+
import CancelIcon from '@mui/icons-material/Cancel'
|
18
|
+
import ZoomInIcon from '@mui/icons-material/ZoomIn'
|
19
|
+
import ZoomOutIcon from '@mui/icons-material/ZoomOut'
|
20
|
+
import { LyricsSegment, Word } from '../types'
|
21
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
22
|
+
import TimelineEditor from './TimelineEditor'
|
23
|
+
|
24
|
+
interface GlobalSyncEditorProps {
|
25
|
+
open: boolean
|
26
|
+
onClose: () => void
|
27
|
+
segments: LyricsSegment[]
|
28
|
+
onSave: (updatedSegments: LyricsSegment[]) => void
|
29
|
+
onPlaySegment?: (startTime: number) => void
|
30
|
+
currentTime?: number
|
31
|
+
setModalSpacebarHandler: (handler: (() => (e: KeyboardEvent) => void) | undefined) => void
|
32
|
+
}
|
33
|
+
|
34
|
+
// Zoom levels in seconds per screen width - more granular at the lower end
|
35
|
+
const ZOOM_LEVELS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 1200];
|
36
|
+
|
37
|
+
interface FlattenedWord {
|
38
|
+
word: Word;
|
39
|
+
segmentIndex: number;
|
40
|
+
wordIndex: number;
|
41
|
+
}
|
42
|
+
|
43
|
+
export default function GlobalSyncEditor({
|
44
|
+
open,
|
45
|
+
onClose,
|
46
|
+
segments,
|
47
|
+
onSave,
|
48
|
+
onPlaySegment,
|
49
|
+
currentTime = 0,
|
50
|
+
setModalSpacebarHandler
|
51
|
+
}: GlobalSyncEditorProps) {
|
52
|
+
const [editedSegments, setEditedSegments] = useState<LyricsSegment[]>([]);
|
53
|
+
const [allWords, setAllWords] = useState<FlattenedWord[]>([]);
|
54
|
+
const [isManualSyncing, setIsManualSyncing] = useState(false);
|
55
|
+
const [syncWordIndex, setSyncWordIndex] = useState<number>(-1);
|
56
|
+
const [zoomLevel, setZoomLevel] = useState<number>(3); // Default to 20 seconds per screen
|
57
|
+
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
58
|
+
const [songDuration, setSongDuration] = useState<number>(0);
|
59
|
+
const [visibleTimeRange, setVisibleTimeRange] = useState<{start: number, end: number}>({start: 0, end: 20});
|
60
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
61
|
+
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
62
|
+
|
63
|
+
// Initialize the edited segments and flatten all words when the modal opens
|
64
|
+
useEffect(() => {
|
65
|
+
if (open && segments) {
|
66
|
+
const segmentsCopy = [...segments];
|
67
|
+
setEditedSegments(segmentsCopy);
|
68
|
+
|
69
|
+
// Create flattened word list with references to original segment and word indices
|
70
|
+
const flattenedWords: FlattenedWord[] = [];
|
71
|
+
segmentsCopy.forEach((segment, segmentIndex) => {
|
72
|
+
segment.words.forEach((word, wordIndex) => {
|
73
|
+
flattenedWords.push({
|
74
|
+
word,
|
75
|
+
segmentIndex,
|
76
|
+
wordIndex
|
77
|
+
});
|
78
|
+
});
|
79
|
+
});
|
80
|
+
|
81
|
+
// Sort words by start time
|
82
|
+
flattenedWords.sort((a, b) => {
|
83
|
+
const aStart = a.word.start_time ?? 0;
|
84
|
+
const bStart = b.word.start_time ?? 0;
|
85
|
+
return aStart - bStart;
|
86
|
+
});
|
87
|
+
|
88
|
+
setAllWords(flattenedWords);
|
89
|
+
|
90
|
+
// Calculate song duration
|
91
|
+
const allEndTimes = flattenedWords.map(item => item.word.end_time).filter((t): t is number => t !== null);
|
92
|
+
const duration = allEndTimes.length > 0 ? Math.max(...allEndTimes) + 5 : 60; // Add 5 seconds padding
|
93
|
+
setSongDuration(duration);
|
94
|
+
|
95
|
+
// Reset scroll position
|
96
|
+
setScrollPosition(0);
|
97
|
+
updateVisibleTimeRange(0, zoomLevel);
|
98
|
+
}
|
99
|
+
}, [open, segments, zoomLevel]);
|
100
|
+
|
101
|
+
// Update visible time range when scroll position or zoom level changes
|
102
|
+
const updateVisibleTimeRange = useCallback((scrollPos: number, zoom: number) => {
|
103
|
+
const secondsPerScreen = ZOOM_LEVELS[zoom];
|
104
|
+
const start = scrollPos * songDuration / 100;
|
105
|
+
const end = start + secondsPerScreen;
|
106
|
+
setVisibleTimeRange({start, end});
|
107
|
+
}, [songDuration]);
|
108
|
+
|
109
|
+
// Handle scroll position change
|
110
|
+
const handleScroll = useCallback(() => {
|
111
|
+
if (!timelineContainerRef.current) return;
|
112
|
+
|
113
|
+
const container = timelineContainerRef.current;
|
114
|
+
const scrollPercentage = (container.scrollLeft / (container.scrollWidth - container.clientWidth)) * 100;
|
115
|
+
setScrollPosition(scrollPercentage);
|
116
|
+
updateVisibleTimeRange(scrollPercentage, zoomLevel);
|
117
|
+
}, [zoomLevel, updateVisibleTimeRange]);
|
118
|
+
|
119
|
+
// Handle zoom level change
|
120
|
+
const handleZoomChange = useCallback((_event: React.SyntheticEvent | Event, newValue: number | number[]) => {
|
121
|
+
const newZoom = Array.isArray(newValue) ? newValue[0] : newValue;
|
122
|
+
setZoomLevel(newZoom);
|
123
|
+
updateVisibleTimeRange(scrollPosition, newZoom);
|
124
|
+
}, [scrollPosition, updateVisibleTimeRange]);
|
125
|
+
|
126
|
+
// Handle word update
|
127
|
+
const handleWordUpdate = useCallback((flatWordIndex: number, updates: Partial<Word>) => {
|
128
|
+
if (flatWordIndex < 0 || flatWordIndex >= allWords.length) return;
|
129
|
+
|
130
|
+
const { segmentIndex, wordIndex } = allWords[flatWordIndex];
|
131
|
+
const newSegments = [...editedSegments];
|
132
|
+
|
133
|
+
if (segmentIndex < 0 || segmentIndex >= newSegments.length) return;
|
134
|
+
const segment = newSegments[segmentIndex];
|
135
|
+
|
136
|
+
if (wordIndex < 0 || wordIndex >= segment.words.length) return;
|
137
|
+
|
138
|
+
// Update the word
|
139
|
+
const newWords = [...segment.words];
|
140
|
+
newWords[wordIndex] = {
|
141
|
+
...newWords[wordIndex],
|
142
|
+
...updates
|
143
|
+
};
|
144
|
+
|
145
|
+
// Update segment start/end times based on words
|
146
|
+
const validStartTimes = newWords.map(w => w.start_time).filter((t): t is number => t !== null);
|
147
|
+
const validEndTimes = newWords.map(w => w.end_time).filter((t): t is number => t !== null);
|
148
|
+
|
149
|
+
const segmentStartTime = validStartTimes.length > 0 ? Math.min(...validStartTimes) : segment.start_time;
|
150
|
+
const segmentEndTime = validEndTimes.length > 0 ? Math.max(...validEndTimes) : segment.end_time;
|
151
|
+
|
152
|
+
newSegments[segmentIndex] = {
|
153
|
+
...segment,
|
154
|
+
words: newWords,
|
155
|
+
start_time: segmentStartTime,
|
156
|
+
end_time: segmentEndTime
|
157
|
+
};
|
158
|
+
|
159
|
+
setEditedSegments(newSegments);
|
160
|
+
|
161
|
+
// Update the flattened words array
|
162
|
+
const newAllWords = [...allWords];
|
163
|
+
newAllWords[flatWordIndex] = {
|
164
|
+
...newAllWords[flatWordIndex],
|
165
|
+
word: {
|
166
|
+
...newAllWords[flatWordIndex].word,
|
167
|
+
...updates
|
168
|
+
}
|
169
|
+
};
|
170
|
+
setAllWords(newAllWords);
|
171
|
+
}, [allWords, editedSegments]);
|
172
|
+
|
173
|
+
// Create a custom manual sync hook for the global timeline
|
174
|
+
const useGlobalManualSync = () => {
|
175
|
+
const [isSpacebarPressed, setIsSpacebarPressed] = useState(false);
|
176
|
+
const wordStartTimeRef = useRef<number | null>(null);
|
177
|
+
const spacebarPressTimeRef = useRef<number | null>(null);
|
178
|
+
const currentTimeRef = useRef(currentTime);
|
179
|
+
|
180
|
+
// Keep currentTimeRef up to date
|
181
|
+
useEffect(() => {
|
182
|
+
currentTimeRef.current = currentTime;
|
183
|
+
}, [currentTime]);
|
184
|
+
|
185
|
+
const cleanupManualSync = useCallback(() => {
|
186
|
+
setIsManualSyncing(false);
|
187
|
+
setSyncWordIndex(-1);
|
188
|
+
setIsSpacebarPressed(false);
|
189
|
+
wordStartTimeRef.current = null;
|
190
|
+
spacebarPressTimeRef.current = null;
|
191
|
+
}, []);
|
192
|
+
|
193
|
+
const startManualSyncFromBeginning = useCallback(() => {
|
194
|
+
if (isManualSyncing) {
|
195
|
+
cleanupManualSync();
|
196
|
+
return;
|
197
|
+
}
|
198
|
+
|
199
|
+
if (!onPlaySegment || allWords.length === 0) return;
|
200
|
+
|
201
|
+
setIsManualSyncing(true);
|
202
|
+
setSyncWordIndex(0);
|
203
|
+
setIsSpacebarPressed(false);
|
204
|
+
wordStartTimeRef.current = null;
|
205
|
+
spacebarPressTimeRef.current = null;
|
206
|
+
|
207
|
+
// Start playing 3 seconds before the first word
|
208
|
+
const firstWordStartTime = allWords[0].word.start_time ?? 0;
|
209
|
+
onPlaySegment(Math.max(0, firstWordStartTime - 3));
|
210
|
+
setIsPlaying(true);
|
211
|
+
}, [isManualSyncing, allWords, onPlaySegment, cleanupManualSync]);
|
212
|
+
|
213
|
+
const startManualSyncFromCurrent = useCallback(() => {
|
214
|
+
if (isManualSyncing) {
|
215
|
+
cleanupManualSync();
|
216
|
+
return;
|
217
|
+
}
|
218
|
+
|
219
|
+
if (!onPlaySegment || allWords.length === 0) return;
|
220
|
+
|
221
|
+
// Find the word closest to the current time
|
222
|
+
const currentT = currentTimeRef.current;
|
223
|
+
let closestIndex = 0;
|
224
|
+
let minDiff = Number.MAX_VALUE;
|
225
|
+
|
226
|
+
allWords.forEach((item, index) => {
|
227
|
+
const wordStart = item.word.start_time ?? 0;
|
228
|
+
const diff = Math.abs(wordStart - currentT);
|
229
|
+
if (diff < minDiff) {
|
230
|
+
minDiff = diff;
|
231
|
+
closestIndex = index;
|
232
|
+
}
|
233
|
+
});
|
234
|
+
|
235
|
+
setIsManualSyncing(true);
|
236
|
+
setSyncWordIndex(closestIndex);
|
237
|
+
setIsSpacebarPressed(false);
|
238
|
+
wordStartTimeRef.current = null;
|
239
|
+
spacebarPressTimeRef.current = null;
|
240
|
+
|
241
|
+
// Start playing 3 seconds before the selected word
|
242
|
+
const wordStartTime = allWords[closestIndex].word.start_time ?? 0;
|
243
|
+
onPlaySegment(Math.max(0, wordStartTime - 3));
|
244
|
+
setIsPlaying(true);
|
245
|
+
}, [isManualSyncing, allWords, onPlaySegment, cleanupManualSync, currentTimeRef]);
|
246
|
+
|
247
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
248
|
+
if (e.code !== 'Space') return;
|
249
|
+
|
250
|
+
console.log('GlobalSyncEditor - Spacebar pressed down', {
|
251
|
+
isManualSyncing,
|
252
|
+
syncWordIndex,
|
253
|
+
currentTime: currentTimeRef.current
|
254
|
+
});
|
255
|
+
|
256
|
+
e.preventDefault();
|
257
|
+
e.stopPropagation();
|
258
|
+
|
259
|
+
if (isManualSyncing && !isSpacebarPressed && syncWordIndex >= 0 && syncWordIndex < allWords.length) {
|
260
|
+
const currentWord = allWords[syncWordIndex];
|
261
|
+
console.log('GlobalSyncEditor - Recording word start time', {
|
262
|
+
wordIndex: syncWordIndex,
|
263
|
+
wordText: currentWord.word.text,
|
264
|
+
time: currentTimeRef.current
|
265
|
+
});
|
266
|
+
|
267
|
+
setIsSpacebarPressed(true);
|
268
|
+
|
269
|
+
// Record the start time of the current word
|
270
|
+
wordStartTimeRef.current = currentTimeRef.current;
|
271
|
+
|
272
|
+
// Record when the spacebar was pressed (for tap detection)
|
273
|
+
spacebarPressTimeRef.current = Date.now();
|
274
|
+
|
275
|
+
// Update the word's start time immediately
|
276
|
+
handleWordUpdate(syncWordIndex, { start_time: currentTimeRef.current });
|
277
|
+
} else if (!isManualSyncing && onPlaySegment) {
|
278
|
+
// Toggle playback when not in manual sync mode
|
279
|
+
if (window.toggleAudioPlayback) {
|
280
|
+
window.toggleAudioPlayback();
|
281
|
+
setIsPlaying(prev => !prev);
|
282
|
+
}
|
283
|
+
}
|
284
|
+
}, [isManualSyncing, syncWordIndex, allWords, isSpacebarPressed, handleWordUpdate, onPlaySegment]);
|
285
|
+
|
286
|
+
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
287
|
+
if (e.code !== 'Space') return;
|
288
|
+
|
289
|
+
console.log('GlobalSyncEditor - Spacebar released', {
|
290
|
+
isManualSyncing,
|
291
|
+
syncWordIndex,
|
292
|
+
currentTime: currentTimeRef.current,
|
293
|
+
wordStartTime: wordStartTimeRef.current
|
294
|
+
});
|
295
|
+
|
296
|
+
e.preventDefault();
|
297
|
+
e.stopPropagation();
|
298
|
+
|
299
|
+
if (isManualSyncing && isSpacebarPressed && syncWordIndex >= 0 && syncWordIndex < allWords.length) {
|
300
|
+
const currentWord = allWords[syncWordIndex];
|
301
|
+
const pressDuration = spacebarPressTimeRef.current ? Date.now() - spacebarPressTimeRef.current : 0;
|
302
|
+
const isTap = pressDuration < 200; // If pressed for less than 200ms, consider it a tap
|
303
|
+
|
304
|
+
console.log('GlobalSyncEditor - Recording word end time', {
|
305
|
+
wordIndex: syncWordIndex,
|
306
|
+
wordText: currentWord.word.text,
|
307
|
+
startTime: wordStartTimeRef.current,
|
308
|
+
endTime: currentTimeRef.current,
|
309
|
+
pressDuration: `${pressDuration}ms`,
|
310
|
+
isTap,
|
311
|
+
duration: (currentTimeRef.current - (wordStartTimeRef.current || 0)).toFixed(2) + 's'
|
312
|
+
});
|
313
|
+
|
314
|
+
setIsSpacebarPressed(false);
|
315
|
+
|
316
|
+
// Set the end time for the current word based on whether it was a tap or hold
|
317
|
+
if (isTap) {
|
318
|
+
// For a tap, set a default duration of 1 second
|
319
|
+
const defaultEndTime = (wordStartTimeRef.current || currentTimeRef.current) + 1.0;
|
320
|
+
handleWordUpdate(syncWordIndex, { end_time: defaultEndTime });
|
321
|
+
} else {
|
322
|
+
// For a hold, use the current time as the end time
|
323
|
+
handleWordUpdate(syncWordIndex, { end_time: currentTimeRef.current });
|
324
|
+
}
|
325
|
+
|
326
|
+
// Move to the next word
|
327
|
+
if (syncWordIndex === allWords.length - 1) {
|
328
|
+
// If this was the last word, finish manual sync
|
329
|
+
console.log('GlobalSyncEditor - Completed manual sync for all words');
|
330
|
+
setIsManualSyncing(false);
|
331
|
+
setSyncWordIndex(-1);
|
332
|
+
wordStartTimeRef.current = null;
|
333
|
+
spacebarPressTimeRef.current = null;
|
334
|
+
} else {
|
335
|
+
// Otherwise, move to the next word
|
336
|
+
const nextWord = allWords[syncWordIndex + 1];
|
337
|
+
console.log('GlobalSyncEditor - Moving to next word', {
|
338
|
+
nextWordIndex: syncWordIndex + 1,
|
339
|
+
nextWordText: nextWord.word.text
|
340
|
+
});
|
341
|
+
setSyncWordIndex(syncWordIndex + 1);
|
342
|
+
|
343
|
+
// If the next word's start time would overlap with the current word's end time,
|
344
|
+
// adjust the next word's start time
|
345
|
+
const currentEndTime = currentWord.word.end_time;
|
346
|
+
const nextStartTime = nextWord.word.start_time;
|
347
|
+
|
348
|
+
if (currentEndTime !== null && nextStartTime !== null && currentEndTime > nextStartTime) {
|
349
|
+
handleWordUpdate(syncWordIndex + 1, { start_time: currentEndTime + 0.01 });
|
350
|
+
}
|
351
|
+
}
|
352
|
+
}
|
353
|
+
}, [isManualSyncing, syncWordIndex, allWords, isSpacebarPressed, handleWordUpdate]);
|
354
|
+
|
355
|
+
// Combine the key handlers into a single function for external use
|
356
|
+
const handleSpacebar = useCallback((e: KeyboardEvent) => {
|
357
|
+
if (e.type === 'keydown') {
|
358
|
+
handleKeyDown(e);
|
359
|
+
} else if (e.type === 'keyup') {
|
360
|
+
handleKeyUp(e);
|
361
|
+
}
|
362
|
+
}, [handleKeyDown, handleKeyUp]);
|
363
|
+
|
364
|
+
return {
|
365
|
+
isSpacebarPressed,
|
366
|
+
startManualSyncFromBeginning,
|
367
|
+
startManualSyncFromCurrent,
|
368
|
+
cleanupManualSync,
|
369
|
+
handleSpacebar
|
370
|
+
};
|
371
|
+
};
|
372
|
+
|
373
|
+
const {
|
374
|
+
isSpacebarPressed,
|
375
|
+
startManualSyncFromBeginning,
|
376
|
+
startManualSyncFromCurrent,
|
377
|
+
cleanupManualSync,
|
378
|
+
handleSpacebar
|
379
|
+
} = useGlobalManualSync();
|
380
|
+
|
381
|
+
// Update the spacebar handler when modal state changes
|
382
|
+
useEffect(() => {
|
383
|
+
const spacebarHandler = handleSpacebar; // Capture the current handler
|
384
|
+
|
385
|
+
if (open) {
|
386
|
+
console.log('GlobalSyncEditor - Setting up modal spacebar handler');
|
387
|
+
|
388
|
+
// Create a function that will be called by the global event listeners
|
389
|
+
const handleKeyEvent = (e: KeyboardEvent) => {
|
390
|
+
if (e.code === 'Space') {
|
391
|
+
spacebarHandler(e);
|
392
|
+
}
|
393
|
+
};
|
394
|
+
|
395
|
+
// Wrap the handler function to match the expected signature
|
396
|
+
setModalSpacebarHandler(() => () => handleKeyEvent);
|
397
|
+
|
398
|
+
// Only cleanup when the effect is re-run or the modal is closed
|
399
|
+
return () => {
|
400
|
+
if (!open) {
|
401
|
+
console.log('GlobalSyncEditor - Cleanup: clearing modal spacebar handler');
|
402
|
+
setModalSpacebarHandler(undefined);
|
403
|
+
}
|
404
|
+
};
|
405
|
+
}
|
406
|
+
}, [
|
407
|
+
open,
|
408
|
+
handleSpacebar,
|
409
|
+
setModalSpacebarHandler
|
410
|
+
]);
|
411
|
+
|
412
|
+
// Update isPlaying state when currentTime changes
|
413
|
+
useEffect(() => {
|
414
|
+
if (window.isAudioPlaying !== undefined) {
|
415
|
+
setIsPlaying(window.isAudioPlaying);
|
416
|
+
}
|
417
|
+
}, [currentTime]);
|
418
|
+
|
419
|
+
const handleClose = useCallback(() => {
|
420
|
+
cleanupManualSync();
|
421
|
+
onClose();
|
422
|
+
}, [onClose, cleanupManualSync]);
|
423
|
+
|
424
|
+
const handleSave = useCallback(() => {
|
425
|
+
onSave(editedSegments);
|
426
|
+
onClose();
|
427
|
+
}, [editedSegments, onSave, onClose]);
|
428
|
+
|
429
|
+
const handlePlayFromTime = useCallback((time: number) => {
|
430
|
+
if (onPlaySegment) {
|
431
|
+
onPlaySegment(time);
|
432
|
+
setIsPlaying(true);
|
433
|
+
}
|
434
|
+
}, [onPlaySegment]);
|
435
|
+
|
436
|
+
const handlePlayPause = useCallback(() => {
|
437
|
+
if (window.toggleAudioPlayback) {
|
438
|
+
window.toggleAudioPlayback();
|
439
|
+
setIsPlaying(prev => !prev);
|
440
|
+
}
|
441
|
+
}, []);
|
442
|
+
|
443
|
+
const handleStop = useCallback(() => {
|
444
|
+
if (window.isAudioPlaying && window.toggleAudioPlayback) {
|
445
|
+
window.toggleAudioPlayback(); // Pause the audio
|
446
|
+
setIsPlaying(false);
|
447
|
+
}
|
448
|
+
}, []);
|
449
|
+
|
450
|
+
// Scroll to current time
|
451
|
+
const scrollToCurrentTime = useCallback(() => {
|
452
|
+
if (!timelineContainerRef.current) return;
|
453
|
+
|
454
|
+
const container = timelineContainerRef.current;
|
455
|
+
const totalWidth = container.scrollWidth;
|
456
|
+
const viewportWidth = container.clientWidth;
|
457
|
+
|
458
|
+
// Calculate the position of the current time as a percentage of the total duration
|
459
|
+
const currentTimePosition = (currentTime / songDuration) * totalWidth;
|
460
|
+
|
461
|
+
// Calculate the scroll position to center the current time in the viewport
|
462
|
+
const scrollLeft = Math.max(0, currentTimePosition - (viewportWidth / 2));
|
463
|
+
|
464
|
+
// Smoothly scroll to the position
|
465
|
+
container.scrollTo({
|
466
|
+
left: scrollLeft,
|
467
|
+
behavior: 'smooth'
|
468
|
+
});
|
469
|
+
}, [currentTime, songDuration]);
|
470
|
+
|
471
|
+
// Early return if no segments
|
472
|
+
if (!segments || segments.length === 0) return null;
|
473
|
+
|
474
|
+
// Calculate zoom-related values
|
475
|
+
const secondsPerScreen = ZOOM_LEVELS[zoomLevel];
|
476
|
+
const totalWidthPercentage = (songDuration / secondsPerScreen) * 100;
|
477
|
+
|
478
|
+
return (
|
479
|
+
<Dialog
|
480
|
+
open={open}
|
481
|
+
onClose={handleClose}
|
482
|
+
maxWidth="lg"
|
483
|
+
fullWidth
|
484
|
+
PaperProps={{
|
485
|
+
sx: {
|
486
|
+
height: '80vh',
|
487
|
+
display: 'flex',
|
488
|
+
flexDirection: 'column'
|
489
|
+
}
|
490
|
+
}}
|
491
|
+
>
|
492
|
+
<DialogTitle>
|
493
|
+
Edit Sync - All Words
|
494
|
+
<IconButton
|
495
|
+
aria-label="close"
|
496
|
+
onClick={handleClose}
|
497
|
+
sx={{
|
498
|
+
position: 'absolute',
|
499
|
+
right: 8,
|
500
|
+
top: 8,
|
501
|
+
color: (theme) => theme.palette.grey[500],
|
502
|
+
}}
|
503
|
+
>
|
504
|
+
<CloseIcon />
|
505
|
+
</IconButton>
|
506
|
+
</DialogTitle>
|
507
|
+
|
508
|
+
<DialogContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
509
|
+
{/* Zoom controls */}
|
510
|
+
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
|
511
|
+
<Typography variant="body2">Zoom:</Typography>
|
512
|
+
<IconButton
|
513
|
+
size="small"
|
514
|
+
onClick={() => setZoomLevel(Math.max(0, zoomLevel - 1))}
|
515
|
+
disabled={zoomLevel === 0}
|
516
|
+
>
|
517
|
+
<ZoomOutIcon />
|
518
|
+
</IconButton>
|
519
|
+
|
520
|
+
<Slider
|
521
|
+
value={zoomLevel}
|
522
|
+
min={0}
|
523
|
+
max={ZOOM_LEVELS.length - 1}
|
524
|
+
step={1}
|
525
|
+
onChange={handleZoomChange}
|
526
|
+
marks
|
527
|
+
sx={{ flexGrow: 1, maxWidth: 300 }}
|
528
|
+
/>
|
529
|
+
|
530
|
+
<IconButton
|
531
|
+
size="small"
|
532
|
+
onClick={() => setZoomLevel(Math.min(ZOOM_LEVELS.length - 1, zoomLevel + 1))}
|
533
|
+
disabled={zoomLevel === ZOOM_LEVELS.length - 1}
|
534
|
+
>
|
535
|
+
<ZoomInIcon />
|
536
|
+
</IconButton>
|
537
|
+
|
538
|
+
<Typography variant="body2">
|
539
|
+
{ZOOM_LEVELS[zoomLevel]} seconds per screen
|
540
|
+
</Typography>
|
541
|
+
</Box>
|
542
|
+
|
543
|
+
{/* Playback and manual sync controls */}
|
544
|
+
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
|
545
|
+
<ButtonGroup variant="outlined">
|
546
|
+
<Button
|
547
|
+
onClick={handlePlayPause}
|
548
|
+
startIcon={isPlaying ? <PauseCircleOutlineIcon /> : <PlayCircleOutlineIcon />}
|
549
|
+
>
|
550
|
+
{isPlaying ? "Pause" : "Play"}
|
551
|
+
</Button>
|
552
|
+
<Button
|
553
|
+
onClick={handleStop}
|
554
|
+
startIcon={<StopCircleIcon />}
|
555
|
+
disabled={!isPlaying}
|
556
|
+
>
|
557
|
+
Stop
|
558
|
+
</Button>
|
559
|
+
<Button
|
560
|
+
onClick={scrollToCurrentTime}
|
561
|
+
disabled={!currentTime}
|
562
|
+
>
|
563
|
+
Go to Current Time
|
564
|
+
</Button>
|
565
|
+
</ButtonGroup>
|
566
|
+
|
567
|
+
<Button
|
568
|
+
variant={isManualSyncing ? "outlined" : "contained"}
|
569
|
+
onClick={startManualSyncFromBeginning}
|
570
|
+
disabled={!onPlaySegment || allWords.length === 0}
|
571
|
+
startIcon={isManualSyncing ? <CancelIcon /> : <PlayCircleOutlineIcon />}
|
572
|
+
color={isManualSyncing ? "error" : "primary"}
|
573
|
+
>
|
574
|
+
{isManualSyncing ? "Cancel Sync" : "Sync From Start"}
|
575
|
+
</Button>
|
576
|
+
|
577
|
+
<Button
|
578
|
+
variant="contained"
|
579
|
+
onClick={startManualSyncFromCurrent}
|
580
|
+
disabled={!onPlaySegment || allWords.length === 0 || isManualSyncing}
|
581
|
+
startIcon={<PlayCircleOutlineIcon />}
|
582
|
+
>
|
583
|
+
Sync From Current Time
|
584
|
+
</Button>
|
585
|
+
</Box>
|
586
|
+
|
587
|
+
{/* Manual sync status */}
|
588
|
+
{isManualSyncing && syncWordIndex >= 0 && syncWordIndex < allWords.length && (
|
589
|
+
<Box sx={{ mb: 2 }}>
|
590
|
+
<Typography variant="body2">
|
591
|
+
Word {syncWordIndex + 1} of {allWords.length}: <strong>{allWords[syncWordIndex].word.text || ''}</strong>
|
592
|
+
</Typography>
|
593
|
+
<Typography variant="caption" color="text.secondary">
|
594
|
+
{isSpacebarPressed ?
|
595
|
+
"Holding spacebar... Release when word ends" :
|
596
|
+
"Press spacebar when word starts (tap for short words, hold for long words)"}
|
597
|
+
</Typography>
|
598
|
+
</Box>
|
599
|
+
)}
|
600
|
+
|
601
|
+
{/* Timeline container with horizontal scrolling */}
|
602
|
+
<Box
|
603
|
+
ref={timelineContainerRef}
|
604
|
+
sx={{
|
605
|
+
flexGrow: 1,
|
606
|
+
overflowX: 'auto',
|
607
|
+
overflowY: 'hidden',
|
608
|
+
position: 'relative',
|
609
|
+
'&::-webkit-scrollbar': {
|
610
|
+
height: '10px',
|
611
|
+
},
|
612
|
+
'&::-webkit-scrollbar-thumb': {
|
613
|
+
backgroundColor: 'rgba(0,0,0,0.2)',
|
614
|
+
borderRadius: '10px',
|
615
|
+
},
|
616
|
+
}}
|
617
|
+
onScroll={handleScroll}
|
618
|
+
>
|
619
|
+
<Box sx={{
|
620
|
+
width: `${totalWidthPercentage}%`,
|
621
|
+
minWidth: '100%',
|
622
|
+
height: '100%',
|
623
|
+
position: 'relative',
|
624
|
+
}}>
|
625
|
+
{/* Timeline editor */}
|
626
|
+
<TimelineEditor
|
627
|
+
words={allWords.map(item => item.word)}
|
628
|
+
startTime={0}
|
629
|
+
endTime={songDuration}
|
630
|
+
onWordUpdate={(index, updates) => handleWordUpdate(index, updates)}
|
631
|
+
currentTime={currentTime}
|
632
|
+
onPlaySegment={handlePlayFromTime}
|
633
|
+
showPlaybackIndicator={false} // Disable the built-in indicator to avoid duplication
|
634
|
+
/>
|
635
|
+
|
636
|
+
{/* Custom current time indicator */}
|
637
|
+
{currentTime >= 0 && currentTime <= songDuration && (
|
638
|
+
<Box
|
639
|
+
sx={{
|
640
|
+
position: 'absolute',
|
641
|
+
top: 0,
|
642
|
+
left: `${(currentTime / songDuration) * 100}%`,
|
643
|
+
width: '2px',
|
644
|
+
height: '100%',
|
645
|
+
backgroundColor: 'error.main',
|
646
|
+
zIndex: 10,
|
647
|
+
}}
|
648
|
+
/>
|
649
|
+
)}
|
650
|
+
</Box>
|
651
|
+
</Box>
|
652
|
+
|
653
|
+
{/* Time range indicator */}
|
654
|
+
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'space-between' }}>
|
655
|
+
<Typography variant="body2">
|
656
|
+
Visible: {visibleTimeRange.start.toFixed(1)}s - {visibleTimeRange.end.toFixed(1)}s
|
657
|
+
</Typography>
|
658
|
+
<Typography variant="body2">
|
659
|
+
Total Duration: {songDuration.toFixed(1)}s
|
660
|
+
</Typography>
|
661
|
+
</Box>
|
662
|
+
|
663
|
+
{/* Word count indicator */}
|
664
|
+
<Typography variant="body2" sx={{ mt: 1 }}>
|
665
|
+
Total Words: {allWords.length}
|
666
|
+
</Typography>
|
667
|
+
</DialogContent>
|
668
|
+
|
669
|
+
<DialogActions>
|
670
|
+
<Button onClick={handleClose}>Cancel</Button>
|
671
|
+
<Button onClick={handleSave} variant="contained" color="primary">Save</Button>
|
672
|
+
</DialogActions>
|
673
|
+
</Dialog>
|
674
|
+
);
|
675
|
+
}
|