From fe5d6d8d96adc908f7e897d3d66369cab2a62fb5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:48:59 +0200 Subject: [PATCH] fix(music): play audio playlists from context menus --- lib/widgets/media_context_menu.dart | 35 +++++ test/widgets/media_context_menu_test.dart | 163 ++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1b7b2eee..0847254a 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1384,11 +1384,46 @@ class MediaContextMenuState extends State { /// backend — Plex uses server-side `/playQueues`, Jellyfin builds an /// in-memory queue locally. Future _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async { + final playlist = _playlist; + if (playlist?.playlistType == 'audio') { + await _launchAudioPlaylist(context, playlist!, shuffle: shuffle); + return; + } + // Launcher accepts both MediaItem (for collections) and MediaPlaylist. final launcher = MediaListPlaybackLauncher.forItem(context, widget.item); await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle, showLoadingIndicator: false); } + Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { + // Match PlaylistDetailScreen: fail the availability gate before paying + // for a full playlist fetch, then hand the tracks to the music session. + if (!ensureMusicPlaybackAvailable(context)) return; + + List tracks; + try { + tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id); + } catch (e, st) { + appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); + if (context.mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + return; + } + if (!context.mounted) return; + if (tracks.isEmpty) { + showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); + return; + } + + await playTracks( + context, + tracks: tracks, + playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), + shuffle: shuffle, + ); + } + /// Handle delete action for collections and playlists Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { final client = _getMediaClientForItem(); diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 782cbafd..eea112ac 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -7,9 +7,14 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_playlist.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/profile.dart'; @@ -20,8 +25,10 @@ import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; @@ -77,6 +84,108 @@ void main() { }); group('MediaContextMenu actions', () { + testWidgets('audio playlist play and shuffle actions use music playback', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final tracks = [ + MediaItem( + id: 'track-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Track One', + serverId: 'srv-1', + ), + MediaItem( + id: 'track-2', + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Track Two', + serverId: 'srv-1', + ), + ]; + final client = _AudioPlaylistClient(tracks); + final music = _RecordingMusicPlaybackService(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + addTearDown(() async { + activeProfileProvider.dispose(); + await plexHome.dispose(); + music.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + final menuKey = GlobalKey(); + const playlist = MediaPlaylist( + id: 'playlist-1', + backend: MediaBackend.jellyfin, + title: 'Road Trip', + playlistType: 'audio', + serverId: 'srv-1', + ); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: music), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: playlist, + child: const SizedBox(width: 120, height: 80, child: Text('audio target')), + ), + ), + ), + ), + ), + ), + ); + + menuKey.currentState!.showContextMenu(tester.element(find.text('audio target'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.common.play)); + await tester.pumpAndSettle(); + + expect(music.playedTracks, tracks); + expect(music.playedContext?.id, playlist.id); + expect(music.playedContext?.title, playlist.title); + expect(music.playedContext?.kind, MusicPlayContextKind.playlist); + expect(music.shuffle, isFalse); + + menuKey.currentState!.showContextMenu(tester.element(find.text('audio target'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.mediaMenu.shufflePlay)); + await tester.pumpAndSettle(); + + expect(music.callCount, 2); + expect(music.playedTracks, tracks); + expect(music.shuffle, isTrue); + expect(tester.takeException(), isNull); + }); + testWidgets('file info client resolution failure shows an error without popping another route', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en); TvDetectionService.debugSetAppleTVOverride(true); @@ -150,6 +259,60 @@ void main() { }); } +class _AudioPlaylistClient implements MediaServerClient { + final List tracks; + + _AudioPlaylistClient(this.tracks); + + @override + ServerId get serverId => ServerId('srv-1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + + @override + Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { + final offset = start ?? 0; + final limit = size ?? tracks.length; + return LibraryPage(items: tracks.skip(offset).take(limit).toList(), totalCount: tracks.length, offset: offset); + } + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RecordingMusicPlaybackService extends StubMusicPlaybackService { + List? playedTracks; + MusicPlayContext? playedContext; + bool? shuffle; + int callCount = 0; + + @override + bool get isAvailable => true; + + @override + Future playFromList({ + required List tracks, + MediaItem? startTrack, + required MusicPlayContext playContext, + bool shuffle = false, + }) async { + callCount++; + playedTracks = tracks; + playedContext = playContext; + this.shuffle = shuffle; + } +} + PlexHomeUser _homeUser({required bool admin}) { return PlexHomeUser( id: 0,