Files
plezy/lib/services/device_performance.dart
T
edde746 f13f5af6e2 fix(images): scale artwork budgets to the physical display
Every artwork budget in the image pipeline was tuned for 1080p surfaces:
the transcode request clamp (1920x1080), the per-type decode caps
(poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV
image-cache bytes. Those numbers are exact on phones and on the many TV
boxes that composite the app at 1080p, but a TV compositing at 4K
renders every capped image below its slot and GPU-upscales the result:
hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x,
shelf posters by ~1.13x - the softness reported against the official
Plex client in #1697, and the class #860's min-2x-DPR fix could not
reach.

DevicePerformance now latches a display budget factor - the display's
shortest physical axis over 1080, capped at 2x - whenever the image
cache budget is applied (startup, post-mount, effects-setting changes).
The transcode clamp, the full-tier decode caps and the TV cache bytes
all scale by it, so a 4K surface fetches and decodes 4K backdrops and
proportionally larger cards. The reduced tier stays pinned to 1.0, and
sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art
(~33MB per decode) cannot starve mid-RAM boxes; latching once per
session keeps transcode URLs - and with them the disk cache keys -
stable across rotation and rebuilds.

Whether a given TV composites at 1080p or 4K decides whether any of
this can help, and logs never recorded it: the startup banner and the
log-upload header now carry a display line (physical, logical, DPR,
latched budget) so uploaded logs answer that question directly.

The two pre-existing Windows-host test failures (automotive auto-PiP
gate, backdrop temp-dir teardown lock) reproduce unchanged on the base
commit.
2026-07-30 01:40:24 +02:00

233 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import '../utils/async_singleton.dart';
import '../utils/device_channel.dart';
import '../utils/platform_detector.dart';
/// User override for the visual-effects tier (stored by SettingsService).
enum VisualEffectsSetting { auto, full, reduced }
/// Detects whether the device is too weak for the full visual-effects budget
/// and exposes a single sync gate ([isReduced]) the effect chokepoints check.
///
/// The reduced tier auto-triggers only on low-end Android hardware: a 32-bit
/// process (cheap TV boxes/sticks run 32-bit userspace), the system low-RAM
/// flag, or ≤ ~2.2 GiB total memory. All other platforms are always full
/// unless the user forces "reduced" via the setting.
class DevicePerformance {
DevicePerformance._();
static final AsyncSingleton<DevicePerformance> _singleton = AsyncSingleton();
@visibleForTesting
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
/// reservations), below 3 GB Shield-class devices (~2.8 GiB).
static const int _lowMemThresholdBytes = 2252 << 20;
bool _autoReduced = false;
VisualEffectsSetting _override = VisualEffectsSetting.auto;
// Raw signals retained for the startup log line.
bool? _is64Bit;
bool? _isLowRam;
int? _totalMemBytes;
/// Get the singleton, detecting hardware signals on first call.
/// [override] is the persisted SettingsService.visualEffects value.
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) =>
_singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect());
Future<void> _detect() async {
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
try {
final result = await deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
if (result == null) return;
_is64Bit = result['is64Bit'] == true;
_isLowRam = result['isLowRamDevice'] == true;
_totalMemBytes = (result['totalMemBytes'] as num?)?.toInt();
_autoReduced =
_is64Bit == false ||
_isLowRam == true ||
(_totalMemBytes != null && _totalMemBytes! <= _lowMemThresholdBytes);
} on MissingPluginException {
// Stale native build — stay on the full tier.
} on PlatformException {
// Signal query failed — stay on the full tier.
}
}
/// Total device RAM as reported by the platform, or null off-Android /
/// before init. Used to scale memory-watchdog thresholds to the device.
static int? get totalMemBytes => _singleton.instance?._totalMemBytes;
/// 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 => _singleton.instance?._autoReduced ?? false;
/// Primary gate for effect chokepoints. Safe before init (full tier).
static bool get isReduced {
final instance = _singleton.instance;
if (instance == null) return false;
return switch (instance._override) {
VisualEffectsSetting.auto => instance._autoReduced,
VisualEffectsSetting.full => false,
VisualEffectsSetting.reduced => true,
};
}
/// [full] on the full tier, [Duration.zero] on the reduced tier.
static Duration reducedDuration(Duration full) => isReduced ? Duration.zero : full;
/// ~2.5 GiB: below what 3 GB Shield-class devices report (~2.8 GiB) so they
/// keep the full display budget, above the 2.2 GiB reduced-tier threshold.
static const int _fullDisplayBudgetMemBytes = 2560 << 20;
static double _displayBudgetFactor = 1.0;
@visibleForTesting
static double? debugDisplayShortestSideOverride;
/// Scales the artwork pixel budgets (transcode size clamp, decode caps,
/// image-cache bytes) to the physical display. The 1080p-tuned budgets are
/// exact on phones and 1080p-surface TVs, but a TV compositing the app at
/// 4K re-upscales every capped image by 1.132× (#1697), so denser displays
/// raise the budgets proportionally, up to 2× on a 4K surface.
///
/// Returns the value latched by [applyImageCacheBudget] — image callsites
/// must never probe the display per call, both because URL cache keys
/// derived from the budget have to stay stable for the whole session and
/// because the engine reports no metrics during early startup.
static double displayBudgetFactor() => isReduced ? 1.0 : _displayBudgetFactor;
/// Derives the display budget from the display's shortest physical axis
/// (orientation-stable, unlike its width). Keeps the previous value while
/// the engine has not reported metrics yet, so the pre-first-frame
/// [applyImageCacheBudget] call cannot latch a false 1.0 for the session.
///
/// Held at 1.5 on sub-2.5 GiB hardware: full-budget 4K art decodes at
/// ~33 MB per image, which mid-RAM boxes can't spare while 4K video decode
/// buffers are alive.
static void _detectDisplayBudget() {
final shortestSide = debugDisplayShortestSideOverride ?? _displayShortestSide();
if (shortestSide == null || shortestSide <= 0) return;
var factor = math.min(shortestSide / 1080, 2.0);
final mem = totalMemBytes;
if (mem != null && mem < _fullDisplayBudgetMemBytes) factor = math.min(factor, 1.5);
_displayBudgetFactor = math.max(factor, 1.0);
}
static double? _displayShortestSide() {
try {
return PlatformDispatcher.instance.implicitView?.display.size.shortestSide;
} catch (_) {
return null;
}
}
/// Test-only: run the latch that [applyImageCacheBudget] performs without
/// requiring a painting binding.
@visibleForTesting
static void debugDetectDisplayBudget() => _detectDisplayBudget();
/// Update the user override from the settings screen and re-apply the
/// budgets that were computed at boot.
static void setOverrideSync(VisualEffectsSetting value) {
_singleton.instance?._override = value;
applyImageCacheBudget();
}
/// Flutter image-cache budget per platform/tier — kept modest to leave
/// headroom for Skia decode buffers.
static void applyImageCacheBudget() {
_detectDisplayBudget();
final cache = PaintingBinding.instance.imageCache;
if (PlatformDetector.isDesktopOS()) {
cache.maximumSize = 1000;
cache.maximumSizeBytes = 150 << 20; // 150MB
} else if (isReduced) {
cache.maximumSize = 400;
cache.maximumSizeBytes = 48 << 20; // 48MB
} else if (PlatformDetector.isTV()) {
// TV boxes share limited RAM with 4K video decode buffers. The byte
// budget follows the display budget: 4K-surface artwork carries up to
// 2× the pixels per entry (64MB baseline → 128MB at 4K).
cache.maximumSize = 500;
cache.maximumSizeBytes = ((64 << 20) * displayBudgetFactor()).round();
} else {
cache.maximumSize = 800;
cache.maximumSizeBytes = 100 << 20; // 100MB
}
}
/// One-line display summary for the startup log and bug-report headers,
/// e.g. `3840x2160 physical, 960x540 logical @ 4.00x (budget 2.0x)`.
///
/// This is what tells a 1080p-composited TV apart from a true-4K surface
/// when a user reports soft artwork on a 4K panel: on the former nothing
/// app-side can add sharpness, on the latter the display budget must have
/// engaged.
static String describeDisplay() {
final view = PlatformDispatcher.instance.implicitView;
if (view == null) return 'unknown';
final physical = view.physicalSize;
final dpr = view.devicePixelRatio;
final logicalWidth = dpr > 0 ? physical.width / dpr : 0;
final logicalHeight = dpr > 0 ? physical.height / dpr : 0;
final display = view.display.size;
final buffer = StringBuffer(
'${physical.width.round()}x${physical.height.round()} physical, '
'${logicalWidth.round()}x${logicalHeight.round()} logical @ ${dpr.toStringAsFixed(2)}x',
);
if ((display.width - physical.width).abs() > 1 || (display.height - physical.height).abs() > 1) {
buffer.write(', display ${display.width.round()}x${display.height.round()}');
}
buffer.write(' (budget ${displayBudgetFactor().toStringAsFixed(1)}x)');
return buffer.toString();
}
/// One-line tier summary for the startup log and bug-report headers, e.g.
/// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced; hw: 64-bit, 2.8GiB)`.
///
/// Raw signals are always included (even when the tier is forced) so an
/// uploaded log answers "did the reduced tier engage, and why / why not".
static String describeSync() {
final instance = _singleton.instance;
if (instance == null) return 'unknown';
final tier = isReduced ? 'reduced' : 'full';
final signals = <String>[
if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'),
if (instance._isLowRam != null) 'lowRam:${instance._isLowRam}',
if (instance._totalMemBytes != null) '${(instance._totalMemBytes! / (1024 * 1024 * 1024)).toStringAsFixed(1)}GiB',
];
if (instance._override != VisualEffectsSetting.auto) {
return signals.isEmpty ? '$tier (forced)' : '$tier (forced; hw: ${signals.join(', ')})';
}
return signals.isEmpty ? tier : '$tier (auto: ${signals.join(', ')})';
}
@visibleForTesting
static void debugReset({bool? autoReduced, VisualEffectsSetting? override, int? totalMemBytes}) {
_displayBudgetFactor = 1.0;
debugDisplayShortestSideOverride = null;
if (autoReduced == null && override == null && totalMemBytes == null) {
_singleton.debugReset();
return;
}
final instance = _singleton.instance ?? DevicePerformance._();
_singleton.debugReset(instance: instance);
if (autoReduced != null) instance._autoReduced = autoReduced;
if (override != null) instance._override = override;
if (totalMemBytes != null) instance._totalMemBytes = totalMemBytes;
}
}