diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index ca59bcb9..43131602 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -1306,6 +1306,12 @@ class _CollectionTrackTile extends ConsumerWidget { final isLocal = !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); final colorScheme = Theme.of(context).colorScheme; + Widget placeholder() => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); if (isLocal) { return Image.file( @@ -1313,12 +1319,27 @@ class _CollectionTrackTile extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) return child; + return SizedBox( + width: size, + height: size, + child: Stack( + fit: StackFit.expand, + children: [ + placeholder(), + AnimatedOpacity( + opacity: frame == null ? 0.0 : 1.0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: child, + ), + ], + ), + ); + }, + errorBuilder: (_, _, _) => placeholder(), ); } @@ -1329,12 +1350,10 @@ class _CollectionTrackTile extends ConsumerWidget { fit: BoxFit.cover, memCacheWidth: (size * 2).toInt(), cacheManager: CoverCacheManager.instance, - errorWidget: (_, _, _) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + fadeInDuration: const Duration(milliseconds: 180), + fadeOutDuration: const Duration(milliseconds: 90), + placeholder: (_, _) => placeholder(), + errorWidget: (_, _, _) => placeholder(), ); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index d12345bd..8aeed2d5 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -61,6 +61,9 @@ class _QueueTabState extends ConsumerState { final _FileExistsListenableCache _fileExistsCache = _FileExistsListenableCache(); static const int _maxSearchIndexCacheSize = 4000; + static const double _libraryGridMinExtent = 92; + static const double _libraryGridDefaultExtent = 126; + static const double _libraryGridMaxExtent = 190; bool _embeddedCoverRefreshScheduled = false; // Version counter to trigger targeted cover image rebuilds // without rebuilding the entire widget tree via setState. @@ -142,6 +145,8 @@ class _QueueTabState extends ConsumerState { String? _filterFormat; String? _filterMetadata; String _sortMode = 'latest'; + double _libraryGridExtent = _libraryGridDefaultExtent; + double? _libraryGridScaleStartExtent; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); @@ -157,6 +162,30 @@ class _QueueTabState extends ConsumerState { return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0); } + double get _libraryAlbumGridExtent => + (_libraryGridExtent * 1.45).clamp(150.0, 300.0); + + void _handleLibraryGridScaleStart(ScaleStartDetails details) { + if (details.pointerCount < 2) return; + _libraryGridScaleStartExtent = _libraryGridExtent; + } + + void _handleLibraryGridScaleUpdate(ScaleUpdateDetails details) { + final startExtent = _libraryGridScaleStartExtent; + if (startExtent == null || details.pointerCount < 2) return; + + final nextExtent = (startExtent * details.scale).clamp( + _libraryGridMinExtent, + _libraryGridMaxExtent, + ); + if ((nextExtent - _libraryGridExtent).abs() < 0.5) return; + setState(() => _libraryGridExtent = nextExtent); + } + + void _handleLibraryGridScaleEnd(ScaleEndDetails details) { + _libraryGridScaleStartExtent = null; + } + @override void initState() { super.initState(); @@ -1657,6 +1686,43 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-album-artist', ), ), + FilterChip( + label: const Text('Missing track number'), + selected: + tempMetadata == 'missing-track-number', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-track-number', + ), + ), + FilterChip( + label: const Text('Missing disc number'), + selected: tempMetadata == 'missing-disc-number', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-disc-number', + ), + ), + FilterChip( + label: const Text('Missing artist'), + selected: tempMetadata == 'missing-artist', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-artist', + ), + ), + FilterChip( + label: const Text('Incorrect ISRC format'), + selected: + tempMetadata == 'incorrect-isrc-format', + onSelected: (_) => setSheetState( + () => tempMetadata = 'incorrect-isrc-format', + ), + ), + FilterChip( + label: const Text('Missing label'), + selected: tempMetadata == 'missing-label', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-label', + ), + ), ], ), const SizedBox(height: 16), @@ -3221,7 +3287,7 @@ class _QueueTabState extends ConsumerState { } } - return CustomScrollView( + final content = CustomScrollView( slivers: [ if (totalTrackCount > 0 && filterMode == 'all') SliverToBoxAdapter( @@ -3367,13 +3433,11 @@ class _QueueTabState extends ConsumerState { filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), + sliver: _AnimatedLibrarySliverGrid( + maxCrossAxisExtent: _libraryAlbumGridExtent, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.72, delegate: SliverChildBuilderDelegate( (context, index) { if (index < filteredGroupedAlbums.length) { @@ -3406,13 +3470,11 @@ class _QueueTabState extends ConsumerState { if (historyViewMode == 'grid') SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), + sliver: _AnimatedLibrarySliverGrid( + maxCrossAxisExtent: _libraryGridExtent, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.66, delegate: SliverChildBuilderDelegate( (context, index) { final collectionEntries = _getVisibleCollectionEntries( @@ -3578,14 +3640,11 @@ class _QueueTabState extends ConsumerState { historyViewMode == 'grid' ? SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), + sliver: _AnimatedLibrarySliverGrid( + maxCrossAxisExtent: _libraryGridExtent, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.66, delegate: SliverChildBuilderDelegate((context, index) { final item = filteredUnifiedItems[index]; return KeyedSubtree( @@ -3641,6 +3700,15 @@ class _QueueTabState extends ConsumerState { ), ], ); + + if (historyViewMode != 'grid') return content; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onScaleStart: _handleLibraryGridScaleStart, + onScaleUpdate: _handleLibraryGridScaleUpdate, + onScaleEnd: _handleLibraryGridScaleEnd, + child: content, + ); } Widget _buildPauseResumeButton( @@ -5381,6 +5449,8 @@ class _QueueTabState extends ConsumerState { width: coverSize, height: coverSize, borderRadius: BorderRadius.circular(8), + fadeInDuration: const Duration(milliseconds: 180), + fadeOutDuration: const Duration(milliseconds: 90), ) : Container( width: coverSize, @@ -5614,6 +5684,24 @@ class _QueueTabState extends ConsumerState { ); } + Widget fadeInFileImage(Widget child, int? frame, bool wasSync) { + if (wasSync) return child; + final animated = Stack( + fit: StackFit.expand, + children: [ + buildPlaceholder(isLocal: !isDownloaded), + AnimatedOpacity( + opacity: frame == null ? 0.0 : 1.0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: child, + ), + ], + ); + if (size == null) return animated; + return SizedBox(width: size, height: size, child: animated); + } + if (isDownloaded) { final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( item.filePath, @@ -5628,6 +5716,9 @@ class _QueueTabState extends ConsumerState { fit: BoxFit.cover, cacheWidth: cacheSize, cacheHeight: cacheSize, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + fadeInFileImage(child, frame, wasSynchronouslyLoaded), errorBuilder: (context, error, stackTrace) => buildPlaceholder(), ), ); @@ -5644,6 +5735,8 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), placeholder: (context, url) => buildPlaceholder(), errorWidget: (context, url, error) => buildPlaceholder(), + fadeInDuration: const Duration(milliseconds: 180), + fadeOutDuration: const Duration(milliseconds: 90), ); } @@ -5657,6 +5750,9 @@ class _QueueTabState extends ConsumerState { fit: BoxFit.cover, cacheWidth: cacheSize, cacheHeight: cacheSize, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + fadeInFileImage(child, frame, wasSynchronouslyLoaded), errorBuilder: (context, error, stackTrace) => buildPlaceholder(isLocal: true), ), @@ -6101,3 +6197,80 @@ class _QueueTabState extends ConsumerState { ); } } + +class _AnimatedLibrarySliverGrid extends StatefulWidget { + final double maxCrossAxisExtent; + final double mainAxisSpacing; + final double crossAxisSpacing; + final double childAspectRatio; + final SliverChildDelegate delegate; + + const _AnimatedLibrarySliverGrid({ + required this.maxCrossAxisExtent, + required this.mainAxisSpacing, + required this.crossAxisSpacing, + required this.childAspectRatio, + required this.delegate, + }); + + @override + State<_AnimatedLibrarySliverGrid> createState() => + _AnimatedLibrarySliverGridState(); +} + +class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final CurvedAnimation _curve; + late double _beginExtent; + late double _endExtent; + + @override + void initState() { + super.initState(); + _beginExtent = widget.maxCrossAxisExtent; + _endExtent = widget.maxCrossAxisExtent; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 130), + )..value = 1; + _curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + } + + @override + void didUpdateWidget(covariant _AnimatedLibrarySliverGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return; + _beginExtent = _currentExtent; + _endExtent = widget.maxCrossAxisExtent; + _controller.forward(from: 0); + } + + double get _currentExtent => + _beginExtent + ((_endExtent - _beginExtent) * _curve.value); + + @override + void dispose() { + _curve.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _currentExtent, + mainAxisSpacing: widget.mainAxisSpacing, + crossAxisSpacing: widget.crossAxisSpacing, + childAspectRatio: widget.childAspectRatio, + ), + delegate: widget.delegate, + ); + }, + ); + } +} diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 19e77d3d..63268d3b 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -93,6 +93,14 @@ class UnifiedLibraryItem { String? get genre => historyItem?.genre ?? localItem?.genre; + int? get trackNumber => historyItem?.trackNumber ?? localItem?.trackNumber; + + int? get discNumber => historyItem?.discNumber ?? localItem?.discNumber; + + String? get isrc => historyItem?.isrc ?? localItem?.isrc; + + String? get label => historyItem?.label ?? localItem?.label; + String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => @@ -451,18 +459,36 @@ DateTime? _queueParseReleaseDate(String? value) { bool _queueMatchesMetadataFilter({ required String? filterMetadata, + required String? artistName, required String? albumArtist, required String? releaseDate, required String? genre, + required int? trackNumber, + required int? discNumber, + required String? isrc, + required String? label, }) { if (filterMetadata == null) { return true; } + final hasArtist = _queueHasMetadataValue(artistName); final hasAlbumArtist = _queueHasMetadataValue(albumArtist); final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null; final hasGenre = _queueHasMetadataValue(genre); - final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre; + final hasTrackNumber = trackNumber != null && trackNumber > 0; + final hasDiscNumber = discNumber != null && discNumber > 0; + final hasLabel = _queueHasMetadataValue(label); + final hasIncorrectIsrc = _queueHasIncorrectIsrcFormat(isrc); + final isComplete = + hasArtist && + hasAlbumArtist && + hasReleaseDate && + hasGenre && + hasTrackNumber && + hasDiscNumber && + hasLabel && + !hasIncorrectIsrc; switch (filterMetadata) { case 'complete': @@ -475,20 +501,42 @@ bool _queueMatchesMetadataFilter({ return !hasGenre; case 'missing-album-artist': return !hasAlbumArtist; + case 'missing-track-number': + return !hasTrackNumber; + case 'missing-disc-number': + return !hasDiscNumber; + case 'missing-artist': + return !hasArtist; + case 'incorrect-isrc-format': + return hasIncorrectIsrc; + case 'missing-label': + return !hasLabel; default: return true; } } +bool _queueHasIncorrectIsrcFormat(String? isrc) { + final raw = isrc?.trim() ?? ''; + if (raw.isEmpty) return false; + final normalized = raw.toUpperCase().replaceAll(RegExp(r'[-\s]'), ''); + return !RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$').hasMatch(normalized); +} + bool _queueUnifiedItemMatchesMetadataFilter( UnifiedLibraryItem item, String? filterMetadata, ) { return _queueMatchesMetadataFilter( filterMetadata: filterMetadata, + artistName: item.artistName, albumArtist: item.albumArtist, releaseDate: item.releaseDate, genre: item.genre, + trackNumber: item.trackNumber, + discNumber: item.discNumber, + isrc: item.isrc, + label: item.label, ); } @@ -773,9 +821,14 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( } if (!_queueMatchesMetadataFilter( filterMetadata: request.filterMetadata, + artistName: track.artistName, albumArtist: track.albumArtist, releaseDate: track.releaseDate, genre: track.genre, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + isrc: track.isrc, + label: track.label, )) { continue; } @@ -925,9 +978,14 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( } if (!_queueMatchesMetadataFilter( filterMetadata: request.filterMetadata, + artistName: track.artistName, albumArtist: track.albumArtist, releaseDate: track.releaseDate, genre: track.genre, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + isrc: track.isrc, + label: track.label, )) { continue; } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index f6135ffe..2f1b2fb7 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -869,14 +869,13 @@ class _TrackMetadataScreenState extends ConsumerState { if (targetIndex < 0 || targetIndex >= _navigationLength) return; _isTrackSwipeNavigationInFlight = true; - final result = await Navigator.of(context).push( + await Navigator.of(context).pushReplacement( adjacentHorizontalPageRoute( page: _buildSiblingTrackScreen(targetIndex), fromRight: offset > 0, ), + result: _hasMetadataChanges ? true : null, ); - if (!mounted) return; - Navigator.pop(context, result == true || _hasMetadataChanges ? true : null); } TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) { diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 580bec22..cbe1e92b 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -99,29 +99,52 @@ Route adjacentHorizontalPageRoute({ required Widget page, required bool fromRight, }) { - final begin = Offset(fromRight ? 0.22 : -0.22, 0); - return PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 240), - reverseTransitionDuration: const Duration(milliseconds: 220), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - return SlideTransition( - position: Tween(begin: begin, end: Offset.zero).animate(curved), - child: FadeTransition( - opacity: Tween(begin: 0.92, end: 1.0).animate(curved), - child: child, - ), - ); - }, + return _AdjacentHorizontalPageRoute( + builder: (context) => page, + fromRight: fromRight, ); } +class _AdjacentHorizontalPageRoute extends MaterialPageRoute { + final bool fromRight; + + _AdjacentHorizontalPageRoute({ + required super.builder, + required this.fromRight, + }); + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (animation.status == AnimationStatus.reverse) { + return super.buildTransitions( + context, + animation, + secondaryAnimation, + child, + ); + } + + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + final begin = Offset(fromRight ? 0.22 : -0.22, 0); + + return SlideTransition( + position: Tween(begin: begin, end: Offset.zero).animate(curved), + child: FadeTransition( + opacity: Tween(begin: 0.92, end: 1.0).animate(curved), + child: child, + ), + ); + } +} + /// A shimmer effect widget that can wrap skeleton placeholders. class ShimmerLoading extends StatefulWidget { final Widget child; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index b7d7e89b..33acb392 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -17,6 +17,8 @@ class CachedCoverImage extends StatelessWidget { final Widget Function(BuildContext, String)? placeholder; final BorderRadius? borderRadius; final bool resizeDiskCache; + final Duration fadeInDuration; + final Duration fadeOutDuration; const CachedCoverImage({ super.key, @@ -31,6 +33,8 @@ class CachedCoverImage extends StatelessWidget { this.placeholder, this.borderRadius, this.resizeDiskCache = false, + this.fadeInDuration = Duration.zero, + this.fadeOutDuration = Duration.zero, }); @override @@ -52,8 +56,8 @@ class CachedCoverImage extends StatelessWidget { maxWidthDiskCache: diskCacheWidth, maxHeightDiskCache: diskCacheHeight, cacheManager: CoverCacheManager.instance, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, + fadeInDuration: fadeInDuration, + fadeOutDuration: fadeOutDuration, useOldImageOnUrlChange: true, filterQuality: FilterQuality.low, errorWidget: errorWidget,