fix(exoplayer): apply subtitle styling

This commit is contained in:
edde746
2026-02-06 05:46:07 +01:00
parent f3fbdad4dc
commit 3d255f9920
6 changed files with 162 additions and 18 deletions
+2
View File
@@ -87,4 +87,6 @@ dependencies {
// libass-android for ASS/SSA subtitle rendering
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
// ass-kt core library (needed for AssRender.setFontScale)
implementation("io.github.peerless2012:ass-kt:0.4.0-beta01")
}
@@ -43,8 +43,12 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import java.math.BigDecimal
import java.math.RoundingMode
@@ -76,6 +80,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var surfaceView: SurfaceView? = null
private var surfaceContainer: FrameLayout? = null
private var subtitleView: SubtitleView? = null
private var assHandler: AssHandler? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var exoPlayer: ExoPlayer? = null
private var trackSelector: DefaultTrackSelector? = null
@@ -303,26 +308,53 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val dataSourceFactory = DefaultDataSource.Factory(activity)
val extractorsFactory = DefaultExtractorsFactory()
// Use buildWithAssSupport with OVERLAY_OPEN_GL mode for proper libass rendering
// Inline buildWithAssSupport to retain AssHandler reference for font scale control.
// OVERLAY_OPEN_GL uses TextureView which follows normal View hierarchy z-ordering,
// preventing hardware overlay promotion issues on devices like Nvidia Shield
Log.d(TAG, "SubtitleView childCount before buildWithAssSupport: ${subtitleView?.childCount}")
// preventing hardware overlay promotion issues on devices like Nvidia Shield.
Log.d(TAG, "SubtitleView childCount before ASS setup: ${subtitleView?.childCount}")
val renderType = AssRenderType.OVERLAY_OPEN_GL
val handler = AssHandler(renderType)
assHandler = handler
val assParserFactory = AssSubtitleParserFactory(handler)
// Wrap extractors to replace MatroskaExtractor with ASS-aware variant
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
extractorsFactory.createExtractors().map { extractor ->
if (extractor is androidx.media3.extractor.mkv.MatroskaExtractor) {
AssMatroskaExtractor(assParserFactory, handler)
} else {
extractor
}
}.toTypedArray()
}
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, wrappedExtractorsFactory)
.setSubtitleParserFactory(assParserFactory)
val wrappedRenderersFactory = AssRenderersFactory(handler, renderersFactory)
exoPlayer = ExoPlayer.Builder(activity)
.setTrackSelector(trackSelector!!)
.setAudioAttributes(audioAttributes, false) // We handle audio focus manually
.buildWithAssSupport(
context = activity,
renderType = AssRenderType.OVERLAY_OPEN_GL, // Use OVERLAY_OPEN_GL to fix z-ordering on Nvidia Shield
subtitleView = subtitleView,
dataSourceFactory = dataSourceFactory,
extractorsFactory = extractorsFactory,
renderersFactory = renderersFactory
)
.also { player ->
player.addListener(this)
surfaceView?.let { player.setVideoSurfaceView(it) }
}
Log.d(TAG, "SubtitleView childCount after buildWithAssSupport: ${subtitleView?.childCount}")
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(wrappedRenderersFactory)
.build()
// Add ASS overlay view to SubtitleView for OVERLAY modes
subtitleView?.let { sv ->
val assView = AssSubtitleView(sv.context, handler)
sv.addView(assView)
}
// Initialize handler (registers as Player.Listener, creates Handler)
handler.init(exoPlayer!!)
exoPlayer!!.addListener(this)
surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) }
Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}")
configureSubtitleOverlaySurface()
// Debug: Log SubtitleView child hierarchy
@@ -811,6 +843,53 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
fun setSubtitleStyle(
fontSize: Float,
textColor: String,
borderSize: Float,
borderColor: String,
bgColor: String,
bgOpacity: Int
) {
activity.runOnUiThread {
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
val fgColor = Color.parseColor(textColor)
val bgAlpha = (bgOpacity * 255 / 100)
val bgColorInt = Color.parseColor(bgColor).let {
Color.argb(bgAlpha, Color.red(it), Color.green(it), Color.blue(it))
}
val edgeColor = Color.parseColor(borderColor)
val edgeType = if (borderSize > 0) CaptionStyleCompat.EDGE_TYPE_OUTLINE
else CaptionStyleCompat.EDGE_TYPE_NONE
val style = CaptionStyleCompat(
fgColor,
bgColorInt,
Color.TRANSPARENT,
edgeType,
edgeColor,
null
)
subtitleView?.setStyle(style)
// Font size: MPV sub-font-size is scaled pixels at 720p height
// Convert to fractional size (0.0-1.0 relative to view height)
val fraction = fontSize / 720f
subtitleView?.setFractionalTextSize(fraction)
// 2. ASS subtitles: font scale via libass
// MPV default sub-font-size is 38
val defaultSize = 38f
val scale = fontSize / defaultSize
try {
assHandler?.render?.setFontScale(scale)
} catch (e: Exception) {
Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
}
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, assScale=$scale")
}
}
fun onPipModeChanged(isInPipMode: Boolean) {
activity.runOnUiThread {
// Force recalculation of surface size based on new container dimensions
@@ -1118,6 +1197,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
exoPlayer?.release()
exoPlayer = null
trackSelector = null
assHandler = null
surfaceView?.holder?.removeCallback(surfaceCallback)
overlayLayoutListener?.let { listener ->
@@ -118,6 +118,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
)
"getStats" -> handleGetStats(result)
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
else -> result.notImplemented()
}
}
@@ -411,6 +412,24 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
result.success(null)
}
private fun handleSetSubtitleStyle(call: MethodCall, result: MethodChannel.Result) {
val fontSize = call.argument<Number>("fontSize")?.toFloat() ?: 55f
val textColor = call.argument<String>("textColor") ?: "#FFFFFF"
val borderSize = call.argument<Number>("borderSize")?.toFloat() ?: 3f
val borderColor = call.argument<String>("borderColor") ?: "#000000"
val bgColor = call.argument<String>("bgColor") ?: "#000000"
val bgOpacity = call.argument<Number>("bgOpacity")?.toInt() ?: 0
if (usingMpvFallback) {
// MPV fallback handles styling via setProperty, no-op here
result.success(null)
return
}
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity)
result.success(null)
}
private fun handleGetStats(result: MethodChannel.Result) {
activity?.runOnUiThread {
val stats = if (usingMpvFallback) {
+28
View File
@@ -261,6 +261,34 @@ class PlayerAndroid extends PlayerBase {
}
}
// ============================================
// Subtitle Styling (ExoPlayer Native)
// ============================================
/// Apply subtitle styling to the native ExoPlayer layer.
///
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
/// For ASS subtitles, applies font scale via libass setFontScale().
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
}) async {
checkDisposed();
if (!initialized) return;
await methodChannel.invokeMethod('setSubtitleStyle', {
'fontSize': fontSize,
'textColor': textColor,
'borderSize': borderSize,
'borderColor': borderColor,
'bgColor': bgColor,
'bgOpacity': bgOpacity,
});
}
// ============================================
// Frame Rate Matching
// ============================================
+15
View File
@@ -11,6 +11,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../mpv/mpv.dart';
import '../mpv/player/player_android.dart';
import '../../services/plex_client.dart';
import '../services/plex_api_cache.dart';
@@ -833,6 +834,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
play: !hasExternalSubs,
);
// Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale)
// Must be called after open() since that's when ExoPlayer initializes
if (player is PlayerAndroid) {
final settingsService = await SettingsService.getInstance();
await (player as PlayerAndroid).setSubtitleStyle(
fontSize: settingsService.getSubtitleFontSize().toDouble(),
textColor: settingsService.getSubtitleTextColor(),
borderSize: settingsService.getSubtitleBorderSize().toDouble(),
borderColor: settingsService.getSubtitleBorderColor(),
bgColor: settingsService.getSubtitleBackgroundColor(),
bgOpacity: settingsService.getSubtitleBackgroundOpacity(),
);
}
// Attach player to Watch Together session for sync (if in session)
if (mounted && !widget.isOffline) {
_attachToWatchTogetherSession();
+1 -1
View File
@@ -296,7 +296,7 @@ class SettingsService extends BaseSharedPreferencesService {
}
int getSubtitleFontSize() {
return prefs.getInt(_keySubtitleFontSize) ?? 55;
return prefs.getInt(_keySubtitleFontSize) ?? 38;
}
// Text Color (hex format #RRGGBB, default white)