diff --git a/lib/components/LoginScreen/login_flow.dart b/lib/components/LoginScreen/login_flow.dart index 8fcdc89c..53de8fe5 100644 --- a/lib/components/LoginScreen/login_flow.dart +++ b/lib/components/LoginScreen/login_flow.dart @@ -120,6 +120,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/main.dart b/lib/main.dart index 4b79fd28..187bfe4b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,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'; @@ -91,6 +92,7 @@ void main() async { await _setupDownloadsHelper(); await _setupOSIntegration(); await _setupPlaybackServices(); + await _setupPlayonHandler(); await _setupKeepScreenOnHelper(); } catch (error, trace) { hasFailed = true; @@ -163,6 +165,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/jellyfin_models.dart b/lib/models/jellyfin_models.dart index 03907a2f..84c4374c 100644 --- a/lib/models/jellyfin_models.dart +++ b/lib/models/jellyfin_models.dart @@ -772,10 +772,10 @@ class ClientCapabilities { this.supportedCommands, required this.supportsMediaControl, required this.supportsPersistentIdentifier, - required this.supportsSync, + this.supportsSync, this.deviceProfile, this.iconUrl, - required this.supportsContentUploading, + this.supportsContentUploading, this.messageCallbackUrl, this.appStoreUrl, }); diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 5ca5f1e4..0f57e693 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/queue_service.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.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/services/favorite_provider.dart b/lib/services/favorite_provider.dart index f73b080f..2b0dd71c 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/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart index 636081af..ace4e2ad 100644 --- a/lib/services/jellyfin_api.chopper.dart +++ b/lib/services/jellyfin_api.chopper.dart @@ -343,6 +343,24 @@ final class _$JellyfinApi extends JellyfinApi { 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 28425d96..bd44c47c 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -261,6 +261,11 @@ abstract class JellyfinApi extends ChopperService { @Body() required BaseItemDto newItem, }); + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Capabilities/Full") + Future updateCapabilitiesFull( + @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 ef5d03b8..56845553 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -471,6 +471,11 @@ class JellyfinApiHelper { return (QueryResult_BaseItemDto.fromJson(response).items); } + /// Updates capabilities for this client. + Future updateCapabilities(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/playon_handler.dart b/lib/services/playon_handler.dart new file mode 100644 index 00000000..a9f90c06 --- /dev/null +++ b/lib/services/playon_handler.dart @@ -0,0 +1,245 @@ +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 { + if (FinampSettingsHelper.finampSettings.isOffline) { + await closeListener(); + } else { + await startListener(); + } + }); + + await startListener(); + } + + Future startListener() async { + try { + if (!FinampSettingsHelper.finampSettings.isOffline) { + await 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"], + )); + 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: 1), (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(); + 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"); + // Waiting for planned issue #500 to be resolved to adjust volume + // We now have to report to jellyfin server, probably through playback_history_service, that we updated volume + } + 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", + includeItemTypes: "Audio", + itemIds: List.from(request['Data']['ItemIds'] as List), + ); + if (items!.isNotEmpty) { + 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'], + ) + ); + } 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; + } + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ad7336c7..1d655a3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: chopper: ^8.0.0 get_it: ^7.2.0 just_audio: ^0.9.41 - just_audio_media_kit: ^2.0.6 + just_audio_media_kit: 2.0.6 media_kit_libs_linux: ^1.1.3 # Transcoding does not work on windows with current media-kit release. This fork uses the most recent