fix: platlist drag performance

This commit is contained in:
edde746
2026-02-03 04:19:36 +01:00
parent 87fa2d765b
commit c3e7fae312
3 changed files with 183 additions and 171 deletions
@@ -64,8 +64,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
int? _originalIndex;
List<PlexMetadata>? _originalOrder;
// Scroll-into-view keys
final Map<int, GlobalKey> _itemKeys = {};
// Estimated item height for scroll-into-view (card + vertical margins)
static const double _estimatedItemHeight = 114.0;
// App bar focus state
bool _isAppBarFocused = false;
@@ -356,14 +356,23 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
);
}
/// Ensure the focused item is visible in the list
/// Ensure the focused item is visible in the list using scroll arithmetic.
/// Uses estimated item height instead of per-item GlobalKeys.
void _ensureFocusedVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final key = _itemKeys[_focusedIndex];
final context = key?.currentContext;
if (context != null) {
Scrollable.ensureVisible(context, alignment: 0.25, duration: const Duration(milliseconds: 200));
if (!mounted || !_scrollController.hasClients) return;
final targetOffset = _focusedIndex * _estimatedItemHeight;
final viewportHeight = _scrollController.position.viewportDimension;
final currentOffset = _scrollController.offset;
// Check if the item is outside the visible area (with some padding)
if (targetOffset < currentOffset || targetOffset > currentOffset + viewportHeight - _estimatedItemHeight) {
// Scroll so the item sits ~25% from the top of the viewport
final scrollTo = (targetOffset - viewportHeight * 0.25).clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent,
);
_scrollController.animateTo(scrollTo, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
});
}
@@ -702,6 +711,61 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
Widget build(BuildContext context) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
// For regular playlists, wrap the scroll view with the Focus widget
// (Focus is a RenderObject widget and cannot directly wrap a sliver)
final needsListFocus = !widget.playlist.smart && items.isNotEmpty;
Widget scrollView = CustomScrollView(
controller: _scrollController,
slivers: [
CustomAppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
if (widget.playlist.smart)
Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
const SizedBox(width: 4),
Text(
t.playlists.smartPlaylist,
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
),
],
),
],
),
actions: _buildFocusableAppBarActions(),
),
...buildStateSlivers(),
if (items.isNotEmpty)
if (widget.playlist.smart)
// Smart playlists: Use focusable grid view (cannot be reordered)
_buildSmartPlaylistGrid(isKeyboardMode)
else
// Regular playlists: Use sliver reorderable list
_buildReorderableList(isKeyboardMode),
],
);
if (needsListFocus) {
scrollView = Focus(
autofocus: isKeyboardMode && !_isAppBarFocused,
focusNode: _listFocusNode,
onKeyEvent: _handleListKeyEvent,
onFocusChange: (hasFocus) {
if (hasFocus && mounted) {
setState(() {
_isAppBarFocused = false;
});
}
},
child: scrollView,
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
@@ -712,42 +776,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
CustomAppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
if (widget.playlist.smart)
Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
const SizedBox(width: 4),
Text(
t.playlists.smartPlaylist,
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
),
],
),
],
),
actions: _buildFocusableAppBarActions(),
),
...buildStateSlivers(),
if (items.isNotEmpty)
if (widget.playlist.smart)
// Smart playlists: Use focusable grid view (cannot be reordered)
_buildSmartPlaylistGrid(isKeyboardMode)
else
// Regular playlists: Use reorderable list view with focus
_buildReorderableList(isKeyboardMode),
],
),
),
child: Scaffold(body: scrollView),
);
}
@@ -789,49 +818,31 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
/// Build a reorderable list for regular playlists with focus support
Widget _buildReorderableList(bool isKeyboardMode) {
return SliverToBoxAdapter(
child: Focus(
autofocus: isKeyboardMode && items.isNotEmpty && !_isAppBarFocused,
focusNode: _listFocusNode,
onKeyEvent: _handleListKeyEvent,
onFocusChange: (hasFocus) {
// Rebuild when focus changes to update visual indicators
if (hasFocus && mounted) {
setState(() {
_isAppBarFocused = false;
});
}
},
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorder: _onReorder,
buildDefaultDragHandles: false,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
// Check keyboard mode directly to ensure we get latest value
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
final isFocused = inKeyboardMode && index == _focusedIndex && !_isAppBarFocused;
final isMoving = index == _movingIndex;
return SliverReorderableList(
onReorder: _onReorder,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
// Check keyboard mode directly to ensure we get latest value
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
final isFocused = inKeyboardMode && index == _focusedIndex && !_isAppBarFocused;
final isMoving = index == _movingIndex;
_itemKeys.putIfAbsent(index, () => GlobalKey());
return PlaylistItemCard(
key: _itemKeys[index] ?? ValueKey(item.playlistItemID ?? item.ratingKey),
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: !widget.playlist.smart,
isFocused: isFocused,
focusedColumn: isFocused ? _focusedColumn : null,
isMoving: isMoving,
);
},
),
),
return RepaintBoundary(
key: ValueKey(item.playlistItemID ?? item.ratingKey),
child: PlaylistItemCard(
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: !widget.playlist.smart,
isFocused: isFocused,
focusedColumn: isFocused ? _focusedColumn : null,
isMoving: isMoving,
),
);
},
);
}
}
+15 -25
View File
@@ -12,7 +12,7 @@ import '../../widgets/plex_optimized_image.dart';
/// Custom list item widget for playlist items
/// Shows drag handle, poster, title/metadata, duration, and remove button
class PlaylistItemCard extends StatefulWidget {
class PlaylistItemCard extends StatelessWidget {
final PlexMetadata item;
final int index;
final VoidCallback onRemove;
@@ -38,29 +38,21 @@ class PlaylistItemCard extends StatefulWidget {
this.isMoving = false,
});
@override
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
}
class _PlaylistItemCardState extends State<PlaylistItemCard> {
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
@override
Widget build(BuildContext context) {
final item = widget.item;
final colorScheme = Theme.of(context).colorScheme;
// Determine if row is focused (main content area)
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
final isRowFocused = isFocused && focusedColumn == 0;
// Focus states for individual elements
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
final isDragHandleFocused = isFocused && focusedColumn == 1;
final isRemoveButtonFocused = isFocused && focusedColumn == 2;
// Determine card styling based on focus/move state
Color? cardColor;
ShapeBorder? cardShape;
if (widget.isMoving) {
if (isMoving) {
cardColor = colorScheme.primaryContainer;
} else if (isRowFocused) {
// Row is focused - use visible border like FocusableWrapper
@@ -72,27 +64,26 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
}
return MediaContextMenu(
key: _contextMenuKey,
item: item,
onRefresh: widget.onRefresh,
onTap: widget.onTap,
onRefresh: onRefresh,
onTap: onTap,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: cardColor,
shape: cardShape,
child: InkWell(
onTap: widget.onTap,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Drag handle (if reorderable)
// Wrapped in GestureDetector to consume long-press and prevent context menu
if (widget.canReorder)
if (canReorder)
GestureDetector(
onLongPress: () {},
child: ReorderableDragStartListener(
index: widget.index,
index: index,
child: Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(right: 4),
@@ -100,9 +91,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8))
: null,
child: AppIcon(
widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
fill: 1,
color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey,
color: (isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey,
),
),
),
@@ -162,7 +153,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
: null,
child: IconButton(
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
onPressed: widget.onRemove,
onPressed: onRemove,
tooltip: t.playlists.removeItem,
color: isRemoveButtonFocused ? colorScheme.primary : Colors.grey[400],
),
@@ -177,11 +168,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
/// Get the correct PlexClient for this item's server
PlexClient _getClientForItem(BuildContext context) {
return context.getClientForServer(widget.item.serverId!);
return context.getClientForServer(item.serverId!);
}
Widget _buildPosterImage(BuildContext context) {
final posterUrl = widget.item.posterThumb();
final posterUrl = item.posterThumb();
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: PlexOptimizedImage.poster(
@@ -206,7 +197,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
}
String _buildSubtitle() {
final item = widget.item;
final itemType = item.type.toLowerCase();
if (itemType == 'episode') {
+71 -60
View File
@@ -219,6 +219,11 @@ class PlexOptimizedImage extends StatelessWidget {
localFilePath: localFilePath,
);
/// Whether both width and height are explicitly set to finite positive values,
/// meaning we can skip the LayoutBuilder.
bool get _hasKnownDimensions =>
width != null && width!.isFinite && width! > 0 && height != null && height!.isFinite && height! > 0;
@override
Widget build(BuildContext context) {
// Check for local file first
@@ -237,77 +242,83 @@ class PlexOptimizedImage extends StatelessWidget {
}
}
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
// with NaN/Infinity when rounding to ints for caching.
// When explicit is infinite (double.infinity), prefer the constraint over fallback.
if (explicit == null || explicit.isNaN || explicit.isInfinite || explicit <= 0) {
if (constraintMax.isFinite && constraintMax > 0) {
return constraintMax;
}
return fallback;
}
return explicit;
}
// Return empty container if no image path
if (imagePath == null || imagePath!.isEmpty) {
return _buildFallback(context);
}
// Fast path: skip LayoutBuilder when both dimensions are explicitly known
if (_hasKnownDimensions) {
return _buildCachedImage(context, width!, height!);
}
return LayoutBuilder(
builder: (context, constraints) {
final devicePixelRatio = PlexImageHelper.effectiveDevicePixelRatio(context);
// Calculate effective constraints with safe fallbacks
final effectiveWidth = resolvedDimension(width, constraints.maxWidth, 300.0);
final effectiveHeight = resolvedDimension(height, constraints.maxHeight, 450.0);
// Get optimized image URL
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: imagePath,
maxWidth: effectiveWidth,
maxHeight: effectiveHeight,
devicePixelRatio: devicePixelRatio,
enableTranscoding: enableTranscoding && PlexImageHelper.shouldTranscode(imagePath),
imageType: imageType,
);
if (imageUrl.isEmpty) {
return _buildFallback(context);
}
// Calculate memory cache dimensions
final scaledWidth = effectiveWidth * devicePixelRatio;
final scaledHeight = effectiveHeight * devicePixelRatio;
final (memWidth, memHeight) = PlexImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
);
// Generate cache key if not provided
final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl, memWidth, memHeight);
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
filterQuality: filterQuality,
alignment: alignment,
fadeInDuration: fadeInDuration,
memCacheHeight: memHeight,
cacheKey: effectiveCacheKey,
placeholder: placeholder != null ? placeholder! : (context, url) => _buildPlaceholder(context),
errorWidget: errorWidget != null ? errorWidget! : (context, url, error) => _buildErrorWidget(context, error),
httpHeaders: {'User-Agent': 'Plezy Flutter Client'},
);
final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0);
final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0);
return _buildCachedImage(context, effectiveWidth, effectiveHeight);
},
);
}
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
// with NaN/Infinity when rounding to ints for caching.
if (explicit == null || explicit.isNaN || explicit.isInfinite || explicit <= 0) {
if (constraintMax.isFinite && constraintMax > 0) {
return constraintMax;
}
return fallback;
}
return explicit;
}
Widget _buildCachedImage(BuildContext context, double effectiveWidth, double effectiveHeight) {
final devicePixelRatio = PlexImageHelper.effectiveDevicePixelRatio(context);
// Get optimized image URL
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: imagePath,
maxWidth: effectiveWidth,
maxHeight: effectiveHeight,
devicePixelRatio: devicePixelRatio,
enableTranscoding: enableTranscoding && PlexImageHelper.shouldTranscode(imagePath),
imageType: imageType,
);
if (imageUrl.isEmpty) {
return _buildFallback(context);
}
// Calculate memory cache dimensions
final scaledWidth = effectiveWidth * devicePixelRatio;
final scaledHeight = effectiveHeight * devicePixelRatio;
final (memWidth, memHeight) = PlexImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
);
// Generate cache key if not provided
final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl, memWidth, memHeight);
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
filterQuality: filterQuality,
alignment: alignment,
fadeInDuration: fadeInDuration,
memCacheHeight: memHeight,
cacheKey: effectiveCacheKey,
placeholder: placeholder != null ? placeholder! : (context, url) => _buildPlaceholder(context),
errorWidget: errorWidget != null ? errorWidget! : (context, url, error) => _buildErrorWidget(context, error),
httpHeaders: {'User-Agent': 'Plezy Flutter Client'},
);
}
Widget _buildPlaceholder(BuildContext context) {
return SkeletonLoader(
child: fallbackIcon != null