mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
01a5b43613
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
191 lines
5.5 KiB
Dart
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,
|
|
);
|