From 7141ca980f0c15fd733c2ee96ce4e851dc8f0a7c Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 00:43:13 +0700 Subject: [PATCH] fix(library): sync track playback controls --- lib/providers/music_player_provider.dart | 20 +++++ lib/screens/queue_tab_collection_items.dart | 25 +++--- lib/screens/queue_tab_item_widgets.dart | 90 ++++++++++++++++----- lib/screens/queue_tab_navigation.dart | 4 + lib/widgets/track_card.dart | 6 +- test/music_player_provider_test.dart | 38 +++++++++ test/queue_library_refresh_policy_test.dart | 5 +- 7 files changed, 150 insertions(+), 38 deletions(-) create mode 100644 test/music_player_provider_test.dart diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 14013b86..4060ae5d 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -38,6 +38,26 @@ final playbackLoadingProvider = Provider((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>((ref) { return musicPlayerQueueEvents(); }); diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 46bd4f75..d3b8b001 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -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) { diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 4e672845..8b910086 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -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), + ), + ); + } +} diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index 83164e30..1c157a09 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -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 _openFile( String filePath, { String title = '', diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index e04e3a88..e9f2b8c5 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -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), ), ); } diff --git a/test/music_player_provider_test.dart b/test/music_player_provider_test.dart new file mode 100644 index 00000000..80d25e01 --- /dev/null +++ b/test/music_player_provider_test.dart @@ -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, + )); + }); +} diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart index 6c2442bc..296e0d25 100644 --- a/test/queue_library_refresh_policy_test.dart +++ b/test/queue_library_refresh_policy_test.dart @@ -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(')); }); }