feat: improve library grid, image loading, and metadata filters

UI and UX improvements across the library and queue screens.

- Add pinch-to-zoom for library grid views with animated extent
  transitions via _AnimatedLibrarySliverGrid widget
- Replace fixed grid column count with responsive maxCrossAxisExtent
- Add smooth fade-in for cover images (local files via frameBuilder,
  network via CachedCoverImage fadeInDuration/fadeOutDuration params)
- Refactor track metadata swipe navigation from push+pop to
  pushReplacement to prevent route stack accumulation
- Convert adjacentHorizontalPageRoute to MaterialPageRoute subclass
  to support pushReplacement with proper transition semantics
- Add five new metadata completeness filters: missing track number,
  missing disc number, missing artist, incorrect ISRC format, and
  missing label
- Expose trackNumber, discNumber, isrc, and label on
  UnifiedLibraryItem for filter support
- Tighten metadata completeness definition to include all new fields
This commit is contained in:
zarzet
2026-05-05 04:22:24 +07:00
parent 101ab3f521
commit 9a78798854
6 changed files with 337 additions and 61 deletions
+31 -12
View File
@@ -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(),
);
}
+196 -23
View File
@@ -61,6 +61,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
() => 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<QueueTab> {
}
}
return CustomScrollView(
final content = CustomScrollView(
slivers: [
if (totalTrackCount > 0 && filterMode == 'all')
SliverToBoxAdapter(
@@ -3367,13 +3433,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
),
],
);
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<QueueTab> {
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<QueueTab> {
);
}
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
);
}
}
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,
);
},
);
}
}
+59 -1
View File
@@ -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;
}
+2 -3
View File
@@ -869,14 +869,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
_isTrackSwipeNavigationInFlight = true;
final result = await Navigator.of(context).push<bool>(
await Navigator.of(context).pushReplacement<bool, bool>(
adjacentHorizontalPageRoute<bool>(
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) {
+43 -20
View File
@@ -99,29 +99,52 @@ Route<T> adjacentHorizontalPageRoute<T>({
required Widget page,
required bool fromRight,
}) {
final begin = Offset(fromRight ? 0.22 : -0.22, 0);
return PageRouteBuilder<T>(
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<Offset>(begin: begin, end: Offset.zero).animate(curved),
child: FadeTransition(
opacity: Tween<double>(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
},
return _AdjacentHorizontalPageRoute<T>(
builder: (context) => page,
fromRight: fromRight,
);
}
class _AdjacentHorizontalPageRoute<T> extends MaterialPageRoute<T> {
final bool fromRight;
_AdjacentHorizontalPageRoute({
required super.builder,
required this.fromRight,
});
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> 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<Offset>(begin: begin, end: Offset.zero).animate(curved),
child: FadeTransition(
opacity: Tween<double>(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;
+6 -2
View File
@@ -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,