diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4358760c..f6be7d12 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 296fb19f..70fa5098 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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 -> diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 00e89edf..eebd6a1b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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("fontSize")?.toFloat() ?: 55f + val textColor = call.argument("textColor") ?: "#FFFFFF" + val borderSize = call.argument("borderSize")?.toFloat() ?: 3f + val borderColor = call.argument("borderColor") ?: "#000000" + val bgColor = call.argument("bgColor") ?: "#000000" + val bgOpacity = call.argument("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) { diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index 3ed99c57..be63d810 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -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 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 // ============================================ diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e68e3094..833bff94 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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(); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 82ac07d0..97c3b6d3 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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)