diff --git a/lib/components/LoginScreen/login_flow.dart b/lib/components/LoginScreen/login_flow.dart index 8fcdc89c2..5ef7e5af4 100644 --- a/lib/components/LoginScreen/login_flow.dart +++ b/lib/components/LoginScreen/login_flow.dart @@ -6,6 +6,7 @@ import 'package:finamp/components/LoginScreen/login_server_selection_page.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/screens/view_selector.dart'; import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; @@ -50,6 +51,7 @@ class _LoginFlowState extends State { key: loginNavigatorKey, initialRoute: LoginSplashPage.routeName, onGenerateRoute: (RouteSettings settings) { + final queueService = GetIt.instance(); Route route; Route createRoute(Widget page) => PageRouteBuilder( @@ -120,6 +122,13 @@ class _LoginFlowState extends State { connectionState: connectionState, onAuthenticated: () { Navigator.of(context).popAndPushNamed(ViewSelector.routeName); + final jellyfinApiHelper = GetIt.instance(); + jellyfinApiHelper.updateCapabilities(ClientCapabilities( + supportsMediaControl: true, + supportsPersistentIdentifier: true, + playableMediaTypes: ["Audio"], + supportedCommands: ["MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "TakeScreenshot", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "ToggleFullscreen", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "ChannelUp", "ChannelDown", "Guide", "ToggleStats", "PlayMediaSource", "PlayTrailers", "SetShuffleQueue", "PlayState", "PlayNext", "ToggleOsdMenu", "Play", "SetMaxStreamingBitrate", "SetPlaybackOrder"], + )); }, )); break; diff --git a/lib/components/PlayerScreen/feature_chips.dart b/lib/components/PlayerScreen/feature_chips.dart index b0690c9a9..8b6e8ead1 100644 --- a/lib/components/PlayerScreen/feature_chips.dart +++ b/lib/components/PlayerScreen/feature_chips.dart @@ -67,6 +67,16 @@ class FeatureState { ); } + if (FinampSettingsHelper.finampSettings.currentVolume != 1.0) { + features.add( + FeatureProperties( + text: AppLocalizations.of(context)!.currentVolumeFeatureText( + (FinampSettingsHelper.finampSettings.currentVolume * 100) + .round()), + ), + ); + } + for (var feature in configuration.features) { // TODO this will likely be extremely outdated if offline, hide? if (feature == FinampFeatureChipType.playCount && diff --git a/lib/components/PlayerScreen/output_panel.dart b/lib/components/PlayerScreen/output_panel.dart new file mode 100644 index 000000000..97e1eeecc --- /dev/null +++ b/lib/components/PlayerScreen/output_panel.dart @@ -0,0 +1,533 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:finamp/components/AddToPlaylistScreen/add_to_playlist_list.dart'; +import 'package:finamp/components/AddToPlaylistScreen/playlist_actions_menu.dart'; +import 'package:finamp/components/Buttons/cta_medium.dart'; +import 'package:finamp/components/album_image.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/favorite_provider.dart'; +import '../../services/feedback_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/theme_provider.dart'; +import '../AlbumScreen/song_menu.dart'; +import '../global_snackbar.dart'; +import '../themed_bottom_sheet.dart'; + +const outputMenuRouteName = "/output-menu"; + +Future showOutputMenu({ + required BuildContext context, + bool usePlayerTheme = true, + FinampTheme? themeProvider, +}) async { + final outputPanelLogger = Logger("OutputPanel"); + + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + final jellyfinApiHelper = GetIt.instance(); + final queueService = GetIt.instance(); + + FeedbackHelper.feedback(FeedbackType.selection); + + await showThemedBottomSheet( + context: context, + item: (await queueService.getCurrentTrack()?.baseItem)!, //TODO fix this + routeName: outputMenuRouteName, + minDraggableHeight: 0.2, + buildSlivers: (context) { + var themeColor = Theme.of(context).colorScheme.primary; + var playlistsFuture = jellyfinApiHelper.getItems( + includeItemTypes: "Playlist", + sortBy: "SortName", + ); + + final menuEntries = [ + // SongInfo.condensed( + // item: item, + // useThemeImage: usePlayerTheme, + // ), + const SizedBox(height: 16), + Consumer( + builder: (context, ref, child) { + return VolumeSlider( + initialValue: FinampSettingsHelper.finampSettings.currentVolume, + onChange: (double currentValue) async { + FinampSettingsHelper.setCurrentVolume(currentValue); + outputPanelLogger.fine("Volume set to $currentValue"); + }, + ); + }, + ), + // FutureBuilder( + // future: playlistsFuture.then((value) => + // value?.firstWhereOrNull((x) => x.id == parentPlaylist?.id)), + // initialData: parentPlaylist, + // builder: (context, snapshot) { + // if (snapshot.data != null) { + // return OutputSelectorTile( + // playlist: snapshot.data!, + // song: item, + // playlistItemId: item.playlistItemId, + // ); + // } else { + // return const SizedBox.shrink(); + // } + // }) + ]; + + var menu = [ + SliverStickyHeader( + header: Padding( + padding: const EdgeInsets.only(top: 6.0, bottom: 16.0), + child: Center( + child: Text( + AppLocalizations.of(context)!.outputMenuTitle, + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color!, + fontSize: 18, + fontWeight: FontWeight.w400)), + ), + ), + sliver: SliverToBoxAdapter( + child: SizedBox.shrink(), + )), + SliverStickyHeader( + header: Padding( + padding: const EdgeInsets.only( + top: 10.0, bottom: 8.0, left: 16.0, right: 16.0), + child: Text( + AppLocalizations.of(context)!.outputMenuVolumeSectionTitle, + style: Theme.of(context).textTheme.titleMedium), + ), + sliver: MenuMask( + height: 36.0, + child: SliverList( + delegate: SliverChildListDelegate.fixed( + menuEntries, + ))), + ), + SliverStickyHeader( + header: Padding( + padding: const EdgeInsets.only( + top: 10.0, bottom: 8.0, left: 16.0, right: 16.0), + child: Text( + AppLocalizations.of(context)!.outputMenuDevicesSectionTitle, + style: Theme.of(context).textTheme.titleMedium), + ), + sliver: MenuMask( + height: 35.0, + child: + // SizedBox.shrink(), + OutputTargetList( + playlistsFuture: playlistsFuture + .then((value) => value?.toList() ?? [])), + ), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 100.0)) + ]; + // TODO better estimate, how to deal with lag getting playlists? + var stackHeight = MediaQuery.sizeOf(context).height * 0.9; + return (stackHeight, menu); + }, + usePlayerTheme: usePlayerTheme, + themeProvider: themeProvider); +} + +class OutputTargetList extends StatefulWidget { + const OutputTargetList({ + super.key, + required this.playlistsFuture, + }); + + final Future> playlistsFuture; + + @override + State createState() => _OutputTargetListState(); +} + +class _OutputTargetListState extends State { + @override + void initState() { + super.initState(); + playlistsFuture = widget.playlistsFuture.then( + (value) => value.map((e) => (e, false, null as String?)).toList()); + } + + // playlist, isLoading, playlistItemId + late Future> playlistsFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: playlistsFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == snapshot.data!.length) { + return openOsOutputOptionsButton(context); + } + final (playlist, isLoading, playListItemId) = + snapshot.data![index]; + return OutputSelectorTile( + playlist: playlist, + playlistItemId: playListItemId, + isLoading: isLoading); + }, + childCount: snapshot.data!.length + 1, + )); + } else if (snapshot.hasError) { + GlobalSnackbar.error(snapshot.error); + return const SliverToBoxAdapter( + child: Center( + heightFactor: 3.0, + child: Icon(Icons.error, size: 64), + ), + ); + } else { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 1) { + return openOsOutputOptionsButton(context); + } else { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + }, childCount: 2)); + } + }, + ); + } + + Widget openOsOutputOptionsButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CTAMedium( + text: AppLocalizations.of(context)!.newPlaylist, + icon: TablerIcons.plus, + //accentColor: Theme.of(context).colorScheme.primary, + onPressed: () async { + var dialogResult = await showDialog<(Future, String?)?>( + context: context, + builder: (context) => SizedBox.shrink(), + // NewPlaylistDialog(itemToAdd: widget.itemToAdd.id), + ); + if (dialogResult != null) { + var oldFuture = playlistsFuture; + setState(() { + var loadingItem = [ + ( + BaseItemDto(id: "pending", name: dialogResult.$2), + true, + null as String? + ) + ]; + playlistsFuture = + oldFuture.then((value) => value + loadingItem); + }); + try { + var newId = await dialogResult.$1; + // Give the server time to calculate an initial playlist image + await Future.delayed(const Duration(seconds: 1)); + final jellyfinApiHelper = GetIt.instance(); + var playlist = await jellyfinApiHelper.getItemById(newId); + var playlistItems = await jellyfinApiHelper.getItems( + parentItem: playlist, fields: ""); + // var song = playlistItems?.firstWhere( + // (element) => element.id == widget.itemToAdd.id); + setState(() { + // var newItem = [(playlist, false, song?.playlistItemId)]; + // playlistsFuture = + // oldFuture.then((value) => value + newItem); + }); + } catch (e) { + GlobalSnackbar.error(e); + } + } + }, + ), + ], + ), + ); + } +} + +class OutputSelectorTile extends StatefulWidget { + const OutputSelectorTile( + {super.key, + required this.playlist, + this.playlistItemId, + this.isLoading = false}); + + final BaseItemDto playlist; + final String? playlistItemId; + final bool isLoading; + + @override + State createState() => OutputSelectorTileState(); +} + +class OutputSelectorTileState extends State { + String? playlistItemId; + int? childCount; + bool? itemIsIncluded; + + @override + void initState() { + super.initState(); + _updateState(); + } + + @override + void didUpdateWidget(OutputSelectorTile oldWidget) { + super.didUpdateWidget(oldWidget); + _updateState(); + } + + void _updateState() { + if (!widget.isLoading) { + playlistItemId = widget.playlistItemId; + childCount = widget.playlist.childCount; + if (widget.playlistItemId != null) { + itemIsIncluded = true; + } + } + } + + @override + Widget build(BuildContext context) { + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + return ToggleableListTile( + forceLoading: widget.isLoading, + title: widget.playlist.name ?? AppLocalizations.of(context)!.unknownName, + subtitle: AppLocalizations.of(context)!.songCount(childCount ?? 0), + leading: AlbumImage(item: widget.playlist), + positiveIcon: TablerIcons.circle_check_filled, + negativeIcon: itemIsIncluded == null + // we don't actually know if the track is part of the playlist + ? TablerIcons.circle_dashed_plus + : TablerIcons.circle_plus, + initialState: itemIsIncluded ?? false, + onToggle: (bool currentState) async { + if (currentState) { + // If playlistItemId is null, we need to fetch from the server before we can remove + if (playlistItemId == null) { + final jellyfinApiHelper = GetIt.instance(); + var newItems = await jellyfinApiHelper.getItems( + parentItem: widget.playlist, fields: ""); + + playlistItemId = null; + if (playlistItemId == null) { + // We were already not part of the playlist,. so removal is complete + setState(() { + childCount = newItems?.length ?? 0; + itemIsIncluded = false; + }); + return false; + } + if (!context.mounted) { + return true; + } + } + // part of playlist, remove + bool removed = true; + if (removed) { + setState(() { + childCount = childCount == null ? null : childCount! - 1; + itemIsIncluded = false; + }); + } + return !removed; + } else { + // add to playlist + bool added = true; + if (added) { + final jellyfinApiHelper = GetIt.instance(); + var newItems = await jellyfinApiHelper.getItems( + parentItem: widget.playlist, fields: ""); + setState(() { + childCount = newItems?.length ?? 0; + itemIsIncluded = true; + }); + return true; // this is called before the state is updated + } + return false; + } + }, + enabled: !isOffline, + ); + } +} + +class VolumeSlider extends ConsumerStatefulWidget { + const VolumeSlider({ + super.key, + required this.initialValue, + required this.onChange, + this.forceLoading = false, + this.feedback = true, + }); + + final double initialValue; + final bool forceLoading; + final Future Function(double currentValue) onChange; + final bool feedback; + + @override + ConsumerState createState() => _VolumeSliderState(); +} + +class _VolumeSliderState extends ConsumerState { + double currentValue = 0; + + @override + void initState() { + super.initState(); + currentValue = widget.initialValue; + } + + @override + void didUpdateWidget(VolumeSlider oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.forceLoading) { + currentValue = widget.initialValue; + } + } + + @override + Widget build(BuildContext context) { + var themeColor = Theme.of(context).colorScheme.primary; + return Padding( + padding: + const EdgeInsets.only(left: 12.0, right: 12.0, top: 4.0, bottom: 4.0), + child: Container( + decoration: ShapeDecoration( + color: themeColor.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.zero, + child: Stack(children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: themeColor, + inactiveTrackColor: themeColor.withOpacity(0.3), + trackHeight: 40.0, + trackShape: RoundedRectSliderTrackShape(), + thumbColor: Colors.white, + thumbShape: VerticalSliderThumbShape( + thumbWidth: 2.0, + thumbHeight: 24.0, + borderRadius: 8.0, + offsetLeft: -8.0, + ), + overlayColor: themeColor.withOpacity(0.2), + overlayShape: RoundSliderOverlayShape(overlayRadius: 12.0), + ), + child: Slider( + value: currentValue, + onChanged: (value) { + setState(() { + currentValue = value; + }); + }, + onChangeEnd: (value) async { + unawaited(widget.onChange(value)); + if (widget.feedback) { + FeedbackHelper.feedback(FeedbackType.selection); + } + setState(() { + currentValue = value; + }); + }, + ), + ), + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: Center( + child: Text( + "${(currentValue * 100).round()}%", + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ) + ])), + ); + } +} + +class VerticalSliderThumbShape extends SliderComponentShape { + final double thumbWidth; + final double thumbHeight; + final double borderRadius; + final double offsetLeft; + + VerticalSliderThumbShape({ + this.thumbWidth = 4.0, + this.thumbHeight = 40.0, + this.borderRadius = 8.0, + this.offsetLeft = 0.0, + }); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size(thumbWidth, thumbHeight); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Paint paint = Paint() + ..color = sliderTheme.thumbColor! + ..style = PaintingStyle.fill; + + final Rect thumbRect = Rect.fromCenter( + center: center.translate(offsetLeft, 0), + width: getPreferredSize(true, true).width, + height: getPreferredSize(true, true).height, + ); + + final RRect thumbRRect = RRect.fromRectAndRadius( + thumbRect, + Radius.circular(borderRadius), + ); + + context.canvas.drawRRect(thumbRRect, paint); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 08385e008..4cea6fc0e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -999,6 +999,16 @@ } } }, + "currentVolumeFeatureText": "{volume}% volume", + "@currentVolumeFeatureText": { + "description": "Label for the feature chip that is shown if the volume is not 100%", + "placeholders": { + "volume": { + "type": "int", + "description": "The current volume level, in percent" + } + } + }, "playbackSpeedDecreaseLabel": "Decrease playback speed", "@playbackSpeedDecreaseLabel": { "description": "Label for the button in the speed menu that decreases the playback speed." @@ -1835,5 +1845,17 @@ "genericCancel": "Cancel", "@genericCancel": { "description": "Used when the user stops an action from taking place inside a popup dialog window" + }, + "outputMenuTitle": "Change Output", + "@outputMenuTitle": { + "description": "Title for the output menu that allows the user to change the audio output device and volume" + }, + "outputMenuVolumeSectionTitle": "Volume", + "@outputMenuVolumeSectionTitle": { + "description": "Title for the volume section in the output menu" + }, + "outputMenuDevicesSectionTitle": "Available Devices", + "@outputMenuDevicesSectionTitle": { + "description": "Title for the available devices section in the output menu" } } diff --git a/lib/main.dart b/lib/main.dart index 69ca1dac4..3f4ddf360 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:finamp/services/finamp_user_helper.dart'; import 'package:finamp/services/keep_screen_on_helper.dart'; import 'package:finamp/services/offline_listen_helper.dart'; import 'package:finamp/services/playback_history_service.dart'; +import 'package:finamp/services/playon_handler.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:finamp/services/theme_provider.dart'; import 'package:flutter/material.dart'; @@ -92,6 +93,7 @@ void main() async { await _setupDownloadsHelper(); await _setupOSIntegration(); await _setupPlaybackServices(); + await _setupPlayonHandler(); await _setupKeepScreenOnHelper(); } catch (error, trace) { hasFailed = true; @@ -167,6 +169,10 @@ Future _setupDownloadsHelper() async { await downloadsService.startQueues(); } +Future _setupPlayonHandler() async { + GetIt.instance.registerSingleton(PlayonHandler()); +} + Future _setupKeepScreenOnHelper() async { GetIt.instance.registerSingleton(KeepScreenOnHelper()); } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index bcfc40610..e8afe2102 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -140,6 +140,7 @@ class DefaultSettings { static const showDownloadsWithUnknownLibrary = true; static const downloadWorkers = 5; static const maxConcurrentDownloads = 10; + static const double currentVolume = 1.0; } @HiveType(typeId: 28) @@ -245,7 +246,9 @@ class FinampSettings { this.hasDownloadedPlaylistInfo = DefaultSettings.hasDownloadedPlaylistInfo, this.transcodingSegmentContainer = - DefaultSettings.transcodingSegmentContainer}); + DefaultSettings.transcodingSegmentContainer, + this.currentVolume = DefaultSettings.currentVolume, + }); @HiveField(0, defaultValue: DefaultSettings.isOffline) bool isOffline; @@ -509,6 +512,9 @@ class FinampSettings { @HiveField(79, defaultValue: DefaultSettings.bufferSizeMegabytes) int bufferSizeMegabytes; + @HiveField(80, defaultValue: DefaultSettings.currentVolume) + double currentVolume; + static Future create() async { final downloadLocation = await DownloadLocation.create( name: "Internal Storage", diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 51b8a8112..b7e0d7d03 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -194,6 +194,7 @@ class FinampSettingsAdapter extends TypeAdapter { transcodingSegmentContainer: fields[75] == null ? FinampSegmentContainer.fragmentedMp4 : fields[75] as FinampSegmentContainer, + currentVolume: fields[80] == null ? 1.0 : fields[80] as double, ) ..disableGesture = fields[19] == null ? false : fields[19] as bool ..showFastScroller = fields[25] == null ? true : fields[25] as bool @@ -203,7 +204,7 @@ class FinampSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(76) + ..writeByte(77) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -355,7 +356,9 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(78) ..write(obj.bufferDisableSizeConstraints) ..writeByte(79) - ..write(obj.bufferSizeMegabytes); + ..write(obj.bufferSizeMegabytes) + ..writeByte(80) + ..write(obj.currentVolume); } @override diff --git a/lib/models/jellyfin_models.dart b/lib/models/jellyfin_models.dart index 03907a2fa..2e679b3ad 100644 --- a/lib/models/jellyfin_models.dart +++ b/lib/models/jellyfin_models.dart @@ -768,29 +768,18 @@ class SessionUserInfo { @HiveType(typeId: 16) class ClientCapabilities { ClientCapabilities({ - this.playableMediaTypes, - this.supportedCommands, + required this.playableMediaTypes, + required this.supportedCommands, required this.supportsMediaControl, required this.supportsPersistentIdentifier, - required this.supportsSync, this.deviceProfile, - this.iconUrl, - required this.supportsContentUploading, - this.messageCallbackUrl, this.appStoreUrl, + this.iconUrl, }); @HiveField(0) List? playableMediaTypes; - /// Items Enum: "MoveUp" "MoveDown" "MoveLeft" "MoveRight" "PageUp" "PageDown" - /// "PreviousLetter" "NextLetter" "ToggleOsd" "ToggleContextMenu" "Select" - /// "Back" "TakeScreenshot" "SendKey" "SendString" "GoHome" "GoToSettings" - /// "VolumeUp" "VolumeDown" "Mute" "Unmute" "ToggleMute" "SetVolume" - /// "SetAudioStreamIndex" "SetSubtitleStreamIndex" "ToggleFullscreen" - /// "DisplayContent" "GoToSearch" "DisplayMessage" "SetRepeatMode" "ChannelUp" - /// "ChannelDown" "Guide" "ToggleStats" "PlayMediaSource" "PlayTrailers" - /// "SetShuffleQueue" "PlayState" "PlayNext" "ToggleOsdMenu" "Play" @HiveField(1) List? supportedCommands; @@ -800,9 +789,6 @@ class ClientCapabilities { @HiveField(3) bool? supportsPersistentIdentifier; - @HiveField(4) - bool? supportsSync; - /// Defines the MediaBrowser.Model.Dlna.DeviceProfile. @HiveField(5) DeviceProfile? deviceProfile; @@ -812,12 +798,6 @@ class ClientCapabilities { // Below fields were added during null safety migration (0.5.0) - @HiveField(7) - bool? supportsContentUploading; - - @HiveField(8) - String? messageCallbackUrl; - @HiveField(9) String? appStoreUrl; diff --git a/lib/models/jellyfin_models.g.dart b/lib/models/jellyfin_models.g.dart index af12d54fd..10668e7e1 100644 --- a/lib/models/jellyfin_models.g.dart +++ b/lib/models/jellyfin_models.g.dart @@ -620,19 +620,16 @@ class ClientCapabilitiesAdapter extends TypeAdapter { supportedCommands: (fields[1] as List?)?.cast(), supportsMediaControl: fields[2] as bool?, supportsPersistentIdentifier: fields[3] as bool?, - supportsSync: fields[4] as bool?, deviceProfile: fields[5] as DeviceProfile?, - iconUrl: fields[6] as String?, - supportsContentUploading: fields[7] as bool?, - messageCallbackUrl: fields[8] as String?, appStoreUrl: fields[9] as String?, + iconUrl: fields[6] as String?, ); } @override void write(BinaryWriter writer, ClientCapabilities obj) { writer - ..writeByte(10) + ..writeByte(7) ..writeByte(0) ..write(obj.playableMediaTypes) ..writeByte(1) @@ -641,16 +638,10 @@ class ClientCapabilitiesAdapter extends TypeAdapter { ..write(obj.supportsMediaControl) ..writeByte(3) ..write(obj.supportsPersistentIdentifier) - ..writeByte(4) - ..write(obj.supportsSync) ..writeByte(5) ..write(obj.deviceProfile) ..writeByte(6) ..write(obj.iconUrl) - ..writeByte(7) - ..write(obj.supportsContentUploading) - ..writeByte(8) - ..write(obj.messageCallbackUrl) ..writeByte(9) ..write(obj.appStoreUrl); } @@ -3451,15 +3442,12 @@ ClientCapabilities _$ClientCapabilitiesFromJson(Map json) => ClientCapabilities( supportsMediaControl: json['SupportsMediaControl'] as bool?, supportsPersistentIdentifier: json['SupportsPersistentIdentifier'] as bool?, - supportsSync: json['SupportsSync'] as bool?, deviceProfile: json['DeviceProfile'] == null ? null : DeviceProfile.fromJson( Map.from(json['DeviceProfile'] as Map)), - iconUrl: json['IconUrl'] as String?, - supportsContentUploading: json['SupportsContentUploading'] as bool?, - messageCallbackUrl: json['MessageCallbackUrl'] as String?, appStoreUrl: json['AppStoreUrl'] as String?, + iconUrl: json['IconUrl'] as String?, ); Map _$ClientCapabilitiesToJson(ClientCapabilities instance) => @@ -3468,11 +3456,8 @@ Map _$ClientCapabilitiesToJson(ClientCapabilities instance) => 'SupportedCommands': instance.supportedCommands, 'SupportsMediaControl': instance.supportsMediaControl, 'SupportsPersistentIdentifier': instance.supportsPersistentIdentifier, - 'SupportsSync': instance.supportsSync, 'DeviceProfile': instance.deviceProfile?.toJson(), 'IconUrl': instance.iconUrl, - 'SupportsContentUploading': instance.supportsContentUploading, - 'MessageCallbackUrl': instance.messageCallbackUrl, 'AppStoreUrl': instance.appStoreUrl, }; diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 4de6d6cef..a4fc21aa4 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:finamp/services/playon_handler.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:flutter/gestures.dart'; @@ -28,6 +29,7 @@ final _musicScreenLogger = Logger("MusicScreen"); void postLaunchHook(WidgetRef ref) async { final downloadsService = GetIt.instance(); final queueService = GetIt.instance(); + final playonHandler = GetIt.instance(); // make sure playlist info is downloaded for users upgrading from older versions and new installations AFTER logging in and selecting their libraries/views if (!FinampSettingsHelper.finampSettings.hasDownloadedPlaylistInfo) { @@ -38,6 +40,10 @@ void postLaunchHook(WidgetRef ref) async { FinampSettingsHelper.setHasDownloadedPlaylistInfo(true); } + // Initialize playon handler + unawaited(playonHandler.initialize()); + playonHandler.ref = ref; + // Restore queue unawaited(queueService .performInitialQueueLoad() diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 84bae56ba..bfdbc068a 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -6,6 +6,7 @@ import 'package:balanced_text/balanced_text.dart'; import 'package:finamp/color_schemes.g.dart'; import 'package:finamp/components/AlbumScreen/song_menu.dart'; import 'package:finamp/components/Buttons/simple_button.dart'; +import 'package:finamp/components/PlayerScreen/output_panel.dart'; import 'package:finamp/components/PlayerScreen/player_screen_appbar_title.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/screens/lyrics_screen.dart'; @@ -433,7 +434,16 @@ class _PlayerScreenContent extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Spacer(), + Flexible( + fit: FlexFit.tight, + child: SimpleButton( + text: "Output", + icon: TablerIcons.device_speaker, + onPressed: () async { + await showOutputMenu(context: context); + }, + ), + ), const Flexible(fit: FlexFit.tight, child: QueueButton()), Flexible( fit: FlexFit.tight, diff --git a/lib/services/favorite_provider.dart b/lib/services/favorite_provider.dart index f73b080f4..2b0dd71cb 100644 --- a/lib/services/favorite_provider.dart +++ b/lib/services/favorite_provider.dart @@ -111,6 +111,10 @@ class IsFavorite extends _$IsFavorite { return state; } + void updateState(bool isFavorite) { + state = isFavorite; + } + void toggleFavorite() async { if (_initializing != null) { await _initializing; diff --git a/lib/services/favorite_provider.g.dart b/lib/services/favorite_provider.g.dart index a5df6812b..bb5e20d8f 100644 --- a/lib/services/favorite_provider.g.dart +++ b/lib/services/favorite_provider.g.dart @@ -6,7 +6,7 @@ part of 'favorite_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$isFavoriteHash() => r'4c20e32cb59b9ae69ad7cfc9f79ae1061ecae760'; +String _$isFavoriteHash() => r'e5a1dd454fa11a3223d631d9d1c8e302d6049701'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index 604720849..e82b5fa2b 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -551,6 +551,18 @@ class FinampSettingsHelper { .put("FinampSettings", finampSettingsTemp); } + static void setCurrentVolume(double newVolume) { + FinampSettings finampSettingsTemp = finampSettings; + if (newVolume < 0) { + newVolume = 0.0; + } else if (newVolume > 1) { + newVolume = 1.0; + } + finampSettingsTemp.currentVolume = newVolume; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static IconButton makeSettingsResetButtonWithDialog( BuildContext context, Function() resetFunction, {bool isGlobal = false}) { diff --git a/lib/services/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart index 636081afa..365c9bc51 100644 --- a/lib/services/jellyfin_api.chopper.dart +++ b/lib/services/jellyfin_api.chopper.dart @@ -343,6 +343,51 @@ final class _$JellyfinApi extends JellyfinApi { return $response.bodyOrThrow; } + @override + Future updateCapabilities({ + required String playableMediaTypes, + required String supportedCommands, + required bool supportsMediaControl, + required bool supportsPersistentIdentifier, + }) async { + final Uri $url = Uri.parse('/Sessions/Capabilities'); + final Map $params = { + 'playableMediaTypes': playableMediaTypes, + 'supportedCommands': supportedCommands, + 'supportsMediaControl': supportsMediaControl, + 'supportsPersistentIdentifier': supportsPersistentIdentifier, + }; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future updateCapabilitiesFull( + ClientCapabilities clientCapabilities) async { + final Uri $url = Uri.parse('/Sessions/Capabilities/Full'); + final $body = clientCapabilities; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + @override Future startPlayback( PlaybackProgressInfo playbackProgressInfo) async { diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index 2d5672d36..53036702f 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -262,6 +262,23 @@ abstract class JellyfinApi extends ChopperService { @Body() required BaseItemDto newItem, }); + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Capabilities") + Future updateCapabilities({ + @Query() required String playableMediaTypes, + @Query() required String supportedCommands, + @Query() required bool supportsMediaControl, + @Query() required bool supportsPersistentIdentifier, + }); + + + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Capabilities/Full") + Future updateCapabilitiesFull( + // @Query() required String id, + @Body() ClientCapabilities clientCapabilities, + ); + @FactoryConverter(request: JsonConverter.requestFactory) @Post(path: "/Sessions/Playing") Future startPlayback( diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index ef5d03b8f..20d578b01 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -471,6 +471,22 @@ class JellyfinApiHelper { return (QueryResult_BaseItemDto.fromJson(response).items); } + /// Updates capabilities for this client. + Future updateCapabilities(ClientCapabilities capabilities) async { + await jellyfinApi.updateCapabilities( + playableMediaTypes: capabilities.playableMediaTypes?.join(",") ?? "", + supportedCommands: capabilities.supportedCommands?.join(",") ?? "", + supportsMediaControl: capabilities.supportsMediaControl ?? false, + supportsPersistentIdentifier: + capabilities.supportsPersistentIdentifier ?? false, + ); + } + + /// Updates capabilities for this client. + Future updateCapabilitiesFull(ClientCapabilities capabilities) async { + await jellyfinApi.updateCapabilitiesFull(capabilities); + } + /// Tells the Jellyfin server that playback has started Future reportPlaybackStart( PlaybackProgressInfo playbackProgressInfo) async { diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index eda79f7ad..cbc066f8c 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -189,6 +189,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { }); FinampSettingsHelper.finampSettingsListener.addListener(() { + final finampSettings = FinampSettingsHelper.finampSettings; // update replay gain settings every time settings are changed iosBaseVolumeGainFactor = pow( 10.0, @@ -196,13 +197,16 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { .finampSettings.volumeNormalizationIOSBaseGain / 20.0) as double; // https://sound.stackexchange.com/questions/38722/convert-db-value-to-linear-scale - if (FinampSettingsHelper.finampSettings.volumeNormalizationActive) { + if (finampSettings.volumeNormalizationActive) { _loudnessEnhancerEffect?.setEnabled(true); _applyVolumeNormalization(mediaItem.valueOrNull); } else { _loudnessEnhancerEffect?.setEnabled(false); - _player.setVolume(1.0); // disable replay gain on iOS + _player.setVolume( + finampSettings.currentVolume); // disable replay gain on iOS _volumeNormalizationLogger.info("Replay gain disabled"); + _volumeNormalizationLogger + .info("Current volume: ${finampSettings.currentVolume}"); } }); @@ -733,6 +737,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { if (effectiveGainChange != null) { _volumeNormalizationLogger.info("Gain change: $effectiveGainChange"); if (Platform.isAndroid) { + _player.setVolume(FinampSettingsHelper.finampSettings.currentVolume); _loudnessEnhancerEffect?.setTargetGain(effectiveGainChange / 10.0); //!!! always divide by 10, the just_audio implementation has a bug so it expects a value in Bel and not Decibel (remove once https://github.com/ryanheise/just_audio/pull/1092/commits/436b3274d0233818a061ecc1c0856a630329c4e6 is merged) } else { @@ -743,14 +748,19 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { 20.0); // https://sound.stackexchange.com/questions/38722/convert-db-value-to-linear-scale final newVolumeClamped = newVolume.clamp(0.0, 1.0); _volumeNormalizationLogger - .finer("new volume: $newVolume ($newVolumeClamped clipped)"); - _player.setVolume(newVolumeClamped); + .finer( + "current volume: ${FinampSettingsHelper.finampSettings.currentVolume}"); + _volumeNormalizationLogger.finer( + "new normalization volume: $newVolume ($newVolumeClamped clipped)"); + _player.setVolume(FinampSettingsHelper.finampSettings.currentVolume * + newVolumeClamped); } } else { // reset gain offset _loudnessEnhancerEffect?.setTargetGain(0 / 10.0); //!!! always divide by 10, the just_audio implementation has a bug so it expects a value in Bel and not Decibel (remove once https://github.com/ryanheise/just_audio/pull/1092/commits/436b3274d0233818a061ecc1c0856ua630329c4e6 is merged) _player.setVolume( + FinampSettingsHelper.finampSettings.currentVolume * iosBaseVolumeGainFactor); //!!! it's important that the base gain is used instead of 1.0, so that any tracks without normalization gain information don't fall back to full volume, but to the base volume for iOS } } diff --git a/lib/services/playback_history_service.dart b/lib/services/playback_history_service.dart index e4c318933..eae8dd714 100644 --- a/lib/services/playback_history_service.dart +++ b/lib/services/playback_history_service.dart @@ -590,7 +590,7 @@ class PlaybackHistoryService { final queue = _queueService .peekQueue(next: _maxQueueLengthToReport) .map((e) => jellyfin_models.QueueItem( - id: e.item.id, + id: e.baseItem?.id ?? "", playlistItemId: e.type.name, )) .toList(); diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart new file mode 100644 index 000000000..a6f70b31f --- /dev/null +++ b/lib/services/playon_handler.dart @@ -0,0 +1,317 @@ +import 'dart:async'; + +import 'package:finamp/components/global_snackbar.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/audio_service_helper.dart'; +import 'package:finamp/services/playback_history_service.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:finamp/services/favorite_provider.dart'; +import 'package:logging/logging.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../services/music_player_background_task.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import 'dart:convert'; +import 'finamp_user_helper.dart'; + +import 'package:get_it/get_it.dart'; + +final _playOnHandlerLogger = Logger("PlayOnHandler"); +final finampUserHelper = GetIt.instance(); +final jellyfinApiHelper = GetIt.instance(); +final queueService = GetIt.instance(); +final audioServiceHelper = GetIt.instance(); +final playbackHistoryService = GetIt.instance(); +final audioHandler = GetIt.instance(); +var channel; +var keepaliveSubscription; +var reconnectionSubscription = null; + +class PlayonHandler { + late WidgetRef ref; + + Future initialize() async { + // Turn on/off when offline mode is toggled + var settingsListener = FinampSettingsHelper.finampSettingsListener; + settingsListener.addListener(() async { + _playOnHandlerLogger.info("Settings changed, restarting listener"); + if (FinampSettingsHelper.finampSettings.isOffline) { + await closeListener(); + } else { + await startListener(); + } + }); + + await startListener(); + } + + Future startListener() async { + try { + if (!FinampSettingsHelper.finampSettings.isOffline) { + await jellyfinApiHelper.updateCapabilitiesFull(ClientCapabilities( + supportsMediaControl: true, + supportsPersistentIdentifier: true, + playableMediaTypes: ["Audio"], + supportedCommands: [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "TakeScreenshot", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "ToggleFullscreen", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "ChannelUp", + "ChannelDown", + "Guide", + "ToggleStats", + "PlayMediaSource", + "PlayTrailers", + "SetShuffleQueue", + "PlayState", + "PlayNext", + "ToggleOsdMenu", + "Play", + "SetMaxStreamingBitrate", + "SetPlaybackOrder" + ], + )); + await connectWebsocket(); + } + reconnectionSubscription?.cancel(); + reconnectionSubscription = null; + } catch (e) { + if (reconnectionSubscription == null) { + unawaited(startReconnectionLoop()); + _playOnHandlerLogger.severe("Error starting PlayOn listener: $e"); + } + } + } + + Future startReconnectionLoop() async { + reconnectionSubscription = + Stream.periodic(const Duration(seconds: 5), (count) { + return count; + }).listen((count) { + startListener(); + _playOnHandlerLogger.info("Attempted to restart listener"); + }); + } + + Future connectWebsocket() async { + final url = + "${finampUserHelper.currentUser!.baseUrl}/socket?api_key=${finampUserHelper.currentUser!.accessToken}"; + final parsedUrl = Uri.parse(url); + final wsUrl = + parsedUrl.replace(scheme: parsedUrl.scheme == "https" ? "wss" : "ws"); + channel = WebSocketChannel.connect(wsUrl); + + await channel.ready; + _playOnHandlerLogger.info("WebSocket connection to server established"); + + channel.sink.add('{"MessageType":"KeepAlive"}'); + + channel.stream.listen( + (dynamic message) { + unawaited(handleMessage(message)); + }, + onDone: () { + keepaliveSubscription?.cancel(); + if (FinampSettingsHelper.finampSettings.isOffline) { + _playOnHandlerLogger + .info("WebSocket connection closed, offline mode is on"); + } else { + _playOnHandlerLogger + .warning("WebSocket connection closed, attempting to reconnect"); + startReconnectionLoop(); + } + }, + onError: (error) { + _playOnHandlerLogger.severe("WebSocket Error: $error"); + }, + ); + + keepaliveSubscription = + Stream.periodic(const Duration(seconds: 30), (count) { + return count; + }).listen((event) { + _playOnHandlerLogger.info("Sent KeepAlive message through websocket"); + channel.sink.add('{"MessageType":"KeepAlive"}'); + }); + } + + Future closeListener() async { + _playOnHandlerLogger.info("Closing playon session"); + channel.sink.add('{"MessageType":"SessionsStop"}'); + channel.sink.close(); + keepaliveSubscription?.cancel(); + + // In case offline mod is turned on while attempting to reconnect + reconnectionSubscription?.cancel(); + reconnectionSubscription = null; + } + + Future handleMessage(value) async { + _playOnHandlerLogger.finest("Received message: $value"); + + var request = jsonDecode(value); + + if (request['MessageType'] != 'ForceKeepAlive' && + request['MessageType'] != 'KeepAlive') { + switch (request['MessageType']) { + case "GeneralCommand": + switch (request['Data']['Name']) { + case "DisplayMessage": + final messageFromServer = request['Data']['Arguments']['Text']; + final header = request['Data']['Arguments']['Header']; + final timeout = request['Data']['Arguments']['Timeout']; + _playOnHandlerLogger + .info("Displaying message from server: '$messageFromServer'"); + GlobalSnackbar.message( + (context) => "$header: $messageFromServer"); + break; + case "SetVolume": + _playOnHandlerLogger.info("Server requested a volume adjustment"); + final desiredVolume = request['Data']['Arguments']['Volume']; + FinampSettingsHelper.setCurrentVolume( + double.parse(desiredVolume) / 100.0); + } + break; + case "UserDataChanged": + var item = await jellyfinApiHelper + .getItemById(request['Data']['UserDataList'][0]['ItemId']); + + // Handle favoritig from remote client + _playOnHandlerLogger.info("Updating favorite ui state"); + ref + .read(isFavoriteProvider(FavoriteRequest(item)).notifier) + .updateState(item.userData!.isFavorite); + break; + default: + switch (request['Data']['Command']) { + case "Stop": + await audioHandler.stop(); + break; + case "Pause": + audioHandler.pause(); + break; + case "Unpause": + audioHandler.play(); + break; + case "NextTrack": + await audioHandler.skipToNext(); + break; + case "PreviousTrack": + await audioHandler.skipToPrevious(); + break; + case "Seek": + // val to = message.data?.seekPositionTicks?.ticks ?: Duration.ZERO + final seekPosition = request['Data']['SeekPositionTicks'] != null + ? Duration( + milliseconds: + ((request['Data']['SeekPositionTicks'] as int) / + 10000) + .round()) + : Duration.zero; + await audioHandler.seek(seekPosition); + final currentItem = queueService.getCurrentTrack(); + if (currentItem != null) { + unawaited(playbackHistoryService.onPlaybackStateChanged( + currentItem, audioHandler.playbackState.value, null)); + } + break; + case "Rewind": + await audioHandler.rewind(); + break; + case "FastForward": + await audioHandler.fastForward(); + break; + case "PlayPause": + audioHandler.togglePlayback(); + break; + + // Do nothing + default: + switch (request['Data']['PlayCommand']) { + case 'PlayNow': + if (!request['Data'].containsKey('StartIndex')) { + request['Data']['StartIndex'] = 0; + } + var items = await jellyfinApiHelper.getItems( + // sortBy: "IndexNumber", //!!! don't sort, use the sorting provided by the command! + includeItemTypes: "Audio", + itemIds: + List.from(request['Data']['ItemIds'] as List), + ); + if (items!.isNotEmpty) { + //TODO check if all tracks in the request are in the upcoming queue (peekQueue). If they are, we should try to only reorder the upcoming queue instead of treating it as a new queue, and then skip to the correct index. + unawaited(queueService.startPlayback( + items: items, + source: QueueItemSource( + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: items[0].name), + type: QueueItemSourceType.song, + id: items[0].id, + ), + startingIndex: request['Data'][ + 'StartIndex'], // seems like Jellyfin isn't always sending the correct index + )); + } else { + _playOnHandlerLogger + .severe("Server asked to start an unplayable item"); + } + break; + case 'PlayNext': + var items = await jellyfinApiHelper.getItems( + sortBy: "IndexNumber", + includeItemTypes: "Audio", + itemIds: + List.from(request['Data']['ItemIds'] as List), + ); + unawaited(queueService.addToNextUp( + items: items!, + )); + break; + case 'PlayLast': + var items = await jellyfinApiHelper.getItems( + sortBy: "IndexNumber", + includeItemTypes: "Audio", + itemIds: + List.from(request['Data']['ItemIds'] as List), + ); + unawaited(queueService.addToQueue( + items: items!, + )); + break; + } + } + break; + } + } + } +}