diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1a5b7d49..7ca3c99c 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -175,7 +175,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _firstCharacters = []; AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []); - int _currentFirstVisibleIndex = 0; + final ValueNotifier _currentFirstVisibleIndex = ValueNotifier(0); int _currentColumnCount = 1; double _lastCrossAxisExtent = 0; double _effectiveTopPadding = _gridTopPadding; @@ -189,8 +189,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _isScrollActive = ValueNotifier(false); Timer? _scrollActivityTimer; // Eager prefetch throttle @@ -240,6 +240,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _alphaHelper.currentLetter(index); /// Whether the alpha jump bar should be shown. /// Only shown when sorting by title (titleSort) and not in folders mode. @@ -944,8 +944,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex); - if (lastInRow != _currentFirstVisibleIndex) { - setState(() => _currentFirstVisibleIndex = lastInRow); + if (lastInRow != _currentFirstVisibleIndex.value) { + _currentFirstVisibleIndex.value = lastInRow; } } @@ -978,7 +978,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0); - setState(() => _currentFirstVisibleIndex = clamped); + _currentFirstVisibleIndex.value = clamped; _scrollToItemIndex(clamped); } @@ -1055,19 +1055,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( + valueListenable: _currentFirstVisibleIndex, + builder: (context, visibleIndex, _) => ValueListenableBuilder( + valueListenable: _isScrollActive, + builder: (context, scrolling, _) => AlphaScrollHandle( + firstCharacters: _firstCharacters, + onJump: _jumpToIndex, + currentLetter: _alphaLetterFor(visibleIndex), + isScrolling: scrolling, + ), + ), ) - : AlphaJumpBar( - firstCharacters: _firstCharacters, - onJump: _jumpToIndex, - currentLetter: _currentAlphaLetter, - focusNode: _alphaJumpBarFocusNode, - onNavigateLeft: _navigateToGridNearScroll, - onBack: _navigateToGridNearScroll, + : ValueListenableBuilder( + valueListenable: _currentFirstVisibleIndex, + builder: (context, visibleIndex, _) => AlphaJumpBar( + firstCharacters: _firstCharacters, + onJump: _jumpToIndex, + currentLetter: _alphaLetterFor(visibleIndex), + focusNode: _alphaJumpBarFocusNode, + onNavigateLeft: _navigateToGridNearScroll, + onBack: _navigateToGridNearScroll, + ), ), ), ], @@ -1078,14 +1087,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( onNotification: (notification) { - // Track scroll activity for phone scroll handle + // Track scroll activity for phone scroll handle and range-load gating if (notification is ScrollStartNotification) { - if (!_isScrollActive) setState(() => _isScrollActive = true); + _isScrollActive.value = true; _scrollActivityTimer?.cancel(); } else if (notification is ScrollEndNotification) { _scrollActivityTimer?.cancel(); _scrollActivityTimer = Timer(const Duration(milliseconds: 100), () { - if (mounted) setState(() => _isScrollActive = false); + if (mounted) _isScrollActive.value = false; }); } return false; @@ -1102,7 +1111,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { @override Widget build(BuildContext context) { - final settingsProvider = context.watch(); final ViewMode viewMode; if (widget.forceListMode) { viewMode = ViewMode.list; } else if (widget.forceGridMode) { viewMode = ViewMode.grid; } else { - viewMode = settingsProvider.viewMode; + viewMode = context.select((s) => s.viewMode); } final semanticLabel = _buildSemanticLabel(); @@ -195,7 +194,7 @@ class MediaCardState extends State { onLongPress: _showContextMenu, onSecondaryTapDown: _storeTapPosition, onSecondaryTap: _showContextMenu, - density: settingsProvider.libraryDensity, + density: context.select((s) => s.libraryDensity), isOffline: widget.isOffline, localPosterPath: localPosterPath, showServerName: widget.showServerName, @@ -352,7 +351,7 @@ class _MediaCardList extends StatelessWidget { final base = _basePosterWidth(); // For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size if (item is PlexMetadata) { - final mode = context.watch().episodePosterMode; + final mode = context.select((s) => s.episodePosterMode); if ((item as PlexMetadata).usesWideAspectRatio(mode)) { return base * 1.6; // Wider for 16:9 thumbnails } @@ -364,7 +363,7 @@ class _MediaCardList extends StatelessWidget { final base = _basePosterWidth(); // For episodes with thumbnail mode, use 16:9 aspect ratio if (item is PlexMetadata) { - final mode = context.watch().episodePosterMode; + final mode = context.select((s) => s.episodePosterMode); if ((item as PlexMetadata).usesWideAspectRatio(mode)) { // 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9 return base * 0.9; @@ -627,7 +626,7 @@ class _MediaCardList extends StatelessWidget { ], // Summary (hidden when spoiler protection is active) if (!(item is PlexMetadata && - context.watch().hideSpoilers && + context.select((s) => s.hideSpoilers) && (item as PlexMetadata).shouldHideSpoiler) && item.summary != null) ...[ Text( @@ -702,10 +701,10 @@ Widget _buildPosterImage( localFilePath: localPosterPath, ); } else if (item is PlexMetadata) { - final settingsProvider = context.watch(); - final episodePosterMode = settingsProvider.episodePosterMode; + final episodePosterMode = context.select((s) => s.episodePosterMode); + final hideSpoilers = context.select((s) => s.hideSpoilers); final shouldBlur = - settingsProvider.hideSpoilers && + hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); @@ -842,7 +841,7 @@ class _MediaCardHelpers { /// Builds watch progress overlay (checkmark for watched, progress bar for in-progress) static Widget buildWatchProgress(BuildContext context, PlexMetadata metadata) { - final showUnwatchedCount = context.watch().showUnwatchedCount; + final showUnwatchedCount = context.select((s) => s.showUnwatchedCount); final hasActiveProgress = metadata.viewOffset != null && @@ -1066,72 +1065,21 @@ class _ClickableTextState extends State<_ClickableText> { } } -/// Skeleton loader widget with subtle opacity pulse animation. -/// Set [animate] to false during active scroll to avoid compositor cost. -class SkeletonLoader extends StatefulWidget { +/// Static skeleton placeholder with a fixed semi-transparent fill. +class SkeletonLoader extends StatelessWidget { final Widget? child; final BorderRadius? borderRadius; - final bool animate; - const SkeletonLoader({super.key, this.child, this.borderRadius, this.animate = true}); - - @override - State createState() => _SkeletonLoaderState(); -} - -class _SkeletonLoaderState extends State with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _animation; - - @override - void initState() { - super.initState(); - _animationController = AnimationController(duration: const Duration(milliseconds: 1500), vsync: this); - _animation = Tween( - begin: 0.3, - end: 0.7, - ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); - if (widget.animate) { - _animationController.repeat(reverse: true); - } else { - _animationController.value = 0.5; - } - } - - @override - void didUpdateWidget(SkeletonLoader oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.animate && !oldWidget.animate) { - _animationController.repeat(reverse: true); - } else if (!widget.animate && oldWidget.animate) { - _animationController.stop(); - _animationController.value = 0.5; - } - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } + const SkeletonLoader({super.key, this.child, this.borderRadius}); @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Semantics( - label: "skeleton-loader", - identifier: "skeleton-loader", - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: _animation.value * 0.15), - borderRadius: widget.borderRadius ?? BorderRadius.circular(tokens(context).radiusSm), - ), - child: widget.child, - ), - ); - }, + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.075), + borderRadius: borderRadius ?? BorderRadius.circular(tokens(context).radiusSm), + ), + child: child, ); } }