Skip to content

Commit a257376

Browse files
committed
feat(subtitles): Support ASS subtitle style parsing and rendering
1 parent a062289 commit a257376

File tree

4 files changed

+241
-30
lines changed

4 files changed

+241
-30
lines changed

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/ui/component/player/SubtitleControlFlyout.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ fun SubtitleFlyoutContent(
381381
AnimatedScrollbarLazyColumn(
382382
listState = lazyListState,
383383
modifier = Modifier.height(300.dp),
384-
scrollbarWidth = 2.dp,
384+
scrollbarWidth = 6.dp,
385385
scrollbarOffsetX = 3.dp
386386
) {
387387
// Off option

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/ui/screen/PlayerScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ fun PlayerOverlay(
450450
externalSubtitleUtil?.initialize()
451451
}
452452

453-
var subtitleText by remember { mutableStateOf<String?>(null) }
453+
var subtitleText by remember { mutableStateOf<androidx.compose.ui.text.AnnotatedString?>(null) }
454454
LaunchedEffect(hlsSubtitleUtil, externalSubtitleUtil, mediaPlayer) {
455455
if (hlsSubtitleUtil != null) {
456456
// Loop 1: Fetch loop (runs on IO, less frequent)
@@ -947,7 +947,7 @@ fun PlayerOverlay(
947947
isCursorVisible = true
948948
})
949949

950-
if (!subtitleText.isNullOrBlank()) {
950+
if (subtitleText != null && subtitleText!!.isNotEmpty()) {
951951
Box(
952952
modifier = Modifier
953953
.fillMaxSize()

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/utils/ExternalSubtitleUtil.kt

Lines changed: 227 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package com.jankinwu.fntv.client.utils
22

3+
import androidx.compose.ui.graphics.Color
4+
import androidx.compose.ui.text.AnnotatedString
5+
import androidx.compose.ui.text.SpanStyle
6+
import androidx.compose.ui.text.buildAnnotatedString
7+
import androidx.compose.ui.text.font.FontStyle
8+
import androidx.compose.ui.text.font.FontWeight
9+
import androidx.compose.ui.text.style.TextDecoration
10+
import androidx.compose.ui.text.withStyle
311
import co.touchlab.kermit.Logger
412
import com.jankinwu.fntv.client.data.model.response.SubtitleStream
513
import com.jankinwu.fntv.client.data.store.AccountDataCache
@@ -9,13 +17,45 @@ import io.ktor.client.statement.bodyAsText
917
import kotlinx.coroutines.Dispatchers
1018
import kotlinx.coroutines.withContext
1119

20+
data class AssStyle(
21+
val name: String,
22+
val fontName: String,
23+
val fontSize: Float,
24+
val primaryColor: Color,
25+
val secondaryColor: Color,
26+
val outlineColor: Color,
27+
val backColor: Color,
28+
val bold: Boolean,
29+
val italic: Boolean,
30+
val underline: Boolean,
31+
val strikeOut: Boolean
32+
) {
33+
companion object {
34+
val Default = AssStyle(
35+
name = "Default",
36+
fontName = "Arial",
37+
fontSize = 20f,
38+
primaryColor = Color.White,
39+
secondaryColor = Color.Red,
40+
outlineColor = Color.Black,
41+
backColor = Color.Black,
42+
bold = false,
43+
italic = false,
44+
underline = false,
45+
strikeOut = false
46+
)
47+
}
48+
}
49+
1250
class ExternalSubtitleUtil(
1351
private val client: HttpClient,
1452
private val subtitleStream: SubtitleStream
1553
) {
1654
private val logger = Logger.withTag("ExternalSubtitleUtil")
1755
private val cues = mutableListOf<SubtitleCue>()
56+
private val styles = mutableMapOf<String, AssStyle>()
1857
private var isInitialized = false
58+
private var playResY = 288 // Default ASS height if not specified
1959

2060
suspend fun initialize() {
2161
if (isInitialized) return
@@ -48,49 +88,52 @@ class ExternalSubtitleUtil(
4888
}
4989
}
5090

51-
fun getCurrentSubtitle(currentPositionMs: Long): String? {
91+
fun getCurrentSubtitle(currentPositionMs: Long): AnnotatedString? {
5292
val activeCues = cues.filter { cue ->
5393
currentPositionMs >= cue.startTime && currentPositionMs < cue.endTime
5494
}
5595
if (activeCues.isEmpty()) return null
56-
return activeCues.joinToString("\n") { it.text }
96+
97+
return buildAnnotatedString {
98+
activeCues.forEachIndexed { index, cue ->
99+
if (index > 0) append("\n")
100+
append(cue.text)
101+
}
102+
}
57103
}
58104

59105
private fun parseSrt(content: String): List<SubtitleCue> {
60106
val cues = mutableListOf<SubtitleCue>()
61-
// Normalize line endings
62107
val text = content.replace("\r\n", "\n").replace("\r", "\n")
63108
val blocks = text.split("\n\n")
64109

65110
for (block in blocks) {
66111
val lines = block.trim().lines()
67112
if (lines.size >= 3) {
68-
// Line 0: Index (can be ignored)
69-
// Line 1: Timecode
70113
val timeCodeLine = lines.find { it.contains("-->") } ?: continue
71114
val timeParts = timeCodeLine.split("-->")
72115
if (timeParts.size != 2) continue
73116

74117
val startTime = parseSrtTime(timeParts[0].trim())
75118
val endTime = parseSrtTime(timeParts[1].trim())
76119

77-
// Content starts after timecode line
78120
val timeCodeIndex = lines.indexOf(timeCodeLine)
79121
if (timeCodeIndex < 0 || timeCodeIndex >= lines.size - 1) continue
80122

81123
val textLines = lines.subList(timeCodeIndex + 1, lines.size)
124+
// Simple SRT HTML-like tag stripping for now, or basic support
82125
val subtitleText = textLines.joinToString("\n")
126+
.replace(Regex("<.*?>"), "") // Strip tags for SRT for now to keep it simple or implement basic parsing later
83127

84128
if (subtitleText.isNotBlank()) {
85-
cues.add(SubtitleCue(startTime, endTime, subtitleText))
129+
cues.add(SubtitleCue(startTime, endTime, AnnotatedString(subtitleText)))
86130
}
87131
}
88132
}
89133
return cues
90134
}
91135

92136
private fun parseSrtTime(timeStr: String): Long {
93-
// Format: HH:MM:SS,mmm or HH:MM:SS.mmm
94137
try {
95138
val parts = timeStr.replace(',', '.').split(':')
96139
if (parts.size == 3) {
@@ -112,46 +155,78 @@ class ExternalSubtitleUtil(
112155
val cues = mutableListOf<SubtitleCue>()
113156
val lines = content.lines()
114157
var formatIndexMap = mutableMapOf<String, Int>()
158+
var styleFormatIndexMap = mutableMapOf<String, Int>()
115159

116-
// Find [Events] section and Format line
117-
var inEventsSection = false
160+
var section = ""
118161

119162
for (line in lines) {
120163
val trimmed = line.trim()
121-
if (trimmed.equals("[Events]", ignoreCase = true)) {
122-
inEventsSection = true
164+
if (trimmed.startsWith("[")) {
165+
section = trimmed
123166
continue
124167
}
125168

126-
if (inEventsSection) {
169+
if (section.equals("[Script Info]", ignoreCase = true)) {
170+
if (trimmed.startsWith("PlayResY:", ignoreCase = true)) {
171+
playResY = trimmed.substringAfter(":").trim().toIntOrNull() ?: 288
172+
}
173+
} else if (section.equals("[V4+ Styles]", ignoreCase = true)) {
174+
if (trimmed.startsWith("Format:", ignoreCase = true)) {
175+
val formatLine = trimmed.substringAfter("Format:").trim()
176+
val parts = formatLine.split(",").map { it.trim().lowercase() }
177+
parts.forEachIndexed { index, name -> styleFormatIndexMap[name] = index }
178+
} else if (trimmed.startsWith("Style:", ignoreCase = true)) {
179+
val styleLine = trimmed.substringAfter("Style:").trim()
180+
val formatCount = styleFormatIndexMap.size
181+
if (formatCount > 0) {
182+
val parts = styleLine.split(",", limit = formatCount).map { it.trim() }
183+
if (parts.size >= formatCount) {
184+
val name = parts.getOrNull(styleFormatIndexMap["name"] ?: -1) ?: "Default"
185+
val fontName = parts.getOrNull(styleFormatIndexMap["fontname"] ?: -1) ?: "Arial"
186+
val fontSize = parts.getOrNull(styleFormatIndexMap["fontsize"] ?: -1)?.toFloatOrNull() ?: 20f
187+
val primaryColor = parseAssColor(parts.getOrNull(styleFormatIndexMap["primarycolour"] ?: -1) ?: "")
188+
val secondaryColor = parseAssColor(parts.getOrNull(styleFormatIndexMap["secondarycolour"] ?: -1) ?: "")
189+
val outlineColor = parseAssColor(parts.getOrNull(styleFormatIndexMap["outlinecolour"] ?: -1) ?: "")
190+
val backColor = parseAssColor(parts.getOrNull(styleFormatIndexMap["backcolour"] ?: -1) ?: "")
191+
val bold = (parts.getOrNull(styleFormatIndexMap["bold"] ?: -1) ?: "0") != "0"
192+
val italic = (parts.getOrNull(styleFormatIndexMap["italic"] ?: -1) ?: "0") != "0"
193+
val underline = (parts.getOrNull(styleFormatIndexMap["underline"] ?: -1) ?: "0") != "0"
194+
val strikeOut = (parts.getOrNull(styleFormatIndexMap["strikeout"] ?: -1) ?: "0") != "0"
195+
196+
val style = AssStyle(
197+
name, fontName, fontSize, primaryColor, secondaryColor, outlineColor, backColor,
198+
bold, italic, underline, strikeOut
199+
)
200+
styles[name] = style
201+
}
202+
}
203+
}
204+
} else if (section.equals("[Events]", ignoreCase = true)) {
127205
if (trimmed.startsWith("Format:", ignoreCase = true)) {
128206
val formatLine = trimmed.substringAfter("Format:").trim()
129207
val parts = formatLine.split(",").map { it.trim().lowercase() }
130208
parts.forEachIndexed { index, name -> formatIndexMap[name] = index }
131209
} else if (trimmed.startsWith("Dialogue:", ignoreCase = true)) {
132210
val dialogueLine = trimmed.substringAfter("Dialogue:").trim()
133-
// ASS CSV is tricky because the last field (Text) can contain commas.
134-
// We need to limit the split.
135211
val formatCount = formatIndexMap.size
136212
if (formatCount > 0) {
137213
val parts = dialogueLine.split(",", limit = formatCount).map { it.trim() }
138214
if (parts.size == formatCount) {
139215
val startIndex = formatIndexMap["start"] ?: -1
140216
val endIndex = formatIndexMap["end"] ?: -1
141217
val textIndex = formatIndexMap["text"] ?: -1
218+
val styleIndex = formatIndexMap["style"] ?: -1
142219

143220
if (startIndex != -1 && endIndex != -1 && textIndex != -1) {
144221
val startTime = parseAssTime(parts[startIndex])
145222
val endTime = parseAssTime(parts[endIndex])
146-
var text = parts[textIndex]
223+
val textRaw = parts[textIndex]
224+
val styleName = if (styleIndex != -1) parts[styleIndex] else "Default"
147225

148-
// Clean ASS tags (e.g., {\pos(100,100)})
149-
text = text.replace(Regex("\\{.*?\\}"), "")
150-
text = text.replace("\\N", "\n", ignoreCase = true)
151-
text = text.replace("\\n", "\n", ignoreCase = true)
226+
val annotatedString = parseAssText(textRaw, styleName)
152227

153-
if (text.isNotBlank()) {
154-
cues.add(SubtitleCue(startTime, endTime, text))
228+
if (annotatedString.isNotEmpty()) {
229+
cues.add(SubtitleCue(startTime, endTime, annotatedString))
155230
}
156231
}
157232
}
@@ -162,6 +237,135 @@ class ExternalSubtitleUtil(
162237
return cues
163238
}
164239

240+
private fun parseAssText(textRaw: String, styleName: String): AnnotatedString {
241+
val baseStyle = styles[styleName] ?: styles["Default"] ?: AssStyle.Default
242+
243+
return buildAnnotatedString {
244+
// Replace \h with space, \n with space, \N with newline
245+
val cleanText = textRaw
246+
.replace("\\h", " ")
247+
.replace("\\n", " ")
248+
.replace("\\N", "\n")
249+
250+
val tagRegex = Regex("\\{.*?\\}")
251+
var currentIndex = 0
252+
253+
// State variables
254+
var bold = baseStyle.bold
255+
var italic = baseStyle.italic
256+
var underline = baseStyle.underline
257+
var strikeOut = baseStyle.strikeOut
258+
var color = baseStyle.primaryColor
259+
260+
tagRegex.findAll(cleanText).forEach { match ->
261+
// Append text before tag
262+
if (match.range.first > currentIndex) {
263+
val segment = cleanText.substring(currentIndex, match.range.first)
264+
withStyle(
265+
SpanStyle(
266+
color = color,
267+
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
268+
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
269+
textDecoration = combineTextDecoration(underline, strikeOut)
270+
)
271+
) {
272+
append(segment)
273+
}
274+
}
275+
276+
// Parse tag
277+
val tagContent = match.value.removePrefix("{").removeSuffix("}")
278+
val tags = tagContent.split("\\")
279+
for (tag in tags) {
280+
if (tag.isEmpty()) continue
281+
282+
if (tag.startsWith("b")) {
283+
val param = tag.removePrefix("b")
284+
if (param.isEmpty() || param == "1") bold = true
285+
else if (param == "0") bold = false
286+
else {
287+
// \b<weight>
288+
bold = (param.toIntOrNull() ?: 0) > 400
289+
}
290+
} else if (tag.startsWith("i")) {
291+
italic = tag.removePrefix("i") != "0"
292+
} else if (tag.startsWith("u")) {
293+
underline = tag.removePrefix("u") != "0"
294+
} else if (tag.startsWith("s")) {
295+
strikeOut = tag.removePrefix("s") != "0"
296+
} else if (tag.startsWith("c") || tag.startsWith("1c")) {
297+
val colorStr = tag.substringAfter("&H").substringBefore("&")
298+
if (colorStr.isNotEmpty()) {
299+
color = parseAssColorString(colorStr)
300+
} else if (tag == "c" || tag == "1c") {
301+
color = baseStyle.primaryColor // Reset to style default
302+
}
303+
} else if (tag.startsWith("r")) {
304+
// Reset style
305+
val newStyleName = tag.removePrefix("r")
306+
val newStyle = if (newStyleName.isEmpty()) baseStyle else (styles[newStyleName] ?: baseStyle)
307+
bold = newStyle.bold
308+
italic = newStyle.italic
309+
underline = newStyle.underline
310+
strikeOut = newStyle.strikeOut
311+
color = newStyle.primaryColor
312+
}
313+
}
314+
315+
currentIndex = match.range.last + 1
316+
}
317+
318+
// Append remaining text
319+
if (currentIndex < cleanText.length) {
320+
withStyle(
321+
SpanStyle(
322+
color = color,
323+
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
324+
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
325+
textDecoration = combineTextDecoration(underline, strikeOut)
326+
)
327+
) {
328+
append(cleanText.substring(currentIndex))
329+
}
330+
}
331+
}
332+
}
333+
334+
private fun combineTextDecoration(underline: Boolean, strikeOut: Boolean): TextDecoration {
335+
val decorations = mutableListOf<TextDecoration>()
336+
if (underline) decorations.add(TextDecoration.Underline)
337+
if (strikeOut) decorations.add(TextDecoration.LineThrough)
338+
return if (decorations.isEmpty()) TextDecoration.None else TextDecoration.combine(decorations)
339+
}
340+
341+
private fun parseAssColor(colorStr: String): Color {
342+
// Format: &HBBGGRR& or &HAABBGGRR& (Alpha is 00=opaque, FF=transparent)
343+
val clean = colorStr.replace("&H", "").replace("&", "")
344+
return parseAssColorString(clean)
345+
}
346+
347+
private fun parseAssColorString(clean: String): Color {
348+
try {
349+
val longVal = clean.toLong(16)
350+
return if (clean.length > 6) {
351+
// AABBGGRR
352+
val alpha = ((longVal shr 24) and 0xFF).toInt()
353+
val blue = ((longVal shr 16) and 0xFF).toInt()
354+
val green = ((longVal shr 8) and 0xFF).toInt()
355+
val red = (longVal and 0xFF).toInt()
356+
Color(red, green, blue, 255 - alpha)
357+
} else {
358+
// BBGGRR
359+
val blue = ((longVal shr 16) and 0xFF).toInt()
360+
val green = ((longVal shr 8) and 0xFF).toInt()
361+
val red = (longVal and 0xFF).toInt()
362+
Color(red, green, blue, 255)
363+
}
364+
} catch (e: Exception) {
365+
return Color.White
366+
}
367+
}
368+
165369
private fun parseAssTime(timeStr: String): Long {
166370
// Format: H:MM:SS.cc (centiseconds)
167371
try {
@@ -207,7 +411,7 @@ class ExternalSubtitleUtil(
207411
val text = textBuilder.toString().trim()
208412

209413
if (text.isNotEmpty()) {
210-
cues.add(SubtitleCue(startMs, endMs, text))
414+
cues.add(SubtitleCue(startMs, endMs, AnnotatedString(text)))
211415
}
212416
} catch (e: Exception) {
213417
// Ignore malformed

0 commit comments

Comments
 (0)