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:
zarzet
2026-06-28 22:22:51 +07:00
parent 3a2481e8b2
commit 12fb942f16
10 changed files with 585 additions and 101 deletions
+26 -7
View File
@@ -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();
+167
View File
@@ -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) {
+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+28 -4
View File
@@ -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();
}
}
+39
View File
@@ -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)
+3
View File
@@ -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 {
+100 -51
View File
@@ -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;
+41 -1
View File
@@ -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;