From 2b34f4a9d73711ef0df775f401af689f2623c2ac Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:10:49 +0200 Subject: [PATCH] perf(tv): reduced visual-effects tier for low-end devices --- .../kotlin/com/edde746/plezy/MainActivity.kt | 15 + lib/focus/focus_glow_overlay.dart | 8 +- lib/focus/focus_theme.dart | 4 + lib/i18n/en.i18n.json | 6 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 32 +- lib/main.dart | 21 +- lib/navigation/main_screen_scope.dart | 148 +++++++ lib/screens/discover_screen.dart | 88 ++-- .../tabs/library_recommended_tab.dart | 85 ++-- lib/screens/main_screen.dart | 124 ++---- .../settings/appearance_settings_screen.dart | 35 ++ lib/services/device_performance.dart | 129 ++++++ lib/services/settings_service.dart | 7 + lib/utils/debouncer.dart | 21 + lib/utils/media_image_helper.dart | 19 + lib/utils/navigation_transitions.dart | 8 +- lib/widgets/episode_card.dart | 407 +++++++++--------- lib/widgets/listenable_selector.dart | 68 +++ lib/widgets/media_card.dart | 25 +- lib/widgets/optimized_media_image.dart | 14 +- lib/widgets/tv_browse_rail.dart | 149 +++++-- lib/widgets/tv_spotlight_background.dart | 11 +- lib/widgets/tv_virtual_keyboard.dart | 33 ++ test/screens/discover_screen_test.dart | 9 +- 25 files changed, 1043 insertions(+), 425 deletions(-) create mode 100644 lib/navigation/main_screen_scope.dart create mode 100644 lib/services/device_performance.dart create mode 100644 lib/utils/debouncer.dart create mode 100644 lib/widgets/listenable_selector.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index ee6b456d..ca137398 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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 { + 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() } } diff --git a/lib/focus/focus_glow_overlay.dart b/lib/focus/focus_glow_overlay.dart index 9f609004..f2f1aad3 100644 --- a/lib/focus/focus_glow_overlay.dart +++ b/lib/focus/focus_glow_overlay.dart @@ -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 { @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 { @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 { @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) { diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 94500f0f..318ba84d 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -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()?.fast ?? const Duration(milliseconds: 150); } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 17d37e9b..047e4ff2 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index ca025913..4534b73f 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 408eb8d7..f227909a 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/main.dart b/lib/main.dart index 9da55330..cacab831 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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 = >[]; if (PlatformDetector.isDesktopOS()) { @@ -199,6 +191,8 @@ Future _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 _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 _bootstrapApp() async { if (Platform.isAndroid) { renderer = ' [${await const MethodChannel('com.plezy/theme').invokeMethod('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); diff --git a/lib/navigation/main_screen_scope.dart b/lib/navigation/main_screen_scope.dart new file mode 100644 index 00000000..eeeb87b6 --- /dev/null +++ b/lib/navigation/main_screen_scope.dart @@ -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 { + 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(); + return context.getElementForInheritedWidgetOfExactType()?.widget as MainScreenFocusScope?; + } + + static MainScreenFocusScope? _dependOn(BuildContext context, MainScreenScopeAspect aspect) { + return InheritedModel.inheritFrom(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 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( + tween: Tween(end: targetBleed), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + builder: builder, + child: child, + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 83271729..ff0d9e74 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 final ValueNotifier _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 _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 } 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 } 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 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 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 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( + 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 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(), ], diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index d748a7ac..90c076ce 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -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> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); - MediaItem? _spotlightItem; + // ValueNotifier (not setState) so a spotlight swap rebuilds only the + // TvSpotlightBackground subtree, never the rail/rows. + final ValueNotifier _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 item.globalKey == current.globalKey)) return current; @@ -66,8 +72,20 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _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 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( + 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(); - return context.getElementForInheritedWidgetOfExactType()?.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( - 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 : 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 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 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, diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 4302c139..e419c77c 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -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( + 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( builder: (context, activeProvider, _) { diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart new file mode 100644 index 00000000..ac065171 --- /dev/null +++ b/lib/services/device_performance.dart @@ -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 getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async { + if (_instance == null) { + _instance = DevicePerformance._(); + _instance!._override = override; + await _instance!._detect(); + } + return _instance!; + } + + Future _detect() async { + if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier + try { + final result = await _deviceChannel.invokeMapMethod('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 = [ + 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; + } +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 3a084133..2721995e 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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( + '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, diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 00000000..1d6fb49c --- /dev/null +++ b/lib/utils/debouncer.dart @@ -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(); +} diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index 1b2a947c..6272eb91 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -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), diff --git a/lib/utils/navigation_transitions.dart b/lib/utils/navigation_transitions.dart index 519caa71..cea763f5 100644 --- a/lib/utils/navigation_transitions.dart +++ b/lib/utils/navigation_transitions.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import '../services/device_performance.dart'; import 'layout_constants.dart'; Route fadeRoute(Widget page) { return PageRouteBuilder( + // 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), ); } diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 4ecefa4a..d0edddd8 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -135,216 +135,221 @@ class _EpisodeCardState extends State with ContextMenuTapMixin( - 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( + 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), + ], + ), + ), + ], + ), ), ), ), diff --git a/lib/widgets/listenable_selector.dart b/lib/widgets/listenable_selector.dart new file mode 100644 index 00000000..72eb8f03 --- /dev/null +++ b/lib/widgets/listenable_selector.dart @@ -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 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> createState() => _ListenableSelectorState(); +} + +class _ListenableSelectorState extends State> { + late T _value; + + @override + void initState() { + super.initState(); + _value = widget.selector(); + widget.listenable.addListener(_handleChange); + } + + @override + void didUpdateWidget(ListenableSelector 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); +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 45d23a67..138f05d6 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -253,19 +253,28 @@ class MediaCardState extends State with ContextMenuTapMixin 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), diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index abf038bf..5de086f3 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -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 { 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 _sectionOffsets = const []; double _sectionMaxScrollExtent = 0; Timer? _longPressTimer; @@ -440,6 +451,7 @@ class TvBrowseRailState extends State { _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 { if (widget.hubs.isEmpty) { _hubIndex = 0; _itemIndex = 0; + _focusPosition.set(_hubIndex, _itemIndex, notify: false); _rememberTrailingStates(); return; } @@ -496,6 +509,9 @@ class TvBrowseRailState extends State { 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 { _detachGestureSignalListener(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); + _focusPosition.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -689,10 +706,11 @@ class TvBrowseRailState extends State { 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 { 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 { _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 { 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 { _itemIndex = clampedItemIndex; _hasUserChangedHub = true; _hasUserChangedItem = true; + _focusPosition.set(_hubIndex, _itemIndex, notify: false); }); _rememberFocus(hub); _notifyFocusedItem(); @@ -1228,45 +1246,66 @@ class TvBrowseRailState extends State { ), 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( + 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( + 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 { 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( - 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 { 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(); + } +} diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 572121ba..adcc6d18 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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), ), diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index 0714970e..e7f0ea9e 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -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 showTvVirtualKeyboard({ required BuildContext context, required TextEditingController controller, diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index faa5c602..5e74e194 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -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(find.byType(TvBrowseRail)); - expect(browseRail.backgroundBleedLeft, targetSidebarOffset); + expect(browseRail.backgroundBleedLeft, isNull); + final railBleedPositions = tester + .widgetList(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( find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,