2026-06-12 05:10:49 +02:00
|
|
|
|
import 'dart:io';
|
2026-07-30 01:36:19 +02:00
|
|
|
|
import 'dart:math' as math;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
|
import 'package:flutter/painting.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
|
2026-07-26 03:45:07 +02:00
|
|
|
|
import '../utils/async_singleton.dart';
|
|
|
|
|
|
import '../utils/device_channel.dart';
|
2026-06-12 05:10:49 +02:00
|
|
|
|
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._();
|
|
|
|
|
|
|
2026-07-26 03:45:07 +02:00
|
|
|
|
static final AsyncSingleton<DevicePerformance> _singleton = AsyncSingleton();
|
2026-07-23 02:18:05 +02:00
|
|
|
|
@visibleForTesting
|
2026-07-26 03:45:07 +02:00
|
|
|
|
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
|
|
|
|
|
|
/// ~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.
|
2026-07-26 03:45:07 +02:00
|
|
|
|
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) =>
|
|
|
|
|
|
_singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect());
|
2026-06-12 05:10:49 +02:00
|
|
|
|
|
|
|
|
|
|
Future<void> _detect() async {
|
|
|
|
|
|
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
|
|
|
|
|
try {
|
2026-07-26 03:45:07 +02:00
|
|
|
|
final result = await deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
2026-06-12 05:10:49 +02:00
|
|
|
|
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.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 09:10:36 +02:00
|
|
|
|
/// Total device RAM as reported by the platform, or null off-Android /
|
|
|
|
|
|
/// before init. Used to scale memory-watchdog thresholds to the device.
|
2026-07-26 03:45:07 +02:00
|
|
|
|
static int? get totalMemBytes => _singleton.instance?._totalMemBytes;
|
2026-07-05 09:10:36 +02:00
|
|
|
|
|
2026-06-24 01:34:03 +02:00
|
|
|
|
/// 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.
|
2026-07-26 03:45:07 +02:00
|
|
|
|
static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false;
|
2026-06-24 01:34:03 +02:00
|
|
|
|
|
2026-06-12 05:10:49 +02:00
|
|
|
|
/// Primary gate for effect chokepoints. Safe before init (full tier).
|
|
|
|
|
|
static bool get isReduced {
|
2026-07-26 03:45:07 +02:00
|
|
|
|
final instance = _singleton.instance;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-30 01:36:19 +02:00
|
|
|
|
/// ~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.13–2× (#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();
|
|
|
|
|
|
|
2026-06-12 05:10:49 +02:00
|
|
|
|
/// Update the user override from the settings screen and re-apply the
|
|
|
|
|
|
/// budgets that were computed at boot.
|
|
|
|
|
|
static void setOverrideSync(VisualEffectsSetting value) {
|
2026-07-26 03:45:07 +02:00
|
|
|
|
_singleton.instance?._override = value;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
applyImageCacheBudget();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Flutter image-cache budget per platform/tier — kept modest to leave
|
|
|
|
|
|
/// headroom for Skia decode buffers.
|
|
|
|
|
|
static void applyImageCacheBudget() {
|
2026-07-30 01:36:19 +02:00
|
|
|
|
_detectDisplayBudget();
|
2026-06-12 05:10:49 +02:00
|
|
|
|
final cache = PaintingBinding.instance.imageCache;
|
|
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
|
|
|
|
|
cache.maximumSize = 1000;
|
|
|
|
|
|
cache.maximumSizeBytes = 150 << 20; // 150MB
|
|
|
|
|
|
} else if (isReduced) {
|
2026-08-19 13:16:36 +02:00
|
|
|
|
// Tiles decode at full TV DPR even on this tier (#2020), so the byte
|
|
|
|
|
|
// budget matches the TV baseline. The display budget factor is pinned
|
|
|
|
|
|
// to 1.0 here, keeping the ceiling fixed.
|
2026-06-12 05:10:49 +02:00
|
|
|
|
cache.maximumSize = 400;
|
2026-08-19 13:16:36 +02:00
|
|
|
|
cache.maximumSizeBytes = 64 << 20; // 64MB
|
2026-07-05 02:28:43 +02:00
|
|
|
|
} else if (PlatformDetector.isTV()) {
|
2026-07-30 01:36:19 +02:00
|
|
|
|
// 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).
|
2026-07-05 02:28:43 +02:00
|
|
|
|
cache.maximumSize = 500;
|
2026-07-30 01:36:19 +02:00
|
|
|
|
cache.maximumSizeBytes = ((64 << 20) * displayBudgetFactor()).round();
|
2026-06-12 05:10:49 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
cache.maximumSize = 800;
|
|
|
|
|
|
cache.maximumSizeBytes = 100 << 20; // 100MB
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 01:36:19 +02:00
|
|
|
|
/// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 09:10:36 +02:00
|
|
|
|
/// 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".
|
2026-06-12 05:10:49 +02:00
|
|
|
|
static String describeSync() {
|
2026-07-26 03:45:07 +02:00
|
|
|
|
final instance = _singleton.instance;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
if (instance == null) return 'unknown';
|
|
|
|
|
|
final tier = isReduced ? 'reduced' : 'full';
|
|
|
|
|
|
final signals = <String>[
|
|
|
|
|
|
if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'),
|
2026-07-05 09:10:36 +02:00
|
|
|
|
if (instance._isLowRam != null) 'lowRam:${instance._isLowRam}',
|
2026-06-12 05:10:49 +02:00
|
|
|
|
if (instance._totalMemBytes != null) '${(instance._totalMemBytes! / (1024 * 1024 * 1024)).toStringAsFixed(1)}GiB',
|
|
|
|
|
|
];
|
2026-07-05 09:10:36 +02:00
|
|
|
|
if (instance._override != VisualEffectsSetting.auto) {
|
|
|
|
|
|
return signals.isEmpty ? '$tier (forced)' : '$tier (forced; hw: ${signals.join(', ')})';
|
|
|
|
|
|
}
|
2026-06-12 05:10:49 +02:00
|
|
|
|
return signals.isEmpty ? tier : '$tier (auto: ${signals.join(', ')})';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@visibleForTesting
|
2026-07-30 01:36:19 +02:00
|
|
|
|
static void debugReset({bool? autoReduced, VisualEffectsSetting? override, int? totalMemBytes}) {
|
|
|
|
|
|
_displayBudgetFactor = 1.0;
|
|
|
|
|
|
debugDisplayShortestSideOverride = null;
|
|
|
|
|
|
if (autoReduced == null && override == null && totalMemBytes == null) {
|
2026-07-26 03:45:07 +02:00
|
|
|
|
_singleton.debugReset();
|
2026-06-12 05:10:49 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-26 03:45:07 +02:00
|
|
|
|
final instance = _singleton.instance ?? DevicePerformance._();
|
|
|
|
|
|
_singleton.debugReset(instance: instance);
|
|
|
|
|
|
if (autoReduced != null) instance._autoReduced = autoReduced;
|
|
|
|
|
|
if (override != null) instance._override = override;
|
2026-07-30 01:36:19 +02:00
|
|
|
|
if (totalMemBytes != null) instance._totalMemBytes = totalMemBytes;
|
2026-06-12 05:10:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|