perf: ValueNotifier scroll state, static skeletons, scoped selects
This commit is contained in:
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user