fix: platlist drag performance
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user