feat(android): tune frame rate and subtitle rendering
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user