fix: persist downloaded metadata and refine metadata navigation

This commit is contained in:
zarzet 2026-04-06 03:20:04 +07:00
parent 5e17c9f238
commit 5c48e1b476
7 changed files with 520 additions and 129 deletions

View file

@ -52,7 +52,9 @@ class DownloadHistoryItem {
final String? isrc; final String? isrc;
final String? spotifyId; final String? spotifyId;
final int? trackNumber; final int? trackNumber;
final int? totalTracks;
final int? discNumber; final int? discNumber;
final int? totalDiscs;
final int? duration; final int? duration;
final String? releaseDate; final String? releaseDate;
final String? quality; final String? quality;
@ -81,7 +83,9 @@ class DownloadHistoryItem {
this.isrc, this.isrc,
this.spotifyId, this.spotifyId,
this.trackNumber, this.trackNumber,
this.totalTracks,
this.discNumber, this.discNumber,
this.totalDiscs,
this.duration, this.duration,
this.releaseDate, this.releaseDate,
this.quality, this.quality,
@ -111,7 +115,9 @@ class DownloadHistoryItem {
'isrc': isrc, 'isrc': isrc,
'spotifyId': spotifyId, 'spotifyId': spotifyId,
'trackNumber': trackNumber, 'trackNumber': trackNumber,
'totalTracks': totalTracks,
'discNumber': discNumber, 'discNumber': discNumber,
'totalDiscs': totalDiscs,
'duration': duration, 'duration': duration,
'releaseDate': releaseDate, 'releaseDate': releaseDate,
'quality': quality, 'quality': quality,
@ -142,7 +148,9 @@ class DownloadHistoryItem {
isrc: json['isrc'] as String?, isrc: json['isrc'] as String?,
spotifyId: json['spotifyId'] as String?, spotifyId: json['spotifyId'] as String?,
trackNumber: json['trackNumber'] as int?, trackNumber: json['trackNumber'] as int?,
totalTracks: json['totalTracks'] as int?,
discNumber: json['discNumber'] as int?, discNumber: json['discNumber'] as int?,
totalDiscs: json['totalDiscs'] as int?,
duration: json['duration'] as int?, duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?, releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?, quality: json['quality'] as String?,
@ -169,7 +177,9 @@ class DownloadHistoryItem {
String? isrc, String? isrc,
String? spotifyId, String? spotifyId,
int? trackNumber, int? trackNumber,
int? totalTracks,
int? discNumber, int? discNumber,
int? totalDiscs,
int? duration, int? duration,
String? releaseDate, String? releaseDate,
String? quality, String? quality,
@ -198,7 +208,9 @@ class DownloadHistoryItem {
isrc: isrc ?? this.isrc, isrc: isrc ?? this.isrc,
spotifyId: spotifyId ?? this.spotifyId, spotifyId: spotifyId ?? this.spotifyId,
trackNumber: trackNumber ?? this.trackNumber, trackNumber: trackNumber ?? this.trackNumber,
totalTracks: totalTracks ?? this.totalTracks,
discNumber: discNumber ?? this.discNumber, discNumber: discNumber ?? this.discNumber,
totalDiscs: totalDiscs ?? this.totalDiscs,
duration: duration ?? this.duration, duration: duration ?? this.duration,
releaseDate: releaseDate ?? this.releaseDate, releaseDate: releaseDate ?? this.releaseDate,
quality: quality ?? this.quality, quality: quality ?? this.quality,
@ -586,15 +598,31 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) {
final needsComposerBackfill = final needsComposerBackfill =
normalizeOptionalString(item.composer) == null; normalizeOptionalString(item.composer) == null;
return needsComposerBackfill; final needsTrackNumberBackfill = item.trackNumber == null;
final needsTotalTracksBackfill = item.totalTracks == null;
final needsDiscNumberBackfill = item.discNumber == null;
final needsTotalDiscsBackfill = item.totalDiscs == null;
return needsComposerBackfill ||
needsTrackNumberBackfill ||
needsTotalTracksBackfill ||
needsDiscNumberBackfill ||
needsTotalDiscsBackfill;
} }
final needsComposerBackfill = final needsComposerBackfill =
normalizeOptionalString(item.composer) == null; normalizeOptionalString(item.composer) == null;
final needsTrackNumberBackfill = item.trackNumber == null;
final needsTotalTracksBackfill = item.totalTracks == null;
final needsDiscNumberBackfill = item.discNumber == null;
final needsTotalDiscsBackfill = item.totalDiscs == null;
return needsLosslessSpecProbe || return needsLosslessSpecProbe ||
isPlaceholderQualityLabel(item.quality) || isPlaceholderQualityLabel(item.quality) ||
normalizeOptionalString(item.quality) == null || normalizeOptionalString(item.quality) == null ||
needsComposerBackfill; needsComposerBackfill ||
needsTrackNumberBackfill ||
needsTotalTracksBackfill ||
needsDiscNumberBackfill ||
needsTotalDiscsBackfill;
} }
Future<Map<String, dynamic>?> _probeAudioMetadata( Future<Map<String, dynamic>?> _probeAudioMetadata(
@ -619,11 +647,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
storedQuality: fallbackQuality, storedQuality: fallbackQuality,
); );
final composer = normalizeOptionalString(result['composer']?.toString()); final composer = normalizeOptionalString(result['composer']?.toString());
final trackNumber = _readPositiveInt(result['track_number']);
final totalTracks = _readPositiveInt(result['total_tracks']);
final discNumber = _readPositiveInt(result['disc_number']);
final totalDiscs = _readPositiveInt(result['total_discs']);
if (quality == null && if (quality == null &&
bitDepth == null && bitDepth == null &&
sampleRate == null && sampleRate == null &&
composer == null) { composer == null &&
trackNumber == null &&
totalTracks == null &&
discNumber == null &&
totalDiscs == null) {
return null; return null;
} }
@ -632,6 +668,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
'bitDepth': bitDepth, 'bitDepth': bitDepth,
'sampleRate': sampleRate, 'sampleRate': sampleRate,
'composer': composer, 'composer': composer,
'trackNumber': trackNumber,
'totalTracks': totalTracks,
'discNumber': discNumber,
'totalDiscs': totalDiscs,
}; };
} catch (e) { } catch (e) {
_historyLog.d('Audio metadata probe failed for $filePath: $e'); _historyLog.d('Audio metadata probe failed for $filePath: $e');
@ -701,6 +741,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final resolvedComposer = normalizeOptionalString( final resolvedComposer = normalizeOptionalString(
probed['composer'] as String?, probed['composer'] as String?,
); );
final resolvedTrackNumber = probed['trackNumber'] as int?;
final resolvedTotalTracks = probed['totalTracks'] as int?;
final resolvedDiscNumber = probed['discNumber'] as int?;
final resolvedTotalDiscs = probed['totalDiscs'] as int?;
final qualityChanged = final qualityChanged =
resolvedQuality != null && resolvedQuality != item.quality; resolvedQuality != null && resolvedQuality != item.quality;
@ -710,11 +754,25 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
final composerChanged = final composerChanged =
resolvedComposer != null && resolvedComposer != item.composer; resolvedComposer != null && resolvedComposer != item.composer;
final trackNumberChanged =
resolvedTrackNumber != null &&
resolvedTrackNumber != item.trackNumber;
final totalTracksChanged =
resolvedTotalTracks != null &&
resolvedTotalTracks != item.totalTracks;
final discNumberChanged =
resolvedDiscNumber != null && resolvedDiscNumber != item.discNumber;
final totalDiscsChanged =
resolvedTotalDiscs != null && resolvedTotalDiscs != item.totalDiscs;
if (!qualityChanged && if (!qualityChanged &&
!bitDepthChanged && !bitDepthChanged &&
!sampleRateChanged && !sampleRateChanged &&
!composerChanged) { !composerChanged &&
!trackNumberChanged &&
!totalTracksChanged &&
!discNumberChanged &&
!totalDiscsChanged) {
continue; continue;
} }
@ -723,6 +781,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
composer: resolvedComposer, composer: resolvedComposer,
trackNumber: resolvedTrackNumber,
totalTracks: resolvedTotalTracks,
discNumber: resolvedDiscNumber,
totalDiscs: resolvedTotalDiscs,
); );
updatedItems ??= [...items]; updatedItems ??= [...items];
updatedItems[index] = updated; updatedItems[index] = updated;
@ -768,6 +830,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final mergedItem = existing == null final mergedItem = existing == null
? item ? item
: item.copyWith( : item.copyWith(
trackNumber: item.trackNumber ?? existing.trackNumber,
totalTracks: item.totalTracks ?? existing.totalTracks,
discNumber: item.discNumber ?? existing.discNumber,
totalDiscs: item.totalDiscs ?? existing.totalDiscs,
genre: genre:
normalizeOptionalString(item.genre) ?? normalizeOptionalString(item.genre) ??
normalizeOptionalString(existing.genre), normalizeOptionalString(existing.genre),
@ -840,6 +906,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? quality, String? quality,
int? bitDepth, int? bitDepth,
int? sampleRate, int? sampleRate,
int? trackNumber,
int? totalTracks,
int? discNumber,
int? totalDiscs,
String? composer,
}) async { }) async {
final index = state.items.indexWhere((item) => item.id == id); final index = state.items.indexWhere((item) => item.id == id);
if (index < 0) return; if (index < 0) return;
@ -849,23 +920,28 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
quality: quality, quality: quality,
bitDepth: bitDepth, bitDepth: bitDepth,
sampleRate: sampleRate, sampleRate: sampleRate,
trackNumber: trackNumber,
totalTracks: totalTracks,
discNumber: discNumber,
totalDiscs: totalDiscs,
composer: composer,
); );
if (updated.quality == current.quality && if (updated.quality == current.quality &&
updated.bitDepth == current.bitDepth && updated.bitDepth == current.bitDepth &&
updated.sampleRate == current.sampleRate) { updated.sampleRate == current.sampleRate &&
updated.trackNumber == current.trackNumber &&
updated.totalTracks == current.totalTracks &&
updated.discNumber == current.discNumber &&
updated.totalDiscs == current.totalDiscs &&
updated.composer == current.composer) {
return; return;
} }
final updatedItems = [...state.items]; final updatedItems = [...state.items];
updatedItems[index] = updated; updatedItems[index] = updated;
state = state.copyWith(items: updatedItems); state = state.copyWith(items: updatedItems);
await _db.updateAudioMetadata( await _db.upsert(updated.toJson());
id,
newQuality: quality,
newBitDepth: bitDepth,
newSampleRate: sampleRate,
);
} }
Future<void> updateMetadataForItem({ Future<void> updateMetadataForItem({
@ -876,7 +952,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? albumArtist, String? albumArtist,
String? isrc, String? isrc,
int? trackNumber, int? trackNumber,
int? totalTracks,
int? discNumber, int? discNumber,
int? totalDiscs,
String? releaseDate, String? releaseDate,
String? genre, String? genre,
String? composer, String? composer,
@ -894,7 +972,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
albumArtist: albumArtist, albumArtist: albumArtist,
isrc: isrc, isrc: isrc,
trackNumber: trackNumber, trackNumber: trackNumber,
totalTracks: totalTracks,
discNumber: discNumber, discNumber: discNumber,
totalDiscs: totalDiscs,
releaseDate: releaseDate, releaseDate: releaseDate,
genre: genre, genre: genre,
composer: composer, composer: composer,
@ -5534,9 +5614,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackNumber: (backendTrackNum != null && backendTrackNum > 0) trackNumber: (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum ? backendTrackNum
: trackToDownload.trackNumber, : trackToDownload.trackNumber,
totalTracks: trackToDownload.totalTracks,
discNumber: (backendDiscNum != null && backendDiscNum > 0) discNumber: (backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum ? backendDiscNum
: trackToDownload.discNumber, : trackToDownload.discNumber,
totalDiscs: trackToDownload.totalDiscs,
duration: trackToDownload.duration, duration: trackToDownload.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty) releaseDate: (backendYear != null && backendYear.isNotEmpty)
? backendYear ? backendYear

View file

@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
required List<DownloadHistoryItem> navigationItems,
required int navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: index, index: index,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
index,
),
), ),
); );
}, childCount: tracks.length), }, childCount: tracks.length),
@ -709,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(_buildDiscSeparator(context, colorScheme, discNumber)); children.add(_buildDiscSeparator(context, colorScheme, discNumber));
for (final track in discTracks) { for (final track in discTracks) {
final navigationIndex = tracks.indexOf(track);
children.add( children.add(
KeyedSubtree( KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: revealIndex++, index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
navigationIndex,
),
), ),
), ),
); );
@ -774,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
DownloadHistoryItem track, DownloadHistoryItem track,
List<DownloadHistoryItem> navigationItems,
int navigationIndex,
) { ) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
onLongPress: _isSelectionMode onLongPress: _isSelectionMode
? null ? null
: () => _enterSelectionMode(track.id), : () => _enterSelectionMode(track.id),

View file

@ -1443,7 +1443,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
button: true, button: true,
label: 'Open track ${item.trackName} by ${item.artistName}', label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector( child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item), onTap: () => _navigateToMetadataScreen(
item,
navigationItems: items
.take(itemCount)
.toList(growable: false),
navigationIndex: index,
),
child: Container( child: Container(
width: coverSize, width: coverSize,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
@ -2217,7 +2223,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@ -2226,7 +2236,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,

View file

@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
Future<void> _navigateToHistoryMetadataScreen( Future<void> _navigateToHistoryMetadataScreen(
DownloadHistoryItem item, DownloadHistoryItem item, {
) async { List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath); final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
if (result == true) { if (result == true) {
@ -2988,11 +2996,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
void _navigateToLocalMetadataScreen(LocalLibraryItem item) { void _navigateToLocalMetadataScreen(
LocalLibraryItem item, {
List<LocalLibraryItem>? navigationItems,
int? navigationIndex,
}) {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)), slidePageRoute<void>(
page: TrackMetadataScreen(
localItem: item,
localNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
).then((_) => _searchFocusNode.unfocus()); ).then((_) => _searchFocusNode.unfocus());
} }
@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final filteredUnifiedItems = filterData.filteredUnifiedItems; final filteredUnifiedItems = filterData.filteredUnifiedItems;
final totalTrackCount = filterData.totalTrackCount; final totalTrackCount = filterData.totalTrackCount;
final totalAlbumCount = filterData.totalAlbumCount; final totalAlbumCount = filterData.totalAlbumCount;
final downloadedNavigationItems = <DownloadHistoryItem>[];
final downloadedNavigationIndexByUnifiedId = <String, int>{};
final localNavigationItems = <LocalLibraryItem>[];
final localNavigationIndexByUnifiedId = <String, int>{};
for (final item in filteredUnifiedItems) {
final historyItem = item.historyItem;
if (historyItem != null) {
downloadedNavigationIndexByUnifiedId[item.id] =
downloadedNavigationItems.length;
downloadedNavigationItems.add(historyItem);
}
final localItem = item.localItem;
if (localItem != null) {
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
localNavigationItems.add(localItem);
}
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedGridItem( child: _buildUnifiedGridItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@ -4472,12 +4523,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedLibraryItem( child: _buildUnifiedLibraryItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@ -4540,6 +4604,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@ -4554,6 +4624,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@ -6609,8 +6685,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedLibraryItem( Widget _buildUnifiedLibraryItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final date = item.addedAt; final date = item.addedAt;
@ -6640,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,
@ -6816,8 +6904,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedGridItem( Widget _buildUnifiedGridItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded; final isDownloaded = item.source == LibraryItemSource.downloaded;
@ -6826,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,

View file

@ -24,6 +24,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
final _log = AppLogger('TrackMetadata'); final _log = AppLogger('TrackMetadata');
@ -41,12 +42,35 @@ class _EmbeddedCoverPreviewCacheEntry {
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem? item; final DownloadHistoryItem? item;
final LocalLibraryItem? localItem; final LocalLibraryItem? localItem;
final List<DownloadHistoryItem>? historyNavigationItems;
final List<LocalLibraryItem>? localNavigationItems;
final int? navigationIndex;
const TrackMetadataScreen({super.key, this.item, this.localItem}) const TrackMetadataScreen({
: assert( super.key,
item != null || localItem != null, this.item,
'Either item or localItem must be provided', this.localItem,
); this.historyNavigationItems,
this.localNavigationItems,
this.navigationIndex,
}) : assert(
item != null || localItem != null,
'Either item or localItem must be provided',
),
assert(
historyNavigationItems == null || localNavigationItems == null,
'Provide only one navigation list type',
),
assert(
navigationIndex == null ||
((historyNavigationItems != null &&
navigationIndex >= 0 &&
navigationIndex < historyNavigationItems.length) ||
(localNavigationItems != null &&
navigationIndex >= 0 &&
navigationIndex < localNavigationItems.length)),
'navigationIndex must be within the provided navigation list',
);
@override @override
ConsumerState<TrackMetadataScreen> createState() => ConsumerState<TrackMetadataScreen> createState() =>
@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _isConverting = false; bool _isConverting = false;
bool _hasMetadataChanges = false; bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false; bool _hasLoadedResolvedAudioMetadata = false;
bool _isTrackSwipeNavigationInFlight = false;
Map<String, dynamic>? _editedMetadata; Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath; String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@ -327,15 +352,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Resolve label/copyright from file when the model doesn't carry them // Resolve label/copyright from file when the model doesn't carry them
// (e.g. local library items, or download history items without these fields). // (e.g. local library items, or download history items without these fields).
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']); final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']); final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
final resolvedComposer = metadata['composer']?.toString(); final resolvedComposer = metadata['composer']?.toString();
final resolvedLabel = metadata['label']?.toString(); final resolvedLabel = metadata['label']?.toString();
final resolvedCopyright = metadata['copyright']?.toString(); final resolvedCopyright = metadata['copyright']?.toString();
final needsTrackNumber =
resolvedTrackNumber != null &&
resolvedTrackNumber > 0 &&
trackNumber == null;
final needsTotalTracks = final needsTotalTracks =
resolvedTotalTracks != null && resolvedTotalTracks != null &&
resolvedTotalTracks > 0 && resolvedTotalTracks > 0 &&
totalTracks == null; totalTracks == null;
final needsDiscNumber =
resolvedDiscNumber != null &&
resolvedDiscNumber > 0 &&
discNumber == null;
final needsTotalDiscs = final needsTotalDiscs =
resolvedTotalDiscs != null && resolvedTotalDiscs != null &&
resolvedTotalDiscs > 0 && resolvedTotalDiscs > 0 &&
@ -357,13 +392,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
!_isLocalItem && !_isLocalItem &&
(resolvedBitDepth != null || (resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
needsTrackNumber ||
needsTotalTracks ||
needsDiscNumber ||
needsTotalDiscs ||
needsComposer ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); (isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
if ((resolvedBitDepth != null || if ((resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
needsAlbum || needsAlbum ||
needsDuration || needsDuration ||
needsTrackNumber ||
needsTotalTracks || needsTotalTracks ||
needsDiscNumber ||
needsTotalDiscs || needsTotalDiscs ||
needsComposer || needsComposer ||
needsLabel || needsLabel ||
@ -379,7 +421,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
if (needsAlbum) 'album': resolvedAlbum, if (needsAlbum) 'album': resolvedAlbum,
if (needsDuration) 'duration': resolvedDuration, if (needsDuration) 'duration': resolvedDuration,
if (needsTrackNumber) 'track_number': resolvedTrackNumber,
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks, if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
if (needsDiscNumber) 'disc_number': resolvedDiscNumber,
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs, if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
if (needsComposer) 'composer': resolvedComposer, if (needsComposer) 'composer': resolvedComposer,
if (needsLabel) 'label': resolvedLabel, if (needsLabel) 'label': resolvedLabel,
@ -396,6 +440,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
quality: resolvedQuality, quality: resolvedQuality,
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
composer: needsComposer ? resolvedComposer : null,
); );
} }
} catch (e) { } catch (e) {
@ -468,6 +517,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool get _isLocalItem => widget.localItem != null; bool get _isLocalItem => widget.localItem != null;
DownloadHistoryItem? get _downloadItem => widget.item; DownloadHistoryItem? get _downloadItem => widget.item;
LocalLibraryItem? get _localLibraryItem => widget.localItem; LocalLibraryItem? get _localLibraryItem => widget.localItem;
bool get _hasHistoryNavigation =>
widget.historyNavigationItems != null && widget.navigationIndex != null;
bool get _hasLocalNavigation =>
widget.localNavigationItems != null && widget.navigationIndex != null;
bool get _hasTrackSwipeNavigation =>
_hasHistoryNavigation || _hasLocalNavigation;
int? get _navigationIndex => widget.navigationIndex;
int get _navigationLength =>
widget.historyNavigationItems?.length ??
widget.localNavigationItems?.length ??
0;
String get _itemId => String get _itemId =>
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
@ -505,7 +565,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get totalTracks => int? get totalTracks =>
_readPositiveInt(_editedMetadata?['total_tracks']) ?? _readPositiveInt(_editedMetadata?['total_tracks']) ??
(_isLocalItem ? _localLibraryItem!.totalTracks : null); (_isLocalItem
? _localLibraryItem!.totalTracks
: _downloadItem!.totalTracks);
int? get discNumber { int? get discNumber {
final edited = _editedMetadata?['disc_number']; final edited = _editedMetadata?['disc_number'];
@ -520,7 +582,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get totalDiscs => int? get totalDiscs =>
_readPositiveInt(_editedMetadata?['total_discs']) ?? _readPositiveInt(_editedMetadata?['total_discs']) ??
(_isLocalItem ? _localLibraryItem!.totalDiscs : null); (_isLocalItem
? _localLibraryItem!.totalDiscs
: _downloadItem!.totalDiscs);
String? get releaseDate => String? get releaseDate =>
_editedMetadata?['date']?.toString() ?? _editedMetadata?['date']?.toString() ??
@ -777,118 +841,165 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Navigator.pop(context, _hasMetadataChanges ? true : null); Navigator.pop(context, _hasMetadataChanges ? true : null);
} }
void _handleHorizontalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity;
if (velocity == null || velocity.abs() < 350) return;
if (velocity < 0) {
unawaited(_navigateToAdjacentTrack(1));
} else {
unawaited(_navigateToAdjacentTrack(-1));
}
}
Future<void> _navigateToAdjacentTrack(int offset) async {
if (_isTrackSwipeNavigationInFlight || !_hasTrackSwipeNavigation) return;
final currentIndex = _navigationIndex;
if (currentIndex == null) return;
final targetIndex = currentIndex + offset;
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
_isTrackSwipeNavigationInFlight = true;
final result = await Navigator.of(context).push<bool>(
adjacentHorizontalPageRoute<bool>(
page: _buildSiblingTrackScreen(targetIndex),
fromRight: offset > 0,
),
);
if (!mounted) return;
Navigator.pop(context, result == true || _hasMetadataChanges ? true : null);
}
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) {
if (_hasHistoryNavigation) {
return TrackMetadataScreen(
item: widget.historyNavigationItems![targetIndex],
historyNavigationItems: widget.historyNavigationItems,
navigationIndex: targetIndex,
);
}
return TrackMetadataScreen(
localItem: widget.localNavigationItems![targetIndex],
localNavigationItems: widget.localNavigationItems,
navigationIndex: targetIndex,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final expandedHeight = _calculateExpandedHeight(context); final expandedHeight = _calculateExpandedHeight(context);
return Scaffold( return GestureDetector(
body: CustomScrollView( behavior: HitTestBehavior.translucent,
controller: _scrollController, onHorizontalDragEnd: _handleHorizontalDragEnd,
slivers: [ child: Scaffold(
SliverAppBar( body: CustomScrollView(
expandedHeight: expandedHeight, controller: _scrollController,
pinned: true, slivers: [
stretch: true, SliverAppBar(
backgroundColor: colorScheme.surface, expandedHeight: expandedHeight,
surfaceTintColor: Colors.transparent, pinned: true,
title: AnimatedOpacity( stretch: true,
duration: const Duration(milliseconds: 200), backgroundColor: colorScheme.surface,
opacity: _showTitleInAppBar ? 1.0 : 0.0, surfaceTintColor: Colors.transparent,
child: Text( title: AnimatedOpacity(
trackName, duration: const Duration(milliseconds: 200),
style: TextStyle( opacity: _showTitleInAppBar ? 1.0 : 0.0,
color: colorScheme.onSurface, child: Text(
fontWeight: FontWeight.w600, trackName,
fontSize: 16, style: TextStyle(
), color: colorScheme.onSurface,
maxLines: 1, fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis, fontSize: 16,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(
context,
colorScheme,
expandedHeight,
showContent,
), ),
stretchModes: const [StretchMode.zoomBackground], maxLines: 1,
); overflow: TextOverflow.ellipsis,
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
), ),
child: const Icon(Icons.arrow_back, color: Colors.white),
), ),
onPressed: _popWithMetadataResult, flexibleSpace: LayoutBuilder(
), builder: (context, constraints) {
actions: [ final collapseRatio =
IconButton( (constraints.maxHeight - kToolbarHeight) /
tooltip: MaterialLocalizations.of(context).showMenuTooltip, (expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(
context,
colorScheme,
expandedHeight,
showContent,
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4), color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.more_vert, color: Colors.white), child: const Icon(Icons.arrow_back, color: Colors.white),
), ),
onPressed: () => _showOptionsMenu(context, ref, colorScheme), onPressed: _popWithMetadataResult,
), ),
], actions: [
), IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
SliverToBoxAdapter( icon: Container(
child: Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16), decoration: BoxDecoration(
child: Column( color: Colors.black.withValues(alpha: 0.4),
crossAxisAlignment: CrossAxisAlignment.start, shape: BoxShape.circle,
children: [ ),
_buildMetadataCard(context, colorScheme, _fileSize), child: const Icon(Icons.more_vert, color: Colors.white),
const SizedBox(height: 16),
_buildFileInfoCard(
context,
colorScheme,
_fileExists,
_fileSize,
), ),
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
),
],
),
const SizedBox(height: 16), SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMetadataCard(context, colorScheme, _fileSize),
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
_buildFileInfoCard(
context,
colorScheme,
_fileExists,
_fileSize,
),
const SizedBox(height: 16),
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
],
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
], ],
),
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
), ),
), ),
), ],
], ),
), ),
); );
} }
@ -2767,7 +2878,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
albumArtist: normalizedOrNull(albumArtist), albumArtist: normalizedOrNull(albumArtist),
isrc: normalizedOrNull(isrc), isrc: normalizedOrNull(isrc),
trackNumber: trackNumber, trackNumber: trackNumber,
totalTracks: totalTracks,
discNumber: discNumber, discNumber: discNumber,
totalDiscs: totalDiscs,
releaseDate: normalizedOrNull(releaseDate), releaseDate: normalizedOrNull(releaseDate),
genre: normalizedOrNull(genre), genre: normalizedOrNull(genre),
composer: normalizedOrNull(composer), composer: normalizedOrNull(composer),

View file

@ -31,7 +31,7 @@ class HistoryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 4, version: 5,
onConfigure: (db) async { onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL'); await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL'); await db.execute('PRAGMA synchronous = NORMAL');
@ -63,7 +63,9 @@ class HistoryDatabase {
isrc TEXT, isrc TEXT,
spotify_id TEXT, spotify_id TEXT,
track_number INTEGER, track_number INTEGER,
total_tracks INTEGER,
disc_number INTEGER, disc_number INTEGER,
total_discs INTEGER,
duration INTEGER, duration INTEGER,
release_date TEXT, release_date TEXT,
quality TEXT, quality TEXT,
@ -108,6 +110,22 @@ class HistoryDatabase {
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT'); await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
} }
} }
if (oldVersion < 5) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasTotalTracks = columns.any(
(row) =>
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
);
final hasTotalDiscs = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
);
if (!hasTotalTracks) {
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
}
if (!hasTotalDiscs) {
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
}
}
} }
static final _iosContainerPattern = RegExp( static final _iosContainerPattern = RegExp(
@ -268,7 +286,9 @@ class HistoryDatabase {
'isrc': json['isrc'], 'isrc': json['isrc'],
'spotify_id': json['spotifyId'], 'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'], 'track_number': json['trackNumber'],
'total_tracks': json['totalTracks'],
'disc_number': json['discNumber'], 'disc_number': json['discNumber'],
'total_discs': json['totalDiscs'],
'duration': json['duration'], 'duration': json['duration'],
'release_date': json['releaseDate'], 'release_date': json['releaseDate'],
'quality': json['quality'], 'quality': json['quality'],
@ -300,7 +320,9 @@ class HistoryDatabase {
'isrc': row['isrc'], 'isrc': row['isrc'],
'spotifyId': row['spotify_id'], 'spotifyId': row['spotify_id'],
'trackNumber': row['track_number'], 'trackNumber': row['track_number'],
'totalTracks': row['total_tracks'],
'discNumber': row['disc_number'], 'discNumber': row['disc_number'],
'totalDiscs': row['total_discs'],
'duration': row['duration'], 'duration': row['duration'],
'releaseDate': row['release_date'], 'releaseDate': row['release_date'],
'quality': row['quality'], 'quality': row['quality'],

View file

@ -93,6 +93,35 @@ Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page); return MaterialPageRoute<T>(builder: (context) => page);
} }
/// A directional horizontal transition for adjacent content, such as moving
/// between next/previous items within the same detail context.
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,
),
);
},
);
}
/// A shimmer effect widget that can wrap skeleton placeholders. /// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget { class ShimmerLoading extends StatefulWidget {
final Widget child; final Widget child;