diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 782eea74..3985ff37 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -52,7 +52,9 @@ class DownloadHistoryItem { final String? isrc; final String? spotifyId; final int? trackNumber; + final int? totalTracks; final int? discNumber; + final int? totalDiscs; final int? duration; final String? releaseDate; final String? quality; @@ -81,7 +83,9 @@ class DownloadHistoryItem { this.isrc, this.spotifyId, this.trackNumber, + this.totalTracks, this.discNumber, + this.totalDiscs, this.duration, this.releaseDate, this.quality, @@ -111,7 +115,9 @@ class DownloadHistoryItem { 'isrc': isrc, 'spotifyId': spotifyId, 'trackNumber': trackNumber, + 'totalTracks': totalTracks, 'discNumber': discNumber, + 'totalDiscs': totalDiscs, 'duration': duration, 'releaseDate': releaseDate, 'quality': quality, @@ -142,7 +148,9 @@ class DownloadHistoryItem { isrc: json['isrc'] as String?, spotifyId: json['spotifyId'] as String?, trackNumber: json['trackNumber'] as int?, + totalTracks: json['totalTracks'] as int?, discNumber: json['discNumber'] as int?, + totalDiscs: json['totalDiscs'] as int?, duration: json['duration'] as int?, releaseDate: json['releaseDate'] as String?, quality: json['quality'] as String?, @@ -169,7 +177,9 @@ class DownloadHistoryItem { String? isrc, String? spotifyId, int? trackNumber, + int? totalTracks, int? discNumber, + int? totalDiscs, int? duration, String? releaseDate, String? quality, @@ -198,7 +208,9 @@ class DownloadHistoryItem { isrc: isrc ?? this.isrc, spotifyId: spotifyId ?? this.spotifyId, trackNumber: trackNumber ?? this.trackNumber, + totalTracks: totalTracks ?? this.totalTracks, discNumber: discNumber ?? this.discNumber, + totalDiscs: totalDiscs ?? this.totalDiscs, duration: duration ?? this.duration, releaseDate: releaseDate ?? this.releaseDate, quality: quality ?? this.quality, @@ -586,15 +598,31 @@ class DownloadHistoryNotifier extends Notifier { if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { final needsComposerBackfill = 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 = 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 || isPlaceholderQualityLabel(item.quality) || normalizeOptionalString(item.quality) == null || - needsComposerBackfill; + needsComposerBackfill || + needsTrackNumberBackfill || + needsTotalTracksBackfill || + needsDiscNumberBackfill || + needsTotalDiscsBackfill; } Future?> _probeAudioMetadata( @@ -619,11 +647,19 @@ class DownloadHistoryNotifier extends Notifier { storedQuality: fallbackQuality, ); 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 && bitDepth == null && sampleRate == null && - composer == null) { + composer == null && + trackNumber == null && + totalTracks == null && + discNumber == null && + totalDiscs == null) { return null; } @@ -632,6 +668,10 @@ class DownloadHistoryNotifier extends Notifier { 'bitDepth': bitDepth, 'sampleRate': sampleRate, 'composer': composer, + 'trackNumber': trackNumber, + 'totalTracks': totalTracks, + 'discNumber': discNumber, + 'totalDiscs': totalDiscs, }; } catch (e) { _historyLog.d('Audio metadata probe failed for $filePath: $e'); @@ -701,6 +741,10 @@ class DownloadHistoryNotifier extends Notifier { final resolvedComposer = normalizeOptionalString( 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 = resolvedQuality != null && resolvedQuality != item.quality; @@ -710,11 +754,25 @@ class DownloadHistoryNotifier extends Notifier { resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; final composerChanged = 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 && !bitDepthChanged && !sampleRateChanged && - !composerChanged) { + !composerChanged && + !trackNumberChanged && + !totalTracksChanged && + !discNumberChanged && + !totalDiscsChanged) { continue; } @@ -723,6 +781,10 @@ class DownloadHistoryNotifier extends Notifier { bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, composer: resolvedComposer, + trackNumber: resolvedTrackNumber, + totalTracks: resolvedTotalTracks, + discNumber: resolvedDiscNumber, + totalDiscs: resolvedTotalDiscs, ); updatedItems ??= [...items]; updatedItems[index] = updated; @@ -768,6 +830,10 @@ class DownloadHistoryNotifier extends Notifier { final mergedItem = existing == null ? item : item.copyWith( + trackNumber: item.trackNumber ?? existing.trackNumber, + totalTracks: item.totalTracks ?? existing.totalTracks, + discNumber: item.discNumber ?? existing.discNumber, + totalDiscs: item.totalDiscs ?? existing.totalDiscs, genre: normalizeOptionalString(item.genre) ?? normalizeOptionalString(existing.genre), @@ -840,6 +906,11 @@ class DownloadHistoryNotifier extends Notifier { String? quality, int? bitDepth, int? sampleRate, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? composer, }) async { final index = state.items.indexWhere((item) => item.id == id); if (index < 0) return; @@ -849,23 +920,28 @@ class DownloadHistoryNotifier extends Notifier { quality: quality, bitDepth: bitDepth, sampleRate: sampleRate, + trackNumber: trackNumber, + totalTracks: totalTracks, + discNumber: discNumber, + totalDiscs: totalDiscs, + composer: composer, ); if (updated.quality == current.quality && 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; } final updatedItems = [...state.items]; updatedItems[index] = updated; state = state.copyWith(items: updatedItems); - await _db.updateAudioMetadata( - id, - newQuality: quality, - newBitDepth: bitDepth, - newSampleRate: sampleRate, - ); + await _db.upsert(updated.toJson()); } Future updateMetadataForItem({ @@ -876,7 +952,9 @@ class DownloadHistoryNotifier extends Notifier { String? albumArtist, String? isrc, int? trackNumber, + int? totalTracks, int? discNumber, + int? totalDiscs, String? releaseDate, String? genre, String? composer, @@ -894,7 +972,9 @@ class DownloadHistoryNotifier extends Notifier { albumArtist: albumArtist, isrc: isrc, trackNumber: trackNumber, + totalTracks: totalTracks, discNumber: discNumber, + totalDiscs: totalDiscs, releaseDate: releaseDate, genre: genre, composer: composer, @@ -5534,9 +5614,11 @@ class DownloadQueueNotifier extends Notifier { trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber, + totalTracks: trackToDownload.totalTracks, discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber, + totalDiscs: trackToDownload.totalDiscs, duration: trackToDownload.duration, releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 5a320b39..1e4d65c4 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } - Future _navigateToMetadataScreen(DownloadHistoryItem item) async { + Future _navigateToMetadataScreen( + DownloadHistoryItem item, { + required List navigationItems, + required int navigationIndex, + }) async { final navigator = Navigator.of(context); _precacheCover(item.coverUrl); final beforeModTime = @@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute( + page: TrackMetadataScreen( + item: item, + historyNavigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + ), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState { key: ValueKey(track.id), child: StaggeredListItem( index: index, - child: _buildTrackItem(context, colorScheme, track), + child: _buildTrackItem( + context, + colorScheme, + track, + tracks, + index, + ), ), ); }, childCount: tracks.length), @@ -709,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { children.add(_buildDiscSeparator(context, colorScheme, discNumber)); for (final track in discTracks) { + final navigationIndex = tracks.indexOf(track); children.add( KeyedSubtree( key: ValueKey(track.id), child: StaggeredListItem( index: revealIndex++, - child: _buildTrackItem(context, colorScheme, track), + child: _buildTrackItem( + context, + colorScheme, + track, + tracks, + navigationIndex, + ), ), ), ); @@ -774,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track, + List navigationItems, + int navigationIndex, ) { final isSelected = _selectedIds.contains(track.id); @@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), onTap: _isSelectionMode ? () => _toggleSelection(track.id) - : () => _navigateToMetadataScreen(track), + : () => _navigateToMetadataScreen( + track, + navigationItems: navigationItems, + navigationIndex: navigationIndex, + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f252f89f..fc790373 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1443,7 +1443,13 @@ class _HomeTabState extends ConsumerState button: true, label: 'Open track ${item.trackName} by ${item.artistName}', child: GestureDetector( - onTap: () => _navigateToMetadataScreen(item), + onTap: () => _navigateToMetadataScreen( + item, + navigationItems: items + .take(itemCount) + .toList(growable: false), + navigationIndex: index, + ), child: Container( width: coverSize, margin: const EdgeInsets.only(right: 12), @@ -2217,7 +2223,11 @@ class _HomeTabState extends ConsumerState } } - Future _navigateToMetadataScreen(DownloadHistoryItem item) async { + Future _navigateToMetadataScreen( + DownloadHistoryItem item, { + List? navigationItems, + int? navigationIndex, + }) async { final navigator = Navigator.of(context); _precacheCover(item.coverUrl); final beforeModTime = @@ -2226,7 +2236,13 @@ class _HomeTabState extends ConsumerState ); if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute( + page: TrackMetadataScreen( + item: item, + historyNavigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + ), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 954bd0d6..56b37b95 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState { } Future _navigateToHistoryMetadataScreen( - DownloadHistoryItem item, - ) async { + DownloadHistoryItem item, { + List? navigationItems, + int? navigationIndex, + }) async { final navigator = Navigator.of(context); _precacheCover(item.coverUrl); _searchFocusNode.unfocus(); final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute( + page: TrackMetadataScreen( + item: item, + historyNavigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + ), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2988,11 +2996,21 @@ class _QueueTabState extends ConsumerState { ); } - void _navigateToLocalMetadataScreen(LocalLibraryItem item) { + void _navigateToLocalMetadataScreen( + LocalLibraryItem item, { + List? navigationItems, + int? navigationIndex, + }) { _searchFocusNode.unfocus(); Navigator.push( context, - slidePageRoute(page: TrackMetadataScreen(localItem: item)), + slidePageRoute( + page: TrackMetadataScreen( + localItem: item, + localNavigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + ), ).then((_) => _searchFocusNode.unfocus()); } @@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState { final filteredUnifiedItems = filterData.filteredUnifiedItems; final totalTrackCount = filterData.totalTrackCount; final totalAlbumCount = filterData.totalAlbumCount; + final downloadedNavigationItems = []; + final downloadedNavigationIndexByUnifiedId = {}; + final localNavigationItems = []; + final localNavigationIndexByUnifiedId = {}; + + 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( slivers: [ @@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState { context, item, colorScheme, + downloadedNavigationItems: + downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ), child: _buildUnifiedGridItem( context, item, colorScheme, + downloadedNavigationItems: + downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ), ); @@ -4472,12 +4523,25 @@ class _QueueTabState extends ConsumerState { context, item, colorScheme, + downloadedNavigationItems: + downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ), child: _buildUnifiedLibraryItem( context, item, colorScheme, + downloadedNavigationItems: downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ), ); @@ -4540,6 +4604,12 @@ class _QueueTabState extends ConsumerState { context, item, colorScheme, + downloadedNavigationItems: downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ); }, childCount: filteredUnifiedItems.length), @@ -4554,6 +4624,12 @@ class _QueueTabState extends ConsumerState { context, item, colorScheme, + downloadedNavigationItems: downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], ), ); }, childCount: filteredUnifiedItems.length), @@ -6609,8 +6685,12 @@ class _QueueTabState extends ConsumerState { Widget _buildUnifiedLibraryItem( BuildContext context, UnifiedLibraryItem item, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + required List downloadedNavigationItems, + required int? downloadedNavigationIndex, + required List localNavigationItems, + required int? localNavigationIndex, + }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); final date = item.addedAt; @@ -6640,9 +6720,17 @@ class _QueueTabState extends ConsumerState { onTap: _isSelectionMode ? () => _toggleSelection(item.id) : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + ? () => _navigateToHistoryMetadataScreen( + item.historyItem!, + navigationItems: downloadedNavigationItems, + navigationIndex: downloadedNavigationIndex, + ) : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) + ? () => _navigateToLocalMetadataScreen( + item.localItem!, + navigationItems: localNavigationItems, + navigationIndex: localNavigationIndex, + ) : () => _openFile( item.filePath, title: item.trackName, @@ -6816,8 +6904,12 @@ class _QueueTabState extends ConsumerState { Widget _buildUnifiedGridItem( BuildContext context, UnifiedLibraryItem item, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + required List downloadedNavigationItems, + required int? downloadedNavigationIndex, + required List localNavigationItems, + required int? localNavigationIndex, + }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); final isDownloaded = item.source == LibraryItemSource.downloaded; @@ -6826,9 +6918,17 @@ class _QueueTabState extends ConsumerState { onTap: _isSelectionMode ? () => _toggleSelection(item.id) : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + ? () => _navigateToHistoryMetadataScreen( + item.historyItem!, + navigationItems: downloadedNavigationItems, + navigationIndex: downloadedNavigationIndex, + ) : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) + ? () => _navigateToLocalMetadataScreen( + item.localItem!, + navigationItems: localNavigationItems, + navigationIndex: localNavigationIndex, + ) : () => _openFile( item.filePath, title: item.trackName, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 1887b314..a009e9c3 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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/image_cache_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'; final _log = AppLogger('TrackMetadata'); @@ -41,12 +42,35 @@ class _EmbeddedCoverPreviewCacheEntry { class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem? item; final LocalLibraryItem? localItem; + final List? historyNavigationItems; + final List? localNavigationItems; + final int? navigationIndex; - const TrackMetadataScreen({super.key, this.item, this.localItem}) - : assert( - item != null || localItem != null, - 'Either item or localItem must be provided', - ); + const TrackMetadataScreen({ + super.key, + this.item, + 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 ConsumerState createState() => @@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _isConverting = false; bool _hasMetadataChanges = false; bool _hasLoadedResolvedAudioMetadata = false; + bool _isTrackSwipeNavigationInFlight = false; Map? _editedMetadata; String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); @@ -327,15 +352,25 @@ class _TrackMetadataScreenState extends ConsumerState { // Resolve label/copyright from file when the model doesn't carry them // (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 resolvedDiscNumber = _readPositiveInt(metadata['disc_number']); final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']); final resolvedComposer = metadata['composer']?.toString(); final resolvedLabel = metadata['label']?.toString(); final resolvedCopyright = metadata['copyright']?.toString(); + final needsTrackNumber = + resolvedTrackNumber != null && + resolvedTrackNumber > 0 && + trackNumber == null; final needsTotalTracks = resolvedTotalTracks != null && resolvedTotalTracks > 0 && totalTracks == null; + final needsDiscNumber = + resolvedDiscNumber != null && + resolvedDiscNumber > 0 && + discNumber == null; final needsTotalDiscs = resolvedTotalDiscs != null && resolvedTotalDiscs > 0 && @@ -357,13 +392,20 @@ class _TrackMetadataScreenState extends ConsumerState { !_isLocalItem && (resolvedBitDepth != null || resolvedSampleRate != null || + needsTrackNumber || + needsTotalTracks || + needsDiscNumber || + needsTotalDiscs || + needsComposer || (isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); if ((resolvedBitDepth != null || resolvedSampleRate != null || needsAlbum || needsDuration || + needsTrackNumber || needsTotalTracks || + needsDiscNumber || needsTotalDiscs || needsComposer || needsLabel || @@ -379,7 +421,9 @@ class _TrackMetadataScreenState extends ConsumerState { if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, if (needsAlbum) 'album': resolvedAlbum, if (needsDuration) 'duration': resolvedDuration, + if (needsTrackNumber) 'track_number': resolvedTrackNumber, if (needsTotalTracks) 'total_tracks': resolvedTotalTracks, + if (needsDiscNumber) 'disc_number': resolvedDiscNumber, if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs, if (needsComposer) 'composer': resolvedComposer, if (needsLabel) 'label': resolvedLabel, @@ -396,6 +440,11 @@ class _TrackMetadataScreenState extends ConsumerState { quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, + trackNumber: needsTrackNumber ? resolvedTrackNumber : null, + totalTracks: needsTotalTracks ? resolvedTotalTracks : null, + discNumber: needsDiscNumber ? resolvedDiscNumber : null, + totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null, + composer: needsComposer ? resolvedComposer : null, ); } } catch (e) { @@ -468,6 +517,17 @@ class _TrackMetadataScreenState extends ConsumerState { bool get _isLocalItem => widget.localItem != null; DownloadHistoryItem? get _downloadItem => widget.item; 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 => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; @@ -505,7 +565,9 @@ class _TrackMetadataScreenState extends ConsumerState { int? get totalTracks => _readPositiveInt(_editedMetadata?['total_tracks']) ?? - (_isLocalItem ? _localLibraryItem!.totalTracks : null); + (_isLocalItem + ? _localLibraryItem!.totalTracks + : _downloadItem!.totalTracks); int? get discNumber { final edited = _editedMetadata?['disc_number']; @@ -520,7 +582,9 @@ class _TrackMetadataScreenState extends ConsumerState { int? get totalDiscs => _readPositiveInt(_editedMetadata?['total_discs']) ?? - (_isLocalItem ? _localLibraryItem!.totalDiscs : null); + (_isLocalItem + ? _localLibraryItem!.totalDiscs + : _downloadItem!.totalDiscs); String? get releaseDate => _editedMetadata?['date']?.toString() ?? @@ -777,118 +841,165 @@ class _TrackMetadataScreenState extends ConsumerState { 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 _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( + adjacentHorizontalPageRoute( + 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 Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final expandedHeight = _calculateExpandedHeight(context); - return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - expandedHeight: expandedHeight, - pinned: true, - stretch: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - trackName, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - 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, + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragEnd: _handleHorizontalDragEnd, + child: Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + trackName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, ), - stretchModes: const [StretchMode.zoomBackground], - ); - }, - ), - 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, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - child: const Icon(Icons.arrow_back, color: Colors.white), ), - onPressed: _popWithMetadataResult, - ), - actions: [ - IconButton( - tooltip: MaterialLocalizations.of(context).showMenuTooltip, + 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], + ); + }, + ), + 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.more_vert, color: Colors.white), + child: const Icon(Icons.arrow_back, color: Colors.white), ), - onPressed: () => _showOptionsMenu(context, ref, colorScheme), + onPressed: _popWithMetadataResult, ), - ], - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMetadataCard(context, colorScheme, _fileSize), - - const SizedBox(height: 16), - - _buildFileInfoCard( - context, - colorScheme, - _fileExists, - _fileSize, + actions: [ + IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.more_vert, color: Colors.white), ), + 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), - 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 { albumArtist: normalizedOrNull(albumArtist), isrc: normalizedOrNull(isrc), trackNumber: trackNumber, + totalTracks: totalTracks, discNumber: discNumber, + totalDiscs: totalDiscs, releaseDate: normalizedOrNull(releaseDate), genre: normalizedOrNull(genre), composer: normalizedOrNull(composer), diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 4d9caea8..b63323df 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -31,7 +31,7 @@ class HistoryDatabase { return await openDatabase( path, - version: 4, + version: 5, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -63,7 +63,9 @@ class HistoryDatabase { isrc TEXT, spotify_id TEXT, track_number INTEGER, + total_tracks INTEGER, disc_number INTEGER, + total_discs INTEGER, duration INTEGER, release_date TEXT, quality TEXT, @@ -108,6 +110,22 @@ class HistoryDatabase { 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( @@ -268,7 +286,9 @@ class HistoryDatabase { 'isrc': json['isrc'], 'spotify_id': json['spotifyId'], 'track_number': json['trackNumber'], + 'total_tracks': json['totalTracks'], 'disc_number': json['discNumber'], + 'total_discs': json['totalDiscs'], 'duration': json['duration'], 'release_date': json['releaseDate'], 'quality': json['quality'], @@ -300,7 +320,9 @@ class HistoryDatabase { 'isrc': row['isrc'], 'spotifyId': row['spotify_id'], 'trackNumber': row['track_number'], + 'totalTracks': row['total_tracks'], 'discNumber': row['disc_number'], + 'totalDiscs': row['total_discs'], 'duration': row['duration'], 'releaseDate': row['release_date'], 'quality': row['quality'], diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 4494fa37..580bec22 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -93,6 +93,35 @@ Route slidePageRoute({required Widget page}) { return MaterialPageRoute(builder: (context) => page); } +/// A directional horizontal transition for adjacent content, such as moving +/// between next/previous items within the same detail context. +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, + ), + ); + }, + ); +} + /// A shimmer effect widget that can wrap skeleton placeholders. class ShimmerLoading extends StatefulWidget { final Widget child;