feat: update collection actions for offline-first playback

This commit is contained in:
zarzet 2026-02-27 14:30:10 +07:00
parent 54a7b6b568
commit a07c125454
4 changed files with 450 additions and 165 deletions

View file

@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
@ -761,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget {
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
@ -861,13 +867,7 @@ class _AlbumTrackItem extends ConsumerWidget {
],
),
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
@ -882,47 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(context, ref);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Future<bool> _playLocalIfAvailable(
BuildContext context,
WidgetRef ref,
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
}

View file

@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
@ -1243,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
final isInHistory = ref.watch(
downloadHistoryProvider.select(
(state) => state.isDownloaded(track.id),
),
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null &&
isrc.isNotEmpty &&
state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) !=
null;
}),
);
final showLocalLibraryIndicator = ref.watch(
@ -1268,12 +1277,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final isQueued = queueItem != null;
return InkWell(
onTap: () => _handlePopularTrackTap(
track,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
@ -1344,17 +1348,61 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
ClickableAlbumName(
albumName: track.albumName,
albumId: track.albumId,
artistName: track.artistName,
coverUrl: track.coverUrl,
extensionId: widget.extensionId,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
if (track.albumName.isNotEmpty ||
isInLocalLibrary ||
isInHistory)
Row(
children: [
if (track.albumName.isNotEmpty)
Expanded(
child: ClickableAlbumName(
albumName: track.albumName,
albumId: track.albumId,
artistName: track.artistName,
coverUrl: track.coverUrl,
extensionId: widget.extensionId,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isInLocalLibrary || isInHistory) ...[
if (track.albumName.isNotEmpty)
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
],
),
@ -1369,51 +1417,82 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
/// Handle tap on popular track item
void _handlePopularTrackTap(
Track track, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(track);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
_downloadTrack(track);
}
Future<bool> _playLocalIfAvailable(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
_downloadTrack(track);
return false;
}
void _downloadTrack(Track track) {

View file

@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
@ -631,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget {
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
@ -742,13 +748,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
],
),
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
@ -763,47 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(context, ref);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Future<bool> _playLocalIfAvailable(
BuildContext context,
WidgetRef ref,
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -5,8 +7,11 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
@ -171,9 +176,20 @@ class _TrackOptionsSheet extends ConsumerWidget {
// Action items (matches _QualityOption style)
_OptionTile(
icon: Icons.download_rounded,
title: context.l10n.downloadTitle,
title: 'Download & Play',
onTap: () async {
Navigator.pop(context);
final playedLocal = await _playLocalIfAvailable(
container,
rootContext,
);
if (playedLocal) {
return;
}
if (!rootContext.mounted) {
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
rootContext,
@ -181,35 +197,19 @@ class _TrackOptionsSheet extends ConsumerWidget {
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
container
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
service,
qualityOverride: quality,
);
ScaffoldMessenger.of(rootContext).showSnackBar(
SnackBar(
content: Text(
rootContext.l10n.snackbarAddedToQueue(track.name),
),
),
_enqueueDownloadAndAutoPlay(
container: container,
context: rootContext,
service: service,
quality: quality,
);
},
);
} else {
container
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
if (!rootContext.mounted) {
return;
}
ScaffoldMessenger.of(rootContext).showSnackBar(
SnackBar(
content: Text(
rootContext.l10n.snackbarAddedToQueue(track.name),
),
),
_enqueueDownloadAndAutoPlay(
container: container,
context: rootContext,
service: settings.defaultService,
);
}
},
@ -282,6 +282,138 @@ class _TrackOptionsSheet extends ConsumerWidget {
),
);
}
Future<bool> _playLocalIfAvailable(
ProviderContainer container,
BuildContext context,
) async {
final localState = container.read(localLibraryProvider);
final historyState = container.read(downloadHistoryProvider);
final historyNotifier = container.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await container
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await container
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
void _enqueueDownloadAndAutoPlay({
required ProviderContainer container,
required BuildContext context,
required String service,
String? quality,
}) {
container
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
unawaited(_waitForDownloadedFileAndPlay(container, context));
}
Future<void> _waitForDownloadedFileAndPlay(
ProviderContainer container,
BuildContext context,
) async {
const maxAttempts = 180; // up to ~3 minutes
for (var i = 0; i < maxAttempts; i++) {
final item = _findHistoryMatch(container);
if (item != null && await fileExists(item.filePath)) {
try {
await container
.read(playbackProvider.notifier)
.playLocalPath(
path: item.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile('$e')),
),
);
}
}
return;
}
await Future.delayed(const Duration(seconds: 1));
}
}
DownloadHistoryItem? _findHistoryMatch(ProviderContainer container) {
final historyState = container.read(downloadHistoryProvider);
final historyNotifier = container.read(downloadHistoryProvider.notifier);
final isrc = track.isrc?.trim();
return historyNotifier.getBySpotifyId(track.id) ??
((isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null) ??
historyState.findByTrackAndArtist(track.name, track.artistName);
}
}
/// Styled like _QualityOption in download_service_picker.dart