mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-09 14:18:11 +02:00
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:
@@ -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
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user