Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft for playon compatibility with the jellyfin server #850

Draft
wants to merge 23 commits into
base: redesign
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b30932a
Rough draft for playon compatibility for getting feedback
pinsarda Aug 25, 2024
3e38a93
update client capabilities to allow for remote control, handle media …
Chaphasilor Aug 13, 2024
a5c838f
support showing server messages
Chaphasilor Aug 28, 2024
218dcae
Fix wrong item being picked
pinsarda Sep 19, 2024
634ddde
Fix starting an album only queuing one song
pinsarda Sep 21, 2024
2e253c1
Send KeepAlive message every 30 seconds, apparently fix disconnection…
pinsarda Sep 21, 2024
ef424d3
WIP initial implementation of volume control
pinsarda Sep 21, 2024
0b0722a
Revert previous commit
pinsarda Sep 21, 2024
9df5faf
Improve album queuing performance
pinsarda Sep 25, 2024
76fcbc2
Change queuing behaviour
pinsarda Sep 25, 2024
982a2c1
Implement 'PlayNext' and 'PlayLast' play commands
pinsarda Sep 26, 2024
ba85206
Report playback to server when seeking
pinsarda Sep 29, 2024
7f8600a
fix playbackHistoryService not defined (forgot in last commit)
pinsarda Nov 4, 2024
eda00bd
Separate command handling code from the listener to allow (rough for …
pinsarda Nov 5, 2024
a5a45dc
close/open websocket connection when toggling offline mode
pinsarda Nov 5, 2024
0f3b558
avoid crashing when remote client tries to play non audio files
pinsarda Nov 5, 2024
d1f9265
properly close the keepalive subscription
pinsarda Nov 6, 2024
66e2319
handle websocket errors and disconnection
pinsarda Nov 9, 2024
90f0428
Merge branch 'redesign' into pr/pinsarda/850
Chaphasilor Nov 12, 2024
4e56faf
small cleanup
pinsarda Nov 25, 2024
e3153a9
temporarily downgrade just_audio_media_kit as the latest update break…
pinsarda Nov 27, 2024
026c1f5
Remove duplicate log entry
pinsarda Nov 27, 2024
3e5016b
handle favoriting a song from remote client and initialize playon han…
pinsarda Jan 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/components/LoginScreen/login_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -91,6 +92,7 @@ void main() async {
await _setupDownloadsHelper();
await _setupOSIntegration();
await _setupPlaybackServices();
await _setupPlayonHandler();
await _setupKeepScreenOnHelper();
} catch (error, trace) {
hasFailed = true;
Expand Down Expand Up @@ -163,6 +165,10 @@ Future<void> _setupDownloadsHelper() async {
await downloadsService.startQueues();
}

Future<void> _setupPlayonHandler() async {
GetIt.instance.registerSingleton(PlayonHandler());
}

Future<void> _setupKeepScreenOnHelper() async {
GetIt.instance.registerSingleton(KeepScreenOnHelper());
}
Expand Down
4 changes: 2 additions & 2 deletions lib/models/jellyfin_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
6 changes: 6 additions & 0 deletions lib/screens/music_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions lib/services/favorite_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ class IsFavorite extends _$IsFavorite {
return state;
}

void updateState(bool isFavorite) {
state = isFavorite;
}

void toggleFavorite() async {
if (_initializing != null) {
await _initializing;
Expand Down
18 changes: 18 additions & 0 deletions lib/services/jellyfin_api.chopper.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions lib/services/jellyfin_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,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(
Expand Down
5 changes: 5 additions & 0 deletions lib/services/jellyfin_api_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,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 {
Expand Down
245 changes: 245 additions & 0 deletions lib/services/playon_handler.dart
Original file line number Diff line number Diff line change
@@ -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<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>();
var channel;
var keepaliveSubscription;
var reconnectionSubscription = null;


class PlayonHandler {
late WidgetRef ref;

Future<void> 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<void> 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<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");
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<void> 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<void> 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<String>.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<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;
}
}
}
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down