refactor: migrate local library from in-memory list to database-backed pagination

Replace the full in-memory List<LocalLibraryItem> in LocalLibraryState
with a lightweight lookup index (ISRCs, matchKeys, filePathById) and
database-backed FutureProvider.family pagination providers.

Database changes:
- Add library schema v7 with normalized lookup columns (track_name_norm,
  artist_name_norm, album_name_norm, album_artist_norm, match_key,
  album_key) and corresponding indexes
- Backfill normalized columns on migration from v6
- Add getPage, getPageCount, getAlbumPage, getAlbumCount, getLookupIndex,
  getCoverPaths, getByFilePath, findFirstByTrackAndArtist DB methods

Provider changes:
- LocalLibraryState no longer holds items list; uses totalCount and
  loadedIndexVersion for change tracking
- Deprecate synchronous getByIsrc/findByTrackAndArtist (return null);
  add async findExistingAsync, getByIsrcAsync, getById on notifier
- Add localLibraryPageProvider, localLibraryAlbumPageProvider,
  localLibraryAllItemsProvider family providers for paginated access
- Add localLibraryCoverProvider and localLibraryFirstCoverProvider
  for async cover path resolution from DB

Screen migrations:
- album/artist/playlist screens use findExistingAsync for playback
- library_tracks_folder_screen uses async cover providers and
  existsInLibrary for local library indicator
- queue_tab watches localLibraryAllItemsProvider instead of state.items
- library_settings_page uses state.totalCount
- playback_provider uses findExistingAsync

Track metadata screen:
- Replace pushReplacement navigation with in-place state swap using
  AnimatedSwitcher for smooth cross-fade transitions on track swipe
- Add metadataLoadGeneration counter to prevent stale async callbacks
- Reset all transient state (lyrics, cover, file check) on track change
This commit is contained in:
zarzet committed 2026-05-06 03:15:30 +07:00
1 parent d24435dbc2
commit 149cdc782d
11 files changed
+1128 -333

No files matched your search

+7 -8
View File
@@ -1085,7 +1085,6 @@ class _AlbumTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@@ -1119,13 +1118,13 @@ class _AlbumTrackItem extends ConsumerWidget {
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
final localItem = await ref
.read(localLibraryProvider.notifier)
.findExistingAsync(
isrc: isrc,
trackName: track.name,
artistName: track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
+7 -8
View File
@@ -1600,7 +1600,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
Future<bool> _playLocalIfAvailable(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@@ -1634,13 +1633,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
final localItem = await ref
.read(localLibraryProvider.notifier)
.findExistingAsync(
isrc: isrc,
trackName: track.name,
artistName: track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
+64 -82
View File
@@ -12,7 +12,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -79,45 +78,20 @@ class _LibraryTracksFolderScreenState
};
}
String? _resolveEntryCoverUrl(
CollectionTrackEntry entry,
LocalLibraryState localState,
) {
String? _resolveRawEntryCoverUrl(CollectionTrackEntry entry) {
final rawCover = entry.track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
final isrc = entry.track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = localState.getByIsrc(isrc);
final localCover = byIsrc?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) {
return localCover;
}
}
final byTrack = localState.findByTrackAndArtist(
entry.track.name,
entry.track.artistName,
);
final localCover = byTrack?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) {
return localCover;
}
return null;
}
/// Find the first available cover URL from entries.
String? _firstCoverUrl(
List<CollectionTrackEntry> entries,
LocalLibraryState localState,
) {
String? _firstRawCoverUrl(List<CollectionTrackEntry> entries) {
for (final entry in entries) {
final cover = _resolveEntryCoverUrl(entry, localState);
final cover = _resolveRawEntryCoverUrl(entry);
if (cover != null && cover.isNotEmpty) {
return cover;
}
@@ -212,6 +186,22 @@ class _LibraryTracksFolderScreenState
);
}
LocalLibraryCoverBatchRequest _coverBatchRequest(
List<CollectionTrackEntry> entries,
) {
return LocalLibraryCoverBatchRequest(
entries
.map(
(entry) => LocalLibraryCoverRequest(
isrc: entry.track.isrc?.trim(),
trackName: entry.track.name,
artistName: entry.track.artistName,
),
)
.toList(growable: false),
);
}
void _downloadSelected(List<CollectionTrackEntry> entries) {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
@@ -255,8 +245,7 @@ class _LibraryTracksFolderScreenState
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
final List<CollectionTrackEntry> entries;
switch (widget.mode) {
@@ -337,14 +326,7 @@ class _LibraryTracksFolderScreenState
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(
context,
colorScheme,
title,
entries,
playlist,
localState,
),
_buildAppBar(context, colorScheme, title, entries, playlist),
if (entries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
@@ -366,7 +348,6 @@ class _LibraryTracksFolderScreenState
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
@@ -602,13 +583,21 @@ class _LibraryTracksFolderScreenState
String title,
List<CollectionTrackEntry> entries,
UserPlaylistCollection? playlist,
LocalLibraryState localState,
) {
final expandedHeight = _calculateExpandedHeight(context);
final customCoverPath = playlist?.coverImagePath;
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
final rawCoverUrl = isLovedMode ? null : _firstRawCoverUrl(entries);
final localCoverUrl =
rawCoverUrl == null && !isLovedMode && entries.isNotEmpty
? ref
.watch(
localLibraryFirstCoverProvider(_coverBatchRequest(entries)),
)
.maybeWhen(data: (cover) => cover, orElse: () => null)
: null;
final coverUrl = rawCoverUrl ?? localCoverUrl;
final hasCustomCover =
customCoverPath != null && customCoverPath.isNotEmpty;
final hasCoverUrl = coverUrl != null;
@@ -1069,7 +1058,6 @@ class _CollectionTrackTile extends ConsumerWidget {
final CollectionTrackEntry entry;
final LibraryTracksFolderMode mode;
final String? playlistId;
final LocalLibraryState localLibraryState;
final List<Track> folderTracks;
final bool isSelectionMode;
final bool isSelected;
@@ -1080,7 +1068,6 @@ class _CollectionTrackTile extends ConsumerWidget {
required this.entry,
required this.mode,
required this.playlistId,
required this.localLibraryState,
required this.folderTracks,
this.isSelectionMode = false,
this.isSelected = false,
@@ -1092,7 +1079,21 @@ class _CollectionTrackTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
final effectiveCoverUrl = _resolveCoverUrl(track);
final rawCoverUrl = _resolveRawCoverUrl(track);
final localCoverUrl = rawCoverUrl == null
? ref
.watch(
localLibraryCoverProvider(
LocalLibraryCoverRequest(
isrc: track.isrc?.trim(),
trackName: track.name,
artistName: track.artistName,
),
),
)
.maybeWhen(data: (cover) => cover, orElse: () => null)
: null;
final effectiveCoverUrl = rawCoverUrl ?? localCoverUrl;
// Fine-grained provider watches – only this tile rebuilds when its own
// history / local-library entry changes.
@@ -1113,26 +1114,21 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final localItem = showLocalLibraryIndicator
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select((state) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
return state.findByTrackAndArtist(track.name, track.artistName);
return state.existsInLibrary(
isrc: isrc,
trackName: track.name,
artistName: track.artistName,
);
}),
)
: null;
: false;
final isInHistory = historyItem != null;
final isInLocalLibrary = localItem != null;
final heroTag = historyItem != null
? 'cover_${historyItem.id}'
: localItem != null
? 'cover_lib_${localItem.id}'
: null;
final heroTag = historyItem != null ? 'cover_${historyItem.id}' : null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1245,7 +1241,7 @@ class _CollectionTrackTile extends ConsumerWidget {
),
trailing: isSelectionMode
? null
: historyItem != null || localItem != null
: historyItem != null || isInLocalLibrary
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
@@ -1275,28 +1271,13 @@ class _CollectionTrackTile extends ConsumerWidget {
);
}
String? _resolveCoverUrl(Track track) {
String? _resolveRawCoverUrl(Track track) {
final rawCover = track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = localLibraryState.getByIsrc(isrc);
final localCover = byIsrc?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) return localCover;
}
final byTrack = localLibraryState.findByTrackAndArtist(
track.name,
track.artistName,
);
final localCover = byTrack?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) return localCover;
return null;
}
@@ -1418,13 +1399,14 @@ class _CollectionTrackTile extends ConsumerWidget {
return;
}
final localState = ref.read(localLibraryProvider);
LocalLibraryItem? localItem;
if (track.isrc != null && track.isrc!.isNotEmpty) {
localItem = localState.getByIsrc(track.isrc!);
}
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
final localItem = await ref
.read(localLibraryProvider.notifier)
.findExistingAsync(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
);
if (!context.mounted) return;
if (localItem != null) {
await Navigator.of(context).push(
+7 -8
View File
@@ -942,7 +942,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@@ -976,13 +975,13 @@ class _PlaylistTrackItem extends ConsumerWidget {
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
final localItem = await ref
.read(localLibraryProvider.notifier)
.findExistingAsync(
isrc: isrc,
trackName: track.name,
artistName: track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
+6 -1
View File
@@ -2369,7 +2369,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localLibraryItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items))
? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[];
// Watch with selector on key fields to reduce unnecessary rebuilds.
// LibraryCollectionsState doesn't implement == so watching without
+6 -1
View File
@@ -1106,7 +1106,12 @@ final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) {
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items))
? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[];
return _buildQueueHistoryStats(historyItems, localItems);
});
@@ -374,7 +374,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: _LibraryHeroCard(
itemCount: libraryState.items.length,
itemCount: libraryState.totalCount,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing,
@@ -547,25 +547,23 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
],
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.libraryCleanupMissingFiles,
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
onTap: libraryState.items.isNotEmpty
onTap: libraryState.totalCount > 0
? _cleanupMissingFiles
: null,
),
),
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.delete_outline,
title: context.l10n.libraryClear,
subtitle: context.l10n.libraryClearSubtitle,
onTap: libraryState.items.isNotEmpty
? _clearLibrary
: null,
onTap: libraryState.totalCount > 0 ? _clearLibrary : null,
showDivider: false,
),
),
+179 -69
View File
@@ -24,7 +24,6 @@ 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';
part 'track_metadata_edit_sheet.dart';
@@ -101,6 +100,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
bool _isTrackSwipeNavigationInFlight = false;
int _metadataLoadGeneration = 0;
int _metadataTransitionDirection = 0;
late DownloadHistoryItem? _currentDownloadItem;
late LocalLibraryItem? _currentLocalLibraryItem;
late int? _currentNavigationIndex;
Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
@@ -226,6 +230,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
void initState() {
super.initState();
_currentDownloadItem = widget.item;
_currentLocalLibraryItem = widget.localItem;
_currentNavigationIndex = widget.navigationIndex;
_scrollController.addListener(_onScroll);
_checkFile();
}
@@ -253,6 +260,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _checkFile() async {
final generation = _metadataLoadGeneration;
final filePath = cleanFilePath;
bool exists = false;
@@ -266,6 +274,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
setState(() {
_fileExists = exists;
@@ -274,21 +284,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
}
if (mounted && exists && _lyrics == null && !_lyricsLoading) {
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
_lyrics == null &&
!_lyricsLoading) {
_checkEmbeddedLyrics();
}
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
!_isCueVirtualTrack &&
!_hasLoadedResolvedAudioMetadata) {
unawaited(_refreshResolvedAudioMetadataFromFile());
}
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) {
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
!_hasPath(_embeddedCoverPreviewPath)) {
final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid(
_coverCacheKey,
cleanFilePath,
filePath,
);
if (_hasPath(cachedPath)) {
if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
_hasPath(cachedPath)) {
setState(() => _embeddedCoverPreviewPath = cachedPath);
}
}
@@ -318,6 +342,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _refreshResolvedAudioMetadataFromFile() async {
final generation = _metadataLoadGeneration;
final sourcePath = cleanFilePath;
if ((_isLocalItem && _localLibraryItem == null) ||
(!_isLocalItem && _downloadItem == null) ||
_isCueVirtualTrack ||
@@ -328,7 +354,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_hasLoadedResolvedAudioMetadata = true;
try {
final metadata = await PlatformBridge.readFileMetadata(cleanFilePath);
final metadata = await PlatformBridge.readFileMetadata(sourcePath);
if (!mounted ||
generation != _metadataLoadGeneration ||
sourcePath != cleanFilePath) {
return;
}
if (metadata['error'] != null) {
return;
}
@@ -463,6 +494,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _refreshEmbeddedCoverPreview({bool force = false}) async {
final generation = _metadataLoadGeneration;
final cacheKey = _coverCacheKey;
final sourcePath = cleanFilePath;
if (!force) {
@@ -471,7 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
sourcePath,
);
if (_hasPath(cachedPath)) {
if (mounted && _embeddedCoverPreviewPath != cachedPath) {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath &&
_embeddedCoverPreviewPath != cachedPath) {
setState(() => _embeddedCoverPreviewPath = cachedPath);
}
return;
@@ -483,7 +518,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_fileExists) {
await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey);
await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath);
if (mounted) {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() => _embeddedCoverPreviewPath = null);
}
return;
@@ -511,7 +548,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {}
final oldPreviewPath = _embeddedCoverPreviewPath;
if (!mounted) {
if (!mounted ||
generation != _metadataLoadGeneration ||
sourcePath != cleanFilePath) {
if (newPreviewPath != null) {
await _cleanupTempFileAndParentIfNotCached(newPreviewPath);
}
@@ -524,16 +563,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
bool get _isLocalItem => widget.localItem != null;
DownloadHistoryItem? get _downloadItem => widget.item;
LocalLibraryItem? get _localLibraryItem => widget.localItem;
bool get _isLocalItem => _currentLocalLibraryItem != null;
DownloadHistoryItem? get _downloadItem => _currentDownloadItem;
LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem;
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 _navigationIndex => _currentNavigationIndex;
int get _navigationLength =>
widget.historyNavigationItems?.length ??
widget.localNavigationItems?.length ??
@@ -869,28 +908,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
_isTrackSwipeNavigationInFlight = true;
await Navigator.of(context).pushReplacement<bool, bool>(
adjacentHorizontalPageRoute<bool>(
page: _buildSiblingTrackScreen(targetIndex),
fromRight: offset > 0,
),
result: _hasMetadataChanges ? true : null,
);
}
final oldPreviewPath = _embeddedCoverPreviewPath;
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) {
if (_hasHistoryNavigation) {
return TrackMetadataScreen(
item: widget.historyNavigationItems![targetIndex],
historyNavigationItems: widget.historyNavigationItems,
navigationIndex: targetIndex,
);
try {
setState(() {
_metadataLoadGeneration++;
_metadataTransitionDirection = offset > 0 ? 1 : -1;
_currentNavigationIndex = targetIndex;
if (_hasHistoryNavigation) {
_currentDownloadItem = widget.historyNavigationItems![targetIndex];
_currentLocalLibraryItem = null;
} else {
_currentDownloadItem = null;
_currentLocalLibraryItem = widget.localNavigationItems![targetIndex];
}
_fileExists = false;
_hasCheckedFile = false;
_fileSize = null;
_lyrics = null;
_rawLyrics = null;
_lyricsLoading = false;
_lyricsError = null;
_lyricsSource = null;
_showTitleInAppBar = false;
_lyricsEmbedded = false;
_isInstrumental = false;
_embeddedLyricsChecked = false;
_hasLoadedResolvedAudioMetadata = false;
_editedMetadata = null;
_embeddedCoverPreviewPath = null;
});
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
if (oldPreviewPath != null) {
unawaited(_cleanupTempFileAndParentIfNotCached(oldPreviewPath));
}
await _checkFile();
} finally {
if (mounted) {
_isTrackSwipeNavigationInFlight = false;
}
}
return TrackMetadataScreen(
localItem: widget.localNavigationItems![targetIndex],
localNavigationItems: widget.localNavigationItems,
navigationIndex: targetIndex,
);
}
@override
@@ -973,39 +1034,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
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,
),
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),
],
),
),
child: _buildAnimatedTrackContent(context, ref, colorScheme),
),
],
),
@@ -1013,6 +1042,80 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Widget _buildAnimatedTrackContent(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final currentKey = ValueKey<String>('metadata_content_$_itemId');
return AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
reverseDuration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[...previousChildren, ?currentChild],
);
},
transitionBuilder: (child, animation) {
if (_metadataTransitionDirection == 0) {
return child;
}
final isIncoming = child.key == currentKey;
final direction = _metadataTransitionDirection.toDouble();
final begin = Offset(
isIncoming ? 0.18 * direction : -0.18 * direction,
0,
);
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return ClipRect(
child: SlideTransition(
position: Tween<Offset>(
begin: begin,
end: Offset.zero,
).animate(curved),
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: Padding(
key: currentKey,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
_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),
],
),
),
);
}
Widget _buildHeaderBackground(
BuildContext context,
ColorScheme colorScheme,
@@ -1944,6 +2047,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
/// Called automatically when the screen opens.
Future<void> _checkEmbeddedLyrics() async {
if (_lyricsLoading || !_fileExists) return;
final generation = _metadataLoadGeneration;
final sourcePath = cleanFilePath;
if (!mounted) return;
setState(() {
_lyricsLoading = true;
@@ -1958,7 +2064,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'',
trackName,
artistName,
filePath: cleanFilePath,
filePath: sourcePath,
durationMs: 0,
).timeout(
const Duration(seconds: 5),
@@ -1968,7 +2074,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (mounted) {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
if (embeddedLyrics.isNotEmpty) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
setState(() {
@@ -1989,7 +2097,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
} catch (e) {
if (mounted) {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() {
_lyricsLoading = false;
_embeddedLyricsChecked = true;