@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
66import androidx.compose.foundation.layout.padding
77import androidx.compose.material3.Text
88import androidx.compose.runtime.Composable
9+ import androidx.compose.runtime.remember
910import androidx.compose.ui.Alignment
1011import androidx.compose.ui.Modifier
1112import androidx.compose.ui.draw.clip
@@ -15,9 +16,13 @@ import androidx.compose.ui.graphics.Outline
1516import androidx.compose.ui.graphics.Path
1617import androidx.compose.ui.graphics.Shadow
1718import 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
1822import androidx.compose.ui.graphics.graphicsLayer
1923import androidx.compose.ui.layout.layout
2024import androidx.compose.ui.platform.LocalDensity
25+ import androidx.compose.ui.text.AnnotatedString
2126import androidx.compose.ui.text.TextStyle
2227import androidx.compose.ui.text.font.FontWeight
2328import 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+ }
0 commit comments