lyrics-transcriber 0.34.2__py3-none-any.whl → 0.35.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 (39) hide show
  1. lyrics_transcriber/core/controller.py +10 -1
  2. lyrics_transcriber/correction/corrector.py +4 -3
  3. lyrics_transcriber/frontend/dist/assets/index-CQCER5Fo.js +181 -0
  4. lyrics_transcriber/frontend/dist/index.html +1 -1
  5. lyrics_transcriber/frontend/src/App.tsx +6 -2
  6. lyrics_transcriber/frontend/src/api.ts +9 -0
  7. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +155 -0
  8. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +1 -1
  9. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +23 -191
  10. lyrics_transcriber/frontend/src/components/EditModal.tsx +407 -0
  11. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +255 -221
  12. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +39 -0
  13. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +35 -264
  14. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +232 -0
  15. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  16. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +315 -0
  17. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +116 -138
  18. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +116 -0
  19. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +243 -0
  20. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +28 -0
  21. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +52 -0
  22. lyrics_transcriber/frontend/src/components/{constants.ts → shared/constants.ts} +1 -0
  23. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +137 -0
  24. lyrics_transcriber/frontend/src/components/{styles.ts → shared/styles.ts} +1 -1
  25. lyrics_transcriber/frontend/src/components/shared/types.ts +99 -0
  26. lyrics_transcriber/frontend/src/components/shared/utils/newlineCalculator.ts +37 -0
  27. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +76 -0
  28. lyrics_transcriber/frontend/src/types.ts +2 -43
  29. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  30. lyrics_transcriber/lyrics/spotify.py +11 -0
  31. lyrics_transcriber/output/generator.py +28 -11
  32. lyrics_transcriber/review/server.py +38 -12
  33. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/METADATA +1 -1
  34. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/RECORD +37 -24
  35. lyrics_transcriber/frontend/dist/assets/index-DqFgiUni.js +0 -245
  36. lyrics_transcriber/frontend/src/components/DebugPanel.tsx +0 -311
  37. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/LICENSE +0 -0
  38. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/WHEEL +0 -0
  39. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,315 @@
1
+ import { Box, styled } from '@mui/material'
2
+ import { Word } from '../types'
3
+ import { useRef, useState } from 'react'
4
+
5
+ interface TimelineEditorProps {
6
+ words: Word[]
7
+ startTime: number
8
+ endTime: number
9
+ onWordUpdate: (index: number, updates: Partial<Word>) => void
10
+ currentTime?: number
11
+ }
12
+
13
+ const TimelineContainer = styled(Box)(({ theme }) => ({
14
+ position: 'relative',
15
+ height: '60px',
16
+ backgroundColor: theme.palette.grey[200],
17
+ borderRadius: theme.shape.borderRadius,
18
+ margin: theme.spacing(2, 0),
19
+ padding: theme.spacing(0, 1),
20
+ }))
21
+
22
+ const TimelineRuler = styled(Box)(({ theme }) => ({
23
+ position: 'absolute',
24
+ top: 0,
25
+ left: 0,
26
+ right: 0,
27
+ height: '16px',
28
+ borderBottom: `1px solid ${theme.palette.grey[300]}`,
29
+ }))
30
+
31
+ const TimelineMark = styled(Box)(({ theme }) => ({
32
+ position: 'absolute',
33
+ top: '10px',
34
+ width: '1px',
35
+ height: '6px',
36
+ backgroundColor: theme.palette.grey[400],
37
+ }))
38
+
39
+ const TimelineLabel = styled(Box)(({ theme }) => ({
40
+ position: 'absolute',
41
+ top: 0,
42
+ transform: 'translateX(-50%)',
43
+ fontSize: '0.6rem',
44
+ color: theme.palette.grey[600],
45
+ }))
46
+
47
+ const TimelineWord = styled(Box)(({ theme }) => ({
48
+ position: 'absolute',
49
+ height: '30px',
50
+ top: '22px',
51
+ backgroundColor: theme.palette.primary.main,
52
+ borderRadius: theme.shape.borderRadius,
53
+ color: theme.palette.primary.contrastText,
54
+ padding: theme.spacing(0.5, 1),
55
+ cursor: 'move',
56
+ userSelect: 'none',
57
+ display: 'flex',
58
+ alignItems: 'center',
59
+ fontSize: '0.875rem',
60
+ transition: 'background-color 0.1s ease',
61
+ '&.highlighted': {
62
+ backgroundColor: theme.palette.secondary.main,
63
+ }
64
+ }))
65
+
66
+ const ResizeHandle = styled(Box)(({ theme }) => ({
67
+ position: 'absolute',
68
+ right: -4,
69
+ top: 0,
70
+ width: 8,
71
+ height: '100%',
72
+ cursor: 'col-resize',
73
+ '&:hover': {
74
+ backgroundColor: theme.palette.primary.light,
75
+ },
76
+ }))
77
+
78
+ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate, currentTime = 0 }: TimelineEditorProps) {
79
+ const containerRef = useRef<HTMLDivElement>(null)
80
+ const [dragState, setDragState] = useState<{
81
+ wordIndex: number
82
+ type: 'move' | 'resize'
83
+ initialX: number
84
+ initialTime: number
85
+ word: Word
86
+ } | null>(null)
87
+
88
+ const MIN_DURATION = 0.1 // Minimum word duration in seconds
89
+
90
+ const checkCollision = (
91
+ proposedStart: number,
92
+ proposedEnd: number,
93
+ currentIndex: number,
94
+ isResize: boolean
95
+ ): boolean => {
96
+ if (isResize) {
97
+ // If this is the last word, allow it to extend beyond the timeline
98
+ if (currentIndex === words.length - 1) return false;
99
+
100
+ const nextWord = words[currentIndex + 1]
101
+ if (!nextWord) return false
102
+ const hasCollision = proposedEnd > nextWord.start_time
103
+ if (hasCollision) {
104
+ console.log('Resize collision detected:', {
105
+ proposedEnd,
106
+ nextWordStart: nextWord.start_time,
107
+ word: words[currentIndex].text,
108
+ nextWord: nextWord.text
109
+ })
110
+ }
111
+ return hasCollision
112
+ }
113
+
114
+ // For move operations, check all words
115
+ return words.some((word, index) => {
116
+ if (index === currentIndex) return false
117
+ const overlap = (
118
+ (proposedStart >= word.start_time && proposedStart <= word.end_time) ||
119
+ (proposedEnd >= word.start_time && proposedEnd <= word.end_time) ||
120
+ (proposedStart <= word.start_time && proposedEnd >= word.end_time)
121
+ )
122
+ if (overlap) {
123
+ console.log('Move collision detected:', {
124
+ movingWord: words[currentIndex].text,
125
+ collidingWord: word.text,
126
+ proposedTimes: { start: proposedStart, end: proposedEnd },
127
+ collidingTimes: { start: word.start_time, end: word.end_time }
128
+ })
129
+ }
130
+ return overlap
131
+ })
132
+ }
133
+
134
+ const timeToPosition = (time: number): number => {
135
+ const duration = endTime - startTime
136
+ const position = ((time - startTime) / duration) * 100
137
+ return Math.max(0, Math.min(100, position))
138
+ }
139
+
140
+ const generateTimelineMarks = () => {
141
+ const marks = []
142
+ const startSecond = Math.floor(startTime)
143
+ const endSecond = Math.ceil(endTime)
144
+
145
+ for (let time = startSecond; time <= endSecond; time++) {
146
+ if (time >= startTime && time <= endTime) {
147
+ const position = timeToPosition(time)
148
+ marks.push(
149
+ <Box key={time}>
150
+ <TimelineMark sx={{ left: `${position}%` }} />
151
+ <TimelineLabel sx={{ left: `${position}%` }}>
152
+ {time}s
153
+ </TimelineLabel>
154
+ </Box>
155
+ )
156
+ }
157
+ }
158
+ return marks
159
+ }
160
+
161
+ const handleMouseDown = (e: React.MouseEvent, wordIndex: number, type: 'move' | 'resize') => {
162
+ const rect = containerRef.current?.getBoundingClientRect()
163
+ if (!rect) return
164
+
165
+ const initialX = e.clientX - rect.left
166
+ const initialTime = ((initialX / rect.width) * (endTime - startTime))
167
+
168
+ console.log('Mouse down:', {
169
+ type,
170
+ wordIndex,
171
+ initialX,
172
+ initialTime,
173
+ word: words[wordIndex]
174
+ })
175
+
176
+ setDragState({
177
+ wordIndex,
178
+ type,
179
+ initialX,
180
+ initialTime,
181
+ word: words[wordIndex]
182
+ })
183
+ }
184
+
185
+ const handleMouseMove = (e: React.MouseEvent) => {
186
+ if (!dragState || !containerRef.current) return
187
+
188
+ const rect = containerRef.current.getBoundingClientRect()
189
+ const x = e.clientX - rect.left
190
+ const width = rect.width
191
+
192
+ if (dragState.type === 'resize') {
193
+ const currentWord = words[dragState.wordIndex]
194
+ // Use the initial word duration for consistent scaling
195
+ const initialWordDuration = dragState.word.end_time - dragState.word.start_time
196
+ const initialWordWidth = (initialWordDuration / (endTime - startTime)) * width
197
+
198
+ // Calculate how much the mouse has moved as a percentage of the initial word width
199
+ const pixelDelta = x - dragState.initialX
200
+ const percentageMoved = pixelDelta / initialWordWidth
201
+ const timeDelta = initialWordDuration * percentageMoved
202
+
203
+ console.log('Resize calculation:', {
204
+ initialWordWidth,
205
+ initialWordDuration,
206
+ pixelDelta,
207
+ percentageMoved,
208
+ timeDelta,
209
+ currentDuration: currentWord.end_time - currentWord.start_time
210
+ })
211
+
212
+ const proposedEnd = Math.max(
213
+ currentWord.start_time + MIN_DURATION,
214
+ dragState.word.end_time + timeDelta // Use initial end time as reference
215
+ )
216
+
217
+ // Check for collisions
218
+ if (checkCollision(currentWord.start_time, proposedEnd, dragState.wordIndex, true)) return
219
+
220
+ // If we get here, the resize is valid
221
+ onWordUpdate(dragState.wordIndex, {
222
+ start_time: currentWord.start_time,
223
+ end_time: proposedEnd
224
+ })
225
+ } else if (dragState.type === 'move') {
226
+ // Use timeline scale for consistent movement
227
+ const pixelsPerSecond = width / (endTime - startTime)
228
+ const pixelDelta = x - dragState.initialX
229
+ const timeDelta = pixelDelta / pixelsPerSecond
230
+
231
+ const currentWord = words[dragState.wordIndex]
232
+ const wordDuration = currentWord.end_time - currentWord.start_time
233
+
234
+ console.log('Move calculation:', {
235
+ timelineWidth: width,
236
+ timelineDuration: endTime - startTime,
237
+ pixelsPerSecond,
238
+ pixelDelta,
239
+ timeDelta,
240
+ currentDuration: wordDuration
241
+ })
242
+
243
+ const proposedStart = dragState.word.start_time + timeDelta
244
+ const proposedEnd = proposedStart + wordDuration
245
+
246
+ // Ensure we stay within timeline bounds
247
+ if (proposedStart < startTime || proposedEnd > endTime) return
248
+
249
+ // Check for collisions
250
+ if (checkCollision(proposedStart, proposedEnd, dragState.wordIndex, false)) return
251
+
252
+ // If we get here, the move is valid
253
+ onWordUpdate(dragState.wordIndex, {
254
+ start_time: proposedStart,
255
+ end_time: proposedEnd
256
+ })
257
+ }
258
+ }
259
+
260
+ const handleMouseUp = () => {
261
+ setDragState(null)
262
+ }
263
+
264
+ const isWordHighlighted = (word: Word): boolean => {
265
+ if (!currentTime || !word.start_time || !word.end_time) return false
266
+ return currentTime >= word.start_time && currentTime <= word.end_time
267
+ }
268
+
269
+ return (
270
+ <TimelineContainer
271
+ ref={containerRef}
272
+ onMouseMove={handleMouseMove}
273
+ onMouseUp={handleMouseUp}
274
+ onMouseLeave={handleMouseUp}
275
+ >
276
+ <TimelineRuler>
277
+ {generateTimelineMarks()}
278
+ </TimelineRuler>
279
+ {words.map((word, index) => {
280
+ const leftPosition = timeToPosition(word.start_time)
281
+ const rightPosition = timeToPosition(word.end_time)
282
+ const width = rightPosition - leftPosition
283
+
284
+ // Add visual padding only to right side (2% of total width)
285
+ const visualPadding = 2 // percentage points
286
+ const adjustedWidth = Math.max(0, width - visualPadding)
287
+
288
+ return (
289
+ <TimelineWord
290
+ key={index}
291
+ className={isWordHighlighted(word) ? 'highlighted' : ''}
292
+ sx={{
293
+ left: `${leftPosition}%`, // No adjustment to left position
294
+ width: `${adjustedWidth}%`,
295
+ // Ensure the last word doesn't overflow
296
+ maxWidth: `calc(${100 - leftPosition}% - 2px)`,
297
+ }}
298
+ onMouseDown={(e) => {
299
+ e.stopPropagation(); // Prevent the parent's mousedown from firing
300
+ handleMouseDown(e, index, 'move');
301
+ }}
302
+ >
303
+ {word.text}
304
+ <ResizeHandle
305
+ onMouseDown={(e) => {
306
+ e.stopPropagation(); // Prevent the parent's mousedown from firing
307
+ handleMouseDown(e, index, 'resize');
308
+ }}
309
+ />
310
+ </TimelineWord>
311
+ )
312
+ })}
313
+ </TimelineContainer>
314
+ )
315
+ }
@@ -1,157 +1,135 @@
1
- import { Paper, Typography } from '@mui/material'
2
- import { CorrectionData, AnchorSequence, HighlightInfo } from '../types'
3
- import { FlashType, ModalContent } from './LyricsAnalyzer'
4
- import { COLORS } from './constants'
5
- import { HighlightedWord } from './styles'
1
+ import { useState } from 'react'
2
+ import { Paper, Typography, Box, IconButton } from '@mui/material'
3
+ import { TranscriptionViewProps } from './shared/types'
4
+ import { HighlightedText } from './shared/components/HighlightedText'
5
+ import { styled } from '@mui/material/styles'
6
+ import SegmentDetailsModal from './SegmentDetailsModal'
7
+ import { TranscriptionWordPosition } from './shared/types'
8
+ import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
6
9
 
7
- interface WordClickInfo {
8
- wordIndex: number
9
- type: 'anchor' | 'gap' | 'other'
10
- anchor?: AnchorSequence
11
- gap?: CorrectionData['gap_sequences'][0]
12
- }
10
+ const SegmentIndex = styled(Typography)(({ theme }) => ({
11
+ color: theme.palette.text.secondary,
12
+ width: '2em',
13
+ minWidth: '2em',
14
+ textAlign: 'right',
15
+ marginRight: theme.spacing(1),
16
+ userSelect: 'none',
17
+ fontFamily: 'monospace',
18
+ cursor: 'pointer',
19
+ paddingTop: '3px',
20
+ '&:hover': {
21
+ textDecoration: 'underline',
22
+ },
23
+ }))
13
24
 
14
- interface TranscriptionViewProps {
15
- data: CorrectionData
16
- onElementClick: (content: ModalContent) => void
17
- onWordClick?: (info: WordClickInfo) => void
18
- flashingType: FlashType
19
- highlightInfo: HighlightInfo | null
20
- }
25
+ const TextContainer = styled(Box)({
26
+ flex: 1,
27
+ minWidth: 0,
28
+ })
29
+
30
+ const SegmentControls = styled(Box)({
31
+ display: 'flex',
32
+ alignItems: 'center',
33
+ gap: '4px',
34
+ minWidth: '3em',
35
+ paddingTop: '3px',
36
+ paddingRight: '8px'
37
+ })
21
38
 
22
39
  export default function TranscriptionView({
23
40
  data,
24
41
  onElementClick,
25
42
  onWordClick,
26
43
  flashingType,
27
- highlightInfo
44
+ highlightInfo,
45
+ mode,
46
+ onPlaySegment,
47
+ currentTime = 0
28
48
  }: TranscriptionViewProps) {
29
- const renderHighlightedText = () => {
30
- const normalizedText = data.corrected_text.replace(/\n\n+/g, '\n')
31
- const words = normalizedText.split(/(\s+)/)
32
- let wordIndex = 0 // Track actual word positions, ignoring whitespace
33
-
34
- // Build a map of original positions to their corrections
35
- const correctionMap = new Map()
36
- data.gap_sequences.forEach(gap => {
37
- gap.corrections.forEach(c => {
38
- correctionMap.set(c.original_position, {
39
- original: c.original_word,
40
- corrected: c.corrected_word,
41
- is_deletion: c.is_deletion,
42
- split_total: c.split_total
43
- })
44
- })
45
- })
46
-
47
- return words.map((word, index) => {
48
- if (/^\s+$/.test(word)) {
49
- return word
50
- }
51
-
52
- const currentWordIndex = wordIndex
53
- const anchor = data.anchor_sequences.find(anchor => {
54
- const start = anchor.transcription_position
55
- const end = start + anchor.length
56
- return currentWordIndex >= start && currentWordIndex < end
57
- })
58
-
59
- const gap = data.gap_sequences.find(g => {
60
- const start = g.transcription_position
61
- const end = start + g.length
62
- return currentWordIndex >= start && currentWordIndex < end
63
- })
64
-
65
- const hasCorrections = gap ? gap.corrections.length > 0 : false
66
-
67
- const shouldFlash = Boolean(
68
- (flashingType === 'anchor' && anchor) ||
69
- (flashingType === 'corrected' && hasCorrections) ||
70
- (flashingType === 'uncorrected' && gap && !hasCorrections) ||
71
- (flashingType === 'word' && highlightInfo?.type === 'anchor' && anchor && (
72
- anchor.transcription_position === highlightInfo.transcriptionIndex &&
73
- currentWordIndex >= anchor.transcription_position &&
74
- currentWordIndex < anchor.transcription_position + anchor.length
75
- ))
76
- )
77
-
78
- const wordElement = (
79
- <HighlightedWord
80
- key={`${word}-${index}-${shouldFlash}`}
81
- shouldFlash={shouldFlash}
82
- style={{
83
- backgroundColor: anchor
84
- ? COLORS.anchor
85
- : hasCorrections
86
- ? COLORS.corrected
87
- : gap
88
- ? COLORS.uncorrectedGap
89
- : 'transparent',
90
- padding: anchor || gap ? '2px 4px' : '0',
91
- borderRadius: '3px',
92
- cursor: 'pointer',
93
- }}
94
- onClick={(e) => {
95
- if (e.detail === 1) {
96
- setTimeout(() => {
97
- if (!e.defaultPrevented) {
98
- onWordClick?.({
99
- wordIndex: currentWordIndex,
100
- type: anchor ? 'anchor' : gap ? 'gap' : 'other',
101
- anchor,
102
- gap
103
- })
104
- }
105
- }, 200)
106
- }
107
- }}
108
- onDoubleClick={(e) => {
109
- e.preventDefault() // Prevent single-click from firing
110
- if (anchor) {
111
- onElementClick({
112
- type: 'anchor',
113
- data: {
114
- ...anchor,
115
- position: currentWordIndex
116
- }
117
- })
118
- } else if (gap) {
119
- onElementClick({
120
- type: 'gap',
121
- data: {
122
- ...gap,
123
- position: currentWordIndex,
124
- word: word
125
- }
126
- })
127
- }
128
- }}
129
- >
130
- {word}
131
- </HighlightedWord>
132
- )
49
+ const [selectedSegmentIndex, setSelectedSegmentIndex] = useState<number | null>(null)
133
50
 
134
- wordIndex++
135
- return wordElement
136
- })
137
- }
51
+ // Keep track of global word position
52
+ let globalWordPosition = 0
138
53
 
139
54
  return (
140
55
  <Paper sx={{ p: 2 }}>
141
56
  <Typography variant="h6" gutterBottom>
142
57
  Corrected Transcription
143
58
  </Typography>
144
- <Typography
145
- component="pre"
146
- sx={{
147
- fontFamily: 'monospace',
148
- whiteSpace: 'pre-wrap',
149
- margin: 0,
150
- lineHeight: 1.5,
151
- }}
152
- >
153
- {renderHighlightedText()}
154
- </Typography>
59
+ <Box sx={{ display: 'flex', flexDirection: 'column' }}>
60
+ {data.corrected_segments.map((segment, segmentIndex) => {
61
+ // Convert segment words to TranscriptionWordPosition format
62
+ const segmentWords: TranscriptionWordPosition[] = segment.words.map((word, idx) => {
63
+ const position = globalWordPosition + idx
64
+ const anchor = data.anchor_sequences.find(a =>
65
+ position >= a.transcription_position &&
66
+ position < a.transcription_position + a.length
67
+ )
68
+ const gap = !anchor ? data.gap_sequences.find(g =>
69
+ position >= g.transcription_position &&
70
+ position < g.transcription_position + g.length
71
+ ) : undefined
72
+
73
+ return {
74
+ word: {
75
+ text: word.text,
76
+ start_time: word.start_time,
77
+ end_time: word.end_time
78
+ },
79
+ position,
80
+ type: anchor ? 'anchor' : gap ? 'gap' : 'other',
81
+ sequence: anchor || gap,
82
+ isInRange: true
83
+ }
84
+ })
85
+
86
+ // Update global position counter for next segment
87
+ globalWordPosition += segment.words.length
88
+
89
+ return (
90
+ <Box key={segmentIndex} sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
91
+ <SegmentControls>
92
+ <SegmentIndex
93
+ variant="body2"
94
+ onClick={() => setSelectedSegmentIndex(segmentIndex)}
95
+ >
96
+ {segmentIndex}
97
+ </SegmentIndex>
98
+ {segment.start_time !== undefined && (
99
+ <IconButton
100
+ size="small"
101
+ onClick={() => onPlaySegment?.(segment.start_time)}
102
+ sx={{ padding: '2px' }}
103
+ >
104
+ <PlayCircleOutlineIcon fontSize="small" />
105
+ </IconButton>
106
+ )}
107
+ </SegmentControls>
108
+ <TextContainer>
109
+ <HighlightedText
110
+ wordPositions={segmentWords}
111
+ anchors={data.anchor_sequences}
112
+ gaps={data.gap_sequences}
113
+ onElementClick={onElementClick}
114
+ onWordClick={onWordClick}
115
+ flashingType={flashingType}
116
+ highlightInfo={highlightInfo}
117
+ mode={mode}
118
+ preserveSegments={true}
119
+ currentTime={currentTime}
120
+ />
121
+ </TextContainer>
122
+ </Box>
123
+ )
124
+ })}
125
+ </Box>
126
+
127
+ <SegmentDetailsModal
128
+ open={selectedSegmentIndex !== null}
129
+ onClose={() => setSelectedSegmentIndex(null)}
130
+ segment={selectedSegmentIndex !== null ? data.corrected_segments[selectedSegmentIndex] : null}
131
+ segmentIndex={selectedSegmentIndex}
132
+ />
155
133
  </Paper>
156
134
  )
157
135
  }