perf: ValueNotifier scroll state, static skeletons, scoped selects

This commit is contained in:
edde746
2026-03-25 08:03:47 +01:00
parent 8ad1cb988f
commit 996e8621fa
2 changed files with 68 additions and 115 deletions
@@ -175,7 +175,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Alpha jump bar state
List<PlexFirstCharacter> _firstCharacters = [];
AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []);
int _currentFirstVisibleIndex = 0;
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0);
int _currentColumnCount = 1;
double _lastCrossAxisExtent = 0;
double _effectiveTopPadding = _gridTopPadding;
@@ -189,8 +189,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Incremented on each jump so that overlapping animations don't clobber each other.
int _jumpScrollGeneration = 0;
// Scroll activity tracking (for phone scroll handle)
bool _isScrollActive = false;
// Scroll activity tracking (for phone scroll handle and range-load gating)
final ValueNotifier<bool> _isScrollActive = ValueNotifier<bool>(false);
Timer? _scrollActivityTimer;
// Eager prefetch throttle
@@ -240,6 +240,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
_alphaJumpBarFocusNode.dispose();
_currentFirstVisibleIndex.dispose();
_isScrollActive.dispose();
disposeGridFocusNodes();
super.dispose();
}
@@ -330,11 +332,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void _resetForFullReload() {
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_isScrollActive = false;
_isScrollActive.value = false;
_hasJumpPin = false;
_isJumpScrolling = false;
_jumpScrollGeneration++;
_currentFirstVisibleIndex = 0;
_currentFirstVisibleIndex.value = 0;
// The browse tab state is kept alive across libraries, so ensure each
// library starts from top instead of inheriting the previous offset.
@@ -379,8 +381,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_alphaHelper = AlphaJumpHelper(const []);
_currentFirstVisibleIndex = 0;
});
_currentFirstVisibleIndex.value = 0;
try {
final storage = await StorageService.getInstance();
@@ -790,7 +792,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void _navigateToGridNearScroll() {
if (_totalSize == 0 || _currentColumnCount < 1) return;
final row = _currentFirstVisibleIndex ~/ _currentColumnCount;
final row = _currentFirstVisibleIndex.value ~/ _currentColumnCount;
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
// Find nearest loaded item — skeleton cards have no FocusNode
@@ -844,9 +846,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// The letter currently visible at the top of the grid, determined by
/// how many items we've scrolled past relative to the API's cumulative
/// firstCharacter counts.
String get _currentAlphaLetter {
return _alphaHelper.currentLetter(_currentFirstVisibleIndex);
}
String _alphaLetterFor(int index) => _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<PlexMetadata, LibraryBr
// updates as soon as items with a new letter appear in that row.
final maxIndex = _totalSize > 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<PlexMetadata, LibraryBr
_hasJumpPin = true;
final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0);
setState(() => _currentFirstVisibleIndex = clamped);
_currentFirstVisibleIndex.value = clamped;
_scrollToItemIndex(clamped);
}
@@ -1055,19 +1055,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
right: 0,
bottom: 0,
child: _isPhone(context)
? AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: _currentAlphaLetter,
isScrolling: _isScrollActive,
? ValueListenableBuilder<int>(
valueListenable: _currentFirstVisibleIndex,
builder: (context, visibleIndex, _) => ValueListenableBuilder<bool>(
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<int>(
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<PlexMetadata, LibraryBr
Widget _buildScrollableContent() {
return NotificationListener<ScrollNotification>(
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<PlexMetadata, LibraryBr
/// Self-healing: when a skeleton is rendered after scrolling stops,
/// ensure the visible range gets loaded even if the scroll-idle path missed it.
void _scheduleRangeLoad() {
if (_rangeLoadScheduled || _isScrollActive) return;
if (_rangeLoadScheduled || _isScrollActive.value) return;
_rangeLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_rangeLoadScheduled = false;
@@ -1479,7 +1488,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Show skeleton placeholder for unloaded items
if (item == null) {
_scheduleRangeLoad();
return _SkeletonCard(animate: !_isScrollActive);
return const _SkeletonCard();
}
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
@@ -1505,40 +1514,36 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Skeleton placeholder card that matches the poster + title layout of a real media card.
/// Not focusable — dpad focus skips over these.
class _SkeletonCard extends StatelessWidget {
final bool animate;
const _SkeletonCard({this.animate = true});
const _SkeletonCard();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
return const Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster area — matches the Expanded poster in _buildGridCard
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SkeletonLoader(animate: animate, child: const SizedBox.expand()),
borderRadius: BorderRadius.all(Radius.circular(8)),
child: SkeletonLoader(child: SizedBox.expand()),
),
),
const SizedBox(height: 4),
SizedBox(height: 4),
// Title bar
SkeletonLoader(
animate: animate,
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: const SizedBox(height: 13, width: double.infinity),
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(height: 13, width: double.infinity),
),
const SizedBox(height: 3),
SizedBox(height: 3),
// Subtitle bar
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.6,
child: SkeletonLoader(
animate: animate,
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: const SizedBox(height: 11),
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(height: 11),
),
),
],
+18 -70
View File
@@ -172,14 +172,13 @@ class MediaCardState extends State<MediaCard> {
@override
Widget build(BuildContext context) {
final settingsProvider = context.watch<SettingsProvider>();
final ViewMode viewMode;
if (widget.forceListMode) {
viewMode = ViewMode.list;
} else if (widget.forceGridMode) {
viewMode = ViewMode.grid;
} else {
viewMode = settingsProvider.viewMode;
viewMode = context.select<SettingsProvider, ViewMode>((s) => s.viewMode);
}
final semanticLabel = _buildSemanticLabel();
@@ -195,7 +194,7 @@ class MediaCardState extends State<MediaCard> {
onLongPress: _showContextMenu,
onSecondaryTapDown: _storeTapPosition,
onSecondaryTap: _showContextMenu,
density: settingsProvider.libraryDensity,
density: context.select<SettingsProvider, LibraryDensity>((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<SettingsProvider>().episodePosterMode;
final mode = context.select<SettingsProvider, EpisodePosterMode>((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<SettingsProvider>().episodePosterMode;
final mode = context.select<SettingsProvider, EpisodePosterMode>((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<SettingsProvider>().hideSpoilers &&
context.select<SettingsProvider, bool>((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<SettingsProvider>();
final episodePosterMode = settingsProvider.episodePosterMode;
final episodePosterMode = context.select<SettingsProvider, EpisodePosterMode>((s) => s.episodePosterMode);
final hideSpoilers = context.select<SettingsProvider, bool>((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<SettingsProvider>().showUnwatchedCount;
final showUnwatchedCount = context.select<SettingsProvider, bool>((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<SkeletonLoader> createState() => _SkeletonLoaderState();
}
class _SkeletonLoaderState extends State<SkeletonLoader> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: const Duration(milliseconds: 1500), vsync: this);
_animation = Tween<double>(
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,
);
}
}