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 a12e3cda..60a293f8 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 @@ -3272,6 +3272,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { fps: Float, videoDurationMs: Long, extraDelayMs: Long, + videoWidth: Int, + videoHeight: Int, onComplete: (switched: Boolean) -> Unit ) { val mgr = frameRateManager @@ -3279,7 +3281,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { onComplete(false) return } - mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, onComplete) + mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete) } fun clearVideoFrameRate() { 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 2ed13cfc..5ee6d200 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 @@ -500,22 +500,24 @@ class ExoPlayerPlugin : val fps = call.argument("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L + val videoWidth = call.argument("videoWidth")?.toInt() ?: 0 + val videoHeight = call.argument("videoHeight")?.toInt() ?: 0 - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight") val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } if (usingMpvFallback) { val core = mpvCore if (core == null) { result.success(false) } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) + core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) } } else { val core = playerCore if (core == null) { result.success(false) } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) + core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index c81a7f90..13895dd5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -886,6 +886,8 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { fps: Float, videoDurationMs: Long, extraDelayMs: Long, + videoWidth: Int, + videoHeight: Int, onComplete: (switched: Boolean) -> Unit ) { val mgr = frameRateManager @@ -893,7 +895,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { onComplete(false) return } - mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs) { switched -> + mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight) { switched -> player?.let { updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") { onComplete(switched) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 45af9574..c32e9028 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -343,14 +343,16 @@ class MpvPlayerPlugin : val fps = call.argument("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L + val videoWidth = call.argument("videoWidth")?.toInt() ?: 0 + val videoHeight = call.argument("videoHeight")?.toInt() ?: 0 - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight") val core = playerCore if (core == null) { result.success(false) return } - core.setVideoFrameRate(fps, duration, extraDelayMs) { switched -> + core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched -> result.success(switched) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt index e73c01dc..4b5911fd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt @@ -37,6 +37,8 @@ class FrameRateManager( ) private var currentVideoFps: Float = 0f + private var currentVideoWidth: Int = 0 + private var currentVideoHeight: Int = 0 private var displayListener: DisplayManager.DisplayListener? = null private var pendingSettleRunnable: Runnable? = null private var watchdogRunnable: Runnable? = null @@ -57,9 +59,13 @@ class FrameRateManager( fps: Float, videoDurationMs: Long, extraDelayMs: Long, + videoWidth: Int = 0, + videoHeight: Int = 0, onComplete: (switched: Boolean) -> Unit ) { currentVideoFps = fps + currentVideoWidth = videoWidth + currentVideoHeight = videoHeight if (fps <= 0f) { Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") onComplete(false) @@ -68,7 +74,7 @@ class FrameRateManager( log( "request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " + - "API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}" + "video=${videoWidth}x$videoHeight, API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}" ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -208,17 +214,42 @@ class FrameRateManager( private fun describeSupportedModes(modes: Array): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) } @RequiresApi(Build.VERSION_CODES.M) - private fun findBestModeMatch(fps: Float, currentMode: Display.Mode, supportedModes: Array): DisplayModeCandidate? = supportedModes.asSequence() - .filter { mode -> - mode.physicalHeight == currentMode.physicalHeight && - mode.physicalWidth == currentMode.physicalWidth - } - .mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } } - .minWithOrNull( - compareBy { it.match.priority } - .thenBy { it.match.error } - .thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) } - ) + private fun findBestModeMatch( + fps: Float, + currentMode: Display.Mode, + supportedModes: Array, + videoWidth: Int, + videoHeight: Int + ): DisplayModeCandidate? { + // Tier 1 — a matching-refresh mode at the CURRENT resolution: a refresh-only + // switch, the least disruptive (no resolution/HDMI renegotiation). + supportedModes.asSequence() + .filter { it.physicalHeight == currentMode.physicalHeight && it.physicalWidth == currentMode.physicalWidth } + .mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } } + .minWithOrNull( + compareBy { it.match.priority } + .thenBy { it.match.error } + .thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) } + ) + ?.let { return it } + + // Tier 2 — no same-resolution match (e.g. a 4K panel with no 4K@24 mode, but a + // 1080p@23.976 mode for 1080p content). Allow a resolution change, but never one + // that downscales the video below its native size (trading detail for cadence). + // Requires known video dimensions; without them keep Tier-1-only behaviour. + if (videoWidth <= 0 || videoHeight <= 0) return null + val currentArea = currentMode.physicalWidth.toLong() * currentMode.physicalHeight + return supportedModes.asSequence() + .filter { it.physicalWidth >= videoWidth && it.physicalHeight >= videoHeight } + .mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } } + .minWithOrNull( + // Prefer the resolution closest to the panel's current one (least change, + // keeps panel-native res when a high-res match exists), then refresh match. + compareBy { abs(it.mode.physicalWidth.toLong() * it.mode.physicalHeight - currentArea) } + .thenBy { it.match.priority } + .thenBy { it.match.error } + ) + } @RequiresApi(Build.VERSION_CODES.M) private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { @@ -239,9 +270,12 @@ class FrameRateManager( val currentMode = display.mode log("supported modes=${describeSupportedModes(supportedModes)}") - val modeMatch = findBestModeMatch(fps, currentMode, supportedModes) + val modeMatch = findBestModeMatch(fps, currentMode, supportedModes, currentVideoWidth, currentVideoHeight) if (modeMatch == null) { - log("no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight}") + log( + "no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight} " + + "(video=${currentVideoWidth}x$currentVideoHeight)" + ) onComplete(false) return } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index a1eb947c..318a56ce 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -1,6 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; +import '../../../services/device_performance.dart'; +import '../../../services/settings_service.dart'; import '../../models.dart'; import '../player_base.dart'; @@ -98,6 +100,14 @@ class PlayerAndroid extends PlayerBase { 'tunnelingEnabled': _tunnelingEnabled, 'dvConversionMode': _dvConversionMode, 'audioPassthroughEnabled': _audioPassthroughEnabled, + // Cheap (32-bit) TV boxes run the hardware video path a frame behind a GL + // subtitle overlay; render the ASS one frame earlier there to realign. + 'assVideoLatencyFrames': DevicePerformance.isLowEndHardware ? 1 : 0, + // libass overlay raster scale from the "Render Resolution" subtitle setting + // (Full / ¾ / ½ / ⅓ / ¼); < 1 trades sharpness for throughput on slow GPUs. + 'subtitleRenderScale': SettingsService.instance + .read(SettingsService.subtitleRenderResolution) + .androidRenderScale, }); if (result != true) { throw Exception('Failed to initialize ExoPlayer'); @@ -459,12 +469,20 @@ class PlayerAndroid extends PlayerBase { } @override - Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async { + Future setVideoFrameRate( + double fps, + int durationMs, { + int extraDelayMs = 0, + int videoWidth = 0, + int videoHeight = 0, + }) async { if (disposed || !initialized) return false; final result = await invoke('setVideoFrameRate', { 'fps': fps, 'duration': durationMs, 'extraDelayMs': extraDelayMs, + 'videoWidth': videoWidth, + 'videoHeight': videoHeight, }); return result ?? false; } diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 47c6543a..6e806920 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -254,7 +254,13 @@ abstract class Player { /// the caller is responsible for starting playback itself. /// /// On other platforms, this is a no-op that returns `false`. - Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}); + Future setVideoFrameRate( + double fps, + int durationMs, { + int extraDelayMs = 0, + int videoWidth = 0, + int videoHeight = 0, + }); /// Clear the video frame rate hint and restore default display mode. /// diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index be2ef9a7..c379649e 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -613,7 +613,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future updateFrame() async {} @override - Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false; + Future setVideoFrameRate( + double fps, + int durationMs, { + int extraDelayMs = 0, + int videoWidth = 0, + int videoHeight = 0, + }) async => false; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index f950133c..ed7086cc 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -7,9 +7,7 @@ import '../../media/media_display_criteria.dart'; import '../models.dart'; import 'player_base.dart'; -/// Shared native implementation of [Player] for iOS, macOS, Android (MPV fallback), and Linux. -/// Uses MPVKit via platform channels with Metal rendering (Apple), native window (Android), -/// or FlTextureGL (Linux). +/// MPV-backed player for platforms where AetherEngine is not the native route. class PlayerNative extends PlayerBase { int? _textureIdValue; String _dvConversionMode = 'auto'; @@ -403,12 +401,20 @@ class PlayerNative extends PlayerBase { } @override - Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async { + Future setVideoFrameRate( + double fps, + int durationMs, { + int extraDelayMs = 0, + int videoWidth = 0, + int videoHeight = 0, + }) async { if (!Platform.isAndroid || disposed || !initialized) return false; final result = await invoke('setVideoFrameRate', { 'fps': fps, 'duration': durationMs, 'extraDelayMs': extraDelayMs, + 'videoWidth': videoWidth, + 'videoHeight': videoHeight, }); return result ?? false; } diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 5789960c..2766455a 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -33,6 +33,10 @@ class SubtitleStylingScreen extends StatelessWidget { return switch (value) { SubtitleRenderResolution.screen => t.subtitlingStyling.renderResolutionScreen, SubtitleRenderResolution.video => t.subtitlingStyling.renderResolutionVideo, + SubtitleRenderResolution.threeQuarter => '¾', + SubtitleRenderResolution.half => '½', + SubtitleRenderResolution.third => '⅓', + SubtitleRenderResolution.quarter => '¼', }; } @@ -51,19 +55,37 @@ class SubtitleStylingScreen extends StatelessWidget { decode: (v) => v, encode: (v) => v, ), - // avfoundation VO (iOS/tvOS) only. + // iOS/tvOS avfoundation VO: screen vs video-resolution basis. if (Platform.isIOS) SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, subtitleBuilder: _renderResolutionLabel, - options: SubtitleRenderResolution.values + options: const [SubtitleRenderResolution.screen, SubtitleRenderResolution.video] .map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))) .toList(), decode: (v) => v, encode: (v) => v, ), + // Android libass overlay: full or a fractional render scale (perf knob for + // render-bound low-end TVs; heavy/animated signs raster faster at < 1). + if (Platform.isAndroid) + SettingSelectionTile( + pref: SettingsService.subtitleRenderResolution, + icon: Symbols.aspect_ratio_rounded, + title: t.subtitlingStyling.renderResolution, + subtitleBuilder: _renderResolutionLabel, + options: const [ + SubtitleRenderResolution.screen, + SubtitleRenderResolution.threeQuarter, + SubtitleRenderResolution.half, + SubtitleRenderResolution.third, + SubtitleRenderResolution.quarter, + ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), + decode: (v) => v, + encode: (v) => v, + ), SettingNumberTile( pref: SettingsService.subtitleFontSize, icon: Symbols.format_size_rounded, diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index bd94953c..d0f7c627 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -352,6 +352,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { currentPlayer: currentPlayer, settingsService: settingsService, preKnownFps: displayCriteria?.fps, + preKnownWidth: displayCriteria?.width ?? 0, + preKnownHeight: displayCriteria?.height ?? 0, hasVideoUrl: true, ensureAudioFocus: () => currentPlayer.requestAudioFocus(), ); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 7d806824..7fde3eea 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -5,9 +5,14 @@ part of '../../video_player_screen.dart'; /// behind a startup gate, and which post-open follow-up (fallback switch /// or mpv decoder refresh) releases it. class _FrameRateStartupPlan { - _FrameRateStartupPlan({required this.fps}); + _FrameRateStartupPlan({required this.fps, this.width = 0, this.height = 0}); final double? fps; + + /// Native video dimensions, so a display-mode fallback can avoid downscaling + /// the video below its resolution just to match cadence (0 = unknown). + final int width; + final int height; bool attemptedMpvPreLoad = false; bool didPreLoadSwitch = false; bool preOpenExoHandled = false; @@ -89,10 +94,18 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required SettingsService settingsService, required double fps, required int durationMs, + int videoWidth = 0, + int videoHeight = 0, }) { final delaySec = settingsService.read(SettingsService.displaySwitchDelay); _frameRate.beginSuppressWindow(delaySec); - return player.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000); + return player.setVideoFrameRate( + fps, + durationMs, + extraDelayMs: delaySec * 1000, + videoWidth: videoWidth, + videoHeight: videoHeight, + ); } /// Whether the Android pre-open frame-rate negotiation applies: the user @@ -137,8 +150,10 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required double? preKnownFps, required bool hasVideoUrl, required Future Function() ensureAudioFocus, + int preKnownWidth = 0, + int preKnownHeight = 0, }) async { - final plan = _FrameRateStartupPlan(fps: preKnownFps); + final plan = _FrameRateStartupPlan(fps: preKnownFps, width: preKnownWidth, height: preKnownHeight); final willAutoSwitch = _shouldAutoSwitchFrameRateForOpen(settingsService, preKnownFps); // willAutoSwitch is Android-only, so the strategy fork below is between // the two Android backends: mpv needs its decoder refreshed after a @@ -161,6 +176,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { settingsService: settingsService, fps: preKnownFps!, durationMs: durationMs, + videoWidth: plan.width, + videoHeight: plan.height, ); if (!mounted || player != currentPlayer) return null; if (plan.didPreLoadSwitch) { @@ -192,6 +209,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { settingsService: settingsService, fps: preKnownFps!, durationMs: durationMs, + videoWidth: plan.width, + videoHeight: plan.height, ); if (!mounted || player != currentPlayer) return null; plan.preOpenExoHandled = true; @@ -239,6 +258,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { settingsService: settingsService, fps: plan.fps!, durationMs: durationMs, + videoWidth: plan.width, + videoHeight: plan.height, ); if (!mounted || player != currentPlayer) return; if (didSwitch) { diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index d4cce2c2..472bf720 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -171,6 +171,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { currentPlayer: currentPlayer, settingsService: settingsService, preKnownFps: displayCriteria?.fps, + preKnownWidth: displayCriteria?.width ?? 0, + preKnownHeight: displayCriteria?.height ?? 0, hasVideoUrl: result.videoUrl != null, ensureAudioFocus: ensureAudioFocus, ); diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index ac065171..99fddde5 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -64,6 +64,14 @@ class DevicePerformance { } } + /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), + /// independent of the visual-effects override. Use this for decisions tied to + /// the hardware itself — e.g. the codec→display video pipeline on cheap TV + /// boxes lagging a GL subtitle overlay — where a user's effects preference is + /// irrelevant. Safe before init (returns false). See [isReduced] for the + /// effects-tier gate that the override can force. + static bool get isLowEndHardware => _instance?._autoReduced ?? false; + /// Primary gate for effect chokepoints. Safe before init (full tier). static bool get isReduced { final instance = _instance; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 3bdb55b5..71d14912 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -42,10 +42,27 @@ enum EpisodeAction { play, details } enum SubAssOverride { no, yes, scale, force, strip } -/// Resolution ASS/image subtitles are rasterized at on the avfoundation VO -/// (iOS/tvOS): the display's, or the video's (much cheaper on 4K displays; -/// subs can't carry more detail than the video they're typeset against). -enum SubtitleRenderResolution { screen, video } +/// Resolution ASS/image subtitles are rasterized at. +/// +/// iOS/tvOS (avfoundation VO) uses the [screen] vs [video] basis (video is much +/// cheaper on 4K displays; subs can't carry more detail than the video they're +/// typeset against). Android (libass overlay) instead downscales by a fixed +/// fraction of the surface — [screen] is full, and [threeQuarter]/[half]/[third]/ +/// [quarter] trade sharpness for raster throughput on render-bound low-end TVs. +enum SubtitleRenderResolution { screen, video, threeQuarter, half, third, quarter } + +extension SubtitleRenderScale on SubtitleRenderResolution { + /// Android libass overlay render scale (fraction of the surface resolution). + /// Only Android reads this; the iOS-only [video] basis maps to full scale here. + double get androidRenderScale => switch (this) { + SubtitleRenderResolution.screen => 1.0, + SubtitleRenderResolution.video => 1.0, + SubtitleRenderResolution.threeQuarter => 0.75, + SubtitleRenderResolution.half => 0.5, + SubtitleRenderResolution.third => 1 / 3, + SubtitleRenderResolution.quarter => 0.25, + }; +} enum DvConversionModePreference { auto, disabled, dv81, hevcStrip }