mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
feat: playback queue, preview exclusivity, player bug fixes
- PlaybackController with queue methods for albums/library/playlists - Library tab play builds merged queue (downloaded + local together) - Preview vs main player exclusivity - Preview stops on bottom-nav tab switch - Duration 0:00 fix, deleted track cleanup, Up Next sheet - Animation utilities improvements
This commit is contained in:
@@ -481,6 +481,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _startupSafRepairCursorKey =
|
||||
'history_startup_saf_repair_cursor_v1';
|
||||
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
||||
static const _startupOrphanSuspectPrefix =
|
||||
'history_startup_orphan_suspect_v1_';
|
||||
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||
bool _isLoaded = false;
|
||||
@@ -1541,24 +1543,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
|
||||
final result = await _inspectOrphanedEntries(entries);
|
||||
final confirmedOrphanIds = <String>[];
|
||||
for (final id in result.orphanedIds) {
|
||||
final key = '$_startupOrphanSuspectPrefix$id';
|
||||
if (prefs.getBool(key) == true) {
|
||||
confirmedOrphanIds.add(id);
|
||||
await prefs.remove(key);
|
||||
} else {
|
||||
await prefs.setBool(key, true);
|
||||
_historyLog.d(
|
||||
'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final replacement in result.replacementPaths.entries) {
|
||||
await _db.updateFilePath(replacement.key, replacement.value);
|
||||
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
|
||||
}
|
||||
|
||||
final deletedCount = result.orphanedIds.isEmpty
|
||||
final deletedCount = confirmedOrphanIds.isEmpty
|
||||
? 0
|
||||
: await _db.deleteByIds(result.orphanedIds);
|
||||
: await _db.deleteByIds(confirmedOrphanIds);
|
||||
|
||||
_applyHistoryPathAndDeletionChanges(
|
||||
deletedIds: result.orphanedIds,
|
||||
deletedIds: confirmedOrphanIds,
|
||||
replacementPaths: result.replacementPaths,
|
||||
);
|
||||
|
||||
if (entries.length < maxItems) {
|
||||
await prefs.remove(_startupOrphanCursorKey);
|
||||
} else {
|
||||
final nextCursor =
|
||||
safeCursor + entries.length - result.orphanedIds.length;
|
||||
final nextCursor = result.orphanedIds.isNotEmpty
|
||||
? safeCursor
|
||||
: safeCursor + entries.length;
|
||||
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
||||
}
|
||||
|
||||
@@ -3823,7 +3840,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final newItems = tracks.asMap().entries.map((entry) {
|
||||
final track = entry.value;
|
||||
final index = entry.key;
|
||||
final explicitPosition = playlistPositions != null &&
|
||||
final explicitPosition =
|
||||
playlistPositions != null &&
|
||||
index < playlistPositions.length &&
|
||||
(playlistPositions[index] ?? 0) > 0
|
||||
? playlistPositions[index]
|
||||
@@ -3838,7 +3856,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
qualityOverride: qualityOverride,
|
||||
playlistName: playlistName,
|
||||
playlistPosition:
|
||||
explicitPosition ?? (shouldAssignPlaylistPositions ? index + 1 : null),
|
||||
explicitPosition ??
|
||||
(shouldAssignPlaylistPositions ? index + 1 : null),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ 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/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -16,6 +19,24 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
@override
|
||||
PlaybackState build() => const PlaybackState();
|
||||
|
||||
Future<bool> _useInternalPlayer() async {
|
||||
final mode = ref.read(settingsProvider).playerMode;
|
||||
if (mode != 'internal') return false;
|
||||
return await ref.read(musicPlayerControllerProvider).ensureInitialized() !=
|
||||
null;
|
||||
}
|
||||
|
||||
String? _normalizeArtUri(String cover) {
|
||||
final value = cover.trim();
|
||||
if (value.isEmpty) return null;
|
||||
if (value.startsWith('http') ||
|
||||
value.startsWith('content://') ||
|
||||
value.startsWith('file://')) {
|
||||
return value;
|
||||
}
|
||||
return Uri.file(value).toString();
|
||||
}
|
||||
|
||||
Future<void> playLocalPath({
|
||||
required String path,
|
||||
required String title,
|
||||
@@ -27,14 +48,143 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
if (isCueVirtualPath(path)) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
}
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
_log.d('Playing "$title" in the internal player: $path');
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playSingle(
|
||||
PlayableMedia(
|
||||
id: path,
|
||||
source: path,
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
artUri: _normalizeArtUri(coverUrl),
|
||||
duration: (track != null && track.duration > 0)
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.d('Opening external player for "$title" by $artist: $path');
|
||||
await openFile(path);
|
||||
}
|
||||
|
||||
/// Plays a local-library album/list starting at [startItem], queuing the rest
|
||||
/// so playback continues to the next track automatically. Honors player mode.
|
||||
Future<void> playLocalLibraryQueue(
|
||||
List<LocalLibraryItem> items, {
|
||||
required LocalLibraryItem startItem,
|
||||
}) async {
|
||||
final playable = items
|
||||
.where(
|
||||
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playable.isEmpty) return;
|
||||
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||
if (startIndex < 0) startIndex = 0;
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playLocal(playable, initialIndex: startIndex);
|
||||
} else {
|
||||
await openFile(playable[startIndex].filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a downloaded-history album/list starting at [startItem], queuing the
|
||||
/// rest. Honors player mode.
|
||||
Future<void> playHistoryQueue(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required DownloadHistoryItem startItem,
|
||||
}) async {
|
||||
final playable = items
|
||||
.where(
|
||||
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playable.isEmpty) return;
|
||||
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||
if (startIndex < 0) startIndex = 0;
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playHistory(playable, initialIndex: startIndex);
|
||||
} else {
|
||||
await openFile(playable[startIndex].filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a prebuilt media queue starting at [startIndex]. Honors player mode
|
||||
/// ([externalPath] is opened externally when the built-in player is off).
|
||||
Future<void> playMediaQueue(
|
||||
Iterable<PlayableMedia> queue, {
|
||||
required int startIndex,
|
||||
required String externalPath,
|
||||
}) async {
|
||||
if (await _useInternalPlayer()) {
|
||||
final items = queue.toList(growable: false);
|
||||
if (items.isEmpty) return;
|
||||
final i = startIndex.clamp(0, items.length - 1);
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playAll(items, initialIndex: i);
|
||||
} else {
|
||||
await openFile(externalPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
final queue = <PlayableMedia>[];
|
||||
var skippedCueVirtualTrack = false;
|
||||
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
|
||||
for (var index = 0; index < orderedTracks.length; index++) {
|
||||
final track = orderedTracks[index];
|
||||
final resolvedPath = resolvedPaths[index];
|
||||
if (resolvedPath == null) continue;
|
||||
if (isCueVirtualPath(resolvedPath)) {
|
||||
skippedCueVirtualTrack = true;
|
||||
continue;
|
||||
}
|
||||
queue.add(
|
||||
PlayableMedia(
|
||||
id: resolvedPath,
|
||||
source: resolvedPath,
|
||||
title: track.name,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
artUri: _normalizeArtUri(track.coverUrl ?? ''),
|
||||
duration: track.duration > 0
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
_log.d('Playing ${queue.length} tracks in the internal player');
|
||||
await ref.read(musicPlayerControllerProvider).playAll(queue);
|
||||
return;
|
||||
}
|
||||
if (skippedCueVirtualTrack) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
}
|
||||
throw Exception(
|
||||
'No local audio file is available to play. Download the track first.',
|
||||
);
|
||||
}
|
||||
|
||||
var skippedCueVirtualTrack = false;
|
||||
for (final track in orderedTracks) {
|
||||
final resolvedPath = await _resolveTrackPath(track);
|
||||
@@ -98,6 +248,23 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<String?>> _resolveTrackPaths(List<Track> tracks) async {
|
||||
if (tracks.isEmpty) return const [];
|
||||
final results = List<String?>.filled(tracks.length, null);
|
||||
var next = 0;
|
||||
final workerCount = tracks.length < 4 ? tracks.length : 4;
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final index = next++;
|
||||
if (index >= tracks.length) return;
|
||||
results[index] = await _resolveTrackPath(tracks[index]);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
|
||||
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
|
||||
if (isLocalSource) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PreviewPlayer');
|
||||
@@ -59,7 +60,13 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onStateChange: _handleAppLifecycleState,
|
||||
);
|
||||
ref.onDispose(_disposePlayer);
|
||||
musicPlayerExclusiveAudioHook = () async {
|
||||
if (state.isActive) await stop();
|
||||
};
|
||||
ref.onDispose(() {
|
||||
musicPlayerExclusiveAudioHook = null;
|
||||
_disposePlayer();
|
||||
});
|
||||
return const PreviewPlayerState();
|
||||
}
|
||||
|
||||
@@ -161,6 +168,10 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
try {
|
||||
await musicPlayerHandler?.pause();
|
||||
} catch (_) {}
|
||||
|
||||
state = PreviewPlayerState(
|
||||
activeUrl: trimmed,
|
||||
status: PreviewStatus.loading,
|
||||
|
||||
+29
-19
@@ -26,6 +26,7 @@ import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/mini_player.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -300,6 +301,9 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
final previousIndex = _currentIndex;
|
||||
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
||||
HapticFeedback.selectionClick();
|
||||
// Stop any preview snippet when leaving the current tab. (_onPageChanged
|
||||
// cannot do this because _currentIndex is already updated below.)
|
||||
ref.read(previewPlayerProvider.notifier).stop();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
@@ -597,28 +601,34 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
bottomNavigationBar: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const MiniPlayer(),
|
||||
DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
elevation: 0,
|
||||
height: 64,
|
||||
backgroundColor: settingsGroupColor(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
).withValues(alpha: 0.72),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
elevation: 0,
|
||||
height: 64,
|
||||
backgroundColor: settingsGroupColor(
|
||||
context,
|
||||
).withValues(alpha: 0.72),
|
||||
destinations: destinations,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+140
-18
@@ -24,6 +24,8 @@ import 'package:spotiflac_android/providers/library_collections_provider.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/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
@@ -233,6 +235,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_queueLibraryCountsCache = {};
|
||||
final Map<_QueueLibraryPageRequest, _QueueLibraryPageData>
|
||||
_queueLibraryPageDataCache = {};
|
||||
DateTime? _lastBlankLibraryRepairAt;
|
||||
|
||||
double _effectiveTextScale() {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
@@ -371,6 +374,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_QueueLibraryPageRequest request,
|
||||
) {
|
||||
if (value != null) {
|
||||
final liveData = value.asData?.value;
|
||||
if (liveData != null) {
|
||||
_queueLibraryPageDataCache[request] = liveData;
|
||||
_trimQueueLibraryPageDataCache(protectedRequest: request);
|
||||
}
|
||||
value.whenOrNull(
|
||||
data: (data) {
|
||||
_queueLibraryPageDataCache[request] = data;
|
||||
@@ -400,6 +408,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return _QueueLibraryPageData.combine(pages);
|
||||
}
|
||||
|
||||
void _invalidateLibraryDataCaches() {
|
||||
_queueLibraryCountsCache.clear();
|
||||
_queueLibraryPageDataCache.clear();
|
||||
_unifiedItemsCache.clear();
|
||||
_invalidateFilterContentCache();
|
||||
}
|
||||
|
||||
void _scheduleBlankLibraryRepair({
|
||||
required bool hasQueueItems,
|
||||
required bool hasLibraryContent,
|
||||
required bool hasAnyLibraryItems,
|
||||
required bool isLibraryPageLoading,
|
||||
}) {
|
||||
if (!hasQueueItems ||
|
||||
hasLibraryContent ||
|
||||
hasAnyLibraryItems ||
|
||||
isLibraryPageLoading) {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final last = _lastBlankLibraryRepairAt;
|
||||
if (last != null && now.difference(last) < const Duration(seconds: 8)) {
|
||||
return;
|
||||
}
|
||||
_lastBlankLibraryRepairAt = now;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_invalidateLibraryDataCaches();
|
||||
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimQueueLibraryCountsCache() {
|
||||
const maxCountEntries = 24;
|
||||
while (_queueLibraryCountsCache.length > maxCountEntries) {
|
||||
@@ -2181,6 +2223,68 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays [item] and queues the rest of the merged library (downloaded + local
|
||||
/// in display order) so playback continues to the next track. Honors player
|
||||
/// mode and shuffle.
|
||||
Future<void> _playLibraryItem(
|
||||
UnifiedLibraryItem item,
|
||||
List<UnifiedLibraryItem> libraryItems,
|
||||
) async {
|
||||
final playableItems = libraryItems
|
||||
.where(
|
||||
(u) => u.filePath.trim().isNotEmpty && !isCueVirtualPath(u.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playableItems.isEmpty) return;
|
||||
|
||||
var start = playableItems.indexWhere((u) => u.id == item.id);
|
||||
if (start < 0) start = 0;
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playMediaQueue(
|
||||
playableItems.map(_toPlayableMedia),
|
||||
startIndex: start,
|
||||
externalPath: item.filePath,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayableMedia _toPlayableMedia(UnifiedLibraryItem item) {
|
||||
final history = item.historyItem;
|
||||
if (history != null) return playableFromHistory(history);
|
||||
final local = item.localItem;
|
||||
if (local != null) return playableFromLocal(local);
|
||||
|
||||
final cover = item.coverUrl ?? item.localCoverPath ?? '';
|
||||
String? art;
|
||||
if (cover.isNotEmpty) {
|
||||
art =
|
||||
(cover.startsWith('http') ||
|
||||
cover.startsWith('content://') ||
|
||||
cover.startsWith('file://'))
|
||||
? cover
|
||||
: Uri.file(cover).toString();
|
||||
}
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: art,
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
@@ -2682,6 +2786,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
});
|
||||
ref.listen<int>(
|
||||
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
||||
(previous, next) {
|
||||
if (previous == null || previous == next) return;
|
||||
_invalidateLibraryDataCaches();
|
||||
_resetLibraryPaging();
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
ref.listen<int>(
|
||||
localLibraryProvider.select((state) => state.loadedIndexVersion),
|
||||
(previous, next) {
|
||||
if (previous == null || previous == next) return;
|
||||
_invalidateLibraryDataCaches();
|
||||
_resetLibraryPaging();
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
final hasQueueItems = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||
@@ -2793,6 +2915,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty;
|
||||
final shouldShowLibraryControls =
|
||||
hasLibraryContent || hasAnyLibraryItems || hasActiveSearch;
|
||||
_scheduleBlankLibraryRepair(
|
||||
hasQueueItems: hasQueueItems,
|
||||
hasLibraryContent: hasLibraryContent,
|
||||
hasAnyLibraryItems: hasAnyLibraryItems,
|
||||
isLibraryPageLoading: isLibraryPageLoading,
|
||||
);
|
||||
|
||||
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
@@ -4378,6 +4506,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedGridItem(
|
||||
@@ -4391,6 +4520,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4444,6 +4574,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedLibraryItem(
|
||||
@@ -4456,6 +4587,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4527,6 +4659,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
);
|
||||
}, childCount: leadCount + filteredUnifiedItems.length),
|
||||
@@ -4550,6 +4683,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
);
|
||||
}, childCount: leadCount + filteredUnifiedItems.length),
|
||||
@@ -6755,6 +6889,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required int? downloadedNavigationIndex,
|
||||
required List<LocalLibraryItem> localNavigationItems,
|
||||
required int? localNavigationIndex,
|
||||
required List<UnifiedLibraryItem> libraryItems,
|
||||
}) {
|
||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||
final isSelected = _selectedIds.contains(item.id);
|
||||
@@ -6933,14 +7068,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
children: [
|
||||
if (fileExists)
|
||||
IconButton(
|
||||
onPressed: () => _openFile(
|
||||
item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
coverUrl:
|
||||
item.coverUrl ?? item.localCoverPath ?? '',
|
||||
),
|
||||
onPressed: () =>
|
||||
_playLibraryItem(item, libraryItems),
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
color: colorScheme.primary,
|
||||
@@ -6977,6 +7106,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required int? downloadedNavigationIndex,
|
||||
required List<LocalLibraryItem> localNavigationItems,
|
||||
required int? localNavigationIndex,
|
||||
required List<UnifiedLibraryItem> libraryItems,
|
||||
}) {
|
||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||
final isSelected = _selectedIds.contains(item.id);
|
||||
@@ -7085,16 +7215,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
item.artistName,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => _openFile(
|
||||
item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
coverUrl:
|
||||
item.coverUrl ??
|
||||
item.localCoverPath ??
|
||||
'',
|
||||
),
|
||||
onTap: () =>
|
||||
_playLibraryItem(item, libraryItems),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -580,6 +580,7 @@ class _FileExistsListenableCache {
|
||||
static const int _maxCacheSize = 500;
|
||||
|
||||
final Map<String, bool> _cache = {};
|
||||
final Map<String, int> _missCounts = {};
|
||||
final Map<String, ValueNotifier<bool>> _notifiers = {};
|
||||
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
|
||||
final Set<String> _pendingChecks = {};
|
||||
@@ -627,12 +628,34 @@ class _FileExistsListenableCache {
|
||||
|
||||
_pendingChecks.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
final exists = await fileExists(cleanPath);
|
||||
bool exists;
|
||||
try {
|
||||
exists = await fileExists(cleanPath);
|
||||
} catch (_) {
|
||||
_pendingChecks.remove(cleanPath);
|
||||
Timer(const Duration(milliseconds: 700), () => _startCheck(cleanPath));
|
||||
return;
|
||||
}
|
||||
_pendingChecks.remove(cleanPath);
|
||||
_cache[cleanPath] = exists;
|
||||
if (exists) {
|
||||
_missCounts.remove(cleanPath);
|
||||
_cache[cleanPath] = true;
|
||||
} else {
|
||||
final misses = (_missCounts[cleanPath] ?? 0) + 1;
|
||||
_missCounts[cleanPath] = misses;
|
||||
if (misses < 2) {
|
||||
Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
() => _startCheck(cleanPath),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_cache[cleanPath] = false;
|
||||
}
|
||||
final notifier = _notifiers[cleanPath];
|
||||
if (notifier != null && notifier.value != exists) {
|
||||
notifier.value = exists;
|
||||
final value = _cache[cleanPath] ?? true;
|
||||
if (notifier != null && notifier.value != value) {
|
||||
notifier.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -642,6 +665,7 @@ class _FileExistsListenableCache {
|
||||
notifier.dispose();
|
||||
}
|
||||
_notifiers.clear();
|
||||
_missCounts.clear();
|
||||
_alwaysMissingNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
@@ -1199,6 +1200,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _enqueueThis(WidgetRef ref, {required bool playNext}) async {
|
||||
final controller = ref.read(musicPlayerControllerProvider);
|
||||
final item = widget.item;
|
||||
final localItem = widget.localItem;
|
||||
if (item != null) {
|
||||
if (playNext) {
|
||||
await controller.playNextHistory(item);
|
||||
} else {
|
||||
await controller.addToQueueHistory(item);
|
||||
}
|
||||
} else if (localItem != null) {
|
||||
if (playNext) {
|
||||
await controller.playNextLocal(localItem);
|
||||
} else {
|
||||
await controller.addToQueueLocal(localItem);
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(playNext ? 'Playing next' : 'Added to queue'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnimatedTrackContent(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -3325,9 +3351,22 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final l10n = sheetContext.l10n;
|
||||
|
||||
final options = <_MetadataOption>[
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.playlist_play,
|
||||
label: 'Play next',
|
||||
onTap: () => _enqueueThis(ref, playNext: true),
|
||||
),
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.queue_music,
|
||||
label: 'Add to queue',
|
||||
onTap: () => _enqueueThis(ref, playNext: false),
|
||||
),
|
||||
_MetadataOption(
|
||||
icon: Icons.copy_outlined,
|
||||
label: l10n.trackCopyFilePath,
|
||||
dividerAbove: _fileExists,
|
||||
onTap: () => _copyToClipboard(screenContext, cleanFilePath),
|
||||
),
|
||||
if (_fileExists)
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
|
||||
@@ -279,11 +280,13 @@ Future<void> deleteFile(String? path) async {
|
||||
if (isCueVirtualPath(path)) return;
|
||||
if (isContentUri(path)) {
|
||||
await PlatformBridge.safDelete(path);
|
||||
await musicPlayerHandler?.onSourceDeleted(path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
await musicPlayerHandler?.onSourceDeleted(path);
|
||||
}
|
||||
|
||||
Future<FileAccessStat?> fileStat(String? path) async {
|
||||
|
||||
@@ -274,27 +274,12 @@ class TrackListSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
SkeletonBox(
|
||||
width: screenWidth,
|
||||
height: screenWidth * 0.75,
|
||||
borderRadius: 0,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
|
||||
),
|
||||
],
|
||||
if (showCoverHeader) const _CollectionHeaderSkeleton(),
|
||||
...List.generate(itemCount, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -303,26 +288,37 @@ class TrackListSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SkeletonBox(width: 48, height: 48),
|
||||
const SkeletonBox(width: 48, height: 48, borderRadius: 8),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 140 + (index % 3) * 30,
|
||||
height: 14,
|
||||
width: 150 + (index % 3) * 30,
|
||||
height: 15,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 90 + (index % 2) * 20,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 90 + (index % 2) * 20,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(
|
||||
width: 38,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(width: 24, height: 24, borderRadius: 12),
|
||||
],
|
||||
),
|
||||
@@ -351,32 +347,17 @@ class AlbumTrackListSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
return ShimmerLoading(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
SkeletonBox(
|
||||
width: screenWidth,
|
||||
height: screenWidth * 0.75,
|
||||
borderRadius: 0,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
|
||||
),
|
||||
],
|
||||
if (showCoverHeader) const _CollectionHeaderSkeleton(),
|
||||
...List.generate(itemCount, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -384,32 +365,43 @@ class AlbumTrackListSkeleton extends StatelessWidget {
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: SkeletonBox(
|
||||
width: 14,
|
||||
width: 16,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 120 + (index % 4) * 35,
|
||||
height: 14,
|
||||
width: 130 + (index % 4) * 35,
|
||||
height: 15,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 70 + (index % 3) * 20,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 70 + (index % 3) * 20,
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(
|
||||
width: 38,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(width: 24, height: 24, borderRadius: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -421,6 +413,63 @@ class AlbumTrackListSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Header skeleton matching the redesigned album/playlist header: a blurred
|
||||
/// backdrop block with a centered square cover, title/subtitle bars, a meta
|
||||
/// line (year + quality badges) and the action button row.
|
||||
class _CollectionHeaderSkeleton extends StatelessWidget {
|
||||
const _CollectionHeaderSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: 16,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SkeletonBox(width: screenWidth * 0.6, height: 22, borderRadius: 6),
|
||||
const SizedBox(height: 10),
|
||||
SkeletonBox(width: screenWidth * 0.35, height: 15, borderRadius: 4),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
SkeletonBox(width: 44, height: 14, borderRadius: 6),
|
||||
SizedBox(width: 10),
|
||||
SkeletonBox(width: 70, height: 14, borderRadius: 6),
|
||||
SizedBox(width: 10),
|
||||
SkeletonBox(width: 60, height: 14, borderRadius: 6),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||||
const SizedBox(width: 16),
|
||||
SkeletonBox(
|
||||
width: screenWidth * 0.45,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridSkeleton extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final int crossAxisCount;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/preview_player_provider.dart';
|
||||
|
||||
class PreviewButton extends ConsumerWidget {
|
||||
@@ -21,11 +23,49 @@ class PreviewButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loosely matches the built-in player's current item to this track so the
|
||||
/// per-track button stays in sync with the mini player instead of showing a
|
||||
/// conflicting preview state.
|
||||
bool _isCurrentMainTrack(MediaItem? item) {
|
||||
if (item == null) return false;
|
||||
String norm(String? s) => (s ?? '').toLowerCase().trim();
|
||||
return norm(item.title) == norm(track.name) &&
|
||||
norm(item.artist) == norm(track.artistName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// When the built-in player is currently on this track, mirror and control
|
||||
// it (consistent with the mini player) rather than the preview snippet.
|
||||
final mainItem = ref.watch(currentMediaItemProvider).value;
|
||||
if (_isCurrentMainTrack(mainItem)) {
|
||||
final isPlaying =
|
||||
ref.watch(playbackStateProvider).value?.playing ?? false;
|
||||
return Transform.translate(
|
||||
offset: const Offset(18, 0),
|
||||
child: IconButton(
|
||||
iconSize: size,
|
||||
padding: EdgeInsets.zero,
|
||||
alignment: Alignment.centerRight,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 36),
|
||||
icon: Icon(
|
||||
isPlaying
|
||||
? Icons.pause_circle_filled_rounded
|
||||
: Icons.play_circle_fill_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
tooltip: isPlaying ? context.l10n.previewStop : context.l10n.previewPlay,
|
||||
onPressed: () =>
|
||||
ref.read(musicPlayerControllerProvider).togglePlayPause(isPlaying),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!track.hasPreview) return const SizedBox.shrink();
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final previewState = ref.watch(previewPlayerProvider);
|
||||
final isActive = previewState.isActiveUrl(track.previewUrl);
|
||||
final status = isActive ? previewState.status : PreviewStatus.idle;
|
||||
|
||||
Reference in New Issue
Block a user