mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
fix: persist downloaded metadata and refine metadata navigation
This commit is contained in:
parent
5e17c9f238
commit
5c48e1b476
7 changed files with 520 additions and 129 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue