Skip to content

Commit efa3818

Browse files
committed
feat: support lyrics translation
1 parent c1a82d0 commit efa3818

File tree

2 files changed

+147
-33
lines changed

2 files changed

+147
-33
lines changed

apps/mobile/modules/player/LyricsView.tsx

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { LyricLine } from '@flow/player'
22
import { findLyricLine, parseLyrics, useDisplayTrack, usePlaybackStore } from '@flow/player'
33
import { FlashList } from '@shopify/flash-list'
44
import { useCallback, useEffect, useRef, useState } from 'react'
5-
import { StyleSheet } from 'react-native'
5+
import { StyleSheet, View } from 'react-native'
66
import { Text, useTheme } from 'react-native-paper'
77

88
export default function LyricsView({ mode = 'mini' }: { mode?: 'mini' | 'full' }) {
@@ -38,18 +38,36 @@ export default function LyricsView({ mode = 'mini' }: { mode?: 'mini' | 'full' }
3838
const isMiniMode = mode === 'mini'
3939

4040
return (
41-
<Text
42-
style={[
43-
isMiniMode ? styles.miniLyricLine : styles.fullLyricLine,
44-
{ color: colors.onSurface },
45-
isActive && (isMiniMode ? styles.miniActiveLyricLine : styles.fullActiveLyricLine),
46-
isActive && { color: colors.primary },
47-
]}
48-
>
49-
{line.text}
50-
</Text>
41+
<View style={isMiniMode ? styles.miniLyricContainer : styles.fullLyricContainer}>
42+
{/* 原文歌词 */}
43+
{line.originalText && (
44+
<Text
45+
style={[
46+
isMiniMode ? styles.miniOriginalText : styles.fullOriginalText,
47+
{ color: colors.onSurface },
48+
isActive && (isMiniMode ? styles.miniActiveOriginalText : styles.fullActiveOriginalText),
49+
isActive && { color: colors.primary },
50+
]}
51+
>
52+
{line.originalText}
53+
</Text>
54+
)}
55+
56+
{line.translation && (
57+
<Text
58+
style={[
59+
isMiniMode ? styles.miniTranslationText : styles.fullTranslationText,
60+
{ color: colors.onSurfaceVariant },
61+
isActive && (isMiniMode ? styles.miniActiveTranslationText : styles.fullActiveTranslationText),
62+
isActive && { color: colors.primary },
63+
]}
64+
>
65+
{line.translation}
66+
</Text>
67+
)}
68+
</View>
5169
)
52-
}, [activeLyricLineIndex, colors.primary, colors.onSurface, mode])
70+
}, [activeLyricLineIndex, colors.primary, colors.onSurface, colors.onSurfaceVariant, mode])
5371

5472
const containerStyle = mode === 'mini' ? styles.miniContentContainer : styles.fullContentContainer
5573
const estimatedSize = mode === 'mini' ? 20 : 30
@@ -75,37 +93,66 @@ const styles = StyleSheet.create({
7593
paddingHorizontal: 24,
7694
paddingVertical: 16,
7795
},
78-
miniLyricLine: {
96+
miniLyricContainer: {
97+
marginVertical: 6,
98+
},
99+
miniOriginalText: {
79100
fontSize: 14,
80101
lineHeight: 18,
81-
marginVertical: 6,
82102
fontWeight: '400',
83-
opacity: 0.6,
84-
textAlign: 'left',
103+
opacity: 0.8,
85104
},
86-
miniActiveLyricLine: {
87-
fontSize: 16,
88-
lineHeight: 20,
105+
miniActiveOriginalText: {
106+
fontSize: 15,
107+
lineHeight: 19,
89108
fontWeight: '500',
90109
opacity: 1,
91110
},
111+
miniTranslationText: {
112+
fontSize: 12,
113+
lineHeight: 16,
114+
fontWeight: '400',
115+
opacity: 0.6,
116+
marginTop: 2,
117+
},
118+
miniActiveTranslationText: {
119+
fontSize: 13,
120+
lineHeight: 17,
121+
fontWeight: '500',
122+
opacity: 0.8,
123+
},
92124

93-
// full
125+
// full 模式容器
94126
fullContentContainer: {
95127
paddingHorizontal: 32,
96128
paddingVertical: 40,
97129
},
98-
fullLyricLine: {
130+
fullLyricContainer: {
131+
marginVertical: 12,
132+
},
133+
fullOriginalText: {
99134
fontSize: 18,
100135
lineHeight: 24,
101-
marginVertical: 16,
102136
fontWeight: '500',
103-
opacity: 0.7,
137+
opacity: 0.8,
104138
},
105-
fullActiveLyricLine: {
106-
fontSize: 22,
107-
lineHeight: 28,
139+
fullActiveOriginalText: {
140+
fontSize: 20,
141+
lineHeight: 26,
108142
fontWeight: '600',
109143
opacity: 1,
110144
},
145+
fullTranslationText: {
146+
fontSize: 14,
147+
lineHeight: 18,
148+
fontWeight: '400',
149+
opacity: 0.6,
150+
marginTop: 4,
151+
},
152+
fullActiveTranslationText: {
153+
fontSize: 16,
154+
lineHeight: 20,
155+
fontWeight: '500',
156+
opacity: 0.8,
157+
},
111158
})

packages/player/src/utils/parseLyrics.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,84 @@ import { parseLyricTime } from '@flow/core'
22

33
export interface LyricLine {
44
time: string
5-
text: string
5+
originalText: string
6+
translation?: string
67
}
78

89
export function parseLyrics(lyrics: string): LyricLine[] {
9-
return lyrics.split('\n').filter(line => line.trim() !== '').map((line) => {
10-
const [time, text] = line.split(']')
11-
if (time && text) {
12-
return { time: time.slice(1).trim(), text: text.trim() }
10+
if (!lyrics.trim()) {
11+
return []
12+
}
13+
14+
const lines = lyrics.split('\n').filter(line => line.trim() !== '')
15+
const timeRegex = /^\[(\d{2}:\d{2}\.\d{3})\]/
16+
17+
// find start of translation
18+
const translationStartIndex = lines.findIndex(line =>
19+
line.includes('[by:') || line.includes('[offset:') || line.includes('[ar:') || line.includes('[ti:'),
20+
)
21+
22+
let originalLines: string[]
23+
let translatedLines: string[] = []
24+
25+
if (translationStartIndex !== -1) {
26+
// found translation marker, separate original and translation
27+
originalLines = lines.slice(0, translationStartIndex)
28+
29+
// skip marker line, get translation content
30+
const afterMarker = lines.slice(translationStartIndex + 1)
31+
translatedLines = afterMarker.filter(line => timeRegex.test(line))
32+
}
33+
else {
34+
// no translation marker, only original lyrics
35+
originalLines = lines.filter(line => timeRegex.test(line))
36+
}
37+
38+
// parse original lyrics
39+
const originalMap = new Map<string, string>()
40+
for (const line of originalLines) {
41+
const match = line.match(timeRegex)
42+
if (match && match[1]) {
43+
const time = match[1]
44+
const text = line.slice(match[0].length).trim()
45+
if (text) {
46+
originalMap.set(time, text)
47+
}
1348
}
14-
return { time: '', text: '' }
15-
}).filter(line => line.time !== '')
49+
}
50+
51+
// parse translation lyrics
52+
const translationMap = new Map<string, string>()
53+
for (const line of translatedLines) {
54+
const match = line.match(timeRegex)
55+
if (match && match[1]) {
56+
const time = match[1]
57+
const text = line.slice(match[0].length).trim()
58+
if (text) {
59+
translationMap.set(time, text)
60+
}
61+
}
62+
}
63+
64+
// merge original and translation, sort by time
65+
const result: LyricLine[] = []
66+
const allTimes = Array.from(new Set([...originalMap.keys(), ...translationMap.keys()]))
67+
.sort((a, b) => parseLyricTime(a) - parseLyricTime(b))
68+
69+
for (const time of allTimes) {
70+
const originalText = originalMap.get(time)
71+
const translation = translationMap.get(time)
72+
73+
if (originalText || translation) {
74+
result.push({
75+
time,
76+
originalText: originalText || '',
77+
translation: translation || undefined,
78+
})
79+
}
80+
}
81+
82+
return result
1683
}
1784

1885
/**

0 commit comments

Comments
 (0)