diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 4a8dc326..4b0ad271 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -64,8 +64,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen? _originalOrder; - // Scroll-into-view keys - final Map _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 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 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, + ), + ); + }, ); } } diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index dbe44235..6df03429 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -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 createState() => _PlaylistItemCardState(); -} - -class _PlaylistItemCardState extends State { - final _contextMenuKey = GlobalKey(); - @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 { } 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 { ? 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 { : 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 { /// 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 { } String _buildSubtitle() { - final item = widget.item; final itemType = item.type.toLowerCase(); if (itemType == 'episode') { diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index bdd30d8c..8c3e09ec 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -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