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,116 @@
1
+ import { Button, Box, TextField } from '@mui/material'
2
+ import DeleteIcon from '@mui/icons-material/Delete'
3
+ import { useState, useEffect } from 'react'
4
+ import { ModalContent } from './LyricsAnalyzer'
5
+
6
+ interface WordEditControlsProps {
7
+ content: ModalContent
8
+ onUpdateCorrection?: (position: number, updatedWords: string[]) => void
9
+ onClose: () => void
10
+ }
11
+
12
+ export function useWordEdit(content: ModalContent | null) {
13
+ const [editedWord, setEditedWord] = useState('')
14
+ const [isEditing, setIsEditing] = useState(false)
15
+
16
+ useEffect(() => {
17
+ if (content) {
18
+ setEditedWord(content.type === 'gap' ? content.data.word : content.type === 'anchor' ? content.data.words[0] : '')
19
+ setIsEditing(false)
20
+ }
21
+ }, [content])
22
+
23
+ return {
24
+ editedWord,
25
+ setEditedWord,
26
+ isEditing,
27
+ setIsEditing
28
+ }
29
+ }
30
+
31
+ export default function WordEditControls({ content, onUpdateCorrection, onClose }: WordEditControlsProps) {
32
+ const {
33
+ editedWord,
34
+ setEditedWord,
35
+ isEditing,
36
+ setIsEditing
37
+ } = useWordEdit(content)
38
+
39
+ const handleStartEdit = () => {
40
+ if (content.type === 'gap') {
41
+ setEditedWord(content.data.word)
42
+ } else if (content.type === 'anchor') {
43
+ setEditedWord(content.data.words[0])
44
+ }
45
+ setIsEditing(true)
46
+ }
47
+
48
+ const handleDelete = () => {
49
+ if (!onUpdateCorrection) return
50
+ onUpdateCorrection(content.data.position, [])
51
+ onClose()
52
+ }
53
+
54
+ const handleSaveEdit = () => {
55
+ if (onUpdateCorrection) {
56
+ onUpdateCorrection(content.data.position, [editedWord])
57
+ }
58
+ onClose()
59
+ }
60
+
61
+ const handleCancelEdit = () => {
62
+ if (content.type === 'gap') {
63
+ setEditedWord(content.data.word)
64
+ setIsEditing(false)
65
+ }
66
+ }
67
+
68
+ const handleWordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
69
+ setEditedWord(event.target.value)
70
+ }
71
+
72
+ return isEditing ? (
73
+ <Box>
74
+ <TextField
75
+ value={editedWord}
76
+ onChange={handleWordChange}
77
+ fullWidth
78
+ label="Edit word"
79
+ variant="outlined"
80
+ size="small"
81
+ sx={{ mb: 1 }}
82
+ />
83
+ <Box sx={{ display: 'flex', gap: 1 }}>
84
+ <Button variant="contained" onClick={handleSaveEdit}>
85
+ Save Changes
86
+ </Button>
87
+ <Button variant="outlined" onClick={handleCancelEdit}>
88
+ Cancel
89
+ </Button>
90
+ <Button
91
+ variant="outlined"
92
+ color="error"
93
+ startIcon={<DeleteIcon />}
94
+ onClick={handleDelete}
95
+ >
96
+ Delete
97
+ </Button>
98
+ </Box>
99
+ </Box>
100
+ ) : (
101
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
102
+ <Button variant="outlined" size="small" onClick={handleStartEdit}>
103
+ Edit
104
+ </Button>
105
+ <Button
106
+ variant="outlined"
107
+ size="small"
108
+ color="error"
109
+ startIcon={<DeleteIcon />}
110
+ onClick={handleDelete}
111
+ >
112
+ Delete
113
+ </Button>
114
+ </Box>
115
+ )
116
+ }
@@ -0,0 +1,243 @@
1
+ import { Typography, Box } from '@mui/material'
2
+ import { Word } from './Word'
3
+ import { useWordClick } from '../hooks/useWordClick'
4
+ import { AnchorSequence, GapSequence, HighlightInfo, InteractionMode } from '../../../types'
5
+ import { ModalContent } from '../../LyricsAnalyzer'
6
+ import { WordClickInfo, TranscriptionWordPosition, FlashType, LinePosition } from '../types'
7
+ import React from 'react'
8
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
9
+ import IconButton from '@mui/material/IconButton';
10
+
11
+ interface HighlightedTextProps {
12
+ // Input can be either raw text or pre-processed word positions
13
+ text?: string
14
+ wordPositions?: TranscriptionWordPosition[]
15
+ // Common props
16
+ anchors: AnchorSequence[]
17
+ gaps: GapSequence[]
18
+ highlightInfo: HighlightInfo | null
19
+ mode: InteractionMode
20
+ onElementClick: (content: ModalContent) => void
21
+ onWordClick?: (info: WordClickInfo) => void
22
+ flashingType: FlashType
23
+ // Reference-specific props
24
+ isReference?: boolean
25
+ currentSource?: 'genius' | 'spotify'
26
+ preserveSegments?: boolean
27
+ linePositions?: LinePosition[]
28
+ currentTime?: number
29
+ }
30
+
31
+ export function HighlightedText({
32
+ text,
33
+ wordPositions,
34
+ anchors,
35
+ highlightInfo,
36
+ mode,
37
+ onElementClick,
38
+ onWordClick,
39
+ flashingType,
40
+ isReference,
41
+ currentSource,
42
+ preserveSegments = false,
43
+ linePositions = [],
44
+ currentTime = 0
45
+ }: HighlightedTextProps) {
46
+ const { handleWordClick } = useWordClick({
47
+ mode,
48
+ onElementClick,
49
+ onWordClick,
50
+ isReference,
51
+ currentSource
52
+ })
53
+
54
+ const shouldWordFlash = (wordPos: TranscriptionWordPosition | { word: string; index: number }): boolean => {
55
+ if (!flashingType) return false
56
+
57
+ if ('type' in wordPos) {
58
+ // Handle TranscriptionWordPosition
59
+ const hasCorrections = wordPos.type === 'gap' &&
60
+ Boolean((wordPos.sequence as GapSequence)?.corrections?.length)
61
+
62
+ return Boolean(
63
+ (flashingType === 'anchor' && wordPos.type === 'anchor') ||
64
+ (flashingType === 'corrected' && hasCorrections) ||
65
+ (flashingType === 'uncorrected' && wordPos.type === 'gap' && !hasCorrections) ||
66
+ (flashingType === 'word' && highlightInfo?.type === 'anchor' &&
67
+ wordPos.type === 'anchor' && wordPos.sequence && (
68
+ (wordPos.sequence as AnchorSequence).transcription_position === highlightInfo.transcriptionIndex ||
69
+ (isReference && currentSource &&
70
+ (wordPos.sequence as AnchorSequence).reference_positions[currentSource] === highlightInfo.referenceIndices?.[currentSource])
71
+ ))
72
+ )
73
+ } else {
74
+ // Handle reference word
75
+ const thisWordIndex = wordPos.index
76
+ const anchor = anchors.find(a => {
77
+ const position = isReference
78
+ ? a.reference_positions[currentSource!]
79
+ : a.transcription_position
80
+ if (position === undefined) return false
81
+ return thisWordIndex >= position && thisWordIndex < position + a.length
82
+ })
83
+
84
+ return Boolean(
85
+ (flashingType === 'anchor' && anchor) ||
86
+ (flashingType === 'word' && highlightInfo?.type === 'anchor' && anchor && (
87
+ anchor.transcription_position === highlightInfo.transcriptionIndex ||
88
+ (isReference && currentSource && anchor.reference_positions[currentSource] === highlightInfo.referenceIndices?.[currentSource])
89
+ ))
90
+ )
91
+ }
92
+ }
93
+
94
+ const shouldHighlightWord = (wordPos: TranscriptionWordPosition): boolean => {
95
+ if (!currentTime || !wordPos.word.start_time || !wordPos.word.end_time) return false
96
+ return currentTime >= wordPos.word.start_time && currentTime <= wordPos.word.end_time
97
+ }
98
+
99
+ const handleCopyLine = (text: string) => {
100
+ navigator.clipboard.writeText(text);
101
+ };
102
+
103
+ const renderContent = () => {
104
+ if (wordPositions) {
105
+ return wordPositions.map((wordPos, index) => (
106
+ <React.Fragment key={`${wordPos.word.text}-${index}`}>
107
+ <Word
108
+ word={wordPos.word.text}
109
+ shouldFlash={shouldWordFlash(wordPos)}
110
+ isCurrentlyPlaying={shouldHighlightWord(wordPos)}
111
+ isAnchor={wordPos.type === 'anchor'}
112
+ isCorrectedGap={wordPos.type === 'gap' && Boolean((wordPos.sequence as GapSequence)?.corrections?.length)}
113
+ isUncorrectedGap={wordPos.type === 'gap' && !(wordPos.sequence as GapSequence)?.corrections?.length}
114
+ onClick={() => handleWordClick(
115
+ wordPos.word.text,
116
+ wordPos.position,
117
+ wordPos.type === 'anchor' ? wordPos.sequence as AnchorSequence : undefined,
118
+ wordPos.type === 'gap' ? wordPos.sequence as GapSequence : undefined
119
+ )}
120
+ />
121
+ {index < wordPositions.length - 1 && ' '}
122
+ </React.Fragment>
123
+ ))
124
+ } else if (text) {
125
+ const lines = text.split('\n')
126
+ let globalWordIndex = 0
127
+
128
+ return lines.map((line, lineIndex) => {
129
+ const currentLinePosition = linePositions?.find((pos: LinePosition) => pos.position === globalWordIndex)
130
+ if (currentLinePosition?.isEmpty) {
131
+ globalWordIndex++
132
+ return (
133
+ <Box key={`empty-${lineIndex}`} sx={{ display: 'flex', alignItems: 'flex-start' }}>
134
+ <Typography
135
+ component="span"
136
+ sx={{
137
+ color: 'text.secondary',
138
+ width: '2em',
139
+ minWidth: '2em',
140
+ textAlign: 'right',
141
+ marginRight: 1,
142
+ userSelect: 'none',
143
+ fontFamily: 'monospace',
144
+ paddingTop: '4px',
145
+ }}
146
+ >
147
+ {currentLinePosition.lineNumber}
148
+ </Typography>
149
+ <Box sx={{ width: '28px' }} /> {/* Space for copy button */}
150
+ <Box sx={{ flex: 1, height: '1.5em' }} />
151
+ </Box>
152
+ )
153
+ }
154
+
155
+ const lineContent = line.split(/(\s+)/)
156
+ return (
157
+ <Box key={`line-${lineIndex}`} sx={{ display: 'flex', alignItems: 'flex-start' }}>
158
+ <Typography
159
+ component="span"
160
+ sx={{
161
+ color: 'text.secondary',
162
+ width: '2em',
163
+ minWidth: '2em',
164
+ textAlign: 'right',
165
+ marginRight: 1,
166
+ userSelect: 'none',
167
+ fontFamily: 'monospace',
168
+ paddingTop: '4px',
169
+ }}
170
+ >
171
+ {lineIndex}
172
+ </Typography>
173
+ <IconButton
174
+ size="small"
175
+ onClick={() => handleCopyLine(line)}
176
+ sx={{
177
+ padding: '2px',
178
+ marginRight: 1,
179
+ height: '24px',
180
+ width: '24px'
181
+ }}
182
+ >
183
+ <ContentCopyIcon sx={{ fontSize: '1rem' }} />
184
+ </IconButton>
185
+ <Box sx={{ flex: 1 }}>
186
+ {lineContent.map((word, wordIndex) => {
187
+ if (word === '') return null
188
+ if (/^\s+$/.test(word)) {
189
+ return <span key={`space-${lineIndex}-${wordIndex}`}> </span>
190
+ }
191
+
192
+ const position = globalWordIndex++
193
+ const anchor = anchors.find(a => {
194
+ const refPos = a.reference_positions[currentSource!]
195
+ if (refPos === undefined) return false
196
+ return position >= refPos && position < refPos + a.length
197
+ })
198
+
199
+ // Create a mock TranscriptionWordPosition for highlighting
200
+ const wordPos: TranscriptionWordPosition = {
201
+ word: { text: word },
202
+ position,
203
+ type: anchor ? 'anchor' : 'other',
204
+ sequence: anchor,
205
+ isInRange: true
206
+ }
207
+
208
+ return (
209
+ <Word
210
+ key={`${word}-${lineIndex}-${wordIndex}`}
211
+ word={word}
212
+ shouldFlash={shouldWordFlash({ word, index: position })}
213
+ isCurrentlyPlaying={shouldHighlightWord(wordPos)}
214
+ isAnchor={Boolean(anchor)}
215
+ isCorrectedGap={false}
216
+ isUncorrectedGap={false}
217
+ onClick={() => handleWordClick(word, position, anchor, undefined)}
218
+ />
219
+ )
220
+ })}
221
+ </Box>
222
+ </Box>
223
+ )
224
+ })
225
+ }
226
+
227
+ return null
228
+ }
229
+
230
+ return (
231
+ <Typography
232
+ component="div"
233
+ sx={{
234
+ fontFamily: 'monospace',
235
+ whiteSpace: preserveSegments ? 'normal' : 'pre-wrap',
236
+ margin: 0,
237
+ lineHeight: 1.5
238
+ }}
239
+ >
240
+ {renderContent()}
241
+ </Typography>
242
+ )
243
+ }
@@ -0,0 +1,28 @@
1
+ import { Box, Button } from '@mui/material'
2
+
3
+ interface SourceSelectorProps {
4
+ currentSource: 'genius' | 'spotify'
5
+ onSourceChange: (source: 'genius' | 'spotify') => void
6
+ }
7
+
8
+ export function SourceSelector({ currentSource, onSourceChange }: SourceSelectorProps) {
9
+ return (
10
+ <Box>
11
+ <Button
12
+ size="small"
13
+ variant={currentSource === 'genius' ? 'contained' : 'outlined'}
14
+ onClick={() => onSourceChange('genius')}
15
+ sx={{ mr: 1 }}
16
+ >
17
+ Genius
18
+ </Button>
19
+ <Button
20
+ size="small"
21
+ variant={currentSource === 'spotify' ? 'contained' : 'outlined'}
22
+ onClick={() => onSourceChange('spotify')}
23
+ >
24
+ Spotify
25
+ </Button>
26
+ </Box>
27
+ )
28
+ }
@@ -0,0 +1,52 @@
1
+ import React from 'react'
2
+ import { COLORS } from '../constants'
3
+ import { HighlightedWord } from '../styles'
4
+ import { WordProps } from '../types'
5
+
6
+ export const Word = React.memo(function Word({
7
+ word,
8
+ shouldFlash,
9
+ isAnchor,
10
+ isCorrectedGap,
11
+ isUncorrectedGap,
12
+ isCurrentlyPlaying,
13
+ padding = '2px 4px',
14
+ onClick,
15
+ }: WordProps) {
16
+ if (/^\s+$/.test(word)) {
17
+ return word
18
+ }
19
+
20
+ const backgroundColor = isCurrentlyPlaying
21
+ ? COLORS.playing
22
+ : shouldFlash
23
+ ? COLORS.highlighted
24
+ : isAnchor
25
+ ? COLORS.anchor
26
+ : isCorrectedGap
27
+ ? COLORS.corrected
28
+ : isUncorrectedGap
29
+ ? COLORS.uncorrectedGap
30
+ : 'transparent'
31
+
32
+ return (
33
+ <HighlightedWord
34
+ shouldFlash={shouldFlash}
35
+ style={{
36
+ backgroundColor,
37
+ padding,
38
+ cursor: 'pointer',
39
+ borderRadius: '3px',
40
+ color: isCurrentlyPlaying ? '#ffffff' : 'inherit',
41
+ }}
42
+ sx={{
43
+ '&:hover': {
44
+ backgroundColor: '#e0e0e0'
45
+ }
46
+ }}
47
+ onClick={onClick}
48
+ >
49
+ {word}
50
+ </HighlightedWord>
51
+ )
52
+ })
@@ -5,6 +5,7 @@ export const COLORS = {
5
5
  corrected: '#e8f5e9', // Pale green
6
6
  uncorrectedGap: '#fff3e0', // Pale orange
7
7
  highlighted: '#ffeb3b', // or any color you prefer for highlighting
8
+ playing: '#1976d2', // Blue
8
9
  } as const
9
10
 
10
11
  export const flashAnimation = keyframes`
@@ -0,0 +1,137 @@
1
+ import { useCallback } from 'react'
2
+ import { AnchorSequence, GapSequence, InteractionMode } from '../../../types'
3
+ import { ModalContent } from '../../LyricsAnalyzer'
4
+ import { WordClickInfo } from '../types'
5
+
6
+ interface UseWordClickProps {
7
+ mode: InteractionMode
8
+ onElementClick: (content: ModalContent) => void
9
+ onWordClick?: (info: WordClickInfo) => void
10
+ isReference?: boolean
11
+ currentSource?: 'genius' | 'spotify'
12
+ }
13
+
14
+ export function useWordClick({
15
+ mode,
16
+ onElementClick,
17
+ onWordClick,
18
+ isReference,
19
+ currentSource
20
+ }: UseWordClickProps) {
21
+ const handleWordClick = useCallback((
22
+ word: string,
23
+ position: number,
24
+ anchor?: AnchorSequence,
25
+ gap?: GapSequence,
26
+ debugInfo?: any
27
+ ) => {
28
+ console.log(JSON.stringify({
29
+ debug: {
30
+ clickedWord: word,
31
+ position,
32
+ isReference,
33
+ currentSource,
34
+ wordInfo: debugInfo?.wordSplitInfo,
35
+ nearbyAnchors: debugInfo?.nearbyAnchors,
36
+ anchorInfo: anchor && {
37
+ transcriptionPos: anchor.transcription_position,
38
+ length: anchor.length,
39
+ words: anchor.words,
40
+ refPositions: anchor.reference_positions
41
+ },
42
+ gapInfo: gap && {
43
+ transcriptionPos: gap.transcription_position,
44
+ length: gap.length,
45
+ words: gap.words,
46
+ corrections: gap.corrections.map(c => ({
47
+ length: c.length,
48
+ refPositions: c.reference_positions
49
+ }))
50
+ },
51
+ belongsToAnchor: anchor && (
52
+ isReference
53
+ ? position >= (anchor.reference_positions[currentSource!] ?? -1) &&
54
+ position < ((anchor.reference_positions[currentSource!] ?? -1) + anchor.length)
55
+ : position >= anchor.transcription_position &&
56
+ position < (anchor.transcription_position + anchor.length)
57
+ ),
58
+ belongsToGap: gap && (
59
+ isReference
60
+ ? gap.corrections[0]?.reference_positions?.[currentSource!] !== undefined &&
61
+ position >= (gap.corrections[0].reference_positions![currentSource!]) &&
62
+ position < (gap.corrections[0].reference_positions![currentSource!] + gap.corrections[0].length)
63
+ : position >= gap.transcription_position &&
64
+ position < (gap.transcription_position + gap.length)
65
+ )
66
+ }
67
+ }, null, 2))
68
+
69
+ const belongsToAnchor = anchor && (
70
+ isReference
71
+ ? position >= (anchor.reference_positions[currentSource!] ?? -1) &&
72
+ position < ((anchor.reference_positions[currentSource!] ?? -1) + anchor.length)
73
+ : position >= anchor.transcription_position &&
74
+ position < (anchor.transcription_position + anchor.length)
75
+ )
76
+
77
+ const belongsToGap = gap && (
78
+ isReference
79
+ ? gap.corrections[0]?.reference_positions?.[currentSource!] !== undefined &&
80
+ position >= (gap.corrections[0].reference_positions![currentSource!]) &&
81
+ position < (gap.corrections[0].reference_positions![currentSource!] + gap.corrections[0].length)
82
+ : position >= gap.transcription_position &&
83
+ position < (gap.transcription_position + gap.length)
84
+ )
85
+
86
+ if (mode === 'highlight' || mode === 'edit') {
87
+ onWordClick?.({
88
+ wordIndex: position,
89
+ type: belongsToAnchor ? 'anchor' : belongsToGap ? 'gap' : 'other',
90
+ anchor: belongsToAnchor ? anchor : undefined,
91
+ gap: belongsToGap ? gap : undefined
92
+ })
93
+ } else if (mode === 'details') {
94
+ if (belongsToAnchor && anchor) {
95
+ onElementClick({
96
+ type: 'anchor',
97
+ data: {
98
+ ...anchor,
99
+ position,
100
+ word
101
+ }
102
+ })
103
+ } else if (belongsToGap && gap) {
104
+ onElementClick({
105
+ type: 'gap',
106
+ data: {
107
+ ...gap,
108
+ position,
109
+ word
110
+ }
111
+ })
112
+ } else if (!isReference) {
113
+ // Create synthetic gap for non-sequence words (transcription view only)
114
+ const syntheticGap: GapSequence = {
115
+ text: word,
116
+ words: [word],
117
+ transcription_position: position,
118
+ length: 1,
119
+ corrections: [],
120
+ preceding_anchor: null,
121
+ following_anchor: null,
122
+ reference_words: {}
123
+ }
124
+ onElementClick({
125
+ type: 'gap',
126
+ data: {
127
+ ...syntheticGap,
128
+ position: 0,
129
+ word
130
+ }
131
+ })
132
+ }
133
+ }
134
+ }, [mode, onWordClick, onElementClick, isReference, currentSource])
135
+
136
+ return { handleWordClick }
137
+ }
@@ -10,4 +10,4 @@ export const HighlightedWord = styled('span')<{ shouldFlash: boolean }>(
10
10
  animation: `${flashAnimation} 0.4s ease-in-out 3`,
11
11
  }),
12
12
  })
13
- )
13
+ )
@@ -0,0 +1,99 @@
1
+ import { AnchorSequence, GapSequence, HighlightInfo, InteractionMode, LyricsData, LyricsSegment } from '../../types'
2
+ import { ModalContent } from '../LyricsAnalyzer'
3
+
4
+ // Add FlashType definition directly in shared types
5
+ export type FlashType = 'anchor' | 'corrected' | 'uncorrected' | 'word' | null
6
+
7
+ // Common word click handling
8
+ export interface WordClickInfo {
9
+ wordIndex: number
10
+ type: 'anchor' | 'gap' | 'other'
11
+ anchor?: AnchorSequence
12
+ gap?: GapSequence
13
+ }
14
+
15
+ // Base props shared between components
16
+ export interface BaseViewProps {
17
+ onElementClick: (content: ModalContent) => void
18
+ onWordClick?: (info: WordClickInfo) => void
19
+ flashingType: FlashType
20
+ highlightInfo: HighlightInfo | null
21
+ mode: InteractionMode
22
+ }
23
+
24
+ // Base word position interface - remove the word property from here
25
+ export interface BaseWordPosition {
26
+ type: 'anchor' | 'gap' | 'other'
27
+ sequence?: AnchorSequence | GapSequence
28
+ }
29
+
30
+ // Transcription-specific word position with timing info
31
+ export interface TranscriptionWordPosition extends BaseWordPosition {
32
+ position: number
33
+ isInRange: boolean
34
+ word: {
35
+ text: string
36
+ start_time?: number
37
+ end_time?: number
38
+ }
39
+ }
40
+
41
+ // Reference-specific word position with simple string word
42
+ export interface ReferenceWordPosition extends BaseWordPosition {
43
+ index: number
44
+ isHighlighted: boolean
45
+ word: string // Simple string word for reference view
46
+ }
47
+
48
+ // Word component props
49
+ export interface WordProps {
50
+ word: string
51
+ shouldFlash: boolean
52
+ isAnchor?: boolean
53
+ isCorrectedGap?: boolean
54
+ isUncorrectedGap?: boolean
55
+ isCurrentlyPlaying?: boolean
56
+ padding?: string
57
+ onClick?: () => void
58
+ }
59
+
60
+ // Text segment props
61
+ export interface TextSegmentProps extends BaseViewProps {
62
+ wordPositions: TranscriptionWordPosition[] | ReferenceWordPosition[]
63
+ }
64
+
65
+ // View-specific props
66
+ export interface TranscriptionViewProps extends BaseViewProps {
67
+ data: LyricsData
68
+ onPlaySegment?: (startTime: number) => void
69
+ currentTime?: number
70
+ }
71
+
72
+ // Add LinePosition type here since it's used in multiple places
73
+ export interface LinePosition {
74
+ position: number
75
+ lineNumber: number
76
+ isEmpty?: boolean
77
+ }
78
+
79
+ // Reference-specific props
80
+ export interface ReferenceViewProps extends BaseViewProps {
81
+ referenceTexts: Record<string, string>
82
+ anchors: LyricsData['anchor_sequences']
83
+ gaps: LyricsData['gap_sequences']
84
+ currentSource: 'genius' | 'spotify'
85
+ onSourceChange: (source: 'genius' | 'spotify') => void
86
+ corrected_segments: LyricsSegment[]
87
+ }
88
+
89
+ // Update HighlightedTextProps to include linePositions
90
+ export interface HighlightedTextProps extends BaseViewProps {
91
+ text?: string
92
+ wordPositions?: TranscriptionWordPosition[]
93
+ anchors: AnchorSequence[]
94
+ gaps: GapSequence[]
95
+ isReference?: boolean
96
+ currentSource?: 'genius' | 'spotify'
97
+ preserveSegments?: boolean
98
+ linePositions?: LinePosition[]
99
+ }