mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 00:34:07 +02:00
fix: persist downloaded metadata and refine metadata navigation
This commit is contained in:
@@ -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<DownloadHistoryState> {
|
||||
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<Map<String, dynamic>?> _probeAudioMetadata(
|
||||
@@ -619,11 +647,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
'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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<void> updateMetadataForItem({
|
||||
@@ -876,7 +952,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadQueueState> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
DownloadHistoryItem track,
|
||||
List<DownloadHistoryItem> navigationItems,
|
||||
int navigationIndex,
|
||||
) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
@@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _navigateToMetadataScreen(track),
|
||||
: () => _navigateToMetadataScreen(
|
||||
track,
|
||||
navigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(track.id),
|
||||
|
||||
@@ -1443,7 +1443,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
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<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
final beforeModTime =
|
||||
@@ -2226,7 +2236,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
|
||||
+113
-13
@@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
Future<void> _navigateToHistoryMetadataScreen(
|
||||
DownloadHistoryItem item,
|
||||
) async {
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? 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<bool>(page: TrackMetadataScreen(item: item)),
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
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();
|
||||
Navigator.push(
|
||||
context,
|
||||
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
|
||||
slidePageRoute<void>(
|
||||
page: TrackMetadataScreen(
|
||||
localItem: item,
|
||||
localNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
@@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final filteredUnifiedItems = filterData.filteredUnifiedItems;
|
||||
final totalTrackCount = filterData.totalTrackCount;
|
||||
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(
|
||||
slivers: [
|
||||
@@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
Widget _buildUnifiedLibraryItem(
|
||||
BuildContext context,
|
||||
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 isSelected = _selectedIds.contains(item.id);
|
||||
final date = item.addedAt;
|
||||
@@ -6640,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
Widget _buildUnifiedGridItem(
|
||||
BuildContext context,
|
||||
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 isSelected = _selectedIds.contains(item.id);
|
||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||
@@ -6826,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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,
|
||||
|
||||
@@ -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<DownloadHistoryItem>? historyNavigationItems;
|
||||
final List<LocalLibraryItem>? 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<TrackMetadataScreen> createState() =>
|
||||
@@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _isConverting = false;
|
||||
bool _hasMetadataChanges = false;
|
||||
bool _hasLoadedResolvedAudioMetadata = false;
|
||||
bool _isTrackSwipeNavigationInFlight = false;
|
||||
Map<String, dynamic>? _editedMetadata;
|
||||
String? _embeddedCoverPreviewPath;
|
||||
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
|
||||
// (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<TrackMetadataScreen> {
|
||||
!_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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
|
||||
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<TrackMetadataScreen> {
|
||||
|
||||
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<TrackMetadataScreen> {
|
||||
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
|
||||
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<TrackMetadataScreen> {
|
||||
albumArtist: normalizedOrNull(albumArtist),
|
||||
isrc: normalizedOrNull(isrc),
|
||||
trackNumber: trackNumber,
|
||||
totalTracks: totalTracks,
|
||||
discNumber: discNumber,
|
||||
totalDiscs: totalDiscs,
|
||||
releaseDate: normalizedOrNull(releaseDate),
|
||||
genre: normalizedOrNull(genre),
|
||||
composer: normalizedOrNull(composer),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -93,6 +93,35 @@ Route<T> slidePageRoute<T>({required Widget 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.
|
||||
class ShimmerLoading extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
Reference in New Issue
Block a user