fix(exoplayer): apply subtitle styling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user