perf(tv): reduced visual-effects tier for low-end devices
This commit is contained in:
@@ -15,6 +15,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
@@ -191,6 +192,19 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Hardware capability signals used by Dart to pick the visual-effects tier. */
|
||||
private fun getPerformanceSignals(): Map<String, Any> {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
return mapOf(
|
||||
// Actual process bitness: low-end TV boxes often run 32-bit userspace.
|
||||
"is64Bit" to Process.is64Bit(),
|
||||
"isLowRamDevice" to activityManager.isLowRamDevice,
|
||||
"totalMemBytes" to memoryInfo.totalMem,
|
||||
)
|
||||
}
|
||||
|
||||
/** User-assigned device name (Settings > About > Device name), or null. */
|
||||
private fun getDeviceName(): String? {
|
||||
// The name the user gave the device; also used by Cast/Nearby.
|
||||
@@ -441,6 +455,7 @@ class MainActivity : FlutterActivity() {
|
||||
when (call.method) {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/device_performance.dart';
|
||||
import 'focus_theme.dart';
|
||||
|
||||
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
|
||||
@@ -58,7 +59,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isFocused) {
|
||||
if (widget.isFocused && !DevicePerformance.isReduced) {
|
||||
_visible = true;
|
||||
_controller.show();
|
||||
}
|
||||
@@ -67,6 +68,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
@override
|
||||
void didUpdateWidget(FocusGlowOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (DevicePerformance.isReduced) return;
|
||||
if (widget.isFocused == oldWidget.isFocused) return;
|
||||
if (widget.isFocused) {
|
||||
_controller.show();
|
||||
@@ -90,6 +92,10 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Reduced tier: no glow at all — the blurred shadows + fade saveLayer are
|
||||
// too expensive on weak GPUs. The crisp in-card focus border remains.
|
||||
if (DevicePerformance.isReduced) return widget.child;
|
||||
|
||||
// Gate the LeaderLayer to the focused card only: when not focused and not
|
||||
// mid-fade, return the bare child (no OverlayPortal, no leader).
|
||||
if (!widget.isFocused && !_controller.isShowing) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
|
||||
class FocusTheme {
|
||||
@@ -17,6 +18,9 @@ class FocusTheme {
|
||||
}
|
||||
|
||||
static Duration getAnimationDuration(BuildContext context) {
|
||||
// Reduced tier: snap focus transitions (scale/border/glow) instead of
|
||||
// animating — each animation frame re-rasterizes the focused card.
|
||||
if (DevicePerformance.isReduced) return Duration.zero;
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,12 @@
|
||||
"showSeasonPostersOnTabsDescription": "Show each season's poster above its tab",
|
||||
"tvFullCardLayout": "Full TV Cards",
|
||||
"tvFullCardLayoutDescription": "Use image-only TV cards with actor names overlaid",
|
||||
"visualEffects": "Visual Effects",
|
||||
"visualEffectsAuto": "Auto",
|
||||
"visualEffectsAutoDescription": "Reduce effects automatically on low-power devices",
|
||||
"visualEffectsFull": "Full",
|
||||
"visualEffectsReduced": "Reduced",
|
||||
"visualEffectsReducedDescription": "Fewer animations and lower-resolution artwork",
|
||||
"hideSpoilers": "Hide Spoilers for Unwatched Episodes",
|
||||
"hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes",
|
||||
"playerBackend": "Player Backend",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 16
|
||||
/// Strings: 20259 (1266 per locale)
|
||||
/// Strings: 20265 (1266 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -513,6 +513,24 @@ class TranslationsSettingsEn {
|
||||
/// en: 'Use image-only TV cards with actor names overlaid'
|
||||
String get tvFullCardLayoutDescription => 'Use image-only TV cards with actor names overlaid';
|
||||
|
||||
/// en: 'Visual Effects'
|
||||
String get visualEffects => 'Visual Effects';
|
||||
|
||||
/// en: 'Auto'
|
||||
String get visualEffectsAuto => 'Auto';
|
||||
|
||||
/// en: 'Reduce effects automatically on low-power devices'
|
||||
String get visualEffectsAutoDescription => 'Reduce effects automatically on low-power devices';
|
||||
|
||||
/// en: 'Full'
|
||||
String get visualEffectsFull => 'Full';
|
||||
|
||||
/// en: 'Reduced'
|
||||
String get visualEffectsReduced => 'Reduced';
|
||||
|
||||
/// en: 'Fewer animations and lower-resolution artwork'
|
||||
String get visualEffectsReducedDescription => 'Fewer animations and lower-resolution artwork';
|
||||
|
||||
/// en: 'Hide Spoilers for Unwatched Episodes'
|
||||
String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes';
|
||||
|
||||
@@ -4586,6 +4604,12 @@ extension on Translations {
|
||||
'settings.showSeasonPostersOnTabsDescription' => 'Show each season\'s poster above its tab',
|
||||
'settings.tvFullCardLayout' => 'Full TV Cards',
|
||||
'settings.tvFullCardLayoutDescription' => 'Use image-only TV cards with actor names overlaid',
|
||||
'settings.visualEffects' => 'Visual Effects',
|
||||
'settings.visualEffectsAuto' => 'Auto',
|
||||
'settings.visualEffectsAutoDescription' => 'Reduce effects automatically on low-power devices',
|
||||
'settings.visualEffectsFull' => 'Full',
|
||||
'settings.visualEffectsReduced' => 'Reduced',
|
||||
'settings.visualEffectsReducedDescription' => 'Fewer animations and lower-resolution artwork',
|
||||
'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes',
|
||||
'settings.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes',
|
||||
'settings.playerBackend' => 'Player Backend',
|
||||
@@ -4968,14 +4992,14 @@ extension on Translations {
|
||||
'messages.serverLimitBody' => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.',
|
||||
'messages.logsUploaded' => 'Logs uploaded',
|
||||
'messages.logsUploadFailed' => 'Failed to upload logs',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'messages.logId' => 'Log ID',
|
||||
'subtitlingStyling.text' => 'Text',
|
||||
'subtitlingStyling.border' => 'Border',
|
||||
'subtitlingStyling.background' => 'Background',
|
||||
'subtitlingStyling.fontSize' => 'Font Size',
|
||||
'subtitlingStyling.textColor' => 'Text Color',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'subtitlingStyling.borderSize' => 'Border Size',
|
||||
'subtitlingStyling.borderColor' => 'Border Color',
|
||||
'subtitlingStyling.backgroundOpacity' => 'Background Opacity',
|
||||
@@ -5482,14 +5506,14 @@ extension on Translations {
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Failed to connect: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Do you want to disconnect from the remote session?',
|
||||
'companionRemote.remote.reconnecting' => 'Reconnecting...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Attempt ${current} of 5',
|
||||
'companionRemote.remote.retryNow' => 'Retry Now',
|
||||
'companionRemote.remote.tabRemote' => 'Remote',
|
||||
'companionRemote.remote.tabPlay' => 'Play',
|
||||
'companionRemote.remote.tabMore' => 'More',
|
||||
'companionRemote.remote.menu' => 'Menu',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.tabNavigation' => 'Tab Navigation',
|
||||
'companionRemote.remote.tabDiscover' => 'Discover',
|
||||
'companionRemote.remote.tabLibraries' => 'Libraries',
|
||||
|
||||
+11
-10
@@ -27,6 +27,7 @@ import 'screens/auth_screen.dart';
|
||||
import 'screens/profile/pin_entry_dialog.dart';
|
||||
import 'screens/profile/profile_switch_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/device_performance.dart';
|
||||
import 'services/macos_window_service.dart';
|
||||
import 'services/native_window_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
@@ -176,15 +177,6 @@ Future<void> _bootstrapApp() async {
|
||||
await settings.write(SettingsService.cleanedOldImageCache, true);
|
||||
}
|
||||
|
||||
// Configure image cache — keep budget modest to leave headroom for Skia decode buffers
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
PaintingBinding.instance.imageCache.maximumSize = 1000;
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 150 << 20; // 150MB
|
||||
} else {
|
||||
PaintingBinding.instance.imageCache.maximumSize = 800;
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100MB
|
||||
}
|
||||
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
@@ -199,6 +191,8 @@ Future<void> _bootstrapApp() async {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
||||
}
|
||||
// Visual-effects tier (auto-detects low-end Android; full elsewhere).
|
||||
futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));
|
||||
if (Platform.isAndroid) {
|
||||
PipService();
|
||||
}
|
||||
@@ -212,6 +206,10 @@ Future<void> _bootstrapApp() async {
|
||||
await Future.wait(futures);
|
||||
final storage = await storageFuture;
|
||||
|
||||
// Configure image cache — keep budget modest to leave headroom for Skia
|
||||
// decode buffers. Runs after the futures so the effects tier is resolved.
|
||||
DevicePerformance.applyImageCacheBudget();
|
||||
|
||||
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
||||
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
||||
// is available — keeps the deprecated legacy slots out of runtime paths.
|
||||
@@ -225,7 +223,10 @@ Future<void> _bootstrapApp() async {
|
||||
if (Platform.isAndroid) {
|
||||
renderer = ' [${await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer')}]';
|
||||
}
|
||||
appLogger.i('Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer');
|
||||
appLogger.i(
|
||||
'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer'
|
||||
' [effects: ${DevicePerformance.describeSync()}]',
|
||||
);
|
||||
|
||||
await DownloadStorageService.instance.initialize(settings);
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Dependency aspects for [MainScreenFocusScope].
|
||||
///
|
||||
/// The scope is an [InheritedModel] so the per-sidebar-flip values (`offset`)
|
||||
/// only rebuild the few widgets that position against them — depending on the
|
||||
/// whole scope from a screen's top-level build makes the entire screen rebuild
|
||||
/// on every sidebar focus flip (measured 150-330ms frames on low-end TVs).
|
||||
enum MainScreenScopeAspect {
|
||||
/// `foregroundLeft` / `sideNavigationWidth` — change when the sidebar
|
||||
/// expands or collapses. Depend on these only from small positioning
|
||||
/// widgets (e.g. [SideNavigationBleedBuilder] call sites).
|
||||
offset,
|
||||
|
||||
/// `isSidebarFocused`.
|
||||
focus,
|
||||
|
||||
/// `foregroundWidth` / `viewportWidth` / `reservedSideNavigationWidth` —
|
||||
/// stable across sidebar flips (only change with window geometry).
|
||||
layout,
|
||||
}
|
||||
|
||||
class MainScreenFocusScope extends InheritedModel<MainScreenScopeAspect> {
|
||||
final VoidCallback focusSidebar;
|
||||
final VoidCallback focusContent;
|
||||
final bool isSidebarFocused;
|
||||
final double sideNavigationWidth;
|
||||
final double? reservedSideNavigationWidth;
|
||||
final double? foregroundLeft;
|
||||
final double? foregroundWidth;
|
||||
final double? viewportWidth;
|
||||
final void Function(String libraryGlobalKey)? selectLibrary;
|
||||
final VoidCallback? openSettings;
|
||||
|
||||
const MainScreenFocusScope({
|
||||
super.key,
|
||||
required this.focusSidebar,
|
||||
required this.focusContent,
|
||||
required this.isSidebarFocused,
|
||||
required this.sideNavigationWidth,
|
||||
this.reservedSideNavigationWidth,
|
||||
this.foregroundLeft,
|
||||
this.foregroundWidth,
|
||||
this.viewportWidth,
|
||||
this.selectLibrary,
|
||||
this.openSettings,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
/// Whole-scope access. With `listen: true` this registers an aspect-less
|
||||
/// dependency (notified on ANY scope change) — use the aspect-scoped static
|
||||
/// getters instead wherever possible. Callback access (focusSidebar etc.)
|
||||
/// should always pass `listen: false`.
|
||||
static MainScreenFocusScope? of(BuildContext context, {bool listen = true}) {
|
||||
if (listen) return context.dependOnInheritedWidgetOfExactType<MainScreenFocusScope>();
|
||||
return context.getElementForInheritedWidgetOfExactType<MainScreenFocusScope>()?.widget as MainScreenFocusScope?;
|
||||
}
|
||||
|
||||
static MainScreenFocusScope? _dependOn(BuildContext context, MainScreenScopeAspect aspect) {
|
||||
return InheritedModel.inheritFrom<MainScreenFocusScope>(context, aspect: aspect);
|
||||
}
|
||||
|
||||
/// Sidebar bleed target (== content offset). Changes per sidebar flip —
|
||||
/// depend on it only from small positioning builders.
|
||||
static double sideNavigationBleedOf(BuildContext context) {
|
||||
return _dependOn(context, MainScreenScopeAspect.offset)?.sideNavigationWidth ?? 0.0;
|
||||
}
|
||||
|
||||
/// Content box left edge. Changes per sidebar flip — depend on it only from
|
||||
/// small positioning builders.
|
||||
static double foregroundLeftOf(BuildContext context) {
|
||||
final left = _dependOn(context, MainScreenScopeAspect.offset)?.foregroundLeft;
|
||||
if (left != null) return left;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static double foregroundWidthOf(BuildContext context) {
|
||||
final width = _dependOn(context, MainScreenScopeAspect.layout)?.foregroundWidth;
|
||||
if (width != null && width > 0) return width;
|
||||
return MediaQuery.sizeOf(context).width;
|
||||
}
|
||||
|
||||
static Size foregroundSizeOf(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return Size(foregroundWidthOf(context), size.height);
|
||||
}
|
||||
|
||||
static double fullBleedWidthOf(BuildContext context) {
|
||||
final width = _dependOn(context, MainScreenScopeAspect.layout)?.viewportWidth;
|
||||
if (width != null && width > 0) return width;
|
||||
return MediaQuery.sizeOf(context).width;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(MainScreenFocusScope oldWidget) {
|
||||
return isSidebarFocused != oldWidget.isSidebarFocused ||
|
||||
sideNavigationWidth != oldWidget.sideNavigationWidth ||
|
||||
reservedSideNavigationWidth != oldWidget.reservedSideNavigationWidth ||
|
||||
foregroundLeft != oldWidget.foregroundLeft ||
|
||||
foregroundWidth != oldWidget.foregroundWidth ||
|
||||
viewportWidth != oldWidget.viewportWidth;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotifyDependent(MainScreenFocusScope oldWidget, Set<MainScreenScopeAspect> dependencies) {
|
||||
if (dependencies.contains(MainScreenScopeAspect.offset) &&
|
||||
(foregroundLeft != oldWidget.foregroundLeft || sideNavigationWidth != oldWidget.sideNavigationWidth)) {
|
||||
return true;
|
||||
}
|
||||
if (dependencies.contains(MainScreenScopeAspect.focus) && isSidebarFocused != oldWidget.isSidebarFocused) {
|
||||
return true;
|
||||
}
|
||||
if (dependencies.contains(MainScreenScopeAspect.layout) &&
|
||||
(foregroundWidth != oldWidget.foregroundWidth ||
|
||||
viewportWidth != oldWidget.viewportWidth ||
|
||||
reservedSideNavigationWidth != oldWidget.reservedSideNavigationWidth)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Animates toward the sidebar bleed target for overlays that must stay
|
||||
/// viewport-pinned (full-bleed backgrounds, the overlaid app bar) while the
|
||||
/// content box slides during sidebar expansion.
|
||||
///
|
||||
/// The duration/curve MUST mirror the content-slide tween in MainScreen's
|
||||
/// `_buildContent`: pinning works because the two tweens retarget on the same
|
||||
/// frame and track each other exactly, so the animated `-bleed` cancels the
|
||||
/// content translate tick for tick.
|
||||
class SideNavigationBleedBuilder extends StatelessWidget {
|
||||
final double targetBleed;
|
||||
final Widget? child;
|
||||
final Widget Function(BuildContext context, double bleed, Widget? child) builder;
|
||||
|
||||
const SideNavigationBleedBuilder({super.key, required this.targetBleed, this.child, required this.builder});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: targetBleed),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: builder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import '../mixins/item_updatable.dart';
|
||||
import '../mixins/watch_state_aware.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/debouncer.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/media_hub_ordering.dart';
|
||||
@@ -144,7 +145,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
||||
bool _isAutoScrollPaused = false;
|
||||
bool _heroFocusPausedAutoScroll = false;
|
||||
MediaItem? _spotlightItem;
|
||||
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
|
||||
// TvSpotlightBackground subtree, never the rail/rows.
|
||||
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
|
||||
// Settle delay so d-pad scrubbing across a row doesn't fetch/decode a
|
||||
// full-screen backdrop for every intermediate item.
|
||||
final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150));
|
||||
bool _isTabVisible = true;
|
||||
HiddenLibrariesProvider? _hiddenLibrariesProvider;
|
||||
LibrariesProvider? _librariesProvider;
|
||||
@@ -283,7 +289,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
MediaItem? get _effectiveSpotlightItem {
|
||||
final current = _spotlightItem;
|
||||
final current = _spotlightItem.value;
|
||||
if (current == null) return _defaultSpotlightItem;
|
||||
if (_onDeck.any((item) => item.globalKey == current.globalKey)) return current;
|
||||
for (final hub in _hubs) {
|
||||
@@ -293,8 +299,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
void _setSpotlightItem(MediaItem item) {
|
||||
if (_spotlightItem?.globalKey == item.globalKey) return;
|
||||
setState(() => _spotlightItem = item);
|
||||
// Same-key check lives inside the callback: an A→B→A scrub must cancel
|
||||
// the pending B, not early-return and let it fire.
|
||||
_spotlightDebouncer.run(() {
|
||||
if (!mounted) return;
|
||||
if (_spotlightItem.value?.globalKey == item.globalKey) return;
|
||||
_spotlightItem.value = item;
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
@@ -552,6 +563,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_autoScrollTimer?.cancel();
|
||||
_indicatorTimer?.cancel();
|
||||
_spotlightDebouncer.dispose();
|
||||
_spotlightItem.dispose();
|
||||
_pendingSystemShelfItems = null;
|
||||
_indicatorProgress.dispose();
|
||||
_heroController.dispose();
|
||||
@@ -1543,18 +1556,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
Widget _buildTvContent(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
final svc = SettingsService.instance;
|
||||
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
|
||||
final browseHubs = _tvBrowseHubs;
|
||||
final scale = TvLayoutConstants.scaleForSize(size);
|
||||
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
|
||||
context,
|
||||
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
|
||||
);
|
||||
// Only layout-aspect (flip-stable) scope values may be read here: an
|
||||
// offset-aspect read at this level would rebuild the whole screen on
|
||||
// every sidebar focus flip. Offset values are read in small Builders
|
||||
// around the widgets that position against them.
|
||||
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
|
||||
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
||||
final railHeight = browseHubs.isEmpty
|
||||
? 0.0
|
||||
: TvBrowseRailLayout.estimateHeight(
|
||||
@@ -1580,21 +1591,35 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: -foregroundLeft,
|
||||
width: fullBleedWidth,
|
||||
child: TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: _getMediaClientForItem(spotlight),
|
||||
hideSpoilers: hideSpoilers,
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft + foregroundLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
),
|
||||
// The animated -bleed mirrors the content-slide tween in MainScreen,
|
||||
// keeping the full-bleed background viewport-pinned while the
|
||||
// content box slides during sidebar expansion. The Builder scopes
|
||||
// the offset-aspect dependency to just this subtree.
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
||||
return SideNavigationBleedBuilder(
|
||||
targetBleed: foregroundLeft,
|
||||
child: ValueListenableBuilder<MediaItem?>(
|
||||
valueListenable: _spotlightItem,
|
||||
builder: (context, _, _) {
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
return TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: _getMediaClientForItem(spotlight),
|
||||
hideSpoilers: hideSpoilers,
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft + foregroundLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
|
||||
if (_errorMessage != null)
|
||||
@@ -1642,17 +1667,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onNavigateUp: _focusTopActions,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
backgroundBleedLeft: sidebarBleed,
|
||||
selectSuppressionGestureSignal: PlatformDetector.isAppleTV()
|
||||
? AppleTvRemoteTouchService.instance.touchActiveListenable
|
||||
: null,
|
||||
),
|
||||
),
|
||||
SideNavigationBleedBuilder(
|
||||
targetBleed: sidebarBleed,
|
||||
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
Builder(
|
||||
builder: (context) => SideNavigationBleedBuilder(
|
||||
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
|
||||
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
),
|
||||
),
|
||||
if (_switchingProfile) const ProfileSwitchingOverlay(),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../media/media_server_client.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/debouncer.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
@@ -47,7 +48,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
MediaItem? _spotlightItem;
|
||||
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
|
||||
// TvSpotlightBackground subtree, never the rail/rows.
|
||||
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
|
||||
// Settle delay so d-pad scrubbing across a row doesn't fetch/decode a
|
||||
// full-screen backdrop for every intermediate item.
|
||||
final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150));
|
||||
|
||||
MediaItem? get _defaultSpotlightItem {
|
||||
for (final hub in items) {
|
||||
@@ -57,7 +63,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
}
|
||||
|
||||
MediaItem? get _effectiveSpotlightItem {
|
||||
final current = _spotlightItem;
|
||||
final current = _spotlightItem.value;
|
||||
if (current == null) return _defaultSpotlightItem;
|
||||
for (final hub in items) {
|
||||
if (hub.items.any((item) => item.globalKey == current.globalKey)) return current;
|
||||
@@ -66,8 +72,20 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
}
|
||||
|
||||
void _setSpotlightItem(MediaItem item) {
|
||||
if (_spotlightItem?.globalKey == item.globalKey) return;
|
||||
setState(() => _spotlightItem = item);
|
||||
// Same-key check lives inside the callback: an A→B→A scrub must cancel
|
||||
// the pending B, not early-return and let it fire.
|
||||
_spotlightDebouncer.run(() {
|
||||
if (!mounted) return;
|
||||
if (_spotlightItem.value?.globalKey == item.globalKey) return;
|
||||
_spotlightItem.value = item;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spotlightDebouncer.dispose();
|
||||
_spotlightItem.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -298,19 +316,16 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
|
||||
Widget _buildTvContent(List<MediaHub> items) {
|
||||
final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList();
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final svc = SettingsService.instance;
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId ?? widget.library.serverId));
|
||||
final scale = TvLayoutConstants.scaleForSize(size);
|
||||
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
|
||||
context,
|
||||
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
|
||||
);
|
||||
// Only layout-aspect (flip-stable) scope values may be read here: an
|
||||
// offset-aspect read at this level would rebuild the whole screen on
|
||||
// every sidebar focus flip. Offset values are read in small Builders
|
||||
// around the widgets that position against them.
|
||||
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
|
||||
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
||||
final railHeight = tvHubs.isEmpty
|
||||
? 0.0
|
||||
: TvBrowseRailLayout.estimateHeight(
|
||||
@@ -338,21 +353,38 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: -foregroundLeft,
|
||||
width: fullBleedWidth,
|
||||
child: TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: client,
|
||||
hideSpoilers: svc.read(SettingsService.hideSpoilers),
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft + foregroundLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
),
|
||||
// The animated -bleed mirrors the content-slide tween in
|
||||
// MainScreen, keeping the full-bleed background viewport-pinned
|
||||
// while the content box slides during sidebar expansion. The
|
||||
// Builder scopes the offset-aspect dependency to this subtree.
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
||||
return SideNavigationBleedBuilder(
|
||||
targetBleed: foregroundLeft,
|
||||
child: ValueListenableBuilder<MediaItem?>(
|
||||
valueListenable: _spotlightItem,
|
||||
builder: (context, _, _) {
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
final client = context.tryGetMediaClientForServer(
|
||||
serverIdOrNull(spotlight?.serverId ?? widget.library.serverId),
|
||||
);
|
||||
return TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: client,
|
||||
hideSpoilers: svc.read(SettingsService.hideSpoilers),
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft + foregroundLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (tvHubs.isNotEmpty)
|
||||
Positioned(
|
||||
@@ -371,7 +403,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
onBack: widget.onBack,
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
backgroundBleedLeft: sidebarBleed,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
import '../media/ids.dart';
|
||||
import '../navigation/main_screen_scope.dart';
|
||||
import 'dart:io' show Platform, exit;
|
||||
|
||||
export '../navigation/main_screen_scope.dart'
|
||||
show MainScreenFocusScope, MainScreenScopeAspect, SideNavigationBleedBuilder;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show HardwareKeyboard, KeyDownEvent, KeyRepeatEvent, KeyUpEvent, LogicalKeyboardKey;
|
||||
@@ -34,6 +38,7 @@ import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/libraries_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../widgets/tv_virtual_keyboard.dart';
|
||||
import '../services/api_cache.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
@@ -59,96 +64,9 @@ import '../services/system_shelf_service.dart';
|
||||
import '../watch_together/watch_together.dart';
|
||||
|
||||
/// Provides access to the main screen's focus control.
|
||||
class MainScreenFocusScope extends InheritedWidget {
|
||||
final VoidCallback focusSidebar;
|
||||
final VoidCallback focusContent;
|
||||
final bool isSidebarFocused;
|
||||
final double sideNavigationWidth;
|
||||
final double? reservedSideNavigationWidth;
|
||||
final double? foregroundLeft;
|
||||
final double? foregroundWidth;
|
||||
final double? viewportWidth;
|
||||
final void Function(String libraryGlobalKey)? selectLibrary;
|
||||
final VoidCallback? openSettings;
|
||||
|
||||
const MainScreenFocusScope({
|
||||
super.key,
|
||||
required this.focusSidebar,
|
||||
required this.focusContent,
|
||||
required this.isSidebarFocused,
|
||||
required this.sideNavigationWidth,
|
||||
this.reservedSideNavigationWidth,
|
||||
this.foregroundLeft,
|
||||
this.foregroundWidth,
|
||||
this.viewportWidth,
|
||||
this.selectLibrary,
|
||||
this.openSettings,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static MainScreenFocusScope? of(BuildContext context, {bool listen = true}) {
|
||||
if (listen) return context.dependOnInheritedWidgetOfExactType<MainScreenFocusScope>();
|
||||
return context.getElementForInheritedWidgetOfExactType<MainScreenFocusScope>()?.widget as MainScreenFocusScope?;
|
||||
}
|
||||
|
||||
static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) {
|
||||
final width = of(context)?.sideNavigationWidth;
|
||||
if (width != null) return width;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static double foregroundWidthOf(BuildContext context) {
|
||||
final width = of(context)?.foregroundWidth;
|
||||
if (width != null && width > 0) return width;
|
||||
return MediaQuery.sizeOf(context).width;
|
||||
}
|
||||
|
||||
static double foregroundLeftOf(BuildContext context) {
|
||||
final left = of(context)?.foregroundLeft;
|
||||
if (left != null) return left;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static Size foregroundSizeOf(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return Size(foregroundWidthOf(context), size.height);
|
||||
}
|
||||
|
||||
static double fullBleedWidthOf(BuildContext context) {
|
||||
final width = of(context)?.viewportWidth;
|
||||
if (width != null && width > 0) return width;
|
||||
return MediaQuery.sizeOf(context).width;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(MainScreenFocusScope oldWidget) {
|
||||
return isSidebarFocused != oldWidget.isSidebarFocused ||
|
||||
sideNavigationWidth != oldWidget.sideNavigationWidth ||
|
||||
reservedSideNavigationWidth != oldWidget.reservedSideNavigationWidth ||
|
||||
foregroundLeft != oldWidget.foregroundLeft ||
|
||||
foregroundWidth != oldWidget.foregroundWidth ||
|
||||
viewportWidth != oldWidget.viewportWidth;
|
||||
}
|
||||
}
|
||||
|
||||
class SideNavigationBleedBuilder extends StatelessWidget {
|
||||
final double targetBleed;
|
||||
final Widget? child;
|
||||
final Widget Function(BuildContext context, double bleed, Widget? child) builder;
|
||||
|
||||
const SideNavigationBleedBuilder({super.key, required this.targetBleed, this.child, required this.builder});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: targetBleed),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: builder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
// MainScreenFocusScope and SideNavigationBleedBuilder live in
|
||||
// navigation/main_screen_scope.dart (re-exported above) so widgets like the
|
||||
// browse rail can import the scope without an import cycle through this file.
|
||||
|
||||
@visibleForTesting
|
||||
({double left, double width}) mainScreenSideNavigationContentLayout({
|
||||
@@ -344,6 +262,17 @@ class _MainScreenState extends State<MainScreen>
|
||||
: null;
|
||||
_screens = _buildScreens(_isOffline);
|
||||
|
||||
// Warm the TV keyboard's text-layout caches off the first real open
|
||||
// (measured ~315ms first-open frame on low-end boxes, mostly cold font
|
||||
// shaping). Delayed past the startup burst; no-op off TV.
|
||||
if (PlatformDetector.isTV()) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted) warmUpTvVirtualKeyboardText(context);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Set up Watch Together callbacks immediately (must be synchronous to catch early messages)
|
||||
if (!_isOffline) {
|
||||
_setupWatchTogetherCallback();
|
||||
@@ -1616,9 +1545,14 @@ class _MainScreenState extends State<MainScreen>
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportWidth = constraints.maxWidth;
|
||||
// Layout from the tween END value: deriving it from the
|
||||
// animated value changed MainScreenFocusScope every tick
|
||||
// of the sidebar expansion, rebuilding every dependent
|
||||
// (the whole TV content tree) per frame. The slide is a
|
||||
// paint-only translate on the content below instead.
|
||||
final contentLayout = mainScreenSideNavigationContentLayout(
|
||||
viewportWidth: viewportWidth,
|
||||
currentSideNavigationWidth: contentLeftPadding,
|
||||
currentSideNavigationWidth: targetContentOffset,
|
||||
reservedSideNavigationWidth: reservedContentOffset,
|
||||
);
|
||||
return MainScreenFocusScope(
|
||||
@@ -1641,7 +1575,13 @@ class _MainScreenState extends State<MainScreen>
|
||||
bottom: 0,
|
||||
left: contentLayout.left,
|
||||
width: contentLayout.width,
|
||||
child: contentChild!,
|
||||
// Duration/curve of this tween must stay in
|
||||
// sync with SideNavigationBleedBuilder, which
|
||||
// counter-animates viewport-pinned overlays.
|
||||
child: Transform.translate(
|
||||
offset: Offset(contentLeftPadding - targetContentOffset, 0),
|
||||
child: contentChild!,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../navigation/navigation_tabs.dart';
|
||||
import '../../services/settings_service.dart' hide ThemeMode;
|
||||
import '../../services/settings_service.dart' as settings show ThemeMode;
|
||||
import '../../focus/focusable_slider.dart';
|
||||
import '../../services/device_performance.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/setting_tile.dart';
|
||||
@@ -41,6 +42,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
title: t.settings.tvFullCardLayout,
|
||||
subtitle: t.settings.tvFullCardLayoutDescription,
|
||||
),
|
||||
if (Platform.isAndroid) _visualEffectsSelector(context),
|
||||
SettingSwitchTile(
|
||||
pref: SettingsService.showEpisodeNumberOnCards,
|
||||
icon: Symbols.tag_rounded,
|
||||
@@ -286,6 +288,39 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) {
|
||||
VisualEffectsSetting.auto => t.settings.visualEffectsAuto,
|
||||
VisualEffectsSetting.full => t.settings.visualEffectsFull,
|
||||
VisualEffectsSetting.reduced => t.settings.visualEffectsReduced,
|
||||
};
|
||||
|
||||
Widget _visualEffectsSelector(BuildContext context) =>
|
||||
SettingSelectionTile<VisualEffectsSetting, VisualEffectsSetting>(
|
||||
pref: SettingsService.visualEffects,
|
||||
icon: Symbols.animation_rounded,
|
||||
title: t.settings.visualEffects,
|
||||
subtitleBuilder: _visualEffectsLabel,
|
||||
options: [
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.auto,
|
||||
title: t.settings.visualEffectsAuto,
|
||||
subtitle: t.settings.visualEffectsAutoDescription,
|
||||
),
|
||||
DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull),
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.reduced,
|
||||
title: t.settings.visualEffectsReduced,
|
||||
subtitle: t.settings.visualEffectsReducedDescription,
|
||||
),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
onAfterWrite: (value) {
|
||||
DevicePerformance.setOverrideSync(value);
|
||||
_restartApp(context);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _requireProfileSelection() {
|
||||
return Consumer<ActiveProfileProvider>(
|
||||
builder: (context, activeProvider, _) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter/services.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 DevicePerformance? _instance;
|
||||
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
||||
|
||||
/// ~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}) async {
|
||||
if (_instance == null) {
|
||||
_instance = DevicePerformance._();
|
||||
_instance!._override = override;
|
||||
await _instance!._detect();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary gate for effect chokepoints. Safe before init (full tier).
|
||||
static bool get isReduced {
|
||||
final instance = _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;
|
||||
|
||||
/// Update the user override from the settings screen and re-apply the
|
||||
/// budgets that were computed at boot.
|
||||
static void setOverrideSync(VisualEffectsSetting value) {
|
||||
_instance?._override = value;
|
||||
applyImageCacheBudget();
|
||||
}
|
||||
|
||||
/// Flutter image-cache budget per platform/tier — kept modest to leave
|
||||
/// headroom for Skia decode buffers.
|
||||
static void applyImageCacheBudget() {
|
||||
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 {
|
||||
cache.maximumSize = 800;
|
||||
cache.maximumSizeBytes = 100 << 20; // 100MB
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line tier summary for the startup log, e.g.
|
||||
/// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced)`.
|
||||
static String describeSync() {
|
||||
final instance = _instance;
|
||||
if (instance == null) return 'unknown';
|
||||
final tier = isReduced ? 'reduced' : 'full';
|
||||
if (instance._override != VisualEffectsSetting.auto) return '$tier (forced)';
|
||||
final signals = <String>[
|
||||
if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'),
|
||||
if (instance._isLowRam == true) 'lowRam',
|
||||
if (instance._totalMemBytes != null) '${(instance._totalMemBytes! / (1024 * 1024 * 1024)).toStringAsFixed(1)}GiB',
|
||||
];
|
||||
return signals.isEmpty ? tier : '$tier (auto: ${signals.join(', ')})';
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
||||
if (autoReduced == null && override == null) {
|
||||
_instance = null;
|
||||
return;
|
||||
}
|
||||
_instance ??= DevicePerformance._();
|
||||
if (autoReduced != null) _instance!._autoReduced = autoReduced;
|
||||
if (override != null) _instance!._override = override;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../models/mpv_config_models.dart';
|
||||
import '../models/external_player_models.dart';
|
||||
import 'base_shared_preferences_service.dart';
|
||||
import 'device_performance.dart';
|
||||
export 'base_shared_preferences_service.dart'
|
||||
show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref;
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
@@ -362,6 +363,11 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const requireProfileSelectionOnOpen = BoolPref('require_profile_selection_on_open');
|
||||
static const useExternalPlayer = BoolPref('use_external_player');
|
||||
static const forceTvMode = BoolPref('force_tv_mode');
|
||||
static const visualEffects = EnumPref<VisualEffectsSetting>(
|
||||
'visual_effects',
|
||||
values: VisualEffectsSetting.values,
|
||||
defaultValue: VisualEffectsSetting.auto,
|
||||
);
|
||||
static const ambientLighting = BoolPref('ambient_lighting');
|
||||
static const audioPassthrough = BoolPref('audio_passthrough');
|
||||
static const audioNormalization = BoolPref('audio_normalization');
|
||||
@@ -754,6 +760,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
requireProfileSelectionOnOpen,
|
||||
useExternalPlayer,
|
||||
forceTvMode,
|
||||
visualEffects,
|
||||
ambientLighting,
|
||||
audioPassthrough,
|
||||
audioNormalization,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Trailing-edge debouncer: [run] (re)starts the timer; only the last action
|
||||
/// within [delay] executes. Call [dispose] from the owning State's dispose.
|
||||
class Debouncer {
|
||||
Debouncer(this.delay);
|
||||
|
||||
final Duration delay;
|
||||
Timer? _timer;
|
||||
|
||||
void run(VoidCallback action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delay, action);
|
||||
}
|
||||
|
||||
void cancel() => _timer?.cancel();
|
||||
|
||||
void dispose() => cancel();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import 'platform_detector.dart';
|
||||
|
||||
/// Image types for different transcoding strategies
|
||||
@@ -41,6 +42,13 @@ class MediaImageHelper {
|
||||
/// Minimum DPR for TV to ensure sharp artwork on large screens
|
||||
static const double _tvMinDpr = 2.0;
|
||||
|
||||
/// Reduced tier caps: tiles at 1.5× DPR, backdrops at ~720p. Smaller
|
||||
/// transcodes mean fewer bytes fetched AND cheaper decodes on weak 32-bit
|
||||
/// hardware; the art cap is masked by the gradient scrims drawn over it.
|
||||
static const double _reducedMaxDpr = 1.5;
|
||||
static const int _reducedMaxArtWidth = 1280;
|
||||
static const int _reducedMaxArtHeight = 720;
|
||||
|
||||
/// Rounds a value up to the next multiple of [factor]. Shared between the
|
||||
/// URL dimension rounding (transcode bucket) and the mem-cache dimension
|
||||
/// rounding (decode bucket) so both snap to the same grid.
|
||||
@@ -68,6 +76,7 @@ class MediaImageHelper {
|
||||
} catch (_) {
|
||||
dpr = reportedDpr;
|
||||
}
|
||||
if (DevicePerformance.isReduced) return min(dpr, _reducedMaxDpr);
|
||||
if (PlatformDetector.isTV()) dpr = max(dpr, _tvMinDpr);
|
||||
return dpr;
|
||||
}
|
||||
@@ -84,6 +93,13 @@ class MediaImageHelper {
|
||||
|
||||
switch (imageType) {
|
||||
case ImageType.art:
|
||||
if (DevicePerformance.isReduced) {
|
||||
// No 1.1× cover overshoot, capped at ~720p.
|
||||
return roundDimensions(
|
||||
min(targetWidth, _reducedMaxArtWidth.toDouble()),
|
||||
min(targetHeight, _reducedMaxArtHeight.toDouble()),
|
||||
);
|
||||
}
|
||||
final coverWidth = targetWidth * 1.1;
|
||||
final coverHeight = targetHeight * 1.1;
|
||||
|
||||
@@ -218,6 +234,9 @@ class MediaImageHelper {
|
||||
final (int maxW, int maxH) = switch (imageType) {
|
||||
ImageType.poster => (720, 1080),
|
||||
ImageType.thumb => (960, 540),
|
||||
// Match the reduced-tier fetch cap so oversized originals (failed
|
||||
// transcodes, external images) can't decode past the art budget.
|
||||
ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight),
|
||||
ImageType.art => (1920, 1080),
|
||||
ImageType.logo => (600, 300),
|
||||
ImageType.avatar => (300, 300),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/device_performance.dart';
|
||||
import 'layout_constants.dart';
|
||||
|
||||
Route<T> fadeRoute<T>(Widget page) {
|
||||
return PageRouteBuilder<T>(
|
||||
// opaque must stay false: routes composite over the video player layer
|
||||
// below the transparent root scaffold. The reduced tier only drops the
|
||||
// fade (two full-screen layers blending for the whole transition).
|
||||
opaque: false,
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
transitionDuration: AppDurations.animSlow,
|
||||
reverseTransitionDuration: AppDurations.animSlow,
|
||||
transitionDuration: DevicePerformance.reducedDuration(AppDurations.animSlow),
|
||||
reverseTransitionDuration: DevicePerformance.reducedDuration(AppDurations.animSlow),
|
||||
);
|
||||
}
|
||||
|
||||
+206
-201
@@ -135,216 +135,221 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
enableLongPress: true,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onSelect: widget.onTap,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
disableScale: true,
|
||||
child: MediaContextMenu(
|
||||
key: contextMenuKey,
|
||||
item: episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: InkWell(
|
||||
key: Key(episode.id),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
// MergeSemantics: one node per card instead of one per text/progress —
|
||||
// the per-frame semantics pass scales with node count (see MediaCard).
|
||||
// The card has a single action, so merging is safe.
|
||||
child: MergeSemantics(
|
||||
child: FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
enableLongPress: true,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onSelect: widget.onTap,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
disableScale: true,
|
||||
child: MediaContextMenu(
|
||||
key: contextMenuKey,
|
||||
item: episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: widget.onTap,
|
||||
canRequestFocus: false,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: shouldBlur
|
||||
? ClipRect(
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
)
|
||||
: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (hasActiveProgress)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(6),
|
||||
bottomRight: Radius.circular(6),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: tokens(context).outline,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (episode.isWatched && !hasActiveProgress)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens(context).text,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)],
|
||||
),
|
||||
child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Selector<DownloadProvider, _DownloadSlice>(
|
||||
selector: (_, p) =>
|
||||
_DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)),
|
||||
builder: (context, slice, _) {
|
||||
Widget? downloadStatusIcon;
|
||||
|
||||
// Only show download status in online mode
|
||||
if (!widget.isOffline && episode.serverId != null) {
|
||||
final status = slice.status;
|
||||
final mutedBase = tokens(context).textMuted;
|
||||
|
||||
if (slice.isQueueing) {
|
||||
downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase);
|
||||
} else if (status != null) {
|
||||
final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0;
|
||||
downloadStatusIcon = DownloadStatusIcon(
|
||||
status: status,
|
||||
size: iconSize,
|
||||
variant: DownloadStatusIconVariant.muted,
|
||||
mutedBase: mutedBase,
|
||||
progress: slice.progressPercent,
|
||||
);
|
||||
}
|
||||
// Note: No icon shown if not downloaded (null)
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (episode.index != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: Text(
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
fontWeight: .w600,
|
||||
child: InkWell(
|
||||
key: Key(episode.id),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
onTap: widget.onTap,
|
||||
canRequestFocus: false,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: shouldBlur
|
||||
? ClipRect(
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildEpisodeThumbnail(episode),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.title!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: .bold),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
child: const AppIcon(
|
||||
Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
if (PlatformDetector.isTV())
|
||||
Text(
|
||||
episode.summary!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
)
|
||||
else
|
||||
CollapsibleText(
|
||||
text: episode.summary!,
|
||||
maxLines: 3,
|
||||
small: true,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
if (hasActiveProgress)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(6),
|
||||
bottomRight: Radius.circular(6),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: tokens(context).outline,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (episode.isWatched && !hasActiveProgress)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens(context).text,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)],
|
||||
),
|
||||
child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildEpisodeMetaRow(context, episode, qualityLabels),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Selector<DownloadProvider, _DownloadSlice>(
|
||||
selector: (_, p) =>
|
||||
_DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)),
|
||||
builder: (context, slice, _) {
|
||||
Widget? downloadStatusIcon;
|
||||
|
||||
// Only show download status in online mode
|
||||
if (!widget.isOffline && episode.serverId != null) {
|
||||
final status = slice.status;
|
||||
final mutedBase = tokens(context).textMuted;
|
||||
|
||||
if (slice.isQueueing) {
|
||||
downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase);
|
||||
} else if (status != null) {
|
||||
final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0;
|
||||
downloadStatusIcon = DownloadStatusIcon(
|
||||
status: status,
|
||||
size: iconSize,
|
||||
variant: DownloadStatusIconVariant.muted,
|
||||
mutedBase: mutedBase,
|
||||
progress: slice.progressPercent,
|
||||
);
|
||||
}
|
||||
// Note: No icon shown if not downloaded (null)
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (episode.index != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: Text(
|
||||
'E${episode.index}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 11,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
episode.title!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: .bold),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
if (PlatformDetector.isTV())
|
||||
Text(
|
||||
episode.summary!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
)
|
||||
else
|
||||
CollapsibleText(
|
||||
text: episode.summary!,
|
||||
maxLines: 3,
|
||||
small: true,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildEpisodeMetaRow(context, episode, qualityLabels),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Rebuilds [builder] only when [selector]'s result changes (by `==`) after
|
||||
/// [listenable] notifies — unlike [ListenableBuilder], which rebuilds on every
|
||||
/// notification.
|
||||
///
|
||||
/// Pass the expensive subtree as [child]: it is built once by the parent and
|
||||
/// handed through untouched, so a selection flip rebuilds only the cheap
|
||||
/// wrapper in [builder]. Used to give every card in a focus-driven rail its
|
||||
/// own `isFocused` without rebuilding the whole row per d-pad press.
|
||||
class ListenableSelector<T> extends StatefulWidget {
|
||||
const ListenableSelector({
|
||||
super.key,
|
||||
required this.listenable,
|
||||
required this.selector,
|
||||
required this.builder,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final Listenable listenable;
|
||||
|
||||
/// Derives the watched value. Re-evaluated on every notification and on
|
||||
/// widget updates (the closure may capture fresh values from a parent build).
|
||||
final T Function() selector;
|
||||
|
||||
final Widget Function(BuildContext context, T value, Widget? child) builder;
|
||||
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<ListenableSelector<T>> createState() => _ListenableSelectorState<T>();
|
||||
}
|
||||
|
||||
class _ListenableSelectorState<T> extends State<ListenableSelector<T>> {
|
||||
late T _value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = widget.selector();
|
||||
widget.listenable.addListener(_handleChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ListenableSelector<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.listenable, widget.listenable)) {
|
||||
oldWidget.listenable.removeListener(_handleChange);
|
||||
widget.listenable.addListener(_handleChange);
|
||||
}
|
||||
_value = widget.selector();
|
||||
}
|
||||
|
||||
void _handleChange() {
|
||||
final next = widget.selector();
|
||||
if (next == _value) return;
|
||||
setState(() => _value = next);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.listenable.removeListener(_handleChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builder(context, _value, widget.child);
|
||||
}
|
||||
@@ -253,19 +253,28 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
|
||||
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
|
||||
/// flattened Column. Semantics removed (InkWell provides button semantics).
|
||||
///
|
||||
/// MergeSemantics collapses the card (texts, progress, button) into ONE
|
||||
/// semantics node. Browse rails/grids show dozens of cards and the
|
||||
/// platform-driven semantics pass runs every frame on TV boxes with an
|
||||
/// accessibility service active — node count is the cost driver. The card
|
||||
/// has a single action (tap; long-press menu), so merging is safe and gives
|
||||
/// screen readers one coherent announcement per card.
|
||||
Widget _buildGridCard(BuildContext context, Object item, String? localPosterPath) {
|
||||
if (widget.fullBleedImage) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardWidth = widget.width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : null);
|
||||
final cardHeight = widget.height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : null);
|
||||
if (cardHeight == null) return _buildStandardGridCard(context, item, localPosterPath);
|
||||
return _buildFullBleedGridCard(context, item, localPosterPath, width: cardWidth, height: cardHeight);
|
||||
},
|
||||
return MergeSemantics(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardWidth = widget.width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : null);
|
||||
final cardHeight = widget.height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : null);
|
||||
if (cardHeight == null) return _buildStandardGridCard(context, item, localPosterPath);
|
||||
return _buildFullBleedGridCard(context, item, localPosterPath, width: cardWidth, height: cardHeight);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildStandardGridCard(context, item, localPosterPath);
|
||||
return MergeSemantics(child: _buildStandardGridCard(context, item, localPosterPath));
|
||||
}
|
||||
|
||||
Widget _buildFullBleedGridCard(
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
@@ -249,7 +250,7 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
// CachedNetworkImageProvider.
|
||||
cacheHeight: memHeight > 0 ? memHeight : null,
|
||||
fit: fit,
|
||||
filterQuality: filterQuality,
|
||||
filterQuality: _effectiveFilterQuality,
|
||||
alignment: alignment,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (errorWidget != null) {
|
||||
@@ -260,6 +261,10 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reduced tier: bilinear-only sampling — mip generation per texture upload
|
||||
/// adds up during scroll decode bursts on weak GPUs.
|
||||
FilterQuality get _effectiveFilterQuality => DevicePerformance.isReduced ? FilterQuality.low : filterQuality;
|
||||
|
||||
static double _resolvedDimension(double? explicit, double constraintMax, double fallback) {
|
||||
// Pick the explicit size when it's a finite positive number, otherwise
|
||||
// fall back to the constraint or a sensible default so we don't end up
|
||||
@@ -311,7 +316,7 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
filterQuality: filterQuality,
|
||||
filterQuality: _effectiveFilterQuality,
|
||||
alignment: alignment,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_imageFailureCount++;
|
||||
@@ -328,6 +333,11 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
},
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
// Reduced tier: swap in directly — each in-flight fade is a tile-sized
|
||||
// saveLayer, and grid scrolling runs many of them concurrently.
|
||||
if (DevicePerformance.isReduced) {
|
||||
return frame != null ? child : _buildPlaceholder(context, imageUrl);
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: frame != null ? child : _buildPlaceholder(context, imageUrl),
|
||||
|
||||
+105
-44
@@ -12,6 +12,7 @@ import '../focus/focus_theme.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/locked_hub_controller.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../navigation/main_screen_scope.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../screens/hub_detail_screen.dart';
|
||||
@@ -25,6 +26,7 @@ import 'app_icon.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
import 'focus_builders.dart';
|
||||
import 'horizontal_scroll_with_arrows.dart';
|
||||
import 'listenable_selector.dart';
|
||||
import 'media_card.dart';
|
||||
import 'optimized_media_image.dart';
|
||||
import 'settings_builder.dart';
|
||||
@@ -319,7 +321,11 @@ class TvBrowseRail extends StatefulWidget {
|
||||
final bool autofocus;
|
||||
final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub;
|
||||
final double Function(MediaHub hub)? widePosterScaleForHub;
|
||||
final double backgroundBleedLeft;
|
||||
|
||||
/// Explicit background bleed override. When null, the bleed target is read
|
||||
/// from [MainScreenFocusScope] (offset aspect) inside the bleed widget
|
||||
/// itself, so sidebar flips never rebuild the rail — only the bleed layer.
|
||||
final double? backgroundBleedLeft;
|
||||
|
||||
/// Optional signal that is `true` while an input gesture (e.g. a Siri-remote
|
||||
/// touch) is in progress. When select-suppression is armed during an active
|
||||
@@ -352,7 +358,7 @@ class TvBrowseRail extends StatefulWidget {
|
||||
this.autofocus = false,
|
||||
this.episodePosterModeForHub,
|
||||
this.widePosterScaleForHub,
|
||||
this.backgroundBleedLeft = 0,
|
||||
this.backgroundBleedLeft,
|
||||
this.selectSuppressionGestureSignal,
|
||||
});
|
||||
|
||||
@@ -386,6 +392,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
int _hubIndex = 0;
|
||||
int _itemIndex = 0;
|
||||
|
||||
/// Mirrors (_hubIndex, _itemIndex) for the per-card focus selectors, so
|
||||
/// plain left/right moves repaint only the two affected cards instead of
|
||||
/// setState-rebuilding every visible row (expensive on low-end TVs).
|
||||
final _RailFocusPosition _focusPosition = _RailFocusPosition();
|
||||
List<double> _sectionOffsets = const [];
|
||||
double _sectionMaxScrollExtent = 0;
|
||||
Timer? _longPressTimer;
|
||||
@@ -440,6 +451,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
_selectInitialHubIfPossible();
|
||||
final selectedInitialItem = _selectInitialItemIfPossible();
|
||||
_focusPosition.set(_hubIndex, _itemIndex, notify: false);
|
||||
_rememberTrailingStates();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || widget.hubs.isEmpty) return;
|
||||
@@ -476,6 +488,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
if (widget.hubs.isEmpty) {
|
||||
_hubIndex = 0;
|
||||
_itemIndex = 0;
|
||||
_focusPosition.set(_hubIndex, _itemIndex, notify: false);
|
||||
_rememberTrailingStates();
|
||||
return;
|
||||
}
|
||||
@@ -496,6 +509,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
if (hub == null) return;
|
||||
_itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1);
|
||||
final selectedInitialItem = _selectInitialItemIfPossible();
|
||||
// notify:false — this runs during the build phase and the enclosing
|
||||
// rebuild already refreshes every selector.
|
||||
_focusPosition.set(_hubIndex, _itemIndex, notify: false);
|
||||
final activeHubChanged = oldActiveHubId != _activeHub?.id;
|
||||
final activeHubStateChanged =
|
||||
_hubStateChanged(oldWidget.hubs, widget.hubs, _hubIndex) ||
|
||||
@@ -564,6 +580,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_detachGestureSignalListener();
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_focusNode.dispose();
|
||||
_focusPosition.dispose();
|
||||
for (final controller in _scrollControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
@@ -689,10 +706,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
if (key.isLeftKey) {
|
||||
if (_itemIndex > 0) {
|
||||
setState(() {
|
||||
_itemIndex--;
|
||||
_hasUserChangedItem = true;
|
||||
});
|
||||
// No setState: the per-card focus selectors repaint the two affected
|
||||
// cards via _focusPosition; nothing else in the rail depends on it.
|
||||
_itemIndex--;
|
||||
_hasUserChangedItem = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex);
|
||||
_rememberFocus(hub);
|
||||
_notifyFocusedItem();
|
||||
_scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
|
||||
@@ -704,10 +722,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
if (key.isRightKey) {
|
||||
if (_itemIndex < _totalItemCount(hub) - 1) {
|
||||
setState(() {
|
||||
_itemIndex++;
|
||||
_hasUserChangedItem = true;
|
||||
});
|
||||
_itemIndex++;
|
||||
_hasUserChangedItem = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex);
|
||||
_rememberFocus(hub);
|
||||
_notifyFocusedItem();
|
||||
_scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
|
||||
@@ -749,6 +766,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_hubIndex = next;
|
||||
_itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1);
|
||||
_hasUserChangedHub = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex, notify: false);
|
||||
});
|
||||
_notifyFocusedItem();
|
||||
_notifyActiveHubChanged();
|
||||
@@ -792,10 +810,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
void _setHoveredItem(MediaHub hub, int index) {
|
||||
if (_activeHub?.id != hub.id || index >= hub.items.length || _itemIndex == index) return;
|
||||
setState(() {
|
||||
_itemIndex = index;
|
||||
_hasUserChangedItem = true;
|
||||
});
|
||||
_itemIndex = index;
|
||||
_hasUserChangedItem = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex);
|
||||
_rememberFocus(hub);
|
||||
_notifyFocusedItem();
|
||||
}
|
||||
@@ -813,6 +830,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_itemIndex = clampedItemIndex;
|
||||
_hasUserChangedHub = true;
|
||||
_hasUserChangedItem = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex, notify: false);
|
||||
});
|
||||
_rememberFocus(hub);
|
||||
_notifyFocusedItem();
|
||||
@@ -1228,45 +1246,66 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
),
|
||||
itemCount: totalCount,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex;
|
||||
// Focus is observed through _focusPosition so a d-pad move
|
||||
// rebuilds only the cheap wrapper of the two affected cards;
|
||||
// the card content below is passed through as a stable child.
|
||||
bool isItemFocused() => hasFocus && _focusPosition.value == (hubIndex, itemIndex);
|
||||
|
||||
if (itemIndex == hub.items.length) {
|
||||
return Padding(
|
||||
padding: .only(right: metrics.itemGap),
|
||||
child: Align(
|
||||
alignment: .centerLeft,
|
||||
child: _buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale),
|
||||
child: ListenableSelector<bool>(
|
||||
listenable: _focusPosition,
|
||||
selector: isItemFocused,
|
||||
builder: (context, isFocused, _) =>
|
||||
_buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = hub.items[itemIndex];
|
||||
final focusableCard = FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isFocused,
|
||||
borderRadius: tokens(context).radiusSm,
|
||||
focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale,
|
||||
focusBorderStrokeAlign: fullCardLayout ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
|
||||
useFocusGlow: fullCardLayout,
|
||||
useForegroundFocusDecoration: fullCardLayout,
|
||||
glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null,
|
||||
onTap: () {
|
||||
_selectHubItem(hub, hubIndex, itemIndex);
|
||||
unawaited(_activateCurrentItem());
|
||||
},
|
||||
onLongPress: metrics.isPersonHub
|
||||
? null
|
||||
: () {
|
||||
_selectHubItem(hub, hubIndex, itemIndex);
|
||||
_cardKeyFor(hub, itemIndex).currentState?.showContextMenu();
|
||||
},
|
||||
final focusableCard = ListenableSelector<bool>(
|
||||
listenable: _focusPosition,
|
||||
selector: isItemFocused,
|
||||
builder: (context, isFocused, child) => FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isFocused,
|
||||
borderRadius: tokens(context).radiusSm,
|
||||
focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale,
|
||||
focusBorderStrokeAlign: fullCardLayout
|
||||
? BorderSide.strokeAlignOutside
|
||||
: BorderSide.strokeAlignInside,
|
||||
useFocusGlow: fullCardLayout,
|
||||
useForegroundFocusDecoration: fullCardLayout,
|
||||
glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null,
|
||||
onTap: () {
|
||||
_selectHubItem(hub, hubIndex, itemIndex);
|
||||
unawaited(_activateCurrentItem());
|
||||
},
|
||||
onLongPress: metrics.isPersonHub
|
||||
? null
|
||||
: () {
|
||||
_selectHubItem(hub, hubIndex, itemIndex);
|
||||
_cardKeyFor(hub, itemIndex).currentState?.showContextMenu();
|
||||
},
|
||||
child: child!,
|
||||
),
|
||||
// MergeSemantics: one node per card (MediaCard merges
|
||||
// internally) — the per-frame semantics pass scales with
|
||||
// node count on TV boxes with an accessibility service.
|
||||
child: metrics.isPersonHub
|
||||
? _buildPersonCard(
|
||||
context,
|
||||
item,
|
||||
cardWidth: metrics.cardWidth,
|
||||
imageSize: metrics.posterHeight,
|
||||
scale: scale,
|
||||
fullCardLayout: fullCardLayout,
|
||||
? MergeSemantics(
|
||||
child: _buildPersonCard(
|
||||
context,
|
||||
item,
|
||||
cardWidth: metrics.cardWidth,
|
||||
imageSize: metrics.posterHeight,
|
||||
scale: scale,
|
||||
fullCardLayout: fullCardLayout,
|
||||
),
|
||||
)
|
||||
: MediaCard(
|
||||
key: _cardKeyFor(hub, itemIndex),
|
||||
@@ -1572,15 +1611,19 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
class _RailBackgroundBleed extends StatelessWidget {
|
||||
final double width;
|
||||
final double targetBleedLeft;
|
||||
|
||||
/// Explicit target; when null the value comes from [MainScreenFocusScope]
|
||||
/// (offset aspect) so sidebar flips rebuild only this widget, not the rail.
|
||||
final double? targetBleedLeft;
|
||||
final Color backgroundColor;
|
||||
|
||||
const _RailBackgroundBleed({required this.width, required this.targetBleedLeft, required this.backgroundColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final target = targetBleedLeft ?? MainScreenFocusScope.sideNavigationBleedOf(context);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: targetBleedLeft),
|
||||
tween: Tween(end: target),
|
||||
duration: FocusTheme.getAnimationDuration(context),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: DecoratedBox(
|
||||
@@ -1627,3 +1670,21 @@ class _RailClipper extends CustomClipper<Rect> {
|
||||
oldClipper.bottomOverflow != bottomOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
/// (hubIndex, itemIndex) focus position observed by the per-card
|
||||
/// [ListenableSelector]s. `notify: false` covers build-phase syncs
|
||||
/// (initState/didUpdateWidget/setState), where notifying would call setState
|
||||
/// on descendants mid-build and the enclosing rebuild refreshes the selectors
|
||||
/// anyway.
|
||||
class _RailFocusPosition extends ChangeNotifier {
|
||||
(int, int) _value = (0, 0);
|
||||
|
||||
(int, int) get value => _value;
|
||||
|
||||
void set(int hubIndex, int itemIndex, {bool notify = true}) {
|
||||
final next = (hubIndex, itemIndex);
|
||||
if (next == _value) return;
|
||||
_value = next;
|
||||
if (notify) notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../providers/watch_state_store.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/formatters.dart';
|
||||
@@ -56,7 +57,9 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
// Reduced tier swaps instantly: the cross-fade keeps two full-screen
|
||||
// stacks (backdrop + two full-screen gradients each) blending per frame.
|
||||
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
child: SizedBox.expand(
|
||||
@@ -161,6 +164,10 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.cover,
|
||||
memCacheHeight: memHeight,
|
||||
// Explicit fades: the package defaults (500ms in / 1000ms out) double
|
||||
// up with the AnimatedSwitcher cross-fade above on every swap.
|
||||
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
placeholder: (context, url) => ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
@@ -276,6 +283,8 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
fit: BoxFit.contain,
|
||||
alignment: .centerLeft,
|
||||
memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(),
|
||||
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,39 @@ import '../mixins/mounted_set_state_mixin.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
|
||||
bool _keyboardTextWarmedUp = false;
|
||||
|
||||
/// One-shot warm-up of the keyboard's text layout caches.
|
||||
///
|
||||
/// The first keyboard open lays out ~60 key labels in one frame; on low-end
|
||||
/// TVs the first paragraph alone measured 130ms+ (cold font/shaping caches)
|
||||
/// and the full first open ~315ms. Laying the keyboard's glyph set out once
|
||||
/// during startup idle moves that cost off the first real open. Subsequent
|
||||
/// calls are no-ops.
|
||||
void warmUpTvVirtualKeyboardText(BuildContext context) {
|
||||
if (_keyboardTextWarmedUp || !PlatformDetector.isTV()) return;
|
||||
_keyboardTextWarmedUp = true;
|
||||
// Matches the key cap style (titleLarge w800, see _buildKey) at the sizes
|
||||
// the metrics clamp to; shaping caches are per font/size.
|
||||
final baseStyle = Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800);
|
||||
const samples = [
|
||||
'abcdefghijklmnopqrstuvwxyz',
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
'0123456789 @#_/:=&-+()[]{}<>!?\'".,;*%\\^~|',
|
||||
'Space Del Line Shift Symbols Done Cancel Clear Search Next Go',
|
||||
];
|
||||
for (final fontSize in const [18.0, 22.0]) {
|
||||
final style = baseStyle?.copyWith(fontSize: fontSize);
|
||||
for (final sample in samples) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: sample, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
painter.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showTvVirtualKeyboard({
|
||||
required BuildContext context,
|
||||
required TextEditingController controller,
|
||||
|
||||
@@ -182,8 +182,15 @@ void main() {
|
||||
: desiredSpotlightBottom;
|
||||
expect(spotlightBackground.contentBottom, closeTo(expectedSpotlightBottom, 0.001));
|
||||
|
||||
// The rail no longer receives the bleed via constructor (a per-flip param
|
||||
// would rebuild the whole rail); its bleed layer reads the scope's
|
||||
// sideNavigationWidth itself. Assert the rendered bleed position instead.
|
||||
final browseRail = tester.widget<TvBrowseRail>(find.byType(TvBrowseRail));
|
||||
expect(browseRail.backgroundBleedLeft, targetSidebarOffset);
|
||||
expect(browseRail.backgroundBleedLeft, isNull);
|
||||
final railBleedPositions = tester
|
||||
.widgetList<Positioned>(find.descendant(of: find.byType(TvBrowseRail), matching: find.byType(Positioned)))
|
||||
.where((p) => p.left == -targetSidebarOffset);
|
||||
expect(railBleedPositions, isNotEmpty, reason: 'rail bleed layer positions at -sideNavigationWidth');
|
||||
|
||||
final backgroundPosition = tester.widget<Positioned>(
|
||||
find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,
|
||||
|
||||
Reference in New Issue
Block a user