perf(tv): reduced visual-effects tier for low-end devices

This commit is contained in:
edde746
2026-06-12 06:30:38 +02:00
parent ad3cc6b986
commit 2b34f4a9d7
25 changed files with 1043 additions and 425 deletions
@@ -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()
}
}
+7 -1
View File
@@ -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) {
+4
View File
@@ -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);
}
+6
View File
@@ -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",
+1 -1
View File
@@ -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
+28 -4
View File
@@ -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
View File
@@ -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);
+148
View File
@@ -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,
);
}
}
+57 -31
View File
@@ -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,
),
),
],
+32 -92
View File
@@ -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, _) {
+129
View File
@@ -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;
}
}
+7
View File
@@ -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,
+21
View File
@@ -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();
}
+19
View File
@@ -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),
+6 -2
View File
@@ -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
View File
@@ -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),
],
),
),
],
),
),
),
),
+68
View File
@@ -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);
}
+17 -8
View File
@@ -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(
+12 -2
View File
@@ -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
View File
@@ -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();
}
}
+10 -1
View File
@@ -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),
),
+33
View File
@@ -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,
+8 -1
View File
@@ -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,