Files
SpotiFLAC-Mobile/lib/providers/playback_provider.dart
T
zarzet 01a5b43613 perf: unify queue tab with DB-backed pagination and cross-database queries
Replace in-memory list merging in the queue tab with fully database-
backed pagination using ATTACH DATABASE to join library and history
tables in a single UNION ALL query.

Queue tab:
- Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider
- Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed
  by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage
- Implement infinite scroll via _handleLibraryScrollNotification with
  _libraryPageLimit growing by 300 per batch
- Album/single/total counts computed via SQL GROUP BY aggregates

History database (v5 -> v8):
- v6: add idx_history_track_artist index
- v7: add history_path_keys table for cross-DB dedup, backfill from
  existing rows
- v8: add spotify_id_norm, isrc_norm, match_key normalized columns
  with indexes, backfill from existing data
- Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts,
  existsTrack, findExistingTrack, existingTrackKeys batch lookup
- deleteBySpotifyId now returns deleted count for accurate totalCount
- All write paths maintain history_path_keys consistency

Library database (v7 -> v8):
- v8: add library_path_keys table for cross-DB dedup
- Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with
  ATTACH DATABASE for cross-DB UNION ALL queries
- Dedup local items against history via path_keys JOIN
- All write/delete paths maintain library_path_keys consistency

Download history provider:
- Load only 100 recent items into state.items at startup
- Store lookupItems as immutable List field instead of recomputing
  from maps on every access
- Add async fallback to DB in _putInMemoryHistory for items outside
  the 100-item window
- Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider,
  downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider
- Add catchError to adoptNativeHistoryItem async block
- Fix removeBySpotifyId to query actual DB count instead of decrement

Screen migrations:
- album/artist/playlist/home screens use async DB lookups instead of
  sync in-memory state for track existence and playback resolution
- downloaded_album_screen uses downloadedAlbumTracksProvider
- library_tracks_folder_screen uses downloadHistoryBatchExistsProvider
  for skip-downloaded checks and cover resolution
2026-05-06 04:38:51 +07:00

191 lines
5.5 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlaybackProvider');
class PlaybackState {
const PlaybackState();
}
class PlaybackController extends Notifier<PlaybackState> {
@override
PlaybackState build() => const PlaybackState();
Future<void> playLocalPath({
required String path,
required String title,
required String artist,
String album = '',
String coverUrl = '',
Track? track,
}) async {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
if (resolvedPath == null) {
continue;
}
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
_log.d(
'Opening first available external track for list playback: '
'"${track.name}" by ${track.artistName} -> $resolvedPath',
);
await openFile(resolvedPath);
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);
}
List<Track> _orderedTracksFromStartIndex(List<Track> tracks, int startIndex) {
final safeStart = startIndex.clamp(0, tracks.length - 1);
if (safeStart == 0) {
return List<Track>.from(tracks, growable: false);
}
return <Track>[
...tracks.sublist(safeStart),
...tracks.sublist(0, safeStart),
];
}
Future<String?> _resolveTrackPath(Track track) async {
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final localItem = await _findLocalLibraryItemForTrack(track);
if (localItem != null && await fileExists(localItem.filePath)) {
return localItem.filePath;
}
final historyItem = await _findDownloadHistoryItemForTrack(
track,
historyState,
);
if (historyItem != null) {
if (await fileExists(historyItem.filePath)) {
return historyItem.filePath;
}
historyNotifier.removeFromHistory(historyItem.id);
}
return null;
}
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) {
final byId = await ref
.read(localLibraryProvider.notifier)
.getById(track.id);
if (byId != null) return byId;
}
final isrc = track.isrc?.trim();
return ref
.read(localLibraryProvider.notifier)
.findExistingAsync(
isrc: isrc,
trackName: track.name,
artistName: track.artistName,
);
}
Future<DownloadHistoryItem?> _findDownloadHistoryItemForTrack(
Track track,
DownloadHistoryState historyState,
) async {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
for (final candidateId in _spotifyIdLookupCandidates(track.id)) {
final bySpotifyId = historyState.getBySpotifyId(candidateId);
if (bySpotifyId != null) {
return bySpotifyId;
}
final bySpotifyIdAsync = await historyNotifier.getBySpotifyIdAsync(
candidateId,
);
if (bySpotifyIdAsync != null) {
return bySpotifyIdAsync;
}
}
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = historyState.getByIsrc(isrc);
if (byIsrc != null) {
return byIsrc;
}
final byIsrcAsync = await historyNotifier.getByIsrcAsync(isrc);
if (byIsrcAsync != null) {
return byIsrcAsync;
}
}
return historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
}
List<String> _spotifyIdLookupCandidates(String rawId) {
final trimmed = rawId.trim();
if (trimmed.isEmpty) {
return const [];
}
final candidates = <String>{trimmed};
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('spotify:track:')) {
final compact = trimmed.split(':').last.trim();
if (compact.isNotEmpty) {
candidates.add(compact);
}
} else if (!trimmed.contains(':')) {
candidates.add('spotify:track:$trimmed');
}
final uri = Uri.tryParse(trimmed);
final segments = uri?.pathSegments ?? const <String>[];
final trackIndex = segments.indexOf('track');
if (trackIndex >= 0 && trackIndex + 1 < segments.length) {
final pathId = segments[trackIndex + 1].trim();
if (pathId.isNotEmpty) {
candidates.add(pathId);
candidates.add('spotify:track:$pathId');
}
}
return candidates.toList(growable: false);
}
}
final playbackProvider = NotifierProvider<PlaybackController, PlaybackState>(
PlaybackController.new,
);