fix(music): play audio playlists from context menus
This commit is contained in:
@@ -1384,11 +1384,46 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// backend — Plex uses server-side `/playQueues`, Jellyfin builds an
|
||||
/// in-memory queue locally.
|
||||
Future<void> _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<void> _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<MediaItem> 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<void> _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async {
|
||||
final client = _getMediaClientForItem();
|
||||
|
||||
@@ -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<MediaContextMenuState>();
|
||||
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<MultiServerProvider>.value(value: multiServerProvider),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
|
||||
ChangeNotifierProvider<MusicPlaybackService>.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<MediaItem> 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<LibraryPage<MediaItem>> 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<MediaItem>? playedTracks;
|
||||
MusicPlayContext? playedContext;
|
||||
bool? shuffle;
|
||||
int callCount = 0;
|
||||
|
||||
@override
|
||||
bool get isAvailable => true;
|
||||
|
||||
@override
|
||||
Future<void> playFromList({
|
||||
required List<MediaItem> 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,
|
||||
|
||||
Reference in New Issue
Block a user