11package 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
311import co.touchlab.kermit.Logger
412import com.jankinwu.fntv.client.data.model.response.SubtitleStream
513import com.jankinwu.fntv.client.data.store.AccountDataCache
@@ -9,13 +17,45 @@ import io.ktor.client.statement.bodyAsText
917import kotlinx.coroutines.Dispatchers
1018import 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+
1250class 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