Skip to content

Commit 882d8f5

Browse files
committed
feat(subtitles): Optimize the rendering effect of ASS subtitle style
1 parent 6bffaf3 commit 882d8f5

File tree

3 files changed

+145
-53
lines changed

3 files changed

+145
-53
lines changed

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

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
66
import androidx.compose.foundation.layout.padding
77
import androidx.compose.material3.Text
88
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.remember
910
import androidx.compose.ui.Alignment
1011
import androidx.compose.ui.Modifier
1112
import androidx.compose.ui.draw.clip
@@ -15,9 +16,13 @@ import androidx.compose.ui.graphics.Outline
1516
import androidx.compose.ui.graphics.Path
1617
import androidx.compose.ui.graphics.Shadow
1718
import androidx.compose.ui.graphics.Shape
19+
import androidx.compose.ui.graphics.StrokeCap
20+
import androidx.compose.ui.graphics.StrokeJoin
21+
import androidx.compose.ui.graphics.drawscope.Stroke
1822
import androidx.compose.ui.graphics.graphicsLayer
1923
import androidx.compose.ui.layout.layout
2024
import androidx.compose.ui.platform.LocalDensity
25+
import androidx.compose.ui.text.AnnotatedString
2126
import androidx.compose.ui.text.TextStyle
2227
import androidx.compose.ui.text.font.FontWeight
2328
import androidx.compose.ui.text.style.TextAlign
@@ -88,12 +93,12 @@ fun SubtitleOverlay(
8893
// Absolute positioning with GPU acceleration
8994
val align = props.alignment
9095

91-
Text(
96+
AssStyledText(
9297
text = cue.text,
9398
style = TextStyle(
9499
fontSize = fontSizeSp,
95-
// shadow = Shadow(Color.Black, Offset(1f, 1f), 2f)
96100
),
101+
strokeScale = scaleY,
97102
modifier = Modifier
98103
.layout { measurable, constraints ->
99104
val placeable = measurable.measure(constraints)
@@ -269,7 +274,13 @@ fun SubtitleOverlay(
269274
Column(
270275
horizontalAlignment = horizontalAlignment
271276
) {
272-
cues.forEach { cue ->
277+
// For bottom-aligned subtitles (1, 2, 3), standard ASS behavior is to stack UPWARDS.
278+
// This means later cues (in file/list order) are placed ABOVE earlier cues.
279+
// Since Column stacks DOWNWARDS, we need to reverse the list for bottom alignment
280+
// to simulate this "stack up" behavior (last item becomes top).
281+
val orderedCues = if (align in 1..3) cues.reversed() else cues
282+
283+
orderedCues.forEach { cue ->
273284
val props = cue.assProps
274285
if (props != null) {
275286
// ASS Flow Rendering
@@ -278,12 +289,13 @@ fun SubtitleOverlay(
278289
val fontSizeDp = props.fontSize * scaleY * settings.fontScale
279290
val fontSizeSp = with(LocalDensity.current) { fontSizeDp.dp.toSp() }
280291

281-
Text(
292+
AssStyledText(
282293
text = cue.text,
283294
style = TextStyle(
284295
fontSize = fontSizeSp
285296
),
286-
textAlign = TextAlign.Center // Default to center for text content
297+
textAlign = TextAlign.Center,
298+
strokeScale = scaleY
287299
)
288300
} else {
289301
// Legacy/Simple Rendering for non-ASS
@@ -309,3 +321,104 @@ fun SubtitleOverlay(
309321
}
310322
}
311323
}
324+
325+
@Composable
326+
fun AssStyledText(
327+
text: AnnotatedString,
328+
modifier: Modifier = Modifier,
329+
style: TextStyle = TextStyle.Default,
330+
textAlign: TextAlign? = null,
331+
strokeScale: Float = 1f
332+
) {
333+
Box(modifier = modifier) {
334+
// Outline Layer
335+
val outlineText = remember(text, strokeScale) {
336+
val builder = AnnotatedString.Builder(text.text)
337+
// Copy paragraph styles
338+
text.paragraphStyles.forEach { builder.addStyle(it.item, it.start, it.end) }
339+
340+
// Iterate spans to find outline/shadow annotations
341+
text.spanStyles.forEach { range ->
342+
// Check for annotations in this range
343+
val widthAnnos = text.getStringAnnotations("AssOutlineWidth", range.start, range.end)
344+
val colorAnnos = text.getStringAnnotations("AssOutlineColor", range.start, range.end)
345+
val shadowDistAnnos = text.getStringAnnotations("AssShadowDistance", range.start, range.end)
346+
val shadowColorAnnos = text.getStringAnnotations("AssShadowColor", range.start, range.end)
347+
val blurAnnos = text.getStringAnnotations("AssBlurRadius", range.start, range.end)
348+
349+
val widthVal = widthAnnos.firstOrNull()?.item?.toFloatOrNull() ?: 0f
350+
val shadowDistVal = shadowDistAnnos.firstOrNull()?.item?.toFloatOrNull() ?: 0f
351+
val shadowColorVal = shadowColorAnnos.firstOrNull()?.item?.toULongOrNull()
352+
val blurVal = blurAnnos.firstOrNull()?.item?.toFloatOrNull() ?: 0f
353+
354+
val shadowColor = if (shadowColorVal != null) Color(shadowColorVal) else Color.Black
355+
356+
// Construct Shadow if distance > 0
357+
val scaledShadow = if (shadowDistVal > 0f) {
358+
val scaledDist = shadowDistVal * strokeScale * 2f
359+
val scaledBlur = blurVal * strokeScale * 2f
360+
Shadow(
361+
color = shadowColor,
362+
offset = Offset(scaledDist, scaledDist),
363+
blurRadius = scaledBlur
364+
)
365+
} else null
366+
367+
if (widthVal > 0f) {
368+
val colorVal = colorAnnos.firstOrNull()?.item?.toULongOrNull()
369+
val outlineColor = if (colorVal != null) Color(colorVal) else Color.Black
370+
371+
// Create outline style
372+
val outlineStyle = range.item.copy(
373+
color = outlineColor,
374+
shadow = scaledShadow, // Apply shadow to the outline layer
375+
drawStyle = Stroke(
376+
width = widthVal * strokeScale * 4f,
377+
join = StrokeJoin.Round,
378+
cap = StrokeCap.Round
379+
)
380+
)
381+
builder.addStyle(outlineStyle, range.start, range.end)
382+
} else {
383+
// No outline, but maybe shadow?
384+
// If no outline, we still want shadow.
385+
// But this layer is the "Outline Layer" which renders below "Fill Layer".
386+
// If we set color=Transparent, the shadow might still render?
387+
// In Compose, transparent text casting shadow works if shadow color is opaque.
388+
389+
if (scaledShadow != null) {
390+
// Render as Fill with Shadow, but Transparent Color
391+
// Wait, if color is Transparent, Compose might not render shadow properly?
392+
// Actually, Shadow is drawn based on alpha mask.
393+
// Let's assume we can just use Fill with Shadow here for the "Shadow Layer" purpose.
394+
builder.addStyle(
395+
range.item.copy(
396+
color = Color.Transparent, // Transparent fill
397+
shadow = scaledShadow
398+
),
399+
range.start, range.end
400+
)
401+
} else {
402+
// Completely invisible
403+
builder.addStyle(range.item.copy(color = Color.Transparent), range.start, range.end)
404+
}
405+
}
406+
}
407+
builder.toAnnotatedString()
408+
}
409+
410+
// Render Outline
411+
Text(
412+
text = outlineText,
413+
style = style.copy(color = Color.Transparent),
414+
textAlign = textAlign
415+
)
416+
417+
// Render Fill (Foreground)
418+
Text(
419+
text = text,
420+
style = style,
421+
textAlign = textAlign
422+
)
423+
}
424+
}

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

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class ExternalSubtitleUtil(
9393
fun getCurrentSubtitle(currentPositionMs: Long): List<SubtitleCue> {
9494
return cues.filter { cue ->
9595
currentPositionMs >= cue.startTime && currentPositionMs < cue.endTime
96-
}
96+
}.distinctBy { it.text to it.assProps }
9797
}
9898

9999
private fun parseSrt(content: String): List<SubtitleCue> {
@@ -154,8 +154,8 @@ class ExternalSubtitleUtil(
154154
private fun parseAss(content: String): List<SubtitleCue> {
155155
val cues = mutableListOf<SubtitleCue>()
156156
val lines = content.lines()
157-
var formatIndexMap = mutableMapOf<String, Int>()
158-
var styleFormatIndexMap = mutableMapOf<String, Int>()
157+
val formatIndexMap = mutableMapOf<String, Int>()
158+
val styleFormatIndexMap = mutableMapOf<String, Int>()
159159

160160
var section = ""
161161

@@ -355,41 +355,28 @@ class ExternalSubtitleUtil(
355355
if (match.range.first > currentIndex) {
356356
val segment = cleanText.substring(currentIndex, match.range.first)
357357

358-
// Create shadow if distance > 0
359-
// Compose SpanStyle only supports one shadow. We prioritize shadow (\shad) over outline (\bord) if both exist,
360-
// or maybe we can try to use it for Outline if Shadow is 0?
361-
// Actually \4c is Shadow Colour. \3c is Outline Colour.
362-
// If we have Shadow Distance, we use Shadow Color.
363-
364-
val shadowEffect = if (shadowDistance > 0f) {
365-
// Shadow distance in ASS is pixels.
366-
// Compose Shadow offset is in pixels.
367-
androidx.compose.ui.graphics.Shadow(
368-
color = shadowColor,
369-
offset = androidx.compose.ui.geometry.Offset(shadowDistance, shadowDistance),
370-
blurRadius = blurRadius
371-
)
372-
} else if (outlineWidth > 0f) {
373-
// Simulate Outline with Shadow? No, that looks like drop shadow.
374-
// But better than nothing if user wants outline color.
375-
// However, typically Outline is a stroke around text.
376-
// Compose doesn't support stroke in SpanStyle.
377-
null
378-
} else {
379-
null
380-
}
358+
pushStringAnnotation("AssOutlineWidth", outlineWidth.toString())
359+
pushStringAnnotation("AssOutlineColor", outlineColor.value.toString())
360+
pushStringAnnotation("AssShadowDistance", shadowDistance.toString())
361+
pushStringAnnotation("AssShadowColor", shadowColor.value.toString())
362+
pushStringAnnotation("AssBlurRadius", blurRadius.toString())
381363

382364
withStyle(
383365
SpanStyle(
384366
color = color,
385367
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
386368
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
387369
textDecoration = combineTextDecoration(underline, strikeOut),
388-
shadow = shadowEffect
370+
shadow = null // Shadow handled by annotations in AssStyledText
389371
)
390372
) {
391373
append(segment)
392374
}
375+
pop()
376+
pop()
377+
pop()
378+
pop()
379+
pop()
393380
}
394381

395382
// Parse tag
@@ -422,14 +409,7 @@ class ExternalSubtitleUtil(
422409
} else if (tag.startsWith("u")) {
423410
underline = tag.removePrefix("u") != "0"
424411
} else if (tag.startsWith("s")) {
425-
// Ensure it's not 'shad' (handled above) or 'sc' etc.
426-
// 'shad' starts with 's'. We handled 'shad' before.
427-
// But wait, we iterate tags. If tag is 'shad1', it enters here?
428-
// No, because we check 'shad' first in this if-else chain.
429-
// BUT, if we put 'shad' check AFTER 's' check, it would be a bug.
430-
// So order matters.
431-
// 'shad' check must be BEFORE 's' check.
432-
412+
433413
// Also check for 'sc' (Scale constraint?) - ASS tags: \scx, \scy.
434414
if (!tag.startsWith("sc")) {
435415
strikeOut = tag.removePrefix("s") != "0"
@@ -488,27 +468,28 @@ class ExternalSubtitleUtil(
488468

489469
// Append remaining text
490470
if (currentIndex < cleanText.length) {
491-
val shadowEffect = if (shadowDistance > 0f) {
492-
androidx.compose.ui.graphics.Shadow(
493-
color = shadowColor,
494-
offset = androidx.compose.ui.geometry.Offset(shadowDistance, shadowDistance),
495-
blurRadius = blurRadius
496-
)
497-
} else {
498-
null
499-
}
471+
pushStringAnnotation("AssOutlineWidth", outlineWidth.toString())
472+
pushStringAnnotation("AssOutlineColor", outlineColor.value.toString())
473+
pushStringAnnotation("AssShadowDistance", shadowDistance.toString())
474+
pushStringAnnotation("AssShadowColor", shadowColor.value.toString())
475+
pushStringAnnotation("AssBlurRadius", blurRadius.toString())
500476

501477
withStyle(
502478
SpanStyle(
503479
color = color,
504480
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
505481
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
506482
textDecoration = combineTextDecoration(underline, strikeOut),
507-
shadow = shadowEffect
483+
shadow = null // Shadow handled by annotations in AssStyledText
508484
)
509485
) {
510486
append(cleanText.substring(currentIndex))
511487
}
488+
pop()
489+
pop()
490+
pop()
491+
pop()
492+
pop()
512493
}
513494
}
514495
}

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ data class AssProperties(
1818
val move: AssMove? = null,
1919
val fade: AssFade? = null,
2020
val rotationZ: Float? = null,
21-
val alpha: Float? = null, // 0.0 (opaque) to 1.0 (transparent) in ASS usually, but let's store as 0..1 opacity?
22-
// Wait, ASS alpha is &H00 (opaque) to &HFF (transparent). Let's store as opacity (1.0 = opaque, 0.0 = transparent) for Compose easier use?
23-
// Or stick to raw and convert later. Let's use opacity: 1f = visible, 0f = invisible.
21+
val alpha: Float? = null,
2422
val clip: AssClip? = null
2523
)
2624

0 commit comments

Comments
 (0)