feat(android): tune frame rate and subtitle rendering

This commit is contained in:
edde746
2026-06-24 01:34:03 +02:00
parent c9dc15b1d8
commit 21c4ab430e
15 changed files with 187 additions and 37 deletions
@@ -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() {
@@ -500,22 +500,24 @@ class ExoPlayerPlugin :
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("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)
}
}
}
@@ -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)
@@ -343,14 +343,16 @@ class MpvPlayerPlugin :
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("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)
}
}
@@ -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<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
@RequiresApi(Build.VERSION_CODES.M)
private fun findBestModeMatch(fps: Float, currentMode: Display.Mode, supportedModes: Array<Display.Mode>): 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<DisplayModeCandidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
private fun findBestModeMatch(
fps: Float,
currentMode: Display.Mode,
supportedModes: Array<Display.Mode>,
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<DisplayModeCandidate> { 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<DisplayModeCandidate> { 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
}
+19 -1
View File
@@ -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<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
}) async {
if (disposed || !initialized) return false;
final result = await invoke<bool>('setVideoFrameRate', {
'fps': fps,
'duration': durationMs,
'extraDelayMs': extraDelayMs,
'videoWidth': videoWidth,
'videoHeight': videoHeight,
});
return result ?? false;
}
+7 -1
View File
@@ -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<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0});
Future<bool> 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.
///
+7 -1
View File
@@ -613,7 +613,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Future<void> updateFrame() async {}
@override
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false;
Future<bool> 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
+10 -4
View File
@@ -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<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
Future<bool> 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<bool>('setVideoFrameRate', {
'fps': fps,
'duration': durationMs,
'extraDelayMs': extraDelayMs,
'videoWidth': videoWidth,
'videoHeight': videoHeight,
});
return result ?? false;
}
@@ -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<SubtitleRenderResolution, SubtitleRenderResolution>(
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<SubtitleRenderResolution, SubtitleRenderResolution>(
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,
@@ -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(),
);
@@ -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<void> 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) {
@@ -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,
);
+8
View File
@@ -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;
+21 -4
View File
@@ -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 }