diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index b0f58be7..5d7285c0 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -86,22 +86,24 @@ 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), - ); - }, - ); +/// Release each cell's subscription when it scrolls out of the Library. +final mediaItemPlaybackUiProvider = Provider.autoDispose + .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/test/music_player_provider_test.dart b/test/music_player_provider_test.dart index 80d25e01..f433e33e 100644 --- a/test/music_player_provider_test.dart +++ b/test/music_player_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -35,4 +37,70 @@ void main() { isLoading: false, )); }); + + test( + 'Library releases playback state for cells that leave the viewport', + () async { + final mediaItems = StreamController(); + addTearDown(mediaItems.close); + final container = ProviderContainer( + overrides: [ + currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), + playbackPlayingProvider.overrideWith((ref) => true), + playbackLoadingProvider.overrideWith((ref) => false), + ], + ); + addTearDown(container.dispose); + final visibleProvider = mediaItemPlaybackUiProvider('visible-track'); + final visibleSubscription = container.listen(visibleProvider, (_, _) {}); + addTearDown(visibleSubscription.close); + mediaItems.add( + const MediaItem(id: 'visible-track', title: 'Visible track'), + ); + await container.read(currentMediaItemProvider.future); + + for (var index = 0; index < 200; index++) { + final provider = mediaItemPlaybackUiProvider('scrolled-track-$index'); + final subscription = container.listen(provider, (_, _) {}); + expect(container.exists(provider), isTrue); + subscription.close(); + } + await container.pump(); + + for (var index = 0; index < 200; index++) { + expect( + container.exists( + mediaItemPlaybackUiProvider('scrolled-track-$index'), + ), + isFalse, + ); + } + expect(container.exists(visibleProvider), isTrue); + expect(visibleSubscription.read(), ( + isCurrent: true, + isPlaying: true, + isLoading: false, + )); + + mediaItems.add( + const MediaItem(id: 'scrolled-track-0', title: 'Scrolled track'), + ); + await container.pump(); + final returningSubscription = container.listen( + mediaItemPlaybackUiProvider('scrolled-track-0'), + (_, _) {}, + ); + addTearDown(returningSubscription.close); + expect(returningSubscription.read(), ( + isCurrent: true, + isPlaying: true, + isLoading: false, + )); + expect(visibleSubscription.read(), ( + isCurrent: false, + isPlaying: false, + isLoading: false, + )); + }, + ); }