From b30932a87ecdc64a49c92363293126eb6d84e0e0 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sun, 25 Aug 2024 23:27:34 +0200
Subject: [PATCH 01/22] Rough draft for playon compatibility for getting
 feedback

---
 lib/main.dart                    |  7 ++++
 lib/services/playon_handler.dart | 59 ++++++++++++++++++++++++++++++++
 2 files changed, 66 insertions(+)
 create mode 100644 lib/services/playon_handler.dart

diff --git a/lib/main.dart b/lib/main.dart
index 46bad0f5..6e6f9492 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -18,6 +18,7 @@ import 'package:finamp/services/finamp_settings_helper.dart';
 import 'package:finamp/services/finamp_user_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:audio_service/audio_service.dart';
@@ -86,6 +87,7 @@ void main() async {
     await _setupDownloadsHelper();
     await _setupOSIntegration();
     await _setupPlaybackServices();
+    await _setupPlayonHandler();
   } catch (error, trace) {
     hasFailed = true;
     Logger("ErrorApp").severe(error, null, trace);
@@ -151,6 +153,11 @@ Future<void> _setupDownloadsHelper() async {
   await downloadsService.startQueues();
 }
 
+Future<void> _setupPlayonHandler() async {
+  GetIt.instance.registerSingleton(PlayonHandler());
+  GetIt.instance<PlayonHandler>().startListener();
+}
+
 Future<void> setupHive() async {
   await Hive.initFlutter();
   Hive.registerAdapter(BaseItemDtoAdapter());
diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
new file mode 100644
index 00000000..433d9004
--- /dev/null
+++ b/lib/services/playon_handler.dart
@@ -0,0 +1,59 @@
+import 'dart:async';
+
+import 'package:finamp/models/finamp_models.dart';
+import 'package:finamp/services/queue_service.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+import '../../services/music_player_background_task.dart';
+import '../../services/jellyfin_api_helper.dart';
+import 'dart:convert';
+import 'finamp_user_helper.dart';
+
+import 'package:get_it/get_it.dart';
+
+
+class PlayonHandler {
+  Future<void> startListener()  async {
+    final finampUserHelper = GetIt.instance<FinampUserHelper>();
+    // final url="ws://192.168.1.30:8096/socket?api_key=${finampUserHelper.currentUser!.accessToken}&deviceId=AP2A.240705.005";  this should work but doesn't
+    final url="ws://192.168.1.30:8096/socket?api_key=${finampUserHelper.currentUser!.accessToken}&deviceId=TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxMjguMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xMjguMHwxNzIyODczMDk3MDcy";
+    final wsUrl = Uri.parse(url);
+    final channel = WebSocketChannel.connect(wsUrl);
+    final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
+    final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
+    final _queueService = GetIt.instance<QueueService>();
+
+    await channel.ready;
+
+    // Will need to send a Keepalive message every 30 seconds, the code below didn't work
+
+    // final Stream _myStream =
+    //     Stream.periodic(const Duration(seconds: 30), (int count) {
+    //       channel.sink.add('{"MessageType":"KeepAlive"}');
+    // });
+
+    await for (final value in channel.stream) {
+      var response = jsonDecode(value);
+
+      if (response['MessageType'] != 'ForceKeepAlive' && response['MessageType'] != 'KeepAlive') {
+        switch (response['Data']['PlayCommand']) {
+          case 'PlayNow':
+            channel.sink.add('{"MessageType":"KeepAlive"}');
+            // print(response['Data']);
+            // print(response['Data']['ItemIds']);
+            var item = await _jellyfinApiHelper.getItemById(response['Data']['ItemIds'][0]);
+            unawaited(_queueService.startPlayback(
+              items: [item],
+              source: QueueItemSource(
+                name: QueueItemSourceName(
+                    type: QueueItemSourceNameType.preTranslated,
+                    pretranslatedName: item.name),
+                type: QueueItemSourceType.song,
+                id: item.id,
+              ),
+            ));
+        }
+      }
+
+    }
+  }
+}
\ No newline at end of file

From 3e38a9389bc6166dfd4fb0c90a5e497e6d8f5f3f Mon Sep 17 00:00:00 2001
From: Chaphasilor <ppp.u@web.de>
Date: Wed, 14 Aug 2024 00:48:49 +0200
Subject: [PATCH 02/22] update client capabilities to allow for remote control,
 handle media commands

---
 lib/components/LoginScreen/login_flow.dart |   7 ++
 lib/main.dart                              |   2 +-
 lib/models/jellyfin_models.dart            |   4 +-
 lib/services/jellyfin_api.chopper.dart     |  18 +++
 lib/services/jellyfin_api.dart             |   5 +
 lib/services/jellyfin_api_helper.dart      |   5 +
 lib/services/playon_handler.dart           | 126 ++++++++++++++++-----
 7 files changed, 136 insertions(+), 31 deletions(-)

diff --git a/lib/components/LoginScreen/login_flow.dart b/lib/components/LoginScreen/login_flow.dart
index 4b5dd9da..8c12978e 100644
--- a/lib/components/LoginScreen/login_flow.dart
+++ b/lib/components/LoginScreen/login_flow.dart
@@ -120,6 +120,13 @@ class _LoginFlowState extends State<LoginFlow> {
                 connectionState: connectionState,
                 onAuthenticated: () {
                   Navigator.of(context).popAndPushNamed(ViewSelector.routeName);
+                  final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
+                  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 6e6f9492..35a951ec 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -155,7 +155,7 @@ Future<void> _setupDownloadsHelper() async {
 
 Future<void> _setupPlayonHandler() async {
   GetIt.instance.registerSingleton(PlayonHandler());
-  GetIt.instance<PlayonHandler>().startListener();
+  unawaited(GetIt.instance<PlayonHandler>().initialize());
 }
 
 Future<void> setupHive() async {
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/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<dynamic> 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<dynamic, dynamic>(
+      $request,
+      requestConverter: JsonConverter.requestFactory,
+    );
+    return $response.bodyOrThrow;
+  }
+
   @override
   Future<dynamic> startPlayback(
       PlaybackProgressInfo playbackProgressInfo) async {
diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart
index 93240f9d..f5b5e6a9 100644
--- a/lib/services/jellyfin_api.dart
+++ b/lib/services/jellyfin_api.dart
@@ -260,6 +260,11 @@ abstract class JellyfinApi extends ChopperService {
     @Body() required BaseItemDto newItem,
   });
 
+  @FactoryConverter(request: JsonConverter.requestFactory)
+  @Post(path: "/Sessions/Capabilities/Full")
+  Future<dynamic> updateCapabilitiesFull(
+      @Body() ClientCapabilities clientCapabilities);
+
   @FactoryConverter(request: JsonConverter.requestFactory)
   @Post(path: "/Sessions/Playing")
   Future<dynamic> startPlayback(
diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart
index d92648a9..3f643a37 100644
--- a/lib/services/jellyfin_api_helper.dart
+++ b/lib/services/jellyfin_api_helper.dart
@@ -469,6 +469,11 @@ class JellyfinApiHelper {
     return (QueryResult_BaseItemDto.fromJson(response).items);
   }
 
+  /// Updates capabilities for this client.
+  Future<void> updateCapabilities(ClientCapabilities capabilities) async {
+    await jellyfinApi.updateCapabilitiesFull(capabilities);
+  }
+
   /// Tells the Jellyfin server that playback has started
   Future<void> reportPlaybackStart(
       PlaybackProgressInfo playbackProgressInfo) async {
diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 433d9004..94b0875a 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -1,7 +1,9 @@
 import 'dart:async';
 
 import 'package:finamp/models/finamp_models.dart';
+import 'package:finamp/models/jellyfin_models.dart';
 import 'package:finamp/services/queue_service.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';
@@ -10,19 +12,41 @@ import 'finamp_user_helper.dart';
 
 import 'package:get_it/get_it.dart';
 
+final _playOnHandlerLogger = Logger("PlayOnHandler");
+
 
 class PlayonHandler {
-  Future<void> startListener()  async {
+
+  Future<void> initialize() async {
+    try {
+      await startListener();
+    } catch (e) {
+      _playOnHandlerLogger.severe("Error initializing PlayOnHandler: $e");
+    }
+  }
+  
+  Future<void> startListener() async {
     final finampUserHelper = GetIt.instance<FinampUserHelper>();
-    // final url="ws://192.168.1.30:8096/socket?api_key=${finampUserHelper.currentUser!.accessToken}&deviceId=AP2A.240705.005";  this should work but doesn't
-    final url="ws://192.168.1.30:8096/socket?api_key=${finampUserHelper.currentUser!.accessToken}&deviceId=TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxMjguMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xMjguMHwxNzIyODczMDk3MDcy";
-    final wsUrl = Uri.parse(url);
-    final channel = WebSocketChannel.connect(wsUrl);
+    final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
+    final queueService = GetIt.instance<QueueService>();
+
+    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"],
+    ));
     final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
-    final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
-    final _queueService = GetIt.instance<QueueService>();
+
+    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");
+    final channel = WebSocketChannel.connect(wsUrl);
 
     await channel.ready;
+    _playOnHandlerLogger.info("WebSocket connection to server established");
+
+    channel.sink.add('{"MessageType":"KeepAlive"}');
 
     // Will need to send a Keepalive message every 30 seconds, the code below didn't work
 
@@ -31,29 +55,75 @@ class PlayonHandler {
     //       channel.sink.add('{"MessageType":"KeepAlive"}');
     // });
 
+    channel.stream.handleError((error, trace) {
+        _playOnHandlerLogger.severe("WebSocket Error: $error");
+      },
+    );
+
     await for (final value in channel.stream) {
-      var response = jsonDecode(value);
-
-      if (response['MessageType'] != 'ForceKeepAlive' && response['MessageType'] != 'KeepAlive') {
-        switch (response['Data']['PlayCommand']) {
-          case 'PlayNow':
-            channel.sink.add('{"MessageType":"KeepAlive"}');
-            // print(response['Data']);
-            // print(response['Data']['ItemIds']);
-            var item = await _jellyfinApiHelper.getItemById(response['Data']['ItemIds'][0]);
-            unawaited(_queueService.startPlayback(
-              items: [item],
-              source: QueueItemSource(
-                name: QueueItemSourceName(
-                    type: QueueItemSourceNameType.preTranslated,
-                    pretranslatedName: item.name),
-                type: QueueItemSourceType.song,
-                id: item.id,
-              ),
-            ));
+
+      _playOnHandlerLogger.finest("Received message: $value");
+      
+      var request = jsonDecode(value);
+
+      if (request['MessageType'] != 'ForceKeepAlive' && request['MessageType'] != 'KeepAlive') {
+        _playOnHandlerLogger.info("Received a '${request['MessageType']}' message: ${request['Data']}");
+
+        switch (request['Data']['Command']) {
+          case "Stop":
+            await audioHandler.stop();
+            break;
+					case "Pause":
+            await audioHandler.pause();
+            break;
+					case "Unpause":
+            await 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);
+            break;
+					case "Rewind":
+            await audioHandler.rewind();
+            break;
+					case "FastForward":
+            await audioHandler.fastForward();
+            break;
+					case "PlayPause":
+            await audioHandler.togglePlayback();
+            break;
+
+					// Do nothing
+          default:
+            switch (request['Data']['PlayCommand']) {
+              case 'PlayNow':
+                channel.sink.add('{"MessageType":"KeepAlive"}');
+                // print(request['Data']);
+                // print(request['Data']['ItemIds']);
+                var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][0]);
+                unawaited(queueService.startPlayback(
+                  items: [item],
+                  source: QueueItemSource(
+                    name: QueueItemSourceName(
+                        type: QueueItemSourceNameType.preTranslated,
+                        pretranslatedName: item.name),
+                    type: QueueItemSourceType.song,
+                    id: item.id,
+                  ),
+                ));
+            }
+          }
         }
-      }
 
+        // channel.sink.add('{"MessageType":"KeepAlive"}');
+        
     }
   }
-}
\ No newline at end of file
+}

From a5c838f8e420ba7411024aa8d473431bfc851098 Mon Sep 17 00:00:00 2001
From: Chaphasilor <ppp.u@web.de>
Date: Wed, 28 Aug 2024 22:05:17 +0200
Subject: [PATCH 03/22] support showing server messages

---
 lib/services/playon_handler.dart | 113 ++++++++++++++++++-------------
 1 file changed, 65 insertions(+), 48 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 94b0875a..c274eb52 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -1,5 +1,6 @@
 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/queue_service.dart';
@@ -69,57 +70,73 @@ class PlayonHandler {
       if (request['MessageType'] != 'ForceKeepAlive' && request['MessageType'] != 'KeepAlive') {
         _playOnHandlerLogger.info("Received a '${request['MessageType']}' message: ${request['Data']}");
 
-        switch (request['Data']['Command']) {
-          case "Stop":
-            await audioHandler.stop();
-            break;
-					case "Pause":
-            await audioHandler.pause();
-            break;
-					case "Unpause":
-            await 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);
-            break;
-					case "Rewind":
-            await audioHandler.rewind();
-            break;
-					case "FastForward":
-            await audioHandler.fastForward();
+        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;
+            }
             break;
-					case "PlayPause":
-            await audioHandler.togglePlayback();
+          default:
+            switch (request['Data']['Command']) {
+              case "Stop":
+                await audioHandler.stop();
+                break;
+              case "Pause":
+                await audioHandler.pause();
+                break;
+              case "Unpause":
+                await 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);
+                break;
+              case "Rewind":
+                await audioHandler.rewind();
+                break;
+              case "FastForward":
+                await audioHandler.fastForward();
+                break;
+              case "PlayPause":
+                await audioHandler.togglePlayback();
+                break;
+
+              // Do nothing
+              default:
+                switch (request['Data']['PlayCommand']) {
+                  case 'PlayNow':
+                    channel.sink.add('{"MessageType":"KeepAlive"}');
+                    // print(request['Data']);
+                    // print(request['Data']['ItemIds']);
+                    var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][0]);
+                    unawaited(queueService.startPlayback(
+                      items: [item],
+                      source: QueueItemSource(
+                        name: QueueItemSourceName(
+                            type: QueueItemSourceNameType.preTranslated,
+                            pretranslatedName: item.name),
+                        type: QueueItemSourceType.song,
+                        id: item.id,
+                      ),
+                    ));
+                }
+              }
             break;
+        }
 
-					// Do nothing
-          default:
-            switch (request['Data']['PlayCommand']) {
-              case 'PlayNow':
-                channel.sink.add('{"MessageType":"KeepAlive"}');
-                // print(request['Data']);
-                // print(request['Data']['ItemIds']);
-                var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][0]);
-                unawaited(queueService.startPlayback(
-                  items: [item],
-                  source: QueueItemSource(
-                    name: QueueItemSourceName(
-                        type: QueueItemSourceNameType.preTranslated,
-                        pretranslatedName: item.name),
-                    type: QueueItemSourceType.song,
-                    id: item.id,
-                  ),
-                ));
-            }
-          }
         }
 
         // channel.sink.add('{"MessageType":"KeepAlive"}');

From 218dcaefdc8657a684656e243ba3e89440ec53ec Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Thu, 19 Sep 2024 17:01:26 +0200
Subject: [PATCH 04/22] Fix wrong item being picked

---
 lib/services/playon_handler.dart | 32 ++++++++++++++++++++------------
 1 file changed, 20 insertions(+), 12 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index c274eb52..f527eaa2 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -3,11 +3,13 @@ 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/queue_service.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';
 
@@ -30,6 +32,7 @@ class PlayonHandler {
     final finampUserHelper = GetIt.instance<FinampUserHelper>();
     final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
     final queueService = GetIt.instance<QueueService>();
+    final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
 
     await jellyfinApiHelper.updateCapabilities(ClientCapabilities(
       supportsMediaControl: true,
@@ -120,18 +123,23 @@ class PlayonHandler {
                   case 'PlayNow':
                     channel.sink.add('{"MessageType":"KeepAlive"}');
                     // print(request['Data']);
-                    // print(request['Data']['ItemIds']);
-                    var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][0]);
-                    unawaited(queueService.startPlayback(
-                      items: [item],
-                      source: QueueItemSource(
-                        name: QueueItemSourceName(
-                            type: QueueItemSourceNameType.preTranslated,
-                            pretranslatedName: item.name),
-                        type: QueueItemSourceType.song,
-                        id: item.id,
-                      ),
-                    ));
+                    // print(request['Data']['ItemIds']);              
+                    var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][request['Data']['StartIndex']]);
+                      if (FinampSettingsHelper
+                        .finampSettings.startInstantMixForIndividualTracks) {
+                      unawaited(audioServiceHelper.startInstantMixForItem(item));
+                    } else {
+                      unawaited(queueService.startPlayback(
+                        items: [item],
+                        source: QueueItemSource(
+                          name: QueueItemSourceName(
+                              type: QueueItemSourceNameType.preTranslated,
+                              pretranslatedName: item.name),
+                          type: QueueItemSourceType.song,
+                          id: item.id,
+                        ),
+                      ));
+                    }
                 }
               }
             break;

From 634ddde982165c366b39d666f97ccc55da950e31 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sat, 21 Sep 2024 10:47:40 +0200
Subject: [PATCH 05/22] Fix starting an album only queuing one song

---
 lib/services/playon_handler.dart | 43 ++++++++++++++++++++++----------
 1 file changed, 30 insertions(+), 13 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index f527eaa2..65805f7d 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -122,24 +122,41 @@ class PlayonHandler {
                 switch (request['Data']['PlayCommand']) {
                   case 'PlayNow':
                     channel.sink.add('{"MessageType":"KeepAlive"}');
-                    // print(request['Data']);
-                    // print(request['Data']['ItemIds']);              
-                    var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][request['Data']['StartIndex']]);
+                    if (request['Data'].containsKey('StartIndex')) { // User started a single song
+                      var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][request['Data']['StartIndex']]);
                       if (FinampSettingsHelper
                         .finampSettings.startInstantMixForIndividualTracks) {
                       unawaited(audioServiceHelper.startInstantMixForItem(item));
-                    } else {
+                      } else {
+                        unawaited(queueService.startPlayback(
+                          items: [item],
+                          source: QueueItemSource(
+                            name: QueueItemSourceName(
+                                type: QueueItemSourceNameType.preTranslated,
+                                pretranslatedName: item.name),
+                            type: QueueItemSourceType.song,
+                            id: item.id,
+                          ),
+                        ));
+                      }
+                    } else { // User asked to play an album
+                      var items=<BaseItemDto>[];
+                      for (final itemId in request['Data']['ItemIds']) {
+                        items.add(await jellyfinApiHelper.getItemById(itemId));
+                      }
                       unawaited(queueService.startPlayback(
-                        items: [item],
-                        source: QueueItemSource(
-                          name: QueueItemSourceName(
-                              type: QueueItemSourceNameType.preTranslated,
-                              pretranslatedName: item.name),
-                          type: QueueItemSourceType.song,
-                          id: item.id,
-                        ),
-                      ));
+                          items: items,
+                          source: QueueItemSource(
+                            name: QueueItemSourceName(
+                                type: QueueItemSourceNameType.preTranslated,
+                                pretranslatedName: items[0].name),
+                            type: QueueItemSourceType.song,
+                            id: items[0].id,
+                          ),
+                        ));
                     }
+                    
+                      
                 }
               }
             break;

From 2e253c144c2490baf65f35e6d595c88792e5a904 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sat, 21 Sep 2024 11:16:07 +0200
Subject: [PATCH 06/22] Send KeepAlive message every 30 seconds, apparently fix
 disconnection issues

---
 lib/services/playon_handler.dart | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 65805f7d..b1ada924 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -52,12 +52,13 @@ class PlayonHandler {
 
     channel.sink.add('{"MessageType":"KeepAlive"}');
 
-    // Will need to send a Keepalive message every 30 seconds, the code below didn't work
+    final keepaliveSubscription = Stream.periodic(const Duration(seconds: 30), (count) {
+      return count;
+    }).listen((event) {
+      _playOnHandlerLogger.info("Sent KeepAlive message through websocket");
+      channel.sink.add('{"MessageType":"KeepAlive"}');
+    });
 
-    // final Stream _myStream =
-    //     Stream.periodic(const Duration(seconds: 30), (int count) {
-    //       channel.sink.add('{"MessageType":"KeepAlive"}');
-    // });
 
     channel.stream.handleError((error, trace) {
         _playOnHandlerLogger.severe("WebSocket Error: $error");

From ef424d37b9462390be3de3adfbfa78b80177a446 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sat, 21 Sep 2024 12:32:16 +0200
Subject: [PATCH 07/22] WIP initial implementation of volume control

---
 lib/services/playon_handler.dart | 5 +++++
 pubspec.yaml                     | 1 +
 2 files changed, 6 insertions(+)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index b1ada924..dd29cb62 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -7,6 +7,7 @@ import 'package:finamp/services/audio_service_helper.dart';
 import 'package:finamp/services/queue_service.dart';
 import 'package:logging/logging.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
+import 'package:flutter_volume_controller/flutter_volume_controller.dart';
 import '../../services/music_player_background_task.dart';
 import '../../services/jellyfin_api_helper.dart';
 import '../../services/finamp_settings_helper.dart';
@@ -84,6 +85,10 @@ class PlayonHandler {
                 _playOnHandlerLogger.info("Displaying message from server: '$messageFromServer'");
                 GlobalSnackbar.message((context) => "$header: $messageFromServer");
                 break;
+              case "SetVolume":
+                final desiredVolume=request['Data']['Arguments']['Volume'];
+                await FlutterVolumeController.setVolume(float.parse(desiredVolume)/100.0);
+                // We now have to report to jellyfin server, probably through playback_history_service, that we updated volume
             }
             break;
           default:
diff --git a/pubspec.yaml b/pubspec.yaml
index 495becbd..75190ba9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -101,6 +101,7 @@ dependencies:
   scroll_to_index: ^3.0.1
   window_manager: ^0.3.8
   url_launcher: ^6.2.6
+  flutter_volume_controller: ^1.3.2
 
 dev_dependencies:
   flutter_test:

From 0b0722ad85924483e44a27788f296454f4058a0c Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sat, 21 Sep 2024 13:37:05 +0200
Subject: [PATCH 08/22] Revert previous commit

---
 lib/services/playon_handler.dart | 5 ++---
 pubspec.yaml                     | 1 -
 2 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index dd29cb62..365dca00 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -7,7 +7,6 @@ import 'package:finamp/services/audio_service_helper.dart';
 import 'package:finamp/services/queue_service.dart';
 import 'package:logging/logging.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
-import 'package:flutter_volume_controller/flutter_volume_controller.dart';
 import '../../services/music_player_background_task.dart';
 import '../../services/jellyfin_api_helper.dart';
 import '../../services/finamp_settings_helper.dart';
@@ -86,8 +85,8 @@ class PlayonHandler {
                 GlobalSnackbar.message((context) => "$header: $messageFromServer");
                 break;
               case "SetVolume":
-                final desiredVolume=request['Data']['Arguments']['Volume'];
-                await FlutterVolumeController.setVolume(float.parse(desiredVolume)/100.0);
+                _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;
diff --git a/pubspec.yaml b/pubspec.yaml
index 75190ba9..495becbd 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -101,7 +101,6 @@ dependencies:
   scroll_to_index: ^3.0.1
   window_manager: ^0.3.8
   url_launcher: ^6.2.6
-  flutter_volume_controller: ^1.3.2
 
 dev_dependencies:
   flutter_test:

From 9df5faf8872ba48eae949fbaa8eccabd5a0edfef Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Wed, 25 Sep 2024 15:46:27 +0200
Subject: [PATCH 09/22] Improve album queuing performance

---
 lib/services/playon_handler.dart | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 365dca00..b1ab2b7b 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -145,16 +145,17 @@ class PlayonHandler {
                         ));
                       }
                     } else { // User asked to play an album
-                      var items=<BaseItemDto>[];
-                      for (final itemId in request['Data']['ItemIds']) {
-                        items.add(await jellyfinApiHelper.getItemById(itemId));
-                      }
+                      var items = await jellyfinApiHelper.getItems(
+                        sortBy: "IndexNumber",
+                        includeItemTypes: "Audio", 
+                        itemIds: List<String>.from(request['Data']['ItemIds'] as List),
+                      );
                       unawaited(queueService.startPlayback(
-                          items: items,
+                          items: items!,
                           source: QueueItemSource(
                             name: QueueItemSourceName(
                                 type: QueueItemSourceNameType.preTranslated,
-                                pretranslatedName: items[0].name),
+                                pretranslatedName: items![0].name),
                             type: QueueItemSourceType.song,
                             id: items[0].id,
                           ),

From 76fcbc2301b7e9d84a2e84da173a86e7f8dbcbe6 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Wed, 25 Sep 2024 16:45:01 +0200
Subject: [PATCH 10/22] Change queuing behaviour

---
 lib/services/playon_handler.dart | 58 ++++++++++++--------------------
 1 file changed, 21 insertions(+), 37 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index b1ab2b7b..45810605 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -127,44 +127,28 @@ class PlayonHandler {
                 switch (request['Data']['PlayCommand']) {
                   case 'PlayNow':
                     channel.sink.add('{"MessageType":"KeepAlive"}');
-                    if (request['Data'].containsKey('StartIndex')) { // User started a single song
-                      var item = await jellyfinApiHelper.getItemById(request['Data']['ItemIds'][request['Data']['StartIndex']]);
-                      if (FinampSettingsHelper
-                        .finampSettings.startInstantMixForIndividualTracks) {
-                      unawaited(audioServiceHelper.startInstantMixForItem(item));
-                      } else {
-                        unawaited(queueService.startPlayback(
-                          items: [item],
-                          source: QueueItemSource(
-                            name: QueueItemSourceName(
-                                type: QueueItemSourceNameType.preTranslated,
-                                pretranslatedName: item.name),
-                            type: QueueItemSourceType.song,
-                            id: item.id,
-                          ),
-                        ));
-                      }
-                    } else { // User asked to play an album
-                      var items = await jellyfinApiHelper.getItems(
-                        sortBy: "IndexNumber",
-                        includeItemTypes: "Audio", 
-                        itemIds: List<String>.from(request['Data']['ItemIds'] as List),
-                      );
-                      unawaited(queueService.startPlayback(
-                          items: items!,
-                          source: QueueItemSource(
-                            name: QueueItemSourceName(
-                                type: QueueItemSourceNameType.preTranslated,
-                                pretranslatedName: items![0].name),
-                            type: QueueItemSourceType.song,
-                            id: items[0].id,
-                          ),
-                        ));
+                    if (!request['Data'].containsKey('StartIndex')) { 
+                      request['Data']['StartIndex']=0;
                     }
-                    
-                      
-                }
-              }
+                    var items = await jellyfinApiHelper.getItems(
+                      sortBy: "IndexNumber",
+                      includeItemTypes: "Audio", 
+                      itemIds: List<String>.from(request['Data']['ItemIds'] as List),
+                    );
+                    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'],
+                      )
+                    );
+                  }
+            }
             break;
         }
 

From 982a2c1e965fc7928938a9e8eda3cb353c4b917a Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Thu, 26 Sep 2024 14:22:09 +0200
Subject: [PATCH 11/22] Implement 'PlayNext' and 'PlayLast' play commands

---
 lib/services/playon_handler.dart | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 45810605..68d7a0d3 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -147,6 +147,27 @@ class PlayonHandler {
                         startingIndex: request['Data']['StartIndex'],
                       )
                     );
+                    break;
+                    case 'PlayNext':
+                      var items = await jellyfinApiHelper.getItems(
+                        sortBy: "IndexNumber",
+                        includeItemTypes: "Audio", 
+                        itemIds: List<String>.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<String>.from(request['Data']['ItemIds'] as List),
+                      );
+                      unawaited(queueService.addToQueue(
+                        items: items!,
+                      ));
+                    break;
                   }
             }
             break;

From ba85206dff4a689b340e529e030e1de8ff19d8d6 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sun, 29 Sep 2024 18:37:15 +0200
Subject: [PATCH 12/22] Report playback to server when seeking

---
 lib/services/playon_handler.dart | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 68d7a0d3..174166df 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -4,6 +4,7 @@ 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:logging/logging.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
@@ -111,6 +112,10 @@ class PlayonHandler {
                 // 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();

From 7f8600a290dedaff643c42f628203a45fcb8394c Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Mon, 4 Nov 2024 12:19:30 +0100
Subject: [PATCH 13/22] fix playbackHistoryService not defined (forgot in last
 commit)

---
 lib/services/playon_handler.dart | 1 +
 1 file changed, 1 insertion(+)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 174166df..c5fd1e82 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -34,6 +34,7 @@ class PlayonHandler {
     final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
     final queueService = GetIt.instance<QueueService>();
     final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
+    final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
 
     await jellyfinApiHelper.updateCapabilities(ClientCapabilities(
       supportsMediaControl: true,

From eda00bd00c88d43be349882672a2479b665ce453 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Tue, 5 Nov 2024 13:53:42 +0100
Subject: [PATCH 14/22] Separate command handling code from the listener to
 allow (rough for now) websocket reconnection

---
 lib/services/playon_handler.dart | 50 ++++++++++++++++++++------------
 1 file changed, 31 insertions(+), 19 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index c5fd1e82..7129a664 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -3,6 +3,7 @@ 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/screens/active_downloads_screen.dart';
 import 'package:finamp/services/audio_service_helper.dart';
 import 'package:finamp/services/playback_history_service.dart';
 import 'package:finamp/services/queue_service.dart';
@@ -17,6 +18,12 @@ import 'finamp_user_helper.dart';
 import 'package:get_it/get_it.dart';
 
 final _playOnHandlerLogger = Logger("PlayOnHandler");
+final finampUserHelper = GetIt.instance<FinampUserHelper>();
+final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
+final queueService = GetIt.instance<QueueService>();
+final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
+final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
+final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
 
 
 class PlayonHandler {
@@ -30,19 +37,12 @@ class PlayonHandler {
   }
   
   Future<void> startListener() async {
-    final finampUserHelper = GetIt.instance<FinampUserHelper>();
-    final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
-    final queueService = GetIt.instance<QueueService>();
-    final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
-    final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
-
     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"],
     ));
-    final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
 
     final url="${finampUserHelper.currentUser!.baseUrl}/socket?api_key=${finampUserHelper.currentUser!.accessToken}";
     final parsedUrl = Uri.parse(url);
@@ -67,9 +67,26 @@ class PlayonHandler {
       },
     );
 
-    await for (final value in channel.stream) {
+    channel.stream.listen(
+      (dynamic message) {
+          _playOnHandlerLogger.severe("WebSocket connection to server established");
+          unawaited(handleMessage(message));
+        },
+        onDone: () {
+          Future.delayed(const Duration(seconds: 10), () {
+            startListener();
+            _playOnHandlerLogger.severe("Attempted to restart listener");
+          });
+        },
+        onError: (error) {
+          _playOnHandlerLogger.severe("WebSocket connection to server established");
+        },
+    );
+  }
+
 
-      _playOnHandlerLogger.finest("Received message: $value");
+  Future<void> handleMessage(value) async {
+    _playOnHandlerLogger.finest("Received message: $value");
       
       var request = jsonDecode(value);
 
@@ -98,10 +115,11 @@ class PlayonHandler {
                 await audioHandler.stop();
                 break;
               case "Pause":
-                await audioHandler.pause();
+                audioHandler.pause();
+                _playOnHandlerLogger.severe("PAUSEEEEE !");
                 break;
               case "Unpause":
-                await audioHandler.play();
+                audioHandler.play();
                 break;
               case "NextTrack":
                 await audioHandler.skipToNext();
@@ -125,14 +143,13 @@ class PlayonHandler {
                 await audioHandler.fastForward();
                 break;
               case "PlayPause":
-                await audioHandler.togglePlayback();
+                audioHandler.togglePlayback();
                 break;
 
               // Do nothing
               default:
                 switch (request['Data']['PlayCommand']) {
                   case 'PlayNow':
-                    channel.sink.add('{"MessageType":"KeepAlive"}');
                     if (!request['Data'].containsKey('StartIndex')) { 
                       request['Data']['StartIndex']=0;
                     }
@@ -178,11 +195,6 @@ class PlayonHandler {
             }
             break;
         }
-
-        }
-
-        // channel.sink.add('{"MessageType":"KeepAlive"}');
-        
-    }
+      }
   }
 }

From a5a45dc82e8602f366bb8d1aee943797ee0c323c Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Tue, 5 Nov 2024 15:27:28 +0100
Subject: [PATCH 15/22] close/open websocket connection when toggling offline
 mode

---
 lib/services/playon_handler.dart | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 7129a664..2f1888a0 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -24,11 +24,20 @@ final queueService = GetIt.instance<QueueService>();
 final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
 final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
 final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
+var channel;
 
 
 class PlayonHandler {
 
   Future<void> initialize() async {
+    var settingsListener = FinampSettingsHelper.finampSettingsListener;
+    settingsListener.addListener(() {
+      if (FinampSettingsHelper.finampSettings.isOffline) {
+        closeListener();
+      } else {
+        startListener();
+      }
+    });
     try {
       await startListener();
     } catch (e) {
@@ -47,7 +56,7 @@ class PlayonHandler {
     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");
-    final channel = WebSocketChannel.connect(wsUrl);
+    channel = WebSocketChannel.connect(wsUrl);
 
     await channel.ready;
     _playOnHandlerLogger.info("WebSocket connection to server established");
@@ -84,6 +93,10 @@ class PlayonHandler {
     );
   }
 
+  Future<void> closeListener() async {
+    channel.sink.add('{"MessageType":"SessionsStop"}');
+    channel.sink.close();
+  }
 
   Future<void> handleMessage(value) async {
     _playOnHandlerLogger.finest("Received message: $value");

From 0f3b5589282e92ca3366ab36720c9897aa96b8d6 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Tue, 5 Nov 2024 16:09:49 +0100
Subject: [PATCH 16/22] avoid crashing when remote client tries to play non
 audio files

---
 lib/services/playon_handler.dart | 28 ++++++++++++++++------------
 1 file changed, 16 insertions(+), 12 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 2f1888a0..8892faea 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -171,18 +171,22 @@ class PlayonHandler {
                       includeItemTypes: "Audio", 
                       itemIds: List<String>.from(request['Data']['ItemIds'] as List),
                     );
-                    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'],
-                      )
-                    );
+                    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(

From d1f9265d241ad9d106d0ee42751fba439a5a1c39 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Wed, 6 Nov 2024 12:01:41 +0100
Subject: [PATCH 17/22] properly close the keepalive subscription

---
 lib/services/playon_handler.dart | 23 ++++++++++-------------
 1 file changed, 10 insertions(+), 13 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 8892faea..6e738627 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -25,6 +25,7 @@ final audioServiceHelper = GetIt.instance<AudioServiceHelper>();
 final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
 final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
 var channel;
+var keepaliveSubscription;
 
 
 class PlayonHandler {
@@ -63,32 +64,27 @@ class PlayonHandler {
 
     channel.sink.add('{"MessageType":"KeepAlive"}');
 
-    final keepaliveSubscription = Stream.periodic(const Duration(seconds: 30), (count) {
+    keepaliveSubscription = Stream.periodic(const Duration(seconds: 30), (count) {
       return count;
     }).listen((event) {
       _playOnHandlerLogger.info("Sent KeepAlive message through websocket");
       channel.sink.add('{"MessageType":"KeepAlive"}');
     });
 
-
-    channel.stream.handleError((error, trace) {
-        _playOnHandlerLogger.severe("WebSocket Error: $error");
-      },
-    );
-
     channel.stream.listen(
       (dynamic message) {
-          _playOnHandlerLogger.severe("WebSocket connection to server established");
           unawaited(handleMessage(message));
         },
         onDone: () {
-          Future.delayed(const Duration(seconds: 10), () {
-            startListener();
-            _playOnHandlerLogger.severe("Attempted to restart listener");
-          });
+          if (!FinampSettingsHelper.finampSettings.isOffline) {
+            Future.delayed(const Duration(seconds: 1), () {
+              startListener();
+              _playOnHandlerLogger.severe("Attempted to restart listener");
+            });
+          }
         },
         onError: (error) {
-          _playOnHandlerLogger.severe("WebSocket connection to server established");
+          _playOnHandlerLogger.severe("WebSocket Error: $error");
         },
     );
   }
@@ -96,6 +92,7 @@ class PlayonHandler {
   Future<void> closeListener() async {
     channel.sink.add('{"MessageType":"SessionsStop"}');
     channel.sink.close();
+    keepaliveSubscription.cancel();
   }
 
   Future<void> handleMessage(value) async {

From 66e23194e9323c5c217c7ceb54f276c737b0c58a Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sat, 9 Nov 2024 14:38:09 +0100
Subject: [PATCH 18/22] handle websocket errors and disconnection

---
 lib/services/playon_handler.dart | 78 +++++++++++++++++++++-----------
 1 file changed, 51 insertions(+), 27 deletions(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 6e738627..8239511b 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -26,34 +26,57 @@ final playbackHistoryService = GetIt.instance<PlaybackHistoryService>();
 final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
 var channel;
 var keepaliveSubscription;
+var reconnectionSubscription = null;
 
 
 class PlayonHandler {
 
   Future<void> initialize() async {
+
+    // Turn on/off when offline mode is toggled
     var settingsListener = FinampSettingsHelper.finampSettingsListener;
-    settingsListener.addListener(() {
+    settingsListener.addListener(() async {
       if (FinampSettingsHelper.finampSettings.isOffline) {
-        closeListener();
+        await closeListener();
       } else {
-        startListener();
+        await startListener();
       }
     });
+
+    await startListener();
+  }
+
+  Future<void> startListener() async {
     try {
-      await startListener();
+      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) {
-      _playOnHandlerLogger.severe("Error initializing PlayOnHandler: $e");
+      if (reconnectionSubscription == null) {
+        unawaited(startReconnectionLoop());
+        _playOnHandlerLogger.severe("Error starting PlayOn listener: $e");
+      }
     }
   }
-  
-  Future<void> startListener() async {
-    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"],
-    ));
 
+  Future<void> startReconnectionLoop() async {
+    reconnectionSubscription = Stream.periodic(const Duration(seconds: 1), (count) {
+      return count;
+    }).listen((count) {
+      startListener();
+      _playOnHandlerLogger.info("Attempted to restart listener");
+    });
+  }
+  
+  Future<void> 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");
@@ -64,35 +87,36 @@ class PlayonHandler {
 
     channel.sink.add('{"MessageType":"KeepAlive"}');
 
-    keepaliveSubscription = Stream.periodic(const Duration(seconds: 30), (count) {
-      return count;
-    }).listen((event) {
-      _playOnHandlerLogger.info("Sent KeepAlive message through websocket");
-      channel.sink.add('{"MessageType":"KeepAlive"}');
-    });
-
     channel.stream.listen(
       (dynamic message) {
           unawaited(handleMessage(message));
         },
         onDone: () {
-          if (!FinampSettingsHelper.finampSettings.isOffline) {
-            Future.delayed(const Duration(seconds: 1), () {
-              startListener();
-              _playOnHandlerLogger.severe("Attempted to restart listener");
-            });
-          }
+          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<void> closeListener() async {
+    _playOnHandlerLogger.info("Closing playon session");
     channel.sink.add('{"MessageType":"SessionsStop"}');
     channel.sink.close();
-    keepaliveSubscription.cancel();
+    keepaliveSubscription?.cancel();
+
+    // In case offline mod is turned on while attempting to reconnect
+    reconnectionSubscription?.cancel();
+    reconnectionSubscription = null;
   }
 
   Future<void> handleMessage(value) async {

From 4e56faf1d21fd91ecf527256e7e5763872965fd4 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Mon, 25 Nov 2024 13:26:13 +0100
Subject: [PATCH 19/22] small cleanup

---
 lib/services/playon_handler.dart | 1 -
 1 file changed, 1 deletion(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index 8239511b..ac0c3cc6 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -150,7 +150,6 @@ class PlayonHandler {
                 break;
               case "Pause":
                 audioHandler.pause();
-                _playOnHandlerLogger.severe("PAUSEEEEE !");
                 break;
               case "Unpause":
                 audioHandler.play();

From e3153a923a1bb0d90d5626d9a29cd69f8da7b5d5 Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Wed, 27 Nov 2024 13:27:07 +0100
Subject: [PATCH 20/22] temporarily downgrade just_audio_media_kit as the
 latest update breaks jellyfin's web client from showing up controls

---
 pubspec.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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

From 026c1f5db2ca727aa12a6910d9ce01391e1ba7cc Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Wed, 27 Nov 2024 13:33:38 +0100
Subject: [PATCH 21/22] Remove duplicate log entry

---
 lib/services/playon_handler.dart | 1 -
 1 file changed, 1 deletion(-)

diff --git a/lib/services/playon_handler.dart b/lib/services/playon_handler.dart
index ac0c3cc6..4187107f 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -125,7 +125,6 @@ class PlayonHandler {
       var request = jsonDecode(value);
 
       if (request['MessageType'] != 'ForceKeepAlive' && request['MessageType'] != 'KeepAlive') {
-        _playOnHandlerLogger.info("Received a '${request['MessageType']}' message: ${request['Data']}");
 
         switch(request['MessageType']) {
           case "GeneralCommand":

From 3e5016b10de62d434027c3d0396aed74bae98b0d Mon Sep 17 00:00:00 2001
From: "A. Pinsard" <a.pinsrd@proton.me>
Date: Sun, 12 Jan 2025 21:07:18 +0100
Subject: [PATCH 22/22] handle favoriting a song from remote client and
 initialize playon handler from music_screen.dart instead of main.dart

---
 lib/main.dart                       |  1 -
 lib/screens/music_screen.dart       |  6 ++++++
 lib/services/favorite_provider.dart |  4 ++++
 lib/services/playon_handler.dart    | 13 +++++++++++--
 4 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/lib/main.dart b/lib/main.dart
index e6cc2250..187bfe4b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -167,7 +167,6 @@ Future<void> _setupDownloadsHelper() async {
 
 Future<void> _setupPlayonHandler() async {
   GetIt.instance.registerSingleton(PlayonHandler());
-  unawaited(GetIt.instance<PlayonHandler>().initialize());
 }
 
 Future<void> _setupKeepScreenOnHelper() async {
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<DownloadsService>();
   final queueService = GetIt.instance<QueueService>();
+  final playonHandler = GetIt.instance<PlayonHandler>();
 
   // 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/playon_handler.dart b/lib/services/playon_handler.dart
index 4187107f..a9f90c06 100644
--- a/lib/services/playon_handler.dart
+++ b/lib/services/playon_handler.dart
@@ -3,10 +3,11 @@ 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/screens/active_downloads_screen.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';
@@ -30,7 +31,8 @@ var reconnectionSubscription = null;
 
 
 class PlayonHandler {
-
+  late WidgetRef ref;
+  
   Future<void> initialize() async {
 
     // Turn on/off when offline mode is toggled
@@ -142,6 +144,13 @@ class PlayonHandler {
                 // 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":