fix(library): sync track playback controls

This commit is contained in:
zarzet
2026-08-30 00:43:13 +07:00
parent d5465e9768
commit 7141ca980f
7 changed files with 150 additions and 38 deletions
+20
View File
@@ -38,6 +38,26 @@ final playbackLoadingProvider = Provider<bool>((ref) {
);
});
/// Transport state for one media item. Non-current Library cells only watch
/// the current media ID; the active cell additionally follows play/loading so
/// a transport toggle does not rebuild the entire Library grid.
final mediaItemPlaybackUiProvider =
Provider.family<({bool isCurrent, bool isPlaying, bool isLoading}), String>(
(ref, mediaId) {
final currentId = ref.watch(
currentMediaItemProvider.select((state) => state.value?.id),
);
if (mediaId.isEmpty || currentId != mediaId) {
return (isCurrent: false, isPlaying: false, isLoading: false);
}
return (
isCurrent: true,
isPlaying: ref.watch(playbackPlayingProvider),
isLoading: ref.watch(playbackLoadingProvider),
);
},
);
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents();
});
+12 -13
View File
@@ -192,18 +192,20 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
final resolvedPath = result.path;
if (result.status == CompletionBridgePlayableStatus.playable &&
resolvedPath != null) {
return TrackGridPlayButton(
tooltip: context.l10n.a11yPlayTrackByArtist(
return _LibraryPlaybackButton(
mediaId: _cleanFilePath(resolvedPath),
playTooltip: context.l10n.a11yPlayTrackByArtist(
trackName,
artistName,
),
onPressed: () => _openFile(
onPlay: () => _openFile(
resolvedPath,
title: trackName,
artist: artistName,
album: albumName,
coverUrl: coverUrl,
),
grid: true,
);
}
if (result.status == CompletionBridgePlayableStatus.checking) {
@@ -343,22 +345,19 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
final resolvedPath = result.path;
if (result.status == CompletionBridgePlayableStatus.playable &&
resolvedPath != null) {
return IconButton(
onPressed: () => _openFile(
return _LibraryPlaybackButton(
mediaId: _cleanFilePath(resolvedPath),
playTooltip: context.l10n.a11yPlayTrackByArtist(
trackName,
artistName,
),
onPlay: () => _openFile(
resolvedPath,
title: trackName,
artist: artistName,
album: albumName,
coverUrl: coverUrl,
),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
);
}
if (result.status == CompletionBridgePlayableStatus.checking) {
+69 -21
View File
@@ -567,21 +567,19 @@ extension _QueueTabItemWidgets on _QueueTabState {
mainAxisSize: MainAxisSize.min,
children: [
if (fileExists)
IconButton(
onPressed: () => _openFile(
_LibraryPlaybackButton(
mediaId: _cleanFilePath(item.filePath!),
playTooltip: context.l10n.a11yPlayTrackByArtist(
item.track.name,
item.track.artistName,
),
onPlay: () => _openFile(
item.filePath!,
title: item.track.name,
artist: item.track.artistName,
album: item.track.albumName,
coverUrl: item.track.coverUrl ?? '',
),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
)
else
Semantics(
@@ -954,16 +952,13 @@ extension _QueueTabItemWidgets on _QueueTabState {
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
if (fileExists) {
return IconButton(
onPressed: () => _playLibraryItem(item, libraryItems),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
return _LibraryPlaybackButton(
mediaId: _libraryPlaybackMediaId(item),
playTooltip: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
onPlay: () => _playLibraryItem(item, libraryItems),
);
}
return Tooltip(
@@ -1069,12 +1064,14 @@ extension _QueueTabItemWidgets on _QueueTabState {
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
if (fileExists) {
return TrackGridPlayButton(
tooltip: context.l10n.a11yPlayTrackByArtist(
return _LibraryPlaybackButton(
mediaId: _libraryPlaybackMediaId(item),
playTooltip: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
onPressed: () => _playLibraryItem(item, libraryItems),
onPlay: () => _playLibraryItem(item, libraryItems),
grid: true,
);
}
return Tooltip(
@@ -1133,3 +1130,54 @@ extension _QueueTabItemWidgets on _QueueTabState {
);
}
}
class _LibraryPlaybackButton extends ConsumerWidget {
const _LibraryPlaybackButton({
required this.mediaId,
required this.playTooltip,
required this.onPlay,
this.grid = false,
});
final String mediaId;
final String playTooltip;
final VoidCallback onPlay;
final bool grid;
@override
Widget build(BuildContext context, WidgetRef ref) {
final playback = ref.watch(mediaItemPlaybackUiProvider(mediaId));
final icon = playback.isPlaying ? Icons.pause : Icons.play_arrow;
final tooltip = playback.isPlaying ? context.l10n.actionPause : playTooltip;
final onPressed = playback.isLoading
? null
: () {
if (playback.isCurrent) {
ref
.read(musicPlayerControllerProvider)
.togglePlayPause(playback.isPlaying);
} else {
onPlay();
}
};
if (grid) {
return TrackGridPlayButton(
tooltip: tooltip,
onPressed: onPressed,
icon: icon,
);
}
final colorScheme = Theme.of(context).colorScheme;
return IconButton(
onPressed: onPressed,
icon: Icon(icon, color: colorScheme.primary),
tooltip: tooltip,
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
);
}
}
+4
View File
@@ -1,6 +1,10 @@
part of 'queue_tab.dart';
extension _QueueTabNavigation on _QueueTabState {
String _libraryPlaybackMediaId(UnifiedLibraryItem item) {
return item.historyItem?.id ?? item.localItem?.id ?? item.id;
}
Future<void> _openFile(
String filePath, {
String title = '',
+4 -2
View File
@@ -278,12 +278,14 @@ class TrackGridPlayButton extends StatelessWidget {
super.key,
required this.tooltip,
required this.onPressed,
this.icon = Icons.play_arrow,
});
static const double visualDiameter = 36;
final String tooltip;
final VoidCallback onPressed;
final VoidCallback? onPressed;
final IconData icon;
@override
Widget build(BuildContext context) {
@@ -307,7 +309,7 @@ class TrackGridPlayButton extends StatelessWidget {
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(Icons.play_arrow, size: 18, color: colorScheme.onPrimary),
child: Icon(icon, size: 18, color: colorScheme.onPrimary),
),
);
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
void main() {
test('Library playback state follows only the current media item', () async {
final container = ProviderContainer(
overrides: [
currentMediaItemProvider.overrideWith(
(ref) =>
Stream.value(const MediaItem(id: 'track-1', title: 'Track 1')),
),
playbackPlayingProvider.overrideWith((ref) => true),
playbackLoadingProvider.overrideWith((ref) => false),
],
);
addTearDown(container.dispose);
final currentMediaSubscription = container.listen(
currentMediaItemProvider,
(_, _) {},
);
addTearDown(currentMediaSubscription.close);
await container.read(currentMediaItemProvider.future);
expect(container.read(mediaItemPlaybackUiProvider('track-1')), (
isCurrent: true,
isPlaying: true,
isLoading: false,
));
expect(container.read(mediaItemPlaybackUiProvider('track-2')), (
isCurrent: false,
isPlaying: false,
isLoading: false,
));
});
}
+3 -2
View File
@@ -407,12 +407,13 @@ void main() {
expect(gridSource, contains('CompletionBridgePlayableStatus.checking'));
expect(gridSource, contains('semanticsLabel:'));
expect(gridSource, contains('queueCheckingDownloadedFile'));
expect(gridSource, contains('TrackGridPlayButton('));
expect(gridSource, contains('_LibraryPlaybackButton('));
expect(gridSource, contains('grid: true'));
expect(listSource, contains('resolveCompletionBridgePlayablePath('));
expect(listSource, contains('_completionBridgePlayableProbe.listenable('));
expect(listSource, contains('CompletionBridgePlayableStatus.checking'));
expect(listSource, contains('semanticsLabel:'));
expect(listSource, contains('queueCheckingDownloadedFile'));
expect(listSource, contains('Icons.play_arrow'));
expect(listSource, contains('_LibraryPlaybackButton('));
});
}