From e6cb0283b0c92cba1d8d5839b830a18e2e776590 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:04:55 +0200 Subject: [PATCH 01/14] feat(syncplay): add SyncPlay models and Pigeon contracts --- .../fladder/api/TranslationsPigeon.g.kt | 120 ++ .../fladder/api/VideoPlayerHelper.g.kt | 196 ++- lib/models/playback/playback_model.dart | 118 +- lib/models/syncplay/syncplay_models.dart | 311 ++++ .../syncplay/syncplay_models.freezed.dart | 1320 +++++++++++++++++ lib/src/translations_pigeon.g.dart | 226 ++- lib/src/video_player_helper.g.dart | 451 ++++-- pigeons/translations_pigeon.dart | 8 + pigeons/video_player.dart | 50 + 9 files changed, 2574 insertions(+), 226 deletions(-) create mode 100644 lib/models/syncplay/syncplay_models.dart create mode 100644 lib/models/syncplay/syncplay_models.freezed.dart diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt index c2fd9925f..83d7d356a 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt @@ -334,4 +334,124 @@ class TranslationsPigeon(private val binaryMessenger: BinaryMessenger, private v } } } + fun syncPlaySyncingWithGroup(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlaySyncingWithGroup$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandPausing(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPausing$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandPlaying(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPlaying$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandSeeking(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSeeking$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandStopping(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandStopping$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandSyncing(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSyncing$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index 440f8edf4..367e0a680 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -93,6 +93,20 @@ enum class PlaybackType(val raw: Int) { } } +enum class SyncPlayCommandType(val raw: Int) { + NONE(0), + PAUSE(1), + UNPAUSE(2), + SEEK(3), + STOP(4); + + companion object { + fun ofRaw(raw: Int): SyncPlayCommandType? { + return values().firstOrNull { it.raw == raw } + } + } +} + enum class MediaSegmentType(val raw: Int) { COMMERCIAL(0), PREVIEW(1), @@ -107,6 +121,22 @@ enum class MediaSegmentType(val raw: Int) { } } +/** Source of the last playback state change (for SyncPlay: infer user actions from stream). */ +enum class PlaybackChangeSource(val raw: Int) { + /** No specific source (e.g. periodic update, buffering). */ + NONE(0), + /** User tapped play/pause/seek on native; Flutter should send SyncPlay if active. */ + USER(1), + /** Change was caused by applying a SyncPlay command; do not send again. */ + SYNCPLAY(2); + + companion object { + fun ofRaw(raw: Int): PlaybackChangeSource? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class SimpleItemModel ( val id: String, @@ -487,7 +517,9 @@ data class PlaybackState ( val playing: Boolean, val buffering: Boolean, val completed: Boolean, - val failed: Boolean + val failed: Boolean, + /** When set, indicates who caused this state update (for SyncPlay inference). */ + val changeSource: PlaybackChangeSource? = null ) { companion object { @@ -499,7 +531,8 @@ data class PlaybackState ( val buffering = pigeonVar_list[4] as Boolean val completed = pigeonVar_list[5] as Boolean val failed = pigeonVar_list[6] as Boolean - return PlaybackState(position, buffered, duration, playing, buffering, completed, failed) + val changeSource = pigeonVar_list[7] as PlaybackChangeSource? + return PlaybackState(position, buffered, duration, playing, buffering, completed, failed, changeSource) } } fun toList(): List { @@ -511,6 +544,7 @@ data class PlaybackState ( buffering, completed, failed, + changeSource, ) } override fun equals(other: Any?): Boolean { @@ -709,75 +743,85 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() { } 130.toByte() -> { return (readValue(buffer) as Long?)?.let { - MediaSegmentType.ofRaw(it.toInt()) + SyncPlayCommandType.ofRaw(it.toInt()) } } 131.toByte() -> { + return (readValue(buffer) as Long?)?.let { + MediaSegmentType.ofRaw(it.toInt()) + } + } + 132.toByte() -> { + return (readValue(buffer) as Long?)?.let { + PlaybackChangeSource.ofRaw(it.toInt()) + } + } + 133.toByte() -> { return (readValue(buffer) as? List)?.let { SimpleItemModel.fromList(it) } } - 132.toByte() -> { + 134.toByte() -> { return (readValue(buffer) as? List)?.let { MediaInfo.fromList(it) } } - 133.toByte() -> { + 135.toByte() -> { return (readValue(buffer) as? List)?.let { PlayableData.fromList(it) } } - 134.toByte() -> { + 136.toByte() -> { return (readValue(buffer) as? List)?.let { MediaSegment.fromList(it) } } - 135.toByte() -> { + 137.toByte() -> { return (readValue(buffer) as? List)?.let { AudioTrack.fromList(it) } } - 136.toByte() -> { + 138.toByte() -> { return (readValue(buffer) as? List)?.let { SubtitleTrack.fromList(it) } } - 137.toByte() -> { + 139.toByte() -> { return (readValue(buffer) as? List)?.let { Chapter.fromList(it) } } - 138.toByte() -> { + 140.toByte() -> { return (readValue(buffer) as? List)?.let { TrickPlayModel.fromList(it) } } - 139.toByte() -> { + 141.toByte() -> { return (readValue(buffer) as? List)?.let { StartResult.fromList(it) } } - 140.toByte() -> { + 142.toByte() -> { return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) } } - 141.toByte() -> { + 143.toByte() -> { return (readValue(buffer) as? List)?.let { SubtitleSettings.fromList(it) } } - 142.toByte() -> { + 144.toByte() -> { return (readValue(buffer) as? List)?.let { TVGuideModel.fromList(it) } } - 143.toByte() -> { + 145.toByte() -> { return (readValue(buffer) as? List)?.let { GuideChannel.fromList(it) } } - 144.toByte() -> { + 146.toByte() -> { return (readValue(buffer) as? List)?.let { GuideProgram.fromList(it) } @@ -791,64 +835,72 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() { stream.write(129) writeValue(stream, value.raw.toLong()) } - is MediaSegmentType -> { + is SyncPlayCommandType -> { stream.write(130) writeValue(stream, value.raw.toLong()) } - is SimpleItemModel -> { + is MediaSegmentType -> { stream.write(131) + writeValue(stream, value.raw.toLong()) + } + is PlaybackChangeSource -> { + stream.write(132) + writeValue(stream, value.raw.toLong()) + } + is SimpleItemModel -> { + stream.write(133) writeValue(stream, value.toList()) } is MediaInfo -> { - stream.write(132) + stream.write(134) writeValue(stream, value.toList()) } is PlayableData -> { - stream.write(133) + stream.write(135) writeValue(stream, value.toList()) } is MediaSegment -> { - stream.write(134) + stream.write(136) writeValue(stream, value.toList()) } is AudioTrack -> { - stream.write(135) + stream.write(137) writeValue(stream, value.toList()) } is SubtitleTrack -> { - stream.write(136) + stream.write(138) writeValue(stream, value.toList()) } is Chapter -> { - stream.write(137) + stream.write(139) writeValue(stream, value.toList()) } is TrickPlayModel -> { - stream.write(138) + stream.write(140) writeValue(stream, value.toList()) } is StartResult -> { - stream.write(139) + stream.write(141) writeValue(stream, value.toList()) } is PlaybackState -> { - stream.write(140) + stream.write(142) writeValue(stream, value.toList()) } is SubtitleSettings -> { - stream.write(141) + stream.write(143) writeValue(stream, value.toList()) } is TVGuideModel -> { - stream.write(142) + stream.write(144) writeValue(stream, value.toList()) } is GuideChannel -> { - stream.write(143) + stream.write(145) writeValue(stream, value.toList()) } is GuideProgram -> { - stream.write(144) + stream.write(146) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) @@ -941,6 +993,12 @@ interface VideoPlayerApi { fun seekTo(position: Long) fun stop() fun setSubtitleSettings(settings: SubtitleSettings) + /** + * Sets the SyncPlay command state for the native player overlay. + * [processing] indicates if a SyncPlay command is being processed. + * [commandType] is the type of command. + */ + fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) companion object { /** The codec used by VideoPlayerApi. */ @@ -1150,6 +1208,25 @@ interface VideoPlayerApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val processingArg = args[0] as Boolean + val commandTypeArg = args[1] as SyncPlayCommandType + val wrapped: List = try { + api.setSyncPlayCommandState(processingArg, commandTypeArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -1309,4 +1386,61 @@ class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, } } } + /** User-initiated play action from native player (for SyncPlay integration) */ + fun onUserPlay(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + /** User-initiated pause action from native player (for SyncPlay integration) */ + fun onUserPause(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + /** + * User-initiated seek action from native player (for SyncPlay integration) + * Position is in milliseconds + */ + fun onUserSeek(positionMsArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(positionMsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 7fa5745f9..7f2b83b6e 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -1,12 +1,9 @@ +import 'dart:async'; import 'dart:developer'; -import 'package:flutter/material.dart' hide ConnectionState; - import 'package:background_downloader/background_downloader.dart'; import 'package:chopper/chopper.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/audio_model.dart'; @@ -29,6 +26,7 @@ import 'package:fladder/models/playback/tv_playback_model.dart'; export 'playback_queue_source.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/models/syncing/sync_item.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/providers/api_provider.dart'; @@ -36,6 +34,7 @@ import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/util/bitrate_helper.dart'; @@ -44,6 +43,8 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/map_bool_helper.dart'; import 'package:fladder/util/streams_selection.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_riverpod/flutter_riverpod.dart'; class Media { final String url; @@ -94,14 +95,18 @@ class PlaybackModel { Future updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) => throw UnimplementedError(); + Future playbackStarted(Duration position, Ref ref) => throw UnimplementedError(); + Future playbackStopped(Duration position, Duration? totalDuration, Ref ref) => throw UnimplementedError(); void dispose() {} final MediaStreamsModel? mediaStreams; + List? get subStreams => throw UnimplementedError(); + List? get audioStreams => throw UnimplementedError(); bool get isAudioPlayback => item is AudioModel || item.type == FladderItemType.audio; @@ -121,7 +126,9 @@ class PlaybackModel { PlaybackModel? updateUserData(UserData userData) => throw UnimplementedError(); Future? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); + Future? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); + Future? setQualityOption(Map map) => throw UnimplementedError(); PlaybackModel updatePlaybackQueue(PlaybackQueueState newQueue) => throw UnimplementedError(); @@ -161,7 +168,50 @@ class PlaybackModelHelper { JellyService get api => ref.read(jellyApiProvider); + Future _ensureLocalTrackSwitchAutoplay() async { + // Poll for up to ~3 seconds, calling play() on every iteration the + // player isn't already playing and isn't buffering. media-kit on web + // sometimes drops the first one or two play() calls after a track + // change or transcode reload (the underlying media isn't fully + // ready yet, or the player is mid-transition). One-shot retries + // weren't enough; this keeps re-issuing play until the state + // stream confirms playing=true or we time out. + for (var attempt = 0; attempt < 12; attempt++) { + final playbackState = ref.read(mediaPlaybackProvider); + if (playbackState.playing) { + return; + } + if (!playbackState.buffering) { + await ref.read(videoPlayerProvider).play(); + } + await Future.delayed(const Duration(milliseconds: 250)); + } + } + Future loadNewVideo(ItemBaseModel newItem) async { + // When SyncPlay is active, route the next/previous episode through + // the group queue using the lightweight NextItem/PreviousItem + // endpoints (matches jellyfin-web). Determine direction from the + // current playback model's queue and fall back to setNewQueue only + // for non-adjacent jumps (e.g. user picked an arbitrary library item). + if (ref.read(isSyncPlayActiveProvider)) { + // Use the same setNewQueue flow as initial play in _playSyncPlay. + // It reliably triggers the PlayQueue/NewPlaylist broadcast that + // drives _startPlayback through _handlePlayQueue, so the user + // sees the "Switching item…" overlay (SyncPlayCommandIndicator) + // and then the new media without having to navigate away. + // + // NextItem/PreviousItem would preserve the server-side queue + // context but in practice did not reliably trigger the + // PlayQueue broadcast we rely on; setNewQueue does. + await ref.read(syncPlayProvider.notifier).setNewQueue( + itemIds: [newItem.id], + playingItemPosition: 0, + startPositionTicks: 0, + ); + return null; + } + ref.read(videoPlayerProvider).pause(); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(buffering: true)); final currentModel = ref.read(playBackModel); @@ -543,7 +593,10 @@ class PlaybackModelHelper { return Response(response.base, (response.body?.items?.map((e) => EpisodeModel.fromBaseDto(e, ref)).toList() ?? [])); } - Future shouldReload(PlaybackModel playbackModel) async { + Future shouldReload( + PlaybackModel playbackModel, { + bool isLocalTrackSwitch = false, + }) async { if (playbackModel is OfflinePlaybackModel) { return; } @@ -553,7 +606,35 @@ class PlaybackModelHelper { final userId = ref.read(userProvider)?.id; if (userId?.isEmpty == true) return; - final currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); + // Check if syncplay is active and get position from syncplay if so + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + final Duration currentPosition; + + final shouldReportGroupBuffering = (isSyncPlayActive && !isLocalTrackSwitch); + + if (isSyncPlayActive) { + // Set reloading state in the player notifier to prevent premature ready reporting + ref.read(videoPlayerProvider.notifier).setReloading( + true, + reportToSyncPlay: shouldReportGroupBuffering, + ); + + // Estimate the live group position rather than using the stale + // SyncPlayState.positionTicks (which is frozen at the last server + // event). Without this the local player reloads at an old position + // and the drift correction immediately SkipToSyncs forward, producing + // a visible jump after every audio/subtitle switch. + final positionTicks = ref.read(syncPlayProvider.notifier).estimateCurrentGroupPositionTicks(); + currentPosition = Duration(milliseconds: ticksToMilliseconds(positionTicks)); + + if (shouldReportGroupBuffering) { + // Report buffering BEFORE stop/reload only when this reload should + // affect group flow. + await ref.read(syncPlayProvider.notifier).reportBuffering(); + } + } else { + currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); + } final audioIndex = selectAudioStream( ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), @@ -645,9 +726,30 @@ class PlaybackModelHelper { bitRateOptions: playbackModel.bitRateOptions, ); } - if (newModel == null) return; + if (newModel == null) { + if (isSyncPlayActive) { + ref.read(videoPlayerProvider.notifier).setReloading(false); + } + return; + } if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) { - ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, currentPosition); + await ref.read(videoPlayerProvider.notifier).loadPlaybackItem( + newModel, + currentPosition, + waitForSyncPlayCommand: shouldReportGroupBuffering, + ); + if (isLocalTrackSwitch) { + await _ensureLocalTrackSwitchAutoplay(); + } + } else if (isSyncPlayActive) { + // If we didn't call loadPlaybackItem, we must reset reloading state + ref.read(videoPlayerProvider.notifier).setReloading( + false, + reportToSyncPlay: false, + ); + if (isLocalTrackSwitch) { + await _ensureLocalTrackSwitchAutoplay(); + } } } } diff --git a/lib/models/syncplay/syncplay_models.dart b/lib/models/syncplay/syncplay_models.dart new file mode 100644 index 000000000..be7e0bc14 --- /dev/null +++ b/lib/models/syncplay/syncplay_models.dart @@ -0,0 +1,311 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'syncplay_models.freezed.dart'; + +/// Time sync measurement for NTP-like clock synchronization +@Freezed(copyWith: true) +abstract class TimeSyncMeasurement with _$TimeSyncMeasurement { + const TimeSyncMeasurement._(); + + factory TimeSyncMeasurement({ + required DateTime requestSent, + required DateTime requestReceived, + required DateTime responseSent, + required DateTime responseReceived, + }) = _TimeSyncMeasurement; + + /// Clock offset between client and server + /// Positive = server is ahead of client + Duration get offset { + final t1 = requestSent.millisecondsSinceEpoch; + final t2 = requestReceived.millisecondsSinceEpoch; + final t3 = responseSent.millisecondsSinceEpoch; + final t4 = responseReceived.millisecondsSinceEpoch; + final offsetMs = ((t2 - t1) + (t3 - t4)) / 2; + return Duration(milliseconds: offsetMs.round()); + } + + /// Round-trip delay + Duration get delay { + final t1 = requestSent.millisecondsSinceEpoch; + final t2 = requestReceived.millisecondsSinceEpoch; + final t3 = responseSent.millisecondsSinceEpoch; + final t4 = responseReceived.millisecondsSinceEpoch; + final delayMs = (t4 - t1) - (t3 - t2); + return Duration(milliseconds: delayMs); + } + + /// One-way ping (half of round-trip) + Duration get ping => Duration(milliseconds: delay.inMilliseconds ~/ 2); +} + +/// SyncPlay group state +enum SyncPlayGroupState { + idle, + waiting, + paused, + playing, +} + +/// SyncPlay command type emitted by the server in `SyncPlayCommand` +/// messages. Keeps the cross-platform contract typed instead of +/// passing raw strings (AGENTS.md SyncPlay rule 2). +enum SyncPlayCommand { + pause('Pause'), + unpause('Unpause'), + seek('Seek'), + stop('Stop'); + + const SyncPlayCommand(this.wire); + + /// Server-side wire identifier (used in REST/WebSocket payloads). + final String wire; + + /// Parse a wire string from the server. Returns `null` for unknown + /// values so callers can ignore the message instead of crashing. + static SyncPlayCommand? fromWire(String? value) { + if (value == null) { + return null; + } + for (final command in SyncPlayCommand.values) { + if (command.wire == value) { + return command; + } + } + return null; + } +} + +/// Reason field reported alongside `StateUpdate` group updates. +enum SyncPlayStateReason { + newPlaylist('NewPlaylist'), + setCurrentItem('SetCurrentItem'), + unpause('Unpause'), + pause('Pause'), + seek('Seek'), + buffer('Buffer'), + ready('Ready'), + stop('Stop'); + + const SyncPlayStateReason(this.wire); + + final String wire; + + static SyncPlayStateReason? fromWire(String? value) { + if (value == null) { + return null; + } + for (final reason in SyncPlayStateReason.values) { + if (reason.wire == value) { + return reason; + } + } + return null; + } +} + +/// Playback correction strategy used to resync local playback with group time. +enum SyncCorrectionStrategy { + none, + speedToSync, + skipToSync, +} + +/// Config values for playback drift correction. +/// +/// Defaults match official Jellyfin SyncPlay thresholds. +class SyncCorrectionConfig { + const SyncCorrectionConfig({ + this.minDelaySpeedToSyncMs = 60, + this.maxDelaySpeedToSyncMs = 3000, + this.speedToSyncDurationMs = 1000, + this.minDelaySkipToSyncMs = 400, + this.useSpeedToSync = true, + this.useSkipToSync = true, + this.enableSyncCorrection = true, + }); + + final double minDelaySpeedToSyncMs; + final double maxDelaySpeedToSyncMs; + final double speedToSyncDurationMs; + final double minDelaySkipToSyncMs; + final bool useSpeedToSync; + final bool useSkipToSync; + final bool enableSyncCorrection; + + SyncCorrectionConfig copyWith({ + double? minDelaySpeedToSyncMs, + double? maxDelaySpeedToSyncMs, + double? speedToSyncDurationMs, + double? minDelaySkipToSyncMs, + bool? useSpeedToSync, + bool? useSkipToSync, + bool? enableSyncCorrection, + }) { + return SyncCorrectionConfig( + minDelaySpeedToSyncMs: minDelaySpeedToSyncMs ?? this.minDelaySpeedToSyncMs, + maxDelaySpeedToSyncMs: maxDelaySpeedToSyncMs ?? this.maxDelaySpeedToSyncMs, + speedToSyncDurationMs: speedToSyncDurationMs ?? this.speedToSyncDurationMs, + minDelaySkipToSyncMs: minDelaySkipToSyncMs ?? this.minDelaySkipToSyncMs, + useSpeedToSync: useSpeedToSync ?? this.useSpeedToSync, + useSkipToSync: useSkipToSync ?? this.useSkipToSync, + enableSyncCorrection: enableSyncCorrection ?? this.enableSyncCorrection, + ); + } +} + +/// Runtime state of playback correction logic. +class SyncCorrectionState { + const SyncCorrectionState({ + this.syncEnabled = true, + this.playerIsBuffering = false, + this.playbackDiffMillis = 0, + this.syncAttempts = 0, + this.lastSyncAt, + this.activeStrategy = SyncCorrectionStrategy.none, + }); + + final bool syncEnabled; + final bool playerIsBuffering; + final double playbackDiffMillis; + final int syncAttempts; + final DateTime? lastSyncAt; + final SyncCorrectionStrategy activeStrategy; + + SyncCorrectionState copyWith({ + bool? syncEnabled, + bool? playerIsBuffering, + double? playbackDiffMillis, + int? syncAttempts, + DateTime? lastSyncAt, + SyncCorrectionStrategy? activeStrategy, + }) { + return SyncCorrectionState( + syncEnabled: syncEnabled ?? this.syncEnabled, + playerIsBuffering: playerIsBuffering ?? this.playerIsBuffering, + playbackDiffMillis: playbackDiffMillis ?? this.playbackDiffMillis, + syncAttempts: syncAttempts ?? this.syncAttempts, + lastSyncAt: lastSyncAt ?? this.lastSyncAt, + activeStrategy: activeStrategy ?? this.activeStrategy, + ); + } +} + +/// Select correction strategy based on current diff and runtime/config state. +/// +/// Precedence intentionally mirrors official behavior: +/// SpeedToSync first, then SkipToSync fallback. +SyncCorrectionStrategy selectSyncCorrectionStrategy({ + required SyncCorrectionConfig config, + required SyncCorrectionState state, + required double diffMillis, + required bool hasPlaybackRate, +}) { + if (!config.enableSyncCorrection || !state.syncEnabled) { + return SyncCorrectionStrategy.none; + } + + if (state.activeStrategy != SyncCorrectionStrategy.none) { + return SyncCorrectionStrategy.none; + } + + final absDiffMillis = diffMillis.abs(); + + final canUseSpeedToSync = (config.useSpeedToSync && + hasPlaybackRate && + absDiffMillis >= config.minDelaySpeedToSyncMs && + absDiffMillis < config.maxDelaySpeedToSyncMs); + if (canUseSpeedToSync) { + return SyncCorrectionStrategy.speedToSync; + } + + final canUseSkipToSync = (config.useSkipToSync && absDiffMillis >= config.minDelaySkipToSyncMs); + if (canUseSkipToSync) { + return SyncCorrectionStrategy.skipToSync; + } + + return SyncCorrectionStrategy.none; +} + +/// Current SyncPlay session state +@Freezed(copyWith: true) +abstract class SyncPlayState with _$SyncPlayState { + const SyncPlayState._(); + + factory SyncPlayState({ + @Default(false) bool isConnected, + @Default(false) bool isInGroup, + String? groupId, + String? groupName, + @Default(SyncPlayGroupState.idle) SyncPlayGroupState groupState, + String? stateReason, + @Default([]) List participants, + String? playingItemId, + String? playlistItemId, + @Default(0) int positionTicks, + DateTime? lastCommandTime, + + /// Whether a SyncPlay command is currently being processed + @Default(false) bool isProcessingCommand, + + /// The type of command being processed (for UI feedback). Typed + /// as [SyncPlayCommand] to keep cross-platform contracts strongly + /// typed (AGENTS.md SyncPlay rule 2). + SyncPlayCommand? processingCommandType, + + /// Internal correction configuration and thresholds. + @Default(SyncCorrectionConfig()) SyncCorrectionConfig correctionConfig, + + /// Runtime correction status for UI and command logic. + @Default(SyncCorrectionState()) SyncCorrectionState correctionState, + + /// True while a `_startPlayback` call is in flight (loader UX). + @Default(false) bool startPlaybackInProgress, + + /// PlaylistItemId currently being started (for dedup of concurrent + /// PlayQueue updates that race against each other). + String? startingPlaylistItemId, + + /// Number of nested local-only operations currently active. While + /// > 0, the controller suppresses `reportBuffering`/`reportReady` + /// so audio/subtitle reloads don't pause the rest of the group. + @Default(0) int localOnlyOperationCount, + }) = _SyncPlayState; + + bool get isActive => isConnected && isInGroup; + + /// True when local-only mode is active (audio/subtitle switch, etc.). + bool get isInLocalOnlyMode => localOnlyOperationCount > 0; + + /// True when the group has an active item playing/paused/waiting that + /// the local user could re-attach to (used by the "Resume playback" + /// button when the player route is not currently mounted). + bool get hasActivePlayback => isInGroup && playingItemId != null && groupState != SyncPlayGroupState.idle; +} + +/// Last executed command for duplicate detection +@Freezed(copyWith: true) +abstract class LastSyncPlayCommand with _$LastSyncPlayCommand { + factory LastSyncPlayCommand({ + required String when, + required int positionTicks, + required SyncPlayCommand command, + required String playlistItemId, + }) = _LastSyncPlayCommand; +} + +/// Ticks conversion constants +const int ticksPerMillisecond = 10000; +const int ticksPerSecond = 10000000; + +/// Convert seconds to ticks +int secondsToTicks(double seconds) => (seconds * ticksPerSecond).round(); + +/// Convert ticks to seconds +double ticksToSeconds(int ticks) => ticks / ticksPerSecond; + +/// Convert milliseconds to ticks +int millisecondsToTicks(int ms) => ms * ticksPerMillisecond; + +/// Convert ticks to milliseconds +int ticksToMilliseconds(int ticks) => ticks ~/ ticksPerMillisecond; diff --git a/lib/models/syncplay/syncplay_models.freezed.dart b/lib/models/syncplay/syncplay_models.freezed.dart new file mode 100644 index 000000000..54be5b4bc --- /dev/null +++ b/lib/models/syncplay/syncplay_models.freezed.dart @@ -0,0 +1,1320 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'syncplay_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TimeSyncMeasurement { + DateTime get requestSent; + DateTime get requestReceived; + DateTime get responseSent; + DateTime get responseReceived; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $TimeSyncMeasurementCopyWith get copyWith => + _$TimeSyncMeasurementCopyWithImpl(this as TimeSyncMeasurement, _$identity); + + @override + String toString() { + return 'TimeSyncMeasurement(requestSent: $requestSent, requestReceived: $requestReceived, responseSent: $responseSent, responseReceived: $responseReceived)'; + } +} + +/// @nodoc +abstract mixin class $TimeSyncMeasurementCopyWith<$Res> { + factory $TimeSyncMeasurementCopyWith(TimeSyncMeasurement value, $Res Function(TimeSyncMeasurement) _then) = + _$TimeSyncMeasurementCopyWithImpl; + @useResult + $Res call({DateTime requestSent, DateTime requestReceived, DateTime responseSent, DateTime responseReceived}); +} + +/// @nodoc +class _$TimeSyncMeasurementCopyWithImpl<$Res> implements $TimeSyncMeasurementCopyWith<$Res> { + _$TimeSyncMeasurementCopyWithImpl(this._self, this._then); + + final TimeSyncMeasurement _self; + final $Res Function(TimeSyncMeasurement) _then; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? requestSent = null, + Object? requestReceived = null, + Object? responseSent = null, + Object? responseReceived = null, + }) { + return _then(_self.copyWith( + requestSent: null == requestSent + ? _self.requestSent + : requestSent // ignore: cast_nullable_to_non_nullable + as DateTime, + requestReceived: null == requestReceived + ? _self.requestReceived + : requestReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + responseSent: null == responseSent + ? _self.responseSent + : responseSent // ignore: cast_nullable_to_non_nullable + as DateTime, + responseReceived: null == responseReceived + ? _self.responseReceived + : responseReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// Adds pattern-matching-related methods to [TimeSyncMeasurement]. +extension TimeSyncMeasurementPatterns on TimeSyncMeasurement { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_TimeSyncMeasurement value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_TimeSyncMeasurement value) $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_TimeSyncMeasurement value)? $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(DateTime requestSent, DateTime requestReceived, DateTime responseSent, DateTime responseReceived)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that.requestSent, _that.requestReceived, _that.responseSent, _that.responseReceived); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(DateTime requestSent, DateTime requestReceived, DateTime responseSent, DateTime responseReceived) + $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement(): + return $default(_that.requestSent, _that.requestReceived, _that.responseSent, _that.responseReceived); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(DateTime requestSent, DateTime requestReceived, DateTime responseSent, DateTime responseReceived)? + $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that.requestSent, _that.requestReceived, _that.responseSent, _that.responseReceived); + case _: + return null; + } + } +} + +/// @nodoc + +class _TimeSyncMeasurement extends TimeSyncMeasurement { + _TimeSyncMeasurement( + {required this.requestSent, + required this.requestReceived, + required this.responseSent, + required this.responseReceived}) + : super._(); + + @override + final DateTime requestSent; + @override + final DateTime requestReceived; + @override + final DateTime responseSent; + @override + final DateTime responseReceived; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$TimeSyncMeasurementCopyWith<_TimeSyncMeasurement> get copyWith => + __$TimeSyncMeasurementCopyWithImpl<_TimeSyncMeasurement>(this, _$identity); + + @override + String toString() { + return 'TimeSyncMeasurement(requestSent: $requestSent, requestReceived: $requestReceived, responseSent: $responseSent, responseReceived: $responseReceived)'; + } +} + +/// @nodoc +abstract mixin class _$TimeSyncMeasurementCopyWith<$Res> implements $TimeSyncMeasurementCopyWith<$Res> { + factory _$TimeSyncMeasurementCopyWith(_TimeSyncMeasurement value, $Res Function(_TimeSyncMeasurement) _then) = + __$TimeSyncMeasurementCopyWithImpl; + @override + @useResult + $Res call({DateTime requestSent, DateTime requestReceived, DateTime responseSent, DateTime responseReceived}); +} + +/// @nodoc +class __$TimeSyncMeasurementCopyWithImpl<$Res> implements _$TimeSyncMeasurementCopyWith<$Res> { + __$TimeSyncMeasurementCopyWithImpl(this._self, this._then); + + final _TimeSyncMeasurement _self; + final $Res Function(_TimeSyncMeasurement) _then; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? requestSent = null, + Object? requestReceived = null, + Object? responseSent = null, + Object? responseReceived = null, + }) { + return _then(_TimeSyncMeasurement( + requestSent: null == requestSent + ? _self.requestSent + : requestSent // ignore: cast_nullable_to_non_nullable + as DateTime, + requestReceived: null == requestReceived + ? _self.requestReceived + : requestReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + responseSent: null == responseSent + ? _self.responseSent + : responseSent // ignore: cast_nullable_to_non_nullable + as DateTime, + responseReceived: null == responseReceived + ? _self.responseReceived + : responseReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +mixin _$SyncPlayState { + bool get isConnected; + bool get isInGroup; + String? get groupId; + String? get groupName; + SyncPlayGroupState get groupState; + String? get stateReason; + List get participants; + String? get playingItemId; + String? get playlistItemId; + int get positionTicks; + DateTime? get lastCommandTime; + + /// Whether a SyncPlay command is currently being processed + bool get isProcessingCommand; + + /// The type of command being processed (for UI feedback). Typed + /// as [SyncPlayCommand] to keep cross-platform contracts strongly + /// typed (AGENTS.md SyncPlay rule 2). + SyncPlayCommand? get processingCommandType; + + /// Internal correction configuration and thresholds. + SyncCorrectionConfig get correctionConfig; + + /// Runtime correction status for UI and command logic. + SyncCorrectionState get correctionState; + + /// True while a `_startPlayback` call is in flight (loader UX). + bool get startPlaybackInProgress; + + /// PlaylistItemId currently being started (for dedup of concurrent + /// PlayQueue updates that race against each other). + String? get startingPlaylistItemId; + + /// Number of nested local-only operations currently active. While + /// > 0, the controller suppresses `reportBuffering`/`reportReady` + /// so audio/subtitle reloads don't pause the rest of the group. + int get localOnlyOperationCount; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SyncPlayStateCopyWith get copyWith => + _$SyncPlayStateCopyWithImpl(this as SyncPlayState, _$identity); + + @override + String toString() { + return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount)'; + } +} + +/// @nodoc +abstract mixin class $SyncPlayStateCopyWith<$Res> { + factory $SyncPlayStateCopyWith(SyncPlayState value, $Res Function(SyncPlayState) _then) = _$SyncPlayStateCopyWithImpl; + @useResult + $Res call( + {bool isConnected, + bool isInGroup, + String? groupId, + String? groupName, + SyncPlayGroupState groupState, + String? stateReason, + List participants, + String? playingItemId, + String? playlistItemId, + int positionTicks, + DateTime? lastCommandTime, + bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState, + bool startPlaybackInProgress, + String? startingPlaylistItemId, + int localOnlyOperationCount}); +} + +/// @nodoc +class _$SyncPlayStateCopyWithImpl<$Res> implements $SyncPlayStateCopyWith<$Res> { + _$SyncPlayStateCopyWithImpl(this._self, this._then); + + final SyncPlayState _self; + final $Res Function(SyncPlayState) _then; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isConnected = null, + Object? isInGroup = null, + Object? groupId = freezed, + Object? groupName = freezed, + Object? groupState = null, + Object? stateReason = freezed, + Object? participants = null, + Object? playingItemId = freezed, + Object? playlistItemId = freezed, + Object? positionTicks = null, + Object? lastCommandTime = freezed, + Object? isProcessingCommand = null, + Object? processingCommandType = freezed, + Object? correctionConfig = null, + Object? correctionState = null, + Object? startPlaybackInProgress = null, + Object? startingPlaylistItemId = freezed, + Object? localOnlyOperationCount = null, + }) { + return _then(_self.copyWith( + isConnected: null == isConnected + ? _self.isConnected + : isConnected // ignore: cast_nullable_to_non_nullable + as bool, + isInGroup: null == isInGroup + ? _self.isInGroup + : isInGroup // ignore: cast_nullable_to_non_nullable + as bool, + groupId: freezed == groupId + ? _self.groupId + : groupId // ignore: cast_nullable_to_non_nullable + as String?, + groupName: freezed == groupName + ? _self.groupName + : groupName // ignore: cast_nullable_to_non_nullable + as String?, + groupState: null == groupState + ? _self.groupState + : groupState // ignore: cast_nullable_to_non_nullable + as SyncPlayGroupState, + stateReason: freezed == stateReason + ? _self.stateReason + : stateReason // ignore: cast_nullable_to_non_nullable + as String?, + participants: null == participants + ? _self.participants + : participants // ignore: cast_nullable_to_non_nullable + as List, + playingItemId: freezed == playingItemId + ? _self.playingItemId + : playingItemId // ignore: cast_nullable_to_non_nullable + as String?, + playlistItemId: freezed == playlistItemId + ? _self.playlistItemId + : playlistItemId // ignore: cast_nullable_to_non_nullable + as String?, + positionTicks: null == positionTicks + ? _self.positionTicks + : positionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastCommandTime: freezed == lastCommandTime + ? _self.lastCommandTime + : lastCommandTime // ignore: cast_nullable_to_non_nullable + as DateTime?, + isProcessingCommand: null == isProcessingCommand + ? _self.isProcessingCommand + : isProcessingCommand // ignore: cast_nullable_to_non_nullable + as bool, + processingCommandType: freezed == processingCommandType + ? _self.processingCommandType + : processingCommandType // ignore: cast_nullable_to_non_nullable + as SyncPlayCommand?, + correctionConfig: null == correctionConfig + ? _self.correctionConfig + : correctionConfig // ignore: cast_nullable_to_non_nullable + as SyncCorrectionConfig, + correctionState: null == correctionState + ? _self.correctionState + : correctionState // ignore: cast_nullable_to_non_nullable + as SyncCorrectionState, + startPlaybackInProgress: null == startPlaybackInProgress + ? _self.startPlaybackInProgress + : startPlaybackInProgress // ignore: cast_nullable_to_non_nullable + as bool, + startingPlaylistItemId: freezed == startingPlaylistItemId + ? _self.startingPlaylistItemId + : startingPlaylistItemId // ignore: cast_nullable_to_non_nullable + as String?, + localOnlyOperationCount: null == localOnlyOperationCount + ? _self.localOnlyOperationCount + : localOnlyOperationCount // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// Adds pattern-matching-related methods to [SyncPlayState]. +extension SyncPlayStatePatterns on SyncPlayState { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SyncPlayState value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayState() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_SyncPlayState value) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayState(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SyncPlayState value)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayState() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + bool isConnected, + bool isInGroup, + String? groupId, + String? groupName, + SyncPlayGroupState groupState, + String? stateReason, + List participants, + String? playingItemId, + String? playlistItemId, + int positionTicks, + DateTime? lastCommandTime, + bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState, + bool startPlaybackInProgress, + String? startingPlaylistItemId, + int localOnlyOperationCount)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayState() when $default != null: + return $default( + _that.isConnected, + _that.isInGroup, + _that.groupId, + _that.groupName, + _that.groupState, + _that.stateReason, + _that.participants, + _that.playingItemId, + _that.playlistItemId, + _that.positionTicks, + _that.lastCommandTime, + _that.isProcessingCommand, + _that.processingCommandType, + _that.correctionConfig, + _that.correctionState, + _that.startPlaybackInProgress, + _that.startingPlaylistItemId, + _that.localOnlyOperationCount); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + bool isConnected, + bool isInGroup, + String? groupId, + String? groupName, + SyncPlayGroupState groupState, + String? stateReason, + List participants, + String? playingItemId, + String? playlistItemId, + int positionTicks, + DateTime? lastCommandTime, + bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState, + bool startPlaybackInProgress, + String? startingPlaylistItemId, + int localOnlyOperationCount) + $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayState(): + return $default( + _that.isConnected, + _that.isInGroup, + _that.groupId, + _that.groupName, + _that.groupState, + _that.stateReason, + _that.participants, + _that.playingItemId, + _that.playlistItemId, + _that.positionTicks, + _that.lastCommandTime, + _that.isProcessingCommand, + _that.processingCommandType, + _that.correctionConfig, + _that.correctionState, + _that.startPlaybackInProgress, + _that.startingPlaylistItemId, + _that.localOnlyOperationCount); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + bool isConnected, + bool isInGroup, + String? groupId, + String? groupName, + SyncPlayGroupState groupState, + String? stateReason, + List participants, + String? playingItemId, + String? playlistItemId, + int positionTicks, + DateTime? lastCommandTime, + bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState, + bool startPlaybackInProgress, + String? startingPlaylistItemId, + int localOnlyOperationCount)? + $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayState() when $default != null: + return $default( + _that.isConnected, + _that.isInGroup, + _that.groupId, + _that.groupName, + _that.groupState, + _that.stateReason, + _that.participants, + _that.playingItemId, + _that.playlistItemId, + _that.positionTicks, + _that.lastCommandTime, + _that.isProcessingCommand, + _that.processingCommandType, + _that.correctionConfig, + _that.correctionState, + _that.startPlaybackInProgress, + _that.startingPlaylistItemId, + _that.localOnlyOperationCount); + case _: + return null; + } + } +} + +/// @nodoc + +class _SyncPlayState extends SyncPlayState { + _SyncPlayState( + {this.isConnected = false, + this.isInGroup = false, + this.groupId, + this.groupName, + this.groupState = SyncPlayGroupState.idle, + this.stateReason, + final List participants = const [], + this.playingItemId, + this.playlistItemId, + this.positionTicks = 0, + this.lastCommandTime, + this.isProcessingCommand = false, + this.processingCommandType, + this.correctionConfig = const SyncCorrectionConfig(), + this.correctionState = const SyncCorrectionState(), + this.startPlaybackInProgress = false, + this.startingPlaylistItemId, + this.localOnlyOperationCount = 0}) + : _participants = participants, + super._(); + + @override + @JsonKey() + final bool isConnected; + @override + @JsonKey() + final bool isInGroup; + @override + final String? groupId; + @override + final String? groupName; + @override + @JsonKey() + final SyncPlayGroupState groupState; + @override + final String? stateReason; + final List _participants; + @override + @JsonKey() + List get participants { + if (_participants is EqualUnmodifiableListView) return _participants; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_participants); + } + + @override + final String? playingItemId; + @override + final String? playlistItemId; + @override + @JsonKey() + final int positionTicks; + @override + final DateTime? lastCommandTime; + + /// Whether a SyncPlay command is currently being processed + @override + @JsonKey() + final bool isProcessingCommand; + + /// The type of command being processed (for UI feedback). Typed + /// as [SyncPlayCommand] to keep cross-platform contracts strongly + /// typed (AGENTS.md SyncPlay rule 2). + @override + final SyncPlayCommand? processingCommandType; + + /// Internal correction configuration and thresholds. + @override + @JsonKey() + final SyncCorrectionConfig correctionConfig; + + /// Runtime correction status for UI and command logic. + @override + @JsonKey() + final SyncCorrectionState correctionState; + + /// True while a `_startPlayback` call is in flight (loader UX). + @override + @JsonKey() + final bool startPlaybackInProgress; + + /// PlaylistItemId currently being started (for dedup of concurrent + /// PlayQueue updates that race against each other). + @override + final String? startingPlaylistItemId; + + /// Number of nested local-only operations currently active. While + /// > 0, the controller suppresses `reportBuffering`/`reportReady` + /// so audio/subtitle reloads don't pause the rest of the group. + @override + @JsonKey() + final int localOnlyOperationCount; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SyncPlayStateCopyWith<_SyncPlayState> get copyWith => + __$SyncPlayStateCopyWithImpl<_SyncPlayState>(this, _$identity); + + @override + String toString() { + return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount)'; + } +} + +/// @nodoc +abstract mixin class _$SyncPlayStateCopyWith<$Res> implements $SyncPlayStateCopyWith<$Res> { + factory _$SyncPlayStateCopyWith(_SyncPlayState value, $Res Function(_SyncPlayState) _then) = + __$SyncPlayStateCopyWithImpl; + @override + @useResult + $Res call( + {bool isConnected, + bool isInGroup, + String? groupId, + String? groupName, + SyncPlayGroupState groupState, + String? stateReason, + List participants, + String? playingItemId, + String? playlistItemId, + int positionTicks, + DateTime? lastCommandTime, + bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState, + bool startPlaybackInProgress, + String? startingPlaylistItemId, + int localOnlyOperationCount}); +} + +/// @nodoc +class __$SyncPlayStateCopyWithImpl<$Res> implements _$SyncPlayStateCopyWith<$Res> { + __$SyncPlayStateCopyWithImpl(this._self, this._then); + + final _SyncPlayState _self; + final $Res Function(_SyncPlayState) _then; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? isConnected = null, + Object? isInGroup = null, + Object? groupId = freezed, + Object? groupName = freezed, + Object? groupState = null, + Object? stateReason = freezed, + Object? participants = null, + Object? playingItemId = freezed, + Object? playlistItemId = freezed, + Object? positionTicks = null, + Object? lastCommandTime = freezed, + Object? isProcessingCommand = null, + Object? processingCommandType = freezed, + Object? correctionConfig = null, + Object? correctionState = null, + Object? startPlaybackInProgress = null, + Object? startingPlaylistItemId = freezed, + Object? localOnlyOperationCount = null, + }) { + return _then(_SyncPlayState( + isConnected: null == isConnected + ? _self.isConnected + : isConnected // ignore: cast_nullable_to_non_nullable + as bool, + isInGroup: null == isInGroup + ? _self.isInGroup + : isInGroup // ignore: cast_nullable_to_non_nullable + as bool, + groupId: freezed == groupId + ? _self.groupId + : groupId // ignore: cast_nullable_to_non_nullable + as String?, + groupName: freezed == groupName + ? _self.groupName + : groupName // ignore: cast_nullable_to_non_nullable + as String?, + groupState: null == groupState + ? _self.groupState + : groupState // ignore: cast_nullable_to_non_nullable + as SyncPlayGroupState, + stateReason: freezed == stateReason + ? _self.stateReason + : stateReason // ignore: cast_nullable_to_non_nullable + as String?, + participants: null == participants + ? _self._participants + : participants // ignore: cast_nullable_to_non_nullable + as List, + playingItemId: freezed == playingItemId + ? _self.playingItemId + : playingItemId // ignore: cast_nullable_to_non_nullable + as String?, + playlistItemId: freezed == playlistItemId + ? _self.playlistItemId + : playlistItemId // ignore: cast_nullable_to_non_nullable + as String?, + positionTicks: null == positionTicks + ? _self.positionTicks + : positionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastCommandTime: freezed == lastCommandTime + ? _self.lastCommandTime + : lastCommandTime // ignore: cast_nullable_to_non_nullable + as DateTime?, + isProcessingCommand: null == isProcessingCommand + ? _self.isProcessingCommand + : isProcessingCommand // ignore: cast_nullable_to_non_nullable + as bool, + processingCommandType: freezed == processingCommandType + ? _self.processingCommandType + : processingCommandType // ignore: cast_nullable_to_non_nullable + as SyncPlayCommand?, + correctionConfig: null == correctionConfig + ? _self.correctionConfig + : correctionConfig // ignore: cast_nullable_to_non_nullable + as SyncCorrectionConfig, + correctionState: null == correctionState + ? _self.correctionState + : correctionState // ignore: cast_nullable_to_non_nullable + as SyncCorrectionState, + startPlaybackInProgress: null == startPlaybackInProgress + ? _self.startPlaybackInProgress + : startPlaybackInProgress // ignore: cast_nullable_to_non_nullable + as bool, + startingPlaylistItemId: freezed == startingPlaylistItemId + ? _self.startingPlaylistItemId + : startingPlaylistItemId // ignore: cast_nullable_to_non_nullable + as String?, + localOnlyOperationCount: null == localOnlyOperationCount + ? _self.localOnlyOperationCount + : localOnlyOperationCount // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +mixin _$LastSyncPlayCommand { + String get when; + int get positionTicks; + SyncPlayCommand get command; + String get playlistItemId; + + /// Create a copy of LastSyncPlayCommand + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $LastSyncPlayCommandCopyWith get copyWith => + _$LastSyncPlayCommandCopyWithImpl(this as LastSyncPlayCommand, _$identity); + + @override + String toString() { + return 'LastSyncPlayCommand(when: $when, positionTicks: $positionTicks, command: $command, playlistItemId: $playlistItemId)'; + } +} + +/// @nodoc +abstract mixin class $LastSyncPlayCommandCopyWith<$Res> { + factory $LastSyncPlayCommandCopyWith(LastSyncPlayCommand value, $Res Function(LastSyncPlayCommand) _then) = + _$LastSyncPlayCommandCopyWithImpl; + @useResult + $Res call({String when, int positionTicks, SyncPlayCommand command, String playlistItemId}); +} + +/// @nodoc +class _$LastSyncPlayCommandCopyWithImpl<$Res> implements $LastSyncPlayCommandCopyWith<$Res> { + _$LastSyncPlayCommandCopyWithImpl(this._self, this._then); + + final LastSyncPlayCommand _self; + final $Res Function(LastSyncPlayCommand) _then; + + /// Create a copy of LastSyncPlayCommand + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? when = null, + Object? positionTicks = null, + Object? command = null, + Object? playlistItemId = null, + }) { + return _then(_self.copyWith( + when: null == when + ? _self.when + : when // ignore: cast_nullable_to_non_nullable + as String, + positionTicks: null == positionTicks + ? _self.positionTicks + : positionTicks // ignore: cast_nullable_to_non_nullable + as int, + command: null == command + ? _self.command + : command // ignore: cast_nullable_to_non_nullable + as SyncPlayCommand, + playlistItemId: null == playlistItemId + ? _self.playlistItemId + : playlistItemId // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// Adds pattern-matching-related methods to [LastSyncPlayCommand]. +extension LastSyncPlayCommandPatterns on LastSyncPlayCommand { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_LastSyncPlayCommand value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_LastSyncPlayCommand value) $default, + ) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_LastSyncPlayCommand value)? $default, + ) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(String when, int positionTicks, SyncPlayCommand command, String playlistItemId)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand() when $default != null: + return $default(_that.when, _that.positionTicks, _that.command, _that.playlistItemId); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(String when, int positionTicks, SyncPlayCommand command, String playlistItemId) $default, + ) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand(): + return $default(_that.when, _that.positionTicks, _that.command, _that.playlistItemId); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(String when, int positionTicks, SyncPlayCommand command, String playlistItemId)? $default, + ) { + final _that = this; + switch (_that) { + case _LastSyncPlayCommand() when $default != null: + return $default(_that.when, _that.positionTicks, _that.command, _that.playlistItemId); + case _: + return null; + } + } +} + +/// @nodoc + +class _LastSyncPlayCommand implements LastSyncPlayCommand { + _LastSyncPlayCommand( + {required this.when, required this.positionTicks, required this.command, required this.playlistItemId}); + + @override + final String when; + @override + final int positionTicks; + @override + final SyncPlayCommand command; + @override + final String playlistItemId; + + /// Create a copy of LastSyncPlayCommand + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LastSyncPlayCommandCopyWith<_LastSyncPlayCommand> get copyWith => + __$LastSyncPlayCommandCopyWithImpl<_LastSyncPlayCommand>(this, _$identity); + + @override + String toString() { + return 'LastSyncPlayCommand(when: $when, positionTicks: $positionTicks, command: $command, playlistItemId: $playlistItemId)'; + } +} + +/// @nodoc +abstract mixin class _$LastSyncPlayCommandCopyWith<$Res> implements $LastSyncPlayCommandCopyWith<$Res> { + factory _$LastSyncPlayCommandCopyWith(_LastSyncPlayCommand value, $Res Function(_LastSyncPlayCommand) _then) = + __$LastSyncPlayCommandCopyWithImpl; + @override + @useResult + $Res call({String when, int positionTicks, SyncPlayCommand command, String playlistItemId}); +} + +/// @nodoc +class __$LastSyncPlayCommandCopyWithImpl<$Res> implements _$LastSyncPlayCommandCopyWith<$Res> { + __$LastSyncPlayCommandCopyWithImpl(this._self, this._then); + + final _LastSyncPlayCommand _self; + final $Res Function(_LastSyncPlayCommand) _then; + + /// Create a copy of LastSyncPlayCommand + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? when = null, + Object? positionTicks = null, + Object? command = null, + Object? playlistItemId = null, + }) { + return _then(_LastSyncPlayCommand( + when: null == when + ? _self.when + : when // ignore: cast_nullable_to_non_nullable + as String, + positionTicks: null == positionTicks + ? _self.positionTicks + : positionTicks // ignore: cast_nullable_to_non_nullable + as int, + command: null == command + ? _self.command + : command // ignore: cast_nullable_to_non_nullable + as SyncPlayCommand, + playlistItemId: null == playlistItemId + ? _self.playlistItemId + : playlistItemId // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +// dart format on diff --git a/lib/src/translations_pigeon.g.dart b/lib/src/translations_pigeon.g.dart index a68570968..82de1f927 100644 --- a/lib/src/translations_pigeon.g.dart +++ b/lib/src/translations_pigeon.g.dart @@ -18,7 +18,6 @@ List wrapResponse({Object? result, PlatformException? error, bool empty return [error.code, error.message, error.details]; } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -73,11 +72,28 @@ abstract class TranslationsPigeon { String decline(); - static void setUp(TranslationsPigeon? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + String syncPlaySyncingWithGroup(); + + String syncPlayCommandPausing(); + + String syncPlayCommandPlaying(); + + String syncPlayCommandSeeking(); + + String syncPlayCommandStopping(); + + String syncPlayCommandSyncing(); + + static void setUp( + TranslationsPigeon? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.next$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.next$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -88,7 +104,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -96,7 +112,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextVideo$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextVideo$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -107,7 +124,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -115,7 +132,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.close$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.close$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -126,7 +144,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -134,14 +152,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.skip$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.skip$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.skip was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.skip was null.'); final List args = (message as List?)!; final String? arg_name = (args[0] as String?); assert(arg_name != null, @@ -151,7 +170,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -159,7 +178,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.subtitles$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.subtitles$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -170,7 +190,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -178,7 +198,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.off$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.off$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -189,7 +210,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -197,14 +218,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.chapters$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.chapters$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.chapters was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.chapters was null.'); final List args = (message as List?)!; final int? arg_count = (args[0] as int?); assert(arg_count != null, @@ -214,7 +236,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -222,14 +244,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextUpInSeconds$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextUpInSeconds$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextUpInSeconds was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.nextUpInSeconds was null.'); final List args = (message as List?)!; final int? arg_seconds = (args[0] as int?); assert(arg_seconds != null, @@ -239,7 +262,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -247,14 +270,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.hoursAndMinutes$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.hoursAndMinutes$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.hoursAndMinutes was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.hoursAndMinutes was null.'); final List args = (message as List?)!; final String? arg_time = (args[0] as String?); assert(arg_time != null, @@ -264,7 +288,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -272,14 +296,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.endsAt$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.endsAt$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.endsAt was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.endsAt was null.'); final List args = (message as List?)!; final String? arg_time = (args[0] as String?); assert(arg_time != null, @@ -289,7 +314,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -297,7 +322,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannel$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannel$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -308,7 +334,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -316,14 +342,15 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannelDesc$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannelDesc$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannelDesc was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.switchChannelDesc was null.'); final List args = (message as List?)!; final String? arg_programName = (args[0] as String?); assert(arg_programName != null, @@ -336,7 +363,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -344,7 +371,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.watch$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.watch$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -355,7 +383,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -363,7 +391,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.now$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.now$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -374,7 +403,7 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -382,7 +411,8 @@ abstract class TranslationsPigeon { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.decline$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.decline$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -393,7 +423,127 @@ abstract class TranslationsPigeon { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlaySyncingWithGroup$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlaySyncingWithGroup(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPausing$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandPausing(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPlaying$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandPlaying(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSeeking$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandSeeking(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandStopping$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandStopping(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSyncing$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandSyncing(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index b717b5ffe..7c70a7112 100644 --- a/lib/src/video_player_helper.g.dart +++ b/lib/src/video_player_helper.g.dart @@ -24,21 +24,19 @@ List wrapResponse({Object? result, PlatformException? error, bool empty } return [error.code, error.message, error.details]; } + bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && + a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key])); } return a == b; } - enum PlaybackType { direct, transcoded, @@ -46,6 +44,14 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, +} + enum MediaSegmentType { commercial, preview, @@ -54,6 +60,18 @@ enum MediaSegmentType { outro, } +/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + + /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. + user, + + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, +} + class SimpleItemModel { SimpleItemModel({ required this.id, @@ -88,7 +106,8 @@ class SimpleItemModel { } Object encode() { - return _toList(); } + return _toList(); + } static SimpleItemModel decode(Object result) { result as List; @@ -116,8 +135,7 @@ class SimpleItemModel { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class MediaInfo { @@ -138,7 +156,8 @@ class MediaInfo { } Object encode() { - return _toList(); } + return _toList(); + } static MediaInfo decode(Object result) { result as List; @@ -162,8 +181,7 @@ class MediaInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class PlayableData { @@ -232,7 +250,8 @@ class PlayableData { } Object encode() { - return _toList(); } + return _toList(); + } static PlayableData decode(Object result) { result as List; @@ -268,8 +287,7 @@ class PlayableData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class MediaSegment { @@ -298,7 +316,8 @@ class MediaSegment { } Object encode() { - return _toList(); } + return _toList(); + } static MediaSegment decode(Object result) { result as List; @@ -324,8 +343,7 @@ class MediaSegment { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class AudioTrack { @@ -362,7 +380,8 @@ class AudioTrack { } Object encode() { - return _toList(); } + return _toList(); + } static AudioTrack decode(Object result) { result as List; @@ -390,8 +409,7 @@ class AudioTrack { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class SubtitleTrack { @@ -428,7 +446,8 @@ class SubtitleTrack { } Object encode() { - return _toList(); } + return _toList(); + } static SubtitleTrack decode(Object result) { result as List; @@ -456,8 +475,7 @@ class SubtitleTrack { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class Chapter { @@ -482,7 +500,8 @@ class Chapter { } Object encode() { - return _toList(); } + return _toList(); + } static Chapter decode(Object result) { result as List; @@ -507,8 +526,7 @@ class Chapter { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class TrickPlayModel { @@ -549,7 +567,8 @@ class TrickPlayModel { } Object encode() { - return _toList(); } + return _toList(); + } static TrickPlayModel decode(Object result) { result as List; @@ -578,8 +597,7 @@ class TrickPlayModel { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class StartResult { @@ -596,7 +614,8 @@ class StartResult { } Object encode() { - return _toList(); } + return _toList(); + } static StartResult decode(Object result) { result as List; @@ -619,8 +638,7 @@ class StartResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class PlaybackState { @@ -632,6 +650,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); int position; @@ -648,6 +667,9 @@ class PlaybackState { bool failed; + /// When set, indicates who caused this state update (for SyncPlay inference). + PlaybackChangeSource? changeSource; + List _toList() { return [ position, @@ -657,11 +679,13 @@ class PlaybackState { buffering, completed, failed, + changeSource, ]; } Object encode() { - return _toList(); } + return _toList(); + } static PlaybackState decode(Object result) { result as List; @@ -673,6 +697,7 @@ class PlaybackState { buffering: result[4]! as bool, completed: result[5]! as bool, failed: result[6]! as bool, + changeSource: result[7] as PlaybackChangeSource?, ); } @@ -690,8 +715,7 @@ class PlaybackState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class SubtitleSettings { @@ -736,7 +760,8 @@ class SubtitleSettings { } Object encode() { - return _toList(); } + return _toList(); + } static SubtitleSettings decode(Object result) { result as List; @@ -766,8 +791,7 @@ class SubtitleSettings { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class TVGuideModel { @@ -796,7 +820,8 @@ class TVGuideModel { } Object encode() { - return _toList(); } + return _toList(); + } static TVGuideModel decode(Object result) { result as List; @@ -822,8 +847,7 @@ class TVGuideModel { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class GuideChannel { @@ -856,7 +880,8 @@ class GuideChannel { } Object encode() { - return _toList(); } + return _toList(); + } static GuideChannel decode(Object result) { result as List; @@ -883,8 +908,7 @@ class GuideChannel { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class GuideProgram { @@ -929,7 +953,8 @@ class GuideProgram { } Object encode() { - return _toList(); } + return _toList(); + } static GuideProgram decode(Object result) { result as List; @@ -959,11 +984,9 @@ class GuideProgram { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -971,54 +994,60 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlaybackType) { + } else if (value is PlaybackType) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is MediaSegmentType) { + } else if (value is SyncPlayCommandType) { buffer.putUint8(130); writeValue(buffer, value.index); - } else if (value is SimpleItemModel) { + } else if (value is MediaSegmentType) { buffer.putUint8(131); - writeValue(buffer, value.encode()); - } else if (value is MediaInfo) { + writeValue(buffer, value.index); + } else if (value is PlaybackChangeSource) { buffer.putUint8(132); - writeValue(buffer, value.encode()); - } else if (value is PlayableData) { + writeValue(buffer, value.index); + } else if (value is SimpleItemModel) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is MediaSegment) { + } else if (value is MediaInfo) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is AudioTrack) { + } else if (value is PlayableData) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is SubtitleTrack) { + } else if (value is MediaSegment) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is Chapter) { + } else if (value is AudioTrack) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is TrickPlayModel) { + } else if (value is SubtitleTrack) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is StartResult) { + } else if (value is Chapter) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PlaybackState) { + } else if (value is TrickPlayModel) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is SubtitleSettings) { + } else if (value is StartResult) { buffer.putUint8(141); writeValue(buffer, value.encode()); - } else if (value is TVGuideModel) { + } else if (value is PlaybackState) { buffer.putUint8(142); writeValue(buffer, value.encode()); - } else if (value is GuideChannel) { + } else if (value is SubtitleSettings) { buffer.putUint8(143); writeValue(buffer, value.encode()); - } else if (value is GuideProgram) { + } else if (value is TVGuideModel) { buffer.putUint8(144); writeValue(buffer, value.encode()); + } else if (value is GuideChannel) { + buffer.putUint8(145); + writeValue(buffer, value.encode()); + } else if (value is GuideProgram) { + buffer.putUint8(146); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -1027,39 +1056,45 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : PlaybackType.values[value]; - case 130: + case 130: + final int? value = readValue(buffer) as int?; + return value == null ? null : SyncPlayCommandType.values[value]; + case 131: final int? value = readValue(buffer) as int?; return value == null ? null : MediaSegmentType.values[value]; - case 131: + case 132: + final int? value = readValue(buffer) as int?; + return value == null ? null : PlaybackChangeSource.values[value]; + case 133: return SimpleItemModel.decode(readValue(buffer)!); - case 132: + case 134: return MediaInfo.decode(readValue(buffer)!); - case 133: + case 135: return PlayableData.decode(readValue(buffer)!); - case 134: + case 136: return MediaSegment.decode(readValue(buffer)!); - case 135: + case 137: return AudioTrack.decode(readValue(buffer)!); - case 136: + case 138: return SubtitleTrack.decode(readValue(buffer)!); - case 137: + case 139: return Chapter.decode(readValue(buffer)!); - case 138: + case 140: return TrickPlayModel.decode(readValue(buffer)!); - case 139: + case 141: return StartResult.decode(readValue(buffer)!); - case 140: + case 142: return PlaybackState.decode(readValue(buffer)!); - case 141: + case 143: return SubtitleSettings.decode(readValue(buffer)!); - case 142: + case 144: return TVGuideModel.decode(readValue(buffer)!); - case 143: + case 145: return GuideChannel.decode(readValue(buffer)!); - case 144: + case 146: return GuideProgram.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -1081,15 +1116,15 @@ class NativeVideoActivity { final String pigeonVar_messageChannelSuffix; Future launchActivity() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1109,15 +1144,15 @@ class NativeVideoActivity { } Future disposeActivity() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1132,15 +1167,15 @@ class NativeVideoActivity { } Future isLeanBackEnabled() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1174,15 +1209,15 @@ class VideoPlayerApi { final String pigeonVar_messageChannelSuffix; Future sendPlayableModel(PlayableData playableData) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([playableData]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1202,15 +1237,15 @@ class VideoPlayerApi { } Future sendTVGuideModel(TVGuideModel guide) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendTVGuideModel$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendTVGuideModel$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([guide]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1230,15 +1265,15 @@ class VideoPlayerApi { } Future open(String url, bool play) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, play]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1258,15 +1293,15 @@ class VideoPlayerApi { } Future setLooping(bool looping) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([looping]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1282,15 +1317,15 @@ class VideoPlayerApi { /// Sets the volume, with 0.0 being muted and 1.0 being full volume. Future setVolume(double volume) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([volume]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1306,15 +1341,15 @@ class VideoPlayerApi { /// Sets the playback speed as a multiple of normal speed. Future setPlaybackSpeed(double speed) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([speed]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1329,15 +1364,15 @@ class VideoPlayerApi { } Future play() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1353,15 +1388,15 @@ class VideoPlayerApi { /// Pauses playback if the video is currently playing. Future pause() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1377,15 +1412,15 @@ class VideoPlayerApi { /// Seeks to the given playback position, in milliseconds. Future seekTo(int position) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([position]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1400,15 +1435,15 @@ class VideoPlayerApi { } Future stop() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1423,15 +1458,41 @@ class VideoPlayerApi { } Future setSubtitleSettings(SubtitleSettings settings) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSubtitleSettings$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSubtitleSettings$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([settings]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Sets the SyncPlay command state for the native player overlay. + /// [processing] indicates if a SyncPlay command is being processed. + /// [commandType] is the type of command. + Future setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([processing, commandType]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -1451,18 +1512,23 @@ abstract class VideoPlayerListenerCallback { void onPlaybackStateChanged(PlaybackState state); - static void setUp(VideoPlayerListenerCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + static void setUp( + VideoPlayerListenerCallback? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged was null.'); final List args = (message as List?)!; final PlaybackState? arg_state = (args[0] as PlaybackState?); assert(arg_state != null, @@ -1472,7 +1538,7 @@ abstract class VideoPlayerListenerCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1498,11 +1564,26 @@ abstract class VideoPlayerControlsCallback { Future> fetchProgramsForChannel(String channelId); - static void setUp(VideoPlayerControlsCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + /// User-initiated play action from native player (for SyncPlay integration) + void onUserPlay(); + + /// User-initiated pause action from native player (for SyncPlay integration) + void onUserPause(); + + /// User-initiated seek action from native player (for SyncPlay integration) + /// Position is in milliseconds + void onUserSeek(int positionMs); + + static void setUp( + VideoPlayerControlsCallback? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -1513,7 +1594,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1521,7 +1602,8 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -1532,7 +1614,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1540,7 +1622,8 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); @@ -1551,7 +1634,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1559,14 +1642,15 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack was null.'); final List args = (message as List?)!; final int? arg_value = (args[0] as int?); assert(arg_value != null, @@ -1576,7 +1660,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1584,14 +1668,15 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack was null.'); final List args = (message as List?)!; final int? arg_value = (args[0] as int?); assert(arg_value != null, @@ -1601,7 +1686,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1609,14 +1694,15 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadProgram$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadProgram$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadProgram was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadProgram was null.'); final List args = (message as List?)!; final GuideChannel? arg_selection = (args[0] as GuideChannel?); assert(arg_selection != null, @@ -1626,7 +1712,7 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); @@ -1634,14 +1720,15 @@ abstract class VideoPlayerControlsCallback { } { final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.fetchProgramsForChannel$messageChannelSuffix', pigeonChannelCodec, + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.fetchProgramsForChannel$messageChannelSuffix', + pigeonChannelCodec, binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.fetchProgramsForChannel was null.'); + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.fetchProgramsForChannel was null.'); final List args = (message as List?)!; final String? arg_channelId = (args[0] as String?); assert(arg_channelId != null, @@ -1651,7 +1738,73 @@ abstract class VideoPlayerControlsCallback { return wrapResponse(result: output); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onUserPlay(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onUserPause(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null.'); + final List args = (message as List?)!; + final int? arg_positionMs = (args[0] as int?); + assert(arg_positionMs != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null, expected non-null int.'); + try { + api.onUserSeek(arg_positionMs!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); diff --git a/pigeons/translations_pigeon.dart b/pigeons/translations_pigeon.dart index 52b136937..4fe214d7f 100644 --- a/pigeons/translations_pigeon.dart +++ b/pigeons/translations_pigeon.dart @@ -34,4 +34,12 @@ abstract class TranslationsPigeon { String watch(); String now(); String decline(); + + // SyncPlay overlay strings + String syncPlaySyncingWithGroup(); + String syncPlayCommandPausing(); + String syncPlayCommandPlaying(); + String syncPlayCommandSeeking(); + String syncPlayCommandStopping(); + String syncPlayCommandSyncing(); } diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 693228e86..4840facc1 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -34,6 +34,14 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, +} + class MediaInfo { final PlaybackType playbackType; final String videoInformation; @@ -139,6 +147,7 @@ class SubtitleTrack { class Chapter { final String name; final String url; + // Duration in milliseconds final int time; @@ -155,6 +164,7 @@ class TrickPlayModel { final int tileWidth; final int tileHeight; final int thumbnailCount; + //Duration in milliseconds final int interval; final List images; @@ -178,6 +188,7 @@ class StartResult { abstract class NativeVideoActivity { @async StartResult launchActivity(); + void disposeActivity(); bool isLeanBackEnabled(); @@ -213,13 +224,32 @@ abstract class VideoPlayerApi { void stop(); void setSubtitleSettings(SubtitleSettings settings); + + /// Sets the SyncPlay command state for the native player overlay. + /// [processing] indicates if a SyncPlay command is being processed. + /// [commandType] is the type of command. + void setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType); +} + +/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + + /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. + user, + + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, } class PlaybackState { //Milliseconds final int position; + //Milliseconds final int buffered; + //Milliseconds final int duration; final bool playing; @@ -227,6 +257,9 @@ class PlaybackState { final bool completed; final bool failed; + /// When set, indicates who caused this state update (for SyncPlay inference). + final PlaybackChangeSource? changeSource; + const PlaybackState({ required this.position, required this.buffered, @@ -235,6 +268,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); } @@ -320,11 +354,27 @@ abstract class VideoPlayerListenerCallback { @FlutterApi() abstract class VideoPlayerControlsCallback { void loadNextVideo(); + void loadPreviousVideo(); + void onStop(); + void swapSubtitleTrack(int value); + void swapAudioTrack(int value); + void loadProgram(GuideChannel selection); + @async List fetchProgramsForChannel(String channelId); + + /// User-initiated play action from native player (for SyncPlay integration) + void onUserPlay(); + + /// User-initiated pause action from native player (for SyncPlay integration) + void onUserPause(); + + /// User-initiated seek action from native player (for SyncPlay integration) + /// Position is in milliseconds + void onUserSeek(int positionMs); } From d440b19c6f17332ab8533815669ae76dc4bbdbf2 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:05:13 +0200 Subject: [PATCH 02/14] feat(syncplay): add SyncPlay providers, controller and websocket service --- lib/bootstrap/platform/base_app_wrapper.dart | 12 + lib/providers/router_provider.dart | 14 + .../handlers/syncplay_command_handler.dart | 333 ++++ .../handlers/syncplay_message_handler.dart | 336 ++++ lib/providers/syncplay/syncplay.dart | 30 + .../syncplay/syncplay_controller.dart | 1347 +++++++++++++++++ lib/providers/syncplay/syncplay_provider.dart | 263 ++++ .../syncplay/syncplay_provider.freezed.dart | 327 ++++ .../syncplay/syncplay_provider.g.dart | 163 ++ lib/providers/syncplay/time_sync_service.dart | 177 +++ lib/providers/video_player_provider.dart | 394 ++++- .../websocket/jellyfin_websocket.dart | 231 +++ .../jellyfin_websocket_provider.dart | 200 +++ .../jellyfin_websocket_provider.g.dart | 31 + 14 files changed, 3827 insertions(+), 31 deletions(-) create mode 100644 lib/providers/router_provider.dart create mode 100644 lib/providers/syncplay/handlers/syncplay_command_handler.dart create mode 100644 lib/providers/syncplay/handlers/syncplay_message_handler.dart create mode 100644 lib/providers/syncplay/syncplay.dart create mode 100644 lib/providers/syncplay/syncplay_controller.dart create mode 100644 lib/providers/syncplay/syncplay_provider.dart create mode 100644 lib/providers/syncplay/syncplay_provider.freezed.dart create mode 100644 lib/providers/syncplay/syncplay_provider.g.dart create mode 100644 lib/providers/syncplay/time_sync_service.dart create mode 100644 lib/providers/websocket/jellyfin_websocket.dart create mode 100644 lib/providers/websocket/jellyfin_websocket_provider.dart create mode 100644 lib/providers/websocket/jellyfin_websocket_provider.g.dart diff --git a/lib/bootstrap/platform/base_app_wrapper.dart b/lib/bootstrap/platform/base_app_wrapper.dart index 06d96929d..bdfb41e97 100644 --- a/lib/bootstrap/platform/base_app_wrapper.dart +++ b/lib/bootstrap/platform/base_app_wrapper.dart @@ -17,7 +17,9 @@ import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/update_notifications_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket_provider.dart'; import 'package:fladder/routes/auto_router.dart'; +import 'package:fladder/providers/router_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/login/lock_screen.dart'; import 'package:fladder/services/notification_service.dart'; @@ -47,6 +49,12 @@ abstract class BaseAppWrapperState extends ConsumerSta @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + ref.read(routerProvider.notifier).state = autoRouter; + }); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) async { ref.read(sharedUtilityProvider).loadSettings(); @@ -144,6 +152,10 @@ abstract class BaseAppWrapperState extends ConsumerSta @override Widget build(BuildContext context) { + // Activate the app-level Jellyfin WebSocket for the whole session. + // The provider connects/disconnects itself off userProvider; this + // watch only ensures the keepAlive provider is instantiated. + ref.watch(jellyfinWebSocketControllerProvider); return widget.builder( context, autoRouter, diff --git a/lib/providers/router_provider.dart b/lib/providers/router_provider.dart new file mode 100644 index 000000000..bcec1b9ed --- /dev/null +++ b/lib/providers/router_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/routes/auto_router.dart'; + +/// Provider for the global AutoRouter instance +/// Set from main.dart after initialization +final routerProvider = StateProvider((ref) => null); + +/// Get the navigator key from the router for pushing routes without context +GlobalKey? getNavigatorKey(Ref ref) { + return ref.read(routerProvider)?.navigatorKey; +} diff --git a/lib/providers/syncplay/handlers/syncplay_command_handler.dart b/lib/providers/syncplay/handlers/syncplay_command_handler.dart new file mode 100644 index 000000000..84032fd20 --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_command_handler.dart @@ -0,0 +1,333 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; + +/// Callback types for player control commands from SyncPlay +typedef SyncPlayPlayerCallback = Future Function(); +typedef SyncPlaySeekCallback = Future Function(int positionTicks); +typedef SyncPlayPositionCallback = int Function(); +typedef SyncPlayReportReadyCallback = Future Function(); +typedef SyncPlaySetSpeedCallback = Future Function(double speed); + +/// Handles scheduling and execution of SyncPlay commands +class SyncPlayCommandHandler { + SyncPlayCommandHandler({ + required this.timeSync, + required this.onStateUpdate, + }); + + /// Commands more than this late are dropped on the floor — typical + /// trigger is the server replaying its queued backlog after a long + /// client disconnect (phone locked, app backgrounded). The current + /// `StateUpdate` messages from the server then resync us. + static const _staleCommandThreshold = Duration(seconds: 30); + + final TimeSyncService? Function() timeSync; + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + + // Last command for duplicate detection + LastSyncPlayCommand? _lastCommand; + + // Pending command timer + Timer? _commandTimer; + + // Player callbacks + SyncPlayPlayerCallback? onPlay; + SyncPlayPlayerCallback? onPause; + SyncPlaySeekCallback? onSeek; + SyncPlayPlayerCallback? onStop; + SyncPlayPositionCallback? getPositionTicks; + bool Function()? isPlaying; + bool Function()? isBuffering; + + // New callback to signal that a seek has been requested by someone else + SyncPlaySeekCallback? onSeekRequested; + + // Report ready callback (to tell server we're ready after seek) + SyncPlayReportReadyCallback? onReportReady; + + // Playback rate callbacks for SpeedToSync + SyncPlaySetSpeedCallback? onSetSpeed; + bool Function()? hasPlaybackRate; + + /// Last accepted command (non-duplicate), exposed for correction logic. + LastSyncPlayCommand? get lastCommand => _lastCommand; + + /// Handle incoming SyncPlay command from WebSocket + void handleCommand(Map data, SyncPlayState currentState) { + final commandWire = data['Command'] as String?; + final whenStr = data['When'] as String?; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final playlistItemId = data['PlaylistItemId'] as String? ?? ''; + + final command = SyncPlayCommand.fromWire(commandWire); + if (command == null || whenStr == null) { + log('SyncPlay: Ignoring unknown command "$commandWire"'); + return; + } + + // Check for duplicate command + if (_isDuplicateCommand(whenStr, positionTicks, command, playlistItemId)) { + log('SyncPlay: Ignoring duplicate command: ${command.wire}'); + return; + } + + _lastCommand = LastSyncPlayCommand( + when: whenStr, + positionTicks: positionTicks, + command: command, + playlistItemId: playlistItemId, + ); + + onStateUpdate((state) => state.copyWith( + positionTicks: positionTicks, + playlistItemId: playlistItemId, + )); + + // If it's a Seek command, notify the player immediately so it can + // report buffering. + if (command == SyncPlayCommand.seek) { + onSeekRequested?.call(positionTicks); + } + + final when = DateTime.parse(whenStr); + _scheduleCommand(command, when, positionTicks); + } + + bool _isDuplicateCommand( + String when, + int positionTicks, + SyncPlayCommand command, + String playlistItemId, + ) { + if (_lastCommand == null) { + return false; + } + + // For Unpause commands, if we are not currently playing, we should + // NEVER treat it as a duplicate to ensure the player actually + // resumes. + if (command == SyncPlayCommand.unpause && isPlaying?.call() == false) { + return false; + } + + return _lastCommand!.when == when && + _lastCommand!.positionTicks == positionTicks && + _lastCommand!.command == command && + _lastCommand!.playlistItemId == playlistItemId; + } + + /// Guard rules before any playback correction attempt. + /// + /// Rules: + /// - only after `Unpause` command context + /// - skip while player is buffering/reloading + /// - skip when command playlist item does not match current item + bool canAttemptSyncCorrection(SyncPlayState currentState) { + final command = _lastCommand; + if (command == null) { + return false; + } + if (command.command != SyncPlayCommand.unpause) { + return false; + } + if (isBuffering?.call() == true) { + return false; + } + + final commandItemId = command.playlistItemId; + final currentItemId = currentState.playlistItemId; + if (commandItemId.isNotEmpty && currentItemId != null && commandItemId != currentItemId) { + return false; + } + + return true; + } + + void _scheduleCommand( + SyncPlayCommand command, + DateTime serverTime, + int positionTicks, + ) { + final timeSyncService = timeSync(); + if (timeSyncService == null) { + log('SyncPlay: Cannot schedule command without time sync'); + _executeCommand(command, positionTicks); + return; + } + + final localTime = timeSyncService.remoteDateToLocal(serverTime); + final now = DateTime.now().toUtc(); + final delay = localTime.difference(now); + + _commandTimer?.cancel(); + + // Drop commands too stale to act on. Executing them would + // extrapolate to positions far past EOF and start a buffer + // oscillation while the player chases an unreachable target. + if (delay.isNegative && -delay > _staleCommandThreshold) { + log('SyncPlay: Discarding stale ${command.wire} command ' + '(${(-delay).inSeconds}s late > ' + '${_staleCommandThreshold.inSeconds}s threshold). ' + 'Server StateUpdate will resync.'); + return; + } + + // Show processing indicator + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + + if (delay.isNegative) { + // Late but within the staleness threshold. Only Unpause should + // extrapolate the requested position by the elapsed delay — the + // group has been *playing* during that window. Pause/Seek/Stop + // are static targets: the original PositionTicks is the + // authoritative value regardless of how late the command + // arrives. Without this, a late Pause or Seek would seek to + // position+elapsed, often past EOF, which on libMPV/ExoPlayer + // triggers a real buffer cycle. + final ticksToUse = + command == SyncPlayCommand.unpause ? _estimateCurrentTicks(positionTicks, serverTime) : positionTicks; + log('SyncPlay: Executing late command: ${command.wire} ' + '(${delay.inMilliseconds}ms late)'); + _executeCommand(command, ticksToUse); + } else if (delay.inMilliseconds > 5000) { + log('SyncPlay: Warning - large delay: ${delay.inMilliseconds}ms'); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } else { + log('SyncPlay: Scheduling command: ${command.wire} ' + 'in ${delay.inMilliseconds}ms'); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } + } + + int _estimateCurrentTicks(int ticks, DateTime when) { + final timeSyncService = timeSync(); + if (timeSyncService == null) { + return ticks; + } + final remoteNow = timeSyncService.localDateToRemote(DateTime.now().toUtc()); + final elapsedMs = remoteNow.difference(when).inMilliseconds; + return ticks + millisecondsToTicks(elapsedMs); + } + + Future _executeCommand( + SyncPlayCommand command, + int positionTicks, + ) async { + log('SyncPlay: Executing command: ${command.wire} at $positionTicks ticks'); + + try { + switch (command) { + case SyncPlayCommand.pause: + await onPause?.call(); + // Only seek if position is significantly different (>1 sec). + final currentTicks = getPositionTicks?.call() ?? 0; + final needsCorrectionSeek = (positionTicks - currentTicks).abs() > ticksPerSecond; + if (needsCorrectionSeek) { + await onSeek?.call(positionTicks); + // Seek can put native ExoPlayer through STATE_BUFFERING; hold + // isProcessingCommand=true until that clears. Same rationale as + // the Unpause and Seek paths. + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(); + } + } + break; + + case SyncPlayCommand.unpause: + // Only seek if position is significantly different (>1 sec). + // Seek first, then play for smoother unpause alignment. + final currentTicks = getPositionTicks?.call() ?? 0; + if ((positionTicks - currentTicks).abs() > ticksPerSecond) { + await onSeek?.call(positionTicks); + } + await onPlay?.call(); + // Resuming from pause can put native ExoPlayer (Android-TV / + // leanback) through STATE_BUFFERING for several hundred ms + // while it primes the resumed buffer. Hold isProcessingCommand + // true for that window — otherwise the player-state listener + // leaks a stale Buffering report once the time-based cooldown + // expires, which forms a feedback loop in any SyncPlay group + // containing a TV. + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(); + } + break; + + case SyncPlayCommand.seek: + await onPause?.call(); + await onSeek?.call(positionTicks); + // Wait for the seek-induced buffering to clear before + // reporting Ready. The buffering listener in + // video_player_provider is suppressed while + // isProcessingCommand is true, so we own the Ready signal + // here. Without this wait the listener would fire a + // Ready(isPlaying:false) (we paused as part of the seek) + // that overrides the explicit Ready below — server would + // then keep the group paused instead of broadcasting + // Unpause, and the player would not auto-resume. + // + // Cap the wait at 2 s: libMPV (phone/web) keeps + // `paused-for-cache` true conservatively while the player + // is paused — it only flips to false once the cache is + // fully topped up, which can take many seconds even when + // there is plenty already buffered to play. ExoPlayer + // (Android-TV) settles seek-buffering well within 2 s, so + // shortening this timeout doesn't regress the TV path. If + // the cap is reached we still fire onReportReady; the + // server's Unpause then arrives normally and the next + // onPlay flips libMPV to "playing" mode where it emits + // buffering=false immediately. + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(timeout: const Duration(seconds: 2)); + } + await onReportReady?.call(); + break; + + case SyncPlayCommand.stop: + await onPause?.call(); + await onSeek?.call(0); + break; + } + } finally { + // Clear processing state after command completes + onStateUpdate((state) => state.copyWith( + isProcessingCommand: false, + processingCommandType: null, + )); + } + } + + /// Poll the [isBuffering] callback until it returns `false` or the + /// timeout expires. Used by the Seek command handler so the explicit + /// `onReportReady` fires only once the player has finished buffering. + Future _waitUntilNotBuffering({ + Duration timeout = const Duration(seconds: 10), + Duration pollInterval = const Duration(milliseconds: 100), + }) async { + final deadline = DateTime.now().add(timeout); + while (isBuffering?.call() == true && DateTime.now().isBefore(deadline)) { + await Future.delayed(pollInterval); + } + } + + /// Cancel any pending commands + void cancelPendingCommands() { + _commandTimer?.cancel(); + } + + /// Clear last command context used for duplicate detection and correction. + void clearLastCommand() { + _lastCommand = null; + } + + /// Dispose resources + void dispose() { + _commandTimer?.cancel(); + } +} diff --git a/lib/providers/syncplay/handlers/syncplay_message_handler.dart b/lib/providers/syncplay/handlers/syncplay_message_handler.dart new file mode 100644 index 000000000..9a7422a25 --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_message_handler.dart @@ -0,0 +1,336 @@ +import 'dart:developer'; + +import 'package:fladder/l10n/generated/app_localizations.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:flutter/material.dart'; + +/// Callback for reporting ready state after seek +typedef ReportReadyCallback = Future Function({bool isPlaying}); + +/// Callback for starting playback of an item +typedef StartPlaybackCallback = Future Function(String itemId, int startPositionTicks); + +/// Callback that pauses the local player without sending a SyncPlay +/// pause request. Used when the group enters Waiting because another +/// client is buffering — we must mirror the group state locally. +typedef LocalPauseCallback = Future Function(); + +/// Handles SyncPlay group update messages from WebSocket +class SyncPlayMessageHandler { + SyncPlayMessageHandler({ + required this.onStateUpdate, + required this.reportReady, + required this.startPlayback, + required this.isBuffering, + required this.getContext, + required this.onGroupJoined, + required this.onGroupJoinFailed, + this.onGroupLeftOrKicked, + this.onStateUpdateToPlaying, + this.onGroupGone, + this.onLocalPauseForBuffer, + }); + + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + final ReportReadyCallback reportReady; + final StartPlaybackCallback startPlayback; + final bool Function() isBuffering; + final BuildContext? Function() getContext; + final void Function() onGroupJoined; + final void Function() onGroupJoinFailed; + + /// Called when we leave or are kicked so controller can cancel pending commands and clear processing state. + final void Function()? onGroupLeftOrKicked; + + /// Called when group state becomes Playing so controller can ensure player is actually playing (per docs). + final void Function()? onStateUpdateToPlaying; + + /// Called when the user is no longer part of the group from the + /// server's perspective (kicked, group disposed, etc.) so that the + /// controller can surface a user-visible notification. + final void Function({required bool wasKicked})? onGroupGone; + + /// Called when the group enters Waiting because another client is + /// buffering. Mirrors the group state locally before reporting Ready + /// so we don't keep playing while the group is logically paused. + final LocalPauseCallback? onLocalPauseForBuffer; + + /// Handle group update message + void handleGroupUpdate(Map data, SyncPlayState currentState) { + _wasInGroupAtLastUpdate = currentState.isInGroup; + final updateType = data['Type'] as String?; + final updateData = data['Data']; + + switch (updateType) { + case 'GroupJoined': + _handleGroupJoined(updateData as Map); + break; + case 'UserJoined': + _handleUserJoined(updateData as String?, currentState); + break; + case 'UserLeft': + _handleUserLeft(updateData as String?, currentState); + break; + case 'GroupLeft': + _handleGroupLeft(); + break; + case 'GroupDoesNotExist': + _handleGroupDoesNotExist(); + break; + case 'NotInGroup': + _handleNotInGroup(); + break; + case 'StateUpdate': + _handleStateUpdate(updateData as Map); + break; + case 'PlayQueue': + _handlePlayQueue(updateData as Map, currentState); + break; + } + } + + void _handleGroupJoined(Map data) { + final groupId = data['GroupId'] as String?; + final groupName = data['GroupName'] as String?; + final stateStr = data['State'] as String?; + final participants = (data['Participants'] as List?)?.cast() ?? []; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final playingItemId = data['PlayingItemId'] as String?; + + onStateUpdate((state) => state.copyWith( + isInGroup: true, + groupId: groupId, + groupName: groupName, + groupState: _parseGroupState(stateStr), + participants: participants, + positionTicks: positionTicks, + playingItemId: playingItemId ?? state.playingItemId, + )); + + log('SyncPlay: Joined group "$groupName" ($groupId)'); + + // Notify controller that group join was confirmed + onGroupJoined(); + } + + /// Note: SyncPlay's `UserJoined` / `UserLeft` payloads carry the + /// participant's display name directly in `Data` (a plain string), + /// not a userId. No `usersUserIdGet` lookup is needed - calling that + /// endpoint with the username returns a 400. + void _handleUserJoined(String? userName, SyncPlayState currentState) { + if (userName == null) { + return; + } + // The server re-broadcasts `UserJoined` on every `Join` POST — + // including reconnects, silent rejoins and retries after a + // false-negative "Failed to join". Appending unconditionally is + // what stacked the same user multiple times. Ignore if already a + // participant. + if (currentState.participants.contains(userName)) { + log('SyncPlay: Duplicate UserJoined ignored (already a participant): $userName'); + return; + } + final participants = [...currentState.participants, userName]; + onStateUpdate((state) => state.copyWith(participants: participants)); + + _showSnackbar((l) => l.syncPlayUserJoined(userName)); + log('SyncPlay: User joined: $userName'); + } + + void _handleUserLeft(String? userName, SyncPlayState currentState) { + if (userName == null) { + return; + } + final participants = currentState.participants.where((p) => p != userName).toList(); + onStateUpdate((state) => state.copyWith(participants: participants)); + + _showSnackbar((l) => l.syncPlayUserLeft(userName)); + log('SyncPlay: User left: $userName'); + } + + /// Render a snackbar through the global notification overlay. We + /// deliberately do NOT pass the navigator-key context here: that + /// context lives under `Navigator` but not under any `Overlay`, so + /// `Overlay.of(context)` throws. `FladderSnack` keeps a stored root + /// context (set by `NotificationManagerInitializer`) that already + /// resolves to the root overlay. + void _showSnackbar(String Function(AppLocalizations l) builder) { + final context = getContext(); + if (context != null) { + FladderSnack.show(builder(context.localized)); + return; + } + try { + final loc = lookupAppLocalizations(const Locale('en')); + FladderSnack.show(builder(loc)); + } catch (_) { + // No fallback available - silently swallow. + } + } + + void _handleGroupLeft() { + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Left group'); + } + + void _handleGroupDoesNotExist() { + final wasInGroup = _wasInGroupAtLastUpdate; + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Group does not exist'); + + if (wasInGroup) { + onGroupGone?.call(wasKicked: false); + } + + // Notify controller that group join failed + onGroupJoinFailed(); + } + + void _handleNotInGroup() { + final wasInGroup = _wasInGroupAtLastUpdate; + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Not in group - server rejected operation'); + + if (wasInGroup) { + onGroupGone?.call(wasKicked: true); + } + + // Notify controller that group join failed + onGroupJoinFailed(); + } + + bool _wasInGroupAtLastUpdate = false; + + void _handleStateUpdate(Map data) { + final stateStr = data['State'] as String?; + final reasonStr = data['Reason'] as String?; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final newGroupState = _parseGroupState(stateStr); + final reason = SyncPlayStateReason.fromWire(reasonStr); + + onStateUpdate((state) => state.copyWith( + groupState: newGroupState, + stateReason: reasonStr, + positionTicks: positionTicks, + )); + + log('SyncPlay: State update: $stateStr (reason: $reasonStr, positionTicks: $positionTicks)'); + + if (newGroupState == SyncPlayGroupState.waiting) { + _handleWaitingState(reason); + } + + // Per docs: when state becomes Playing, ensure player is actually + // playing (recover if Unpause was missed). + if (newGroupState == SyncPlayGroupState.playing) { + onStateUpdateToPlaying?.call(); + } + } + + void _handleWaitingState(SyncPlayStateReason? reason) { + if (reason == SyncPlayStateReason.buffer) { + // Per spec: another client is buffering — pause locally first, then + // report ready so the server knows we're aligned. + final pauseFuture = onLocalPauseForBuffer?.call() ?? Future.value(); + pauseFuture.then((_) { + if (!isBuffering()) { + reportReady(isPlaying: true); + } + }); + return; + } + if (reason == SyncPlayStateReason.unpause) { + if (!isBuffering()) { + reportReady(isPlaying: true); + } + } + } + + void _handlePlayQueue(Map data, SyncPlayState currentState) { + final playlist = data['Playlist'] as List? ?? []; + final playingItemIndex = data['PlayingItemIndex'] as int? ?? 0; + final startPositionTicks = data['StartPositionTicks'] as int? ?? 0; + final isPlayingNow = data['IsPlaying'] as bool? ?? false; + final reason = data['Reason'] as String?; + + String? playingItemId; + String? playlistItemId; + + if (playlist.isNotEmpty && playingItemIndex < playlist.length) { + final item = playlist[playingItemIndex] as Map; + playingItemId = item['ItemId'] as String?; + playlistItemId = item['PlaylistItemId'] as String?; + } + + final previousItemId = currentState.playingItemId; + + onStateUpdate((state) => state.copyWith( + playingItemId: playingItemId, + playlistItemId: playlistItemId, + positionTicks: startPositionTicks, + )); + + log('SyncPlay: PlayQueue update - playing: $playingItemId (reason: $reason, isPlaying: $isPlayingNow, previousItemId: $previousItemId)'); + + // Trigger playback for NewPlaylist/SetCurrentItem/NextItem/PreviousItem regardless of + // whether the item changed (the same user who set the queue also receives the update + // and needs to start playing). + final shouldTrigger = playingItemId != null && + (reason == 'NewPlaylist' || + reason == 'SetCurrentItem' || + reason == 'NextItem' || + reason == 'PreviousItem' || + (playingItemId != previousItemId && isPlayingNow)); + + log('SyncPlay: shouldTrigger=$shouldTrigger (reason: $reason)'); + + if (shouldTrigger) { + log('SyncPlay: Triggering playback for item: $playingItemId'); + startPlayback(playingItemId, startPositionTicks); + } + } + + SyncPlayGroupState _parseGroupState(String? state) { + switch (state?.toLowerCase()) { + case 'idle': + return SyncPlayGroupState.idle; + case 'waiting': + return SyncPlayGroupState.waiting; + case 'paused': + return SyncPlayGroupState.paused; + case 'playing': + return SyncPlayGroupState.playing; + default: + return SyncPlayGroupState.idle; + } + } +} diff --git a/lib/providers/syncplay/syncplay.dart b/lib/providers/syncplay/syncplay.dart new file mode 100644 index 000000000..50eba95ae --- /dev/null +++ b/lib/providers/syncplay/syncplay.dart @@ -0,0 +1,30 @@ +/// SyncPlay - Synchronized playback for Jellyfin +/// +/// This module provides synchronized playback functionality allowing multiple +/// clients to watch media together in perfect synchronization. +/// +/// Main components: +/// - [SyncPlayController] - Core controller for SyncPlay operations +/// - [SyncPlayState] - Current state of the SyncPlay session +/// - [TimeSyncService] - NTP-like clock synchronization with server +/// +/// The Jellyfin WebSocket is no longer a SyncPlay component; it is the +/// app-level shared `JellyfinWebSocketController` +/// (`package:fladder/providers/websocket/jellyfin_websocket_provider.dart`), +/// which SyncPlay consumes. +/// +/// Usage: +/// ```dart +/// final syncPlay = ref.read(syncPlayProvider.notifier); +/// await syncPlay.connect(); +/// await syncPlay.createGroup('Movie Night'); +/// ``` +library; + +export 'package:fladder/models/syncplay/syncplay_models.dart'; + +export 'handlers/syncplay_command_handler.dart' + show SyncPlayPlayerCallback, SyncPlaySeekCallback, SyncPlayPositionCallback; +export 'syncplay_controller.dart'; +export 'syncplay_provider.dart'; +export 'time_sync_service.dart'; diff --git a/lib/providers/syncplay/syncplay_controller.dart b/lib/providers/syncplay/syncplay_controller.dart new file mode 100644 index 000000000..ecf4fc29a --- /dev/null +++ b/lib/providers/syncplay/syncplay_controller.dart @@ -0,0 +1,1347 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/router_provider.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:fladder/l10n/generated/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Controller for SyncPlay synchronized playback +class SyncPlayController { + static const bool _verboseSyncPlayLogs = false; + + SyncPlayController(this._ref) { + _commandHandler = SyncPlayCommandHandler( + timeSync: () => _timeSync, + onStateUpdate: _updateStateWith, + ); + _messageHandler = SyncPlayMessageHandler( + onStateUpdate: _updateStateWith, + reportReady: ({bool isPlaying = true}) => reportReady(isPlaying: isPlaying), + // Wrap _startPlayback so the loader-UX completer resolves as soon + // as the server's PlayQueue is received (i.e. our queue request + // was accepted and broadcast). The actual local load + // (loadPlaybackItem → media-kit) can then take its time without + // gating the dialog: media-kit on web sometimes leaves + // `state.loadVideo()` hanging which would otherwise let the + // 20s timeout fire and surface a misleading "unable to play + // media format" snack while playback is in fact already running. + startPlayback: (itemId, ticks) async { + final completer = _startPlaybackCompleter; + if (completer != null && !completer.isCompleted) { + log('SyncPlay: PlayQueue accepted - resolving loader ' + 'completer eagerly for item=$itemId'); + completer.complete(true); + } + await _startPlayback(itemId, ticks); + }, + isBuffering: () => _commandHandler.isBuffering?.call() ?? false, + getContext: () => getNavigatorKey(_ref)?.currentContext, + onGroupJoined: _onGroupJoined, + onGroupJoinFailed: _onGroupJoinFailed, + onGroupLeftOrKicked: _onGroupLeftOrKicked, + onStateUpdateToPlaying: _onStateUpdateToPlaying, + onGroupGone: ({required wasKicked}) => notifyGroupGone(wasKicked: wasKicked), + onLocalPauseForBuffer: () async { + final pause = _commandHandler.onPause; + if (pause != null && _commandHandler.isPlaying?.call() == true) { + log('SyncPlay: Pausing locally because another client is buffering'); + await pause(); + } + }, + ); + } + + final Ref _ref; + + TimeSyncService? _timeSync; + StreamSubscription? _wsMessageSubscription; + StreamSubscription? _wsStateSubscription; + Timer? _syncCorrectionTimer; + + late final SyncPlayCommandHandler _commandHandler; + late final SyncPlayMessageHandler _messageHandler; + + SyncPlayState _state = SyncPlayState(); + final _stateController = StreamController.broadcast(); + + Stream get stateStream => _stateController.stream; + + SyncPlayState get state => _state; + + // Lifecycle state for reconnection + String? _lastGroupId; + + // Previous WebSocket state — used to detect reconnect transitions in + // `_handleConnectionState` so we can silently rejoin the last group. + // The very first connect is naturally skipped because `_lastGroupId` + // is null until the user joins a group for the first time. + WebSocketConnectionState? _previousWsState; + + // Completer for waiting on group join confirmation + Completer? _joinGroupCompleter; + + // Completer that resolves the next time `_startPlayback` finishes + // (success or failure). Used by the loader UX for both initiator + // and receivers. + Completer? _startPlaybackCompleter; + + // PlaylistItemId currently being started (dedup against concurrent + // PlayQueue updates issued by simultaneous initiators). + String? _currentlyStartingPlaylistItemId; + Completer? _inFlightStartCompleter; + + // Debounce: timestamp of the last `setNewQueue` API call so two + // initiators don't fire two requests in the same second. + DateTime? _lastSetNewQueueAt; + + // Player callbacks (delegated to command handler) + set onPlay(SyncPlayPlayerCallback? callback) => _commandHandler.onPlay = callback; + + set onPause(SyncPlayPlayerCallback? callback) => _commandHandler.onPause = callback; + + set onSeek(SyncPlaySeekCallback? callback) => _commandHandler.onSeek = callback; + + set onStop(SyncPlayPlayerCallback? callback) => _commandHandler.onStop = callback; + + set getPositionTicks(SyncPlayPositionCallback? callback) => _commandHandler.getPositionTicks = callback; + + set isPlaying(bool Function()? callback) => _commandHandler.isPlaying = callback; + + set isBuffering(bool Function()? callback) => _commandHandler.isBuffering = callback; + + set onSeekRequested(SyncPlaySeekCallback? callback) => _commandHandler.onSeekRequested = callback; + + set onReportReady(SyncPlayReportReadyCallback? callback) => _commandHandler.onReportReady = callback; + + set onSetSpeed(SyncPlaySetSpeedCallback? callback) => _commandHandler.onSetSpeed = callback; + + set hasPlaybackRate(bool Function()? callback) => _commandHandler.hasPlaybackRate = callback; + + void log(String message) { + final isImportant = message.contains('Failed') || message.contains('Error') || message.contains('Cannot'); + if (_verboseSyncPlayLogs || isImportant) { + developer.log(message); + } + } + + /// Mark that a SyncPlay command was executed locally. + /// Used by player-side cooldown logic to avoid feedback loops. + void markCommandExecuted([DateTime? at]) { + _updateStateWith((state) => state.copyWith( + lastCommandTime: at ?? DateTime.now().toUtc(), + )); + } + + /// Update buffering/reloading status used by SyncPlay integration. + void setPlayerBufferingState(bool isBuffering) { + if (isBuffering) { + _syncCorrectionTimer?.cancel(); + _syncCorrectionTimer = null; + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed != null) { + unawaited( + setSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed while buffering: $error'); + }), + ); + } + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playerIsBuffering: true, + syncEnabled: false, + activeStrategy: SyncCorrectionStrategy.none, + ), + )); + return; + } + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playerIsBuffering: false, + syncEnabled: true, + ), + )); + } + + /// Reset correction strategy/state when commands are cleared, on stop, + /// or around rejoin flows. + void resetCorrectionState({ + String reason = 'reset', + bool syncEnabled = true, + }) { + _syncCorrectionTimer?.cancel(); + _syncCorrectionTimer = null; + + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed != null) { + unawaited( + setSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed during correction reset: $error'); + }), + ); + } + _commandHandler.clearLastCommand(); + + log('SyncPlay: Reset correction state ($reason)'); + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + activeStrategy: SyncCorrectionStrategy.none, + syncEnabled: syncEnabled, + playbackDiffMillis: 0, + syncAttempts: 0, + ), + )); + } + + /// Update current playback drift against estimated SyncPlay server time. + /// + /// Drift is computed as: + /// `estimatedServerPositionTicks - currentLocalPositionTicks`. + /// Positive means local player is behind, negative means ahead. + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + }) { + if (!_commandHandler.canAttemptSyncCorrection(_state)) { + return; + } + + final lastCommand = _commandHandler.lastCommand; + if (lastCommand == null) { + return; + } + + final when = DateTime.tryParse(lastCommand.when); + if (when == null) { + return; + } + + final now = (at ?? DateTime.now().toUtc()); + final remoteNow = _timeSync?.localDateToRemote(now) ?? now; + final elapsedMs = remoteNow.difference(when).inMilliseconds; + + final estimatedServerTicks = lastCommand.positionTicks + millisecondsToTicks(elapsedMs); + final diffTicks = estimatedServerTicks - currentPositionTicks; + final diffMillis = ticksToMilliseconds(diffTicks).toDouble(); + final correctionConfig = _state.correctionConfig; + final correctionState = _state.correctionState; + final strategy = selectSyncCorrectionStrategy( + config: correctionConfig, + state: correctionState, + diffMillis: diffMillis, + hasPlaybackRate: _commandHandler.hasPlaybackRate?.call() == true, + ); + + if (strategy == SyncCorrectionStrategy.speedToSync) { + _applySpeedToSync( + diffMillis: diffMillis, + config: correctionConfig, + now: now, + ); + return; + } + + if (strategy == SyncCorrectionStrategy.skipToSync) { + _applySkipToSync( + diffMillis: diffMillis, + targetPositionTicks: estimatedServerTicks, + config: correctionConfig, + now: now, + ); + return; + } + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playbackDiffMillis: diffMillis, + lastSyncAt: now, + ), + )); + } + + /// Estimate where the group's playhead is right now by extrapolating from + /// the last `Unpause`/`Seek` command timestamp. Falls back to + /// `state.positionTicks` if no command context is available — that value + /// is the position from the most recent state update, which may be tens + /// of seconds stale during continuous playback. + int estimateCurrentGroupPositionTicks() { + final lastCommand = _commandHandler.lastCommand; + final timeSyncService = _timeSync; + if (lastCommand == null || timeSyncService == null) { + return _state.positionTicks; + } + final when = DateTime.tryParse(lastCommand.when); + if (when == null) { + return _state.positionTicks; + } + // Only extrapolate from Unpause-style commands; Pause leaves the playhead + // frozen at the command's positionTicks. + if (lastCommand.command != SyncPlayCommand.unpause) { + return lastCommand.positionTicks; + } + final remoteNow = timeSyncService.localDateToRemote(DateTime.now().toUtc()); + final elapsedMs = remoteNow.difference(when).inMilliseconds; + return lastCommand.positionTicks + millisecondsToTicks(elapsedMs); + } + + void _applySpeedToSync({ + required double diffMillis, + required SyncCorrectionConfig config, + required DateTime now, + }) { + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed == null) { + return; + } + + var speedToSyncTimeMs = config.speedToSyncDurationMs; + const minSpeed = 0.2; + if (diffMillis <= -speedToSyncTimeMs * minSpeed) { + speedToSyncTimeMs = diffMillis.abs() / (1.0 - minSpeed); + } + + final rawSpeed = 1.0 + (diffMillis / speedToSyncTimeMs); + final speed = rawSpeed < minSpeed ? minSpeed : rawSpeed; + final resetDuration = Duration( + milliseconds: speedToSyncTimeMs.round(), + ); + + _syncCorrectionTimer?.cancel(); + unawaited( + setSpeed(speed).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to apply SpeedToSync rate: $error'); + }), + ); + log( + 'SyncPlay: SpeedToSync applied ' + '(speed=${speed.toStringAsFixed(2)}, ' + 'diffMs=${diffMillis.toStringAsFixed(1)})', + ); + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playbackDiffMillis: diffMillis, + lastSyncAt: now, + activeStrategy: SyncCorrectionStrategy.speedToSync, + syncEnabled: false, + syncAttempts: state.correctionState.syncAttempts + 1, + ), + )); + + _syncCorrectionTimer = Timer(resetDuration, () { + final resetSpeed = _commandHandler.onSetSpeed; + if (resetSpeed != null) { + unawaited( + resetSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed after SpeedToSync: $error'); + }), + ); + } + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + activeStrategy: SyncCorrectionStrategy.none, + syncEnabled: true, + ), + )); + }); + } + + void _applySkipToSync({ + required double diffMillis, + required int targetPositionTicks, + required SyncCorrectionConfig config, + required DateTime now, + }) { + final seek = _commandHandler.onSeek; + if (seek == null) { + return; + } + + _syncCorrectionTimer?.cancel(); + unawaited( + seek(targetPositionTicks).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to apply SkipToSync seek: $error'); + }), + ); + log( + 'SyncPlay: SkipToSync applied ' + '(targetTicks=$targetPositionTicks, ' + 'diffMs=${diffMillis.toStringAsFixed(1)})', + ); + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playbackDiffMillis: diffMillis, + lastSyncAt: now, + activeStrategy: SyncCorrectionStrategy.skipToSync, + syncEnabled: false, + syncAttempts: state.correctionState.syncAttempts + 1, + ), + )); + + final cooldownDuration = Duration( + milliseconds: (config.maxDelaySpeedToSyncMs / 2.0).round(), + ); + _syncCorrectionTimer = Timer(cooldownDuration, () { + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + activeStrategy: SyncCorrectionStrategy.none, + syncEnabled: true, + ), + )); + }); + } + + JellyfinOpenApi get _api => _ref.read(jellyApiProvider).api; + + /// Subscribe SyncPlay to the shared app-level WebSocket. + /// + /// The socket itself is owned by [JellyfinWebSocketController] and is + /// connected/disconnected off `userProvider`; SyncPlay only attaches + /// its message/state listeners and starts time-sync. + Future connect() async { + final user = _ref.read(userProvider); + if (user == null) { + log('SyncPlay: Cannot connect without user'); + return; + } + + // Activate the shared socket provider and grab its notifier. + final ws = _ref.read(jellyfinWebSocketControllerProvider.notifier); + + // Idempotent: if we are already subscribed, do nothing. (This path + // is hit every time the SyncPlay sheet re-opens via loadGroups().) + if (_wsStateSubscription != null) { + log('SyncPlay: connect() called but already subscribed; reusing shared socket'); + return; + } + + // Initialize time sync (SyncPlay-owned, not part of the socket). + _timeSync = TimeSyncService(_api); + _timeSync!.start(); + + _wsStateSubscription = ws.connectionState.listen(_handleConnectionState); + _wsMessageSubscription = ws.messages.listen(_handleMessage); + + // The shared socket is app-owned and is usually already connected by + // the time the user opens SyncPlay. The re-broadcast stream does not + // replay, so seed from the current state — otherwise `isConnected` + // would stay false and `joinGroup` would be blocked. + _handleConnectionState(ws.currentState); + } + + /// Detach SyncPlay from the shared WebSocket. + /// + /// Does NOT close the socket — it is app-owned and shared. Leaving a + /// SyncPlay group no longer tears down the connection. + Future disconnect() async { + resetCorrectionState( + reason: 'disconnect', + syncEnabled: false, + ); + await leaveGroup(); + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + await _wsMessageSubscription?.cancel(); + await _wsStateSubscription?.cancel(); + _wsMessageSubscription = null; + _wsStateSubscription = null; + _timeSync?.dispose(); + _timeSync = null; + _updateState(SyncPlayState()); + } + + /// List available SyncPlay groups + Future> listGroups() async { + try { + final response = await _api.syncPlayListGet(); + return response.body ?? []; + } catch (e) { + log('SyncPlay: Failed to list groups: $e'); + return []; + } + } + + /// Create a new SyncPlay group + Future createGroup(String groupName) async { + try { + final response = await _api.syncPlayNewPost( + body: NewGroupRequestDto(groupName: groupName), + ); + return response.body; + } catch (e) { + log('SyncPlay: Failed to create group: $e'); + return null; + } + } + + /// Join an existing SyncPlay group + /// Returns true only after receiving GroupJoined confirmation from WebSocket + Future joinGroup(String groupId) async { + // Check if already in a group + if (_state.isInGroup) { + log('SyncPlay: Already in a group, leaving first...'); + await leaveGroup(); + } + + // Check if WebSocket is connected + if (!_state.isConnected) { + log('SyncPlay: WebSocket not connected, cannot join group'); + return false; + } + + log('SyncPlay: Joining group: $groupId'); + final confirmed = await _sendJoinRequest(groupId); + // `_lastGroupId` is stamped in `_onGroupJoined` from the server + // frame (source of truth), so it is correct even if a slow socket + // makes this call reconcile/return before `GroupJoined` lands. + log(confirmed ? 'SyncPlay: Group join confirmed' : 'SyncPlay: Group join not confirmed'); + return confirmed; + } + + /// Send a Join request and wait for the matching `GroupJoined` + /// message via the completer. Caller is responsible for any pre/post + /// state management (e.g. `leaveGroup` first, `_lastGroupId` updates). + /// Used by both [joinGroup] and [_attemptSilentRejoin]. + Future _sendJoinRequest(String groupId) async { + final completer = _joinGroupCompleter = Completer(); + try { + await _api.syncPlayJoinPost( + body: JoinGroupRequestDto(groupId: groupId), + ); + final confirmed = await completer.future.timeout( + const Duration(seconds: 12), + onTimeout: () { + // The POST itself succeeded (no exception). Jellyfin keys + // SyncPlay membership by session and `Join` is idempotent, so + // a missing `GroupJoined` inside the window is almost always a + // slow/stalled WebSocket — not a real rejection. Genuine + // rejections arrive promptly as NotInGroup/GroupDoesNotExist + // and complete the completer `false` long before this fires. + // `_handleGroupJoined` also flips `isInGroup` whenever the + // frame eventually lands (or after a silent rejoin), so + // reconcile against the authoritative state instead of + // reporting a false "Failed to join group". + final joined = _state.isInGroup && _state.groupId == groupId; + log('SyncPlay: GroupJoined not received within timeout; ' + 'reconciled isInGroup=$joined for $groupId'); + return joined; + }, + ); + if (identical(_joinGroupCompleter, completer)) { + _joinGroupCompleter = null; + } + return confirmed; + } catch (e) { + log('SyncPlay: Failed to send join request: $e'); + if (identical(_joinGroupCompleter, completer)) { + _completeJoinRequest(false); + } + return false; + } + } + + /// Complete and clear the pending join completer exactly once. Safe to + /// call from the success path, the failure path, the leave/kick reset + /// path, and a late-arriving `GroupJoined` — without ever risking a + /// "Future already completed" or leaking a stale completer reference. + void _completeJoinRequest(bool joined) { + final completer = _joinGroupCompleter; + _joinGroupCompleter = null; + if (completer != null && !completer.isCompleted) { + completer.complete(joined); + } + } + + /// Called by message handler when GroupJoined is received. + /// + /// If the group is already playing/waiting/paused with an active item, + /// auto-attach the local player to it. This mirrors `jellyfin-web`'s + /// behavior — the group should not stall in Waiting because a fresh + /// joiner forgot to click "Resume Playback". + void _onGroupJoined() { + resetCorrectionState( + reason: 'group_joined', + syncEnabled: true, + ); + // Stamp `_lastGroupId` from the authoritative server frame, not the + // awaited `joinGroup` bool. A slow socket can make `_sendJoinRequest` + // time out (reconciled false) and only deliver `GroupJoined` after + // `joinGroup` already returned — without this, the reconnect + // silent-rejoin invariant (`_handleConnectionState`) would be lost + // even though we are genuinely in the group. This only fires on a + // real `GroupJoined`, so it is still "only on confirmation". + _lastGroupId = _state.groupId ?? _lastGroupId; + _completeJoinRequest(true); + final showSnackbar = _state.groupName != null; + if (showSnackbar) { + _showGroupSnackbar( + (l) => l.syncPlayJoinedGroup(_state.groupName ?? ''), + ); + } + + final hasActiveItem = _state.playingItemId != null && + (_state.groupState == SyncPlayGroupState.playing || + _state.groupState == SyncPlayGroupState.waiting || + _state.groupState == SyncPlayGroupState.paused); + + if (hasActiveItem) { + // If the local player is already showing this exact item, skip + // the reload. This is the silent-rejoin path (WS dropped during + // a brief doze, user is still mid-playback locally): reloading + // would tear the player back to ticks=0 and the subsequent + // reportReady(positionTicks: 0) inside loadPlaybackItem would + // be broadcast by the server as the new group position, + // resetting every other client. The server's authoritative + // position is preserved server-side; the next Pause/Unpause/Seek + // command will resync our local player without a reload. + final currentPlayback = _ref.read(playBackModel); + final alreadyLoaded = currentPlayback?.item.id == _state.playingItemId; + if (alreadyLoaded) { + log('SyncPlay: Joined group with active item ${_state.playingItemId} ' + 'already loaded locally; skipping reload (silent rejoin)'); + } else { + log('SyncPlay: Joined group with active item ${_state.playingItemId} ' + '(state=${_state.groupState.name}); auto-loading playback'); + unawaited(rejoinPlayback()); + } + } + } + + /// Called by message handler when NotInGroup/GroupDoesNotExist is received + void _onGroupJoinFailed() { + _completeJoinRequest(false); + } + + /// Called when we leave or are kicked; cancel pending commands, + /// clear processing state and stop any local playback that was + /// driven by the previous group. Without the local stop, the player + /// keeps the old media loaded in the background and a later + /// `Unpause` command from a *different* group would resume it. + void _onGroupLeftOrKicked() { + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'group_left_or_kicked', + syncEnabled: false, + ); + _updateStateWith((s) => s.copyWith( + isProcessingCommand: false, + processingCommandType: null, + playingItemId: null, + playlistItemId: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + _stopLocalPlayback(); + } + + /// Stop and dispose the local video player & playback model so no + /// leftover media can resume after the SyncPlay session ended. + /// + /// Deferred to a microtask: this method is reached from a synchronous + /// chain that started inside a WebSocket message handler — the + /// `_stateController.add(_state)` from `_updateStateWith` fires the + /// Riverpod-side `state = newState` listener synchronously, which in + /// turn re-enters every `syncPlayProvider` listener (including + /// `videoPlayerProvider`). Reading `videoPlayerProvider` / + /// `playBackModel.notifier` while that re-entrant chain is still on + /// the stack throws `CircularDependencyError`. Yielding to a microtask + /// breaks out of that chain without changing observable behaviour: + /// the player is still stopped, the playback model is still cleared. + void _stopLocalPlayback() { + Future.microtask(() { + try { + unawaited(_ref.read(videoPlayerProvider).stop()); + _ref.read(playBackModel.notifier).update((_) => null); + _ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + } catch (e) { + log('SyncPlay: Failed to stop local playback after leave: $e'); + } + }); + } + + /// Returns `true` once the user has left or been kicked while a + /// long-running playback start is in progress. Callers must check this + /// between every `await` so they don't push a player route or resume + /// media for a group we no longer belong to. + bool _shouldAbortStartPlayback() => !_state.isInGroup; + + /// Clear all in-memory bookkeeping that is only meaningful while in a + /// group. Called from `leaveGroup`, `_onGroupLeftOrKicked`, and + /// `disconnect` so that a subsequent rejoin starts from a clean slate. + /// + /// In particular: `_lastSetNewQueueAt` was previously leaking past + /// leaveGroup, which silently debounced the first `setNewQueue` after + /// rejoin within 1s. + void _resetGroupLifecycleState() { + _lastSetNewQueueAt = null; + _currentlyStartingPlaylistItemId = null; + _inFlightStartCompleter = null; + if (_startPlaybackCompleter != null && !_startPlaybackCompleter!.isCompleted) { + _startPlaybackCompleter!.complete(false); + } + _startPlaybackCompleter = null; + _completeJoinRequest(false); + } + + /// When server reports Playing, ensure player is actually playing (per docs: recover if Unpause command was missed). + void _onStateUpdateToPlaying() { + if (_commandHandler.isPlaying?.call() != true) { + log('SyncPlay: State is Playing but player not playing, triggering play'); + _commandHandler.onPlay?.call(); + } + } + + /// Leave the current SyncPlay group. + /// Resets processing state and cancels pending commands so playback is not stuck (per docs). + Future leaveGroup() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayLeavePost(); + _lastGroupId = null; + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'leave_group', + syncEnabled: false, + ); + _updateState(_state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + positionTicks: 0, + playlistItemId: null, + playingItemId: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + _stopLocalPlayback(); + log('SyncPlay: Left group, state reset'); + } catch (e) { + log('SyncPlay: Failed to leave group: $e'); + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'leave_group_failed_local_reset', + syncEnabled: false, + ); + _updateState(_state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + playingItemId: null, + playlistItemId: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + _stopLocalPlayback(); + } + } + + /// Request pause + Future requestPause() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayPausePost(); + } catch (e) { + log('SyncPlay: Failed to request pause: $e'); + } + } + + /// Request unpause/play (server will move to Waiting until all clients report Ready, then broadcast Unpause). + Future requestUnpause() async { + if (!_state.isInGroup) { + return; + } + try { + log('SyncPlay: Sending Unpause request'); + await _api.syncPlayUnpausePost(); + } catch (e) { + log('SyncPlay: Failed to request unpause: $e'); + } + } + + /// Request seek + Future requestSeek(int positionTicks) async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlaySeekPost( + body: SeekRequestDto(positionTicks: positionTicks), + ); + } catch (e) { + log('SyncPlay: Failed to request seek: $e'); + } + } + + /// Advance to the next item in the SyncPlay queue. + /// + /// Mirrors jellyfin-web's lightweight flow: the server only swaps the + /// current playlist index and broadcasts a `PlayQueue` update with + /// reason=`NextItem`. Pass the current `playlistItemId` so the server + /// can reject the request if our view of the queue is stale. + Future requestNextItem() async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot request NextItem - not in group'); + return; + } + final currentPlaylistItemId = _state.playlistItemId; + if (currentPlaylistItemId == null) { + log('SyncPlay: Cannot request NextItem - no current playlist item'); + return; + } + try { + await _api.syncPlayNextItemPost( + body: NextItemRequestDto(playlistItemId: currentPlaylistItemId), + ); + } catch (e) { + log('SyncPlay: Failed to request NextItem: $e'); + } + } + + /// Step back to the previous item in the SyncPlay queue. Symmetric with + /// [requestNextItem]. + Future requestPreviousItem() async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot request PreviousItem - not in group'); + return; + } + final currentPlaylistItemId = _state.playlistItemId; + if (currentPlaylistItemId == null) { + log('SyncPlay: Cannot request PreviousItem - no current playlist item'); + return; + } + try { + await _api.syncPlayPreviousItemPost( + body: PreviousItemRequestDto(playlistItemId: currentPlaylistItemId), + ); + } catch (e) { + log('SyncPlay: Failed to request PreviousItem: $e'); + } + } + + /// Report buffering state. + /// + /// No-op while a local-only operation is active (track switch) so + /// changing audio/subtitle locally does not pause the group. + /// Report Buffering to the server. By default the position reported + /// is the local player's current position; pass [positionTicks] to + /// override (e.g. during a rejoin/initial-load where the local player + /// is at 0 but the group is mid-playback — sending 0 there would + /// make the server use it as the group's position and reset every + /// other client to the start). + Future reportBuffering({int? positionTicks}) async { + if (!_state.isInGroup) { + return; + } + if (_state.isInLocalOnlyMode) { + log('SyncPlay: Skipping reportBuffering (local-only mode)'); + return; + } + try { + final when = _timeSync?.localDateToRemote(DateTime.now().toUtc()); + final ticks = positionTicks ?? _commandHandler.getPositionTicks?.call() ?? 0; + await _api.syncPlayBufferingPost( + body: BufferRequestDto( + when: when, + positionTicks: ticks, + isPlaying: false, + playlistItemId: _state.playlistItemId, + ), + ); + } catch (e) { + log('SyncPlay: Failed to report buffering: $e'); + } + } + + /// Report ready state (required for server to broadcast Unpause when + /// in Waiting). Suppressed while local-only mode is active. Pass + /// [positionTicks] to override the position sent — same rationale as + /// [reportBuffering]. + Future reportReady({bool isPlaying = true, int? positionTicks}) async { + if (!_state.isInGroup) { + return; + } + if (_state.isInLocalOnlyMode) { + log('SyncPlay: Skipping reportReady (local-only mode)'); + return; + } + try { + final when = _timeSync?.localDateToRemote(DateTime.now().toUtc()); + final ticks = positionTicks ?? _commandHandler.getPositionTicks?.call() ?? 0; + log('SyncPlay: Reporting Ready (isPlaying=$isPlaying, positionTicks=$ticks)'); + await _api.syncPlayReadyPost( + body: ReadyRequestDto( + when: when, + positionTicks: ticks, + isPlaying: isPlaying, + playlistItemId: _state.playlistItemId, + ), + ); + } catch (e) { + log('SyncPlay: Failed to report ready: $e'); + } + } + + /// Run [body] as a "local-only" operation. While this runs the + /// controller will not emit `Buffering`/`Ready` to the server and + /// will trigger an immediate drift correction on completion so the + /// local player catches up to the group time after a track reload. + /// + /// If the group is in `Playing` state when the operation finishes, + /// the local player is explicitly resumed: media-kit on web does + /// not reliably auto-play after `loadVideo` + `setAudioTrack` / + /// `setSubtitleTrack`, and since we suppress `Buffering`/`Ready` + /// reports the server never re-issues an `Unpause` command we could + /// piggy-back on. + Future runLocalOnly(Future Function() body) async { + final wasPlaying = _commandHandler.isPlaying?.call() ?? false; + _updateStateWith( + (state) => state.copyWith( + localOnlyOperationCount: state.localOnlyOperationCount + 1, + ), + ); + try { + return await body(); + } finally { + _updateStateWith( + (state) => state.copyWith( + localOnlyOperationCount: (state.localOnlyOperationCount - 1).clamp(0, 1 << 30), + ), + ); + + final shouldResume = wasPlaying || _state.groupState == SyncPlayGroupState.playing; + if (shouldResume && _state.localOnlyOperationCount == 0 && _commandHandler.isPlaying?.call() == false) { + log('SyncPlay: Resuming local playback after local-only switch'); + try { + await _commandHandler.onPlay?.call(); + } catch (e) { + log('SyncPlay: Failed to resume after local-only switch: $e'); + } + } + + final ticks = _commandHandler.getPositionTicks?.call() ?? 0; + updatePlaybackDrift(currentPositionTicks: ticks); + } + } + + /// Report ping to server + Future reportPing() async { + if (!_state.isInGroup || _timeSync == null) { + return; + } + try { + await _api.syncPlayPingPost( + body: PingRequestDto(ping: _timeSync!.ping.inMilliseconds), + ); + } catch (e) { + log('SyncPlay: Failed to report ping: $e'); + } + } + + /// Set a new queue/playlist. + /// + /// Debounced to 1 second so two participants cannot race the same + /// `setNewQueue` request and crash the player by triggering two + /// concurrent `_startPlayback` flows. + /// Returns `true` when the request was actually sent to the server, + /// `false` when it was suppressed (not in group, or debounced). + /// Callers awaiting the next `_startPlayback` (e.g. the loader UX in + /// `_playSyncPlay`) need this to avoid waiting for a `PlayQueue` + /// broadcast that will never arrive. + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + int startPositionTicks = 0, + }) async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot set queue - not in group'); + return false; + } + final now = DateTime.now().toUtc(); + final lastAt = _lastSetNewQueueAt; + if (lastAt != null && now.difference(lastAt) < const Duration(seconds: 1)) { + log('SyncPlay: Ignoring setNewQueue (debounced, last call ' + '${now.difference(lastAt).inMilliseconds}ms ago)'); + return false; + } + _lastSetNewQueueAt = now; + try { + final body = PlayRequestDto( + playingQueue: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + log('SyncPlay: Setting new queue: ${body.toJson()}'); + final response = await _api.syncPlaySetNewQueuePost(body: body); + log('SyncPlay: SetNewQueue response: ${response.statusCode} - ${response.body}'); + return true; + } catch (e) { + log('SyncPlay: Failed to set new queue: $e'); + _lastSetNewQueueAt = null; + return false; + } + } + + /// Returns a Future that completes the next time `_startPlayback` + /// finishes. Used by the loader UX (initiator path). + /// + /// Resolves to `true` on successful playback start, `false` on + /// error or timeout. + Future awaitNextStartPlayback({ + Duration timeout = const Duration(seconds: 20), + }) { + final completer = _startPlaybackCompleter ??= Completer(); + return completer.future.timeout( + timeout, + onTimeout: () { + log('SyncPlay: awaitNextStartPlayback TIMED OUT after ' + '${timeout.inSeconds}s (no _startPlayback completion)'); + return false; + }, + ).then((value) { + log('SyncPlay: awaitNextStartPlayback resolved with success=$value'); + return value; + }); + } + + /// Re-attach to the currently playing group item from outside the + /// player route. Re-uses [_startPlayback] with the current group + /// position so the local player jumps back into the running session. + Future rejoinPlayback() async { + final itemId = _state.playingItemId; + if (!_state.isInGroup || itemId == null) { + log('SyncPlay: rejoinPlayback called but no active item in group'); + return false; + } + // Extrapolate the live group position from the last Unpause command + // rather than reading the stale `_state.positionTicks` (which only + // updates on server-broadcast state changes, not continuous + // playback). Reporting the stale value to the server triggers a + // catch-up Unpause that schedules every other client to seek BACK + // to that stale position — visible as "everyone restarted to the + // beginning" from the perspective of clients that never paused. + final positionTicks = estimateCurrentGroupPositionTicks(); + final pending = awaitNextStartPlayback(); + log('SyncPlay: Rejoining playback for item=$itemId, ' + 'positionTicks=$positionTicks (estimated live)'); + unawaited(_startPlayback(itemId, positionTicks)); + return pending; + } + + void _handleConnectionState(WebSocketConnectionState wsState) { + log('SyncPlay: WebSocket connection state: $wsState'); + final isConnected = wsState == WebSocketConnectionState.connected; + _updateState(_state.copyWith(isConnected: isConnected)); + log('SyncPlay: isConnected updated to: $isConnected'); + + // Detect a reconnect: previous state was not-connected and we are + // now connected. The initial connect after `connect()` falls into + // this branch too, but the `_lastGroupId != null` guard below makes + // it a no-op until the user has actually been in a group. + final wasConnected = _previousWsState == WebSocketConnectionState.connected; + final isReconnect = isConnected && !wasConnected; + _previousWsState = wsState; + + if (isReconnect) { + // A fresh socket connection (initial or reconnect) may have a + // stale clock offset. Refresh time-sync on every reconnect — a + // safe superset of the old resume-only refresh that used to live + // in the now-deleted _handleAppResume(). + if (_timeSync != null) { + _timeSync!.start(); + unawaited(_timeSync!.forceUpdate()); + } + + if (_lastGroupId != null) { + // ColorOS / aggressive Android battery savers can drop the + // WebSocket during a brief window-focus loss — without a + // corresponding `AppLifecycleState.paused` — so the lifecycle + // observer can't catch it. Auto-rejoin here covers that case + // and runs even when the app stayed in the foreground. + log('SyncPlay: WS reconnected, attempting silent rejoin of $_lastGroupId'); + unawaited(_attemptSilentRejoin()); + } + } + } + + /// Rejoin the last-known group without going through `joinGroup`'s + /// "leave first" path. Used after a transparent WebSocket reconnect + /// where the local `isInGroup` flag may still be true (we never got + /// a NotInGroup signal during the disconnect window) but the server + /// may have already evicted us. The server is the source of truth: + /// if it accepts the join, GroupJoined fires normally; if not, the + /// existing NotInGroup / GroupDoesNotExist handlers run. + Future _attemptSilentRejoin() async { + final groupId = _lastGroupId; + if (groupId == null) { + return; + } + if (!_state.isConnected) { + log('SyncPlay: WS not connected, skipping silent rejoin'); + return; + } + final confirmed = await _sendJoinRequest(groupId); + if (confirmed) { + log('SyncPlay: Silent rejoin confirmed'); + } else { + log('SyncPlay: Silent rejoin not confirmed; clearing _lastGroupId'); + _lastGroupId = null; + } + } + + void _handleMessage(Map message) { + final messageType = message['MessageType'] as String?; + final data = message['Data']; + + log('SyncPlay: Received WebSocket message: $messageType'); + + switch (messageType) { + case 'SyncPlayCommand': + final cmd = (data as Map)['Command'] as String?; + log('SyncPlay: Received SyncPlayCommand: $cmd'); + _commandHandler.handleCommand(data, _state); + break; + case 'SyncPlayGroupUpdate': + log('SyncPlay: GroupUpdate data: $data'); + _messageHandler.handleGroupUpdate(data as Map, _state); + break; + default: + // Log unhandled message types for debugging + if (messageType?.startsWith('SyncPlay') == true) { + log('SyncPlay: Unhandled SyncPlay message type: $messageType'); + } + } + } + + /// Start playback of an item from SyncPlay. + /// + /// Guards against re-entrancy: if a `_startPlayback` is already in + /// flight for the same playlist item, the duplicate call is ignored + /// (this is the crash fix when two participants press play at the + /// same time and the server broadcasts two PlayQueue updates back to + /// back). If a different item is already starting, we wait for it + /// to finish before kicking off the new one. + Future _startPlayback(String itemId, int startPositionTicks) async { + final dedupKey = _state.playlistItemId ?? itemId; + if (_state.startPlaybackInProgress) { + if (_currentlyStartingPlaylistItemId == dedupKey) { + log('SyncPlay: _startPlayback skipped (already starting $dedupKey)'); + return; + } + log('SyncPlay: _startPlayback waiting for previous start to finish'); + try { + await _inFlightStartCompleter?.future.timeout(const Duration(seconds: 15)); + } catch (_) { + // Fall through and try our own start anyway. + } + } + + final localCompleter = _startPlaybackCompleter ??= Completer(); + _inFlightStartCompleter = Completer(); + _currentlyStartingPlaylistItemId = dedupKey; + _updateStateWith((state) => state.copyWith( + startPlaybackInProgress: true, + startingPlaylistItemId: dedupKey, + )); + log('SyncPlay: _startPlayback called for item: $itemId, ticks: $startPositionTicks'); + + var success = false; + try { + final playerRouteAlreadyOpen = _ref.read(isVideoPlayerRouteOpenProvider); + log('SyncPlay: Player route already open: $playerRouteAlreadyOpen'); + + // Clear the old playback model BEFORE re-initializing. This prevents + // the fire-and-forget stop() inside _initPlayer() from entering a + // 1-second delayed playbackStopped flow that races against the new + // loadPlaybackItem call (which also calls stop()). With playBackModel + // null, every stop() becomes a no-op. + if (!playerRouteAlreadyOpen) { + _ref.read(playBackModel.notifier).update((state) => null); + await _ref.read(videoPlayerProvider.notifier).init(); + } + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after init (left group)'); + return; + } + + // Fetch the item from Jellyfin + log('SyncPlay: Fetching item from API...'); + final api = _ref.read(jellyApiProvider); + final itemResponse = await api.usersUserIdItemsItemIdGet(itemId: itemId); + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after item fetch (left group)'); + return; + } + final itemModel = itemResponse.body; + + if (itemModel == null) { + log('SyncPlay: Failed to fetch item $itemId - response body was null'); + return; + } + log('SyncPlay: Fetched item: ${itemModel.name}'); + + // Create playback model (context is optional - null for SyncPlay auto-play) + log('SyncPlay: Creating playback model...'); + final playbackHelper = _ref.read(playbackModelHelper); + final startPosition = Duration(microseconds: startPositionTicks ~/ 10); + + final playbackModel = await playbackHelper.createPlaybackModel( + null, // No context needed for SyncPlay + itemModel, + startPosition: startPosition, + ); + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after playback model (left group)'); + return; + } + + if (playbackModel == null) { + log('SyncPlay: Failed to create playback model for $itemId'); + return; + } + log('SyncPlay: Playback model created successfully'); + + // Load and play + log('SyncPlay: Loading playback item...'); + final loadedCorrectly = await _ref.read(videoPlayerProvider.notifier).loadPlaybackItem( + playbackModel, + startPosition, + ); + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after loadPlaybackItem (left group)'); + // The player loaded media for a group we no longer belong to — + // tear it back down so we don't display the abandoned video. + _stopLocalPlayback(); + return; + } + + if (!loadedCorrectly) { + log('SyncPlay: Failed to load playback item $itemId'); + return; + } + success = true; + log('SyncPlay: Playback item loaded successfully'); + + // Set state to fullScreen + _ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.fullScreen), + ); + log('SyncPlay: Set state to fullScreen'); + + // Only push the player route when it isn't already on screen. + // When the route is already open (e.g. User B whose player stayed + // open), loadPlaybackItem already swapped the video content in the + // existing player — pushing again would stack duplicate routes. + if (!playerRouteAlreadyOpen) { + final navigatorKey = getNavigatorKey(_ref); + final context = navigatorKey?.currentContext; + log('SyncPlay: Navigator context: ${context != null ? "exists" : "null"}'); + + if (context != null && !_shouldAbortStartPlayback()) { + // openPlayer pushes a route via Navigator.push, whose Future + // does not complete until the route is popped (i.e. the user + // closes the player). Awaiting it would hold _startPlayback + // open for as long as the player is visible — and with it + // startPlaybackInProgress and the "Switching item…" overlay. + // Fire-and-forget so we exit the load phase immediately. + unawaited(_ref.read(videoPlayerProvider.notifier).openPlayer(context)); + log('SyncPlay: Pushed player route for $itemId'); + } else { + log('SyncPlay: No navigator context available, player loaded but not opened fullscreen'); + } + } else { + log('SyncPlay: Player route already open, video reloaded in place'); + } + } catch (e, stackTrace) { + log('SyncPlay: Error starting playback: $e\n$stackTrace'); + } finally { + _currentlyStartingPlaylistItemId = null; + _updateStateWith((state) => state.copyWith( + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + if (!success) { + // Failure or aborted-on-leave: clear the buffering flag so the rest + // of the group is not stranded waiting on us. + setPlayerBufferingState(false); + if (_state.isInGroup) { + unawaited(reportReady(isPlaying: false)); + } + } + _inFlightStartCompleter?.complete(); + _inFlightStartCompleter = null; + if (!localCompleter.isCompleted) { + localCompleter.complete(success); + } + _startPlaybackCompleter = null; + } + } + + void _updateState(SyncPlayState newState) { + _state = newState; + _stateController.add(newState); + } + + void _updateStateWith(SyncPlayState Function(SyncPlayState) updater) { + _state = updater(_state); + _stateController.add(_state); + } + + /// Display a SyncPlay-related snackbar through the global overlay. + /// We never pass the navigator-key context to `FladderSnack`: that + /// context isn't under any `Overlay`. The notification manager keeps + /// a stored root context (set by `NotificationManagerInitializer`) + /// that resolves to the root overlay. + void _showGroupSnackbar(String Function(AppLocalizations l) message) { + try { + final loc = lookupAppLocalizations(const Locale('en')); + FladderSnack.show(message(loc)); + } catch (_) { + // Best effort - ignore if localizations are unavailable. + } + } + + /// Notify listeners (and overlays) that we got kicked out of a group + /// while still believing we belonged to it. + void notifyGroupGone({bool wasKicked = false}) { + _showGroupSnackbar( + (l) => wasKicked ? l.syncPlayKickedFromGroup : l.syncPlayGroupNoLongerExists, + ); + } + + /// Dispose resources + Future dispose() async { + _commandHandler.dispose(); + await disconnect(); + await _stateController.close(); + } +} diff --git a/lib/providers/syncplay/syncplay_provider.dart b/lib/providers/syncplay/syncplay_provider.dart new file mode 100644 index 000000000..9dab6a6af --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/syncplay_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'syncplay_provider.freezed.dart'; +part 'syncplay_provider.g.dart'; + +/// Provider for SyncPlay controller instance +@Riverpod(keepAlive: true) +class SyncPlay extends _$SyncPlay { + SyncPlayController? _controller; + StreamSubscription? _stateSubscription; + + @override + SyncPlayState build() { + ref.onDispose(() { + _stateSubscription?.cancel(); + _controller?.dispose(); + }); + return SyncPlayState(); + } + + SyncPlayController get controller { + _controller ??= SyncPlayController(ref); + return _controller!; + } + + /// Initialize and connect to SyncPlay WebSocket + Future connect() async { + await controller.connect(); + _stateSubscription?.cancel(); + _stateSubscription = controller.stateStream.listen((newState) { + state = newState; + }); + } + + /// Disconnect from SyncPlay + Future disconnect() async { + await controller.disconnect(); + state = SyncPlayState(); + } + + /// List available SyncPlay groups + Future> listGroups() => controller.listGroups(); + + /// Create a new SyncPlay group + Future createGroup(String groupName) => controller.createGroup(groupName); + + /// Join an existing group + Future joinGroup(String groupId) => controller.joinGroup(groupId); + + /// Leave current group + Future leaveGroup() => controller.leaveGroup(); + + /// Request pause + Future requestPause() => controller.requestPause(); + + /// Request unpause/play + Future requestUnpause() async => await controller.requestUnpause(); + + /// Request seek + Future requestSeek(int positionTicks) => controller.requestSeek(positionTicks); + + /// Advance to the next item in the SyncPlay queue. + Future requestNextItem() => controller.requestNextItem(); + + /// Step back to the previous item in the SyncPlay queue. + Future requestPreviousItem() => controller.requestPreviousItem(); + + /// Report buffering state. See [SyncPlayController.reportBuffering]. + Future reportBuffering({int? positionTicks}) => controller.reportBuffering(positionTicks: positionTicks); + + /// Report ready state. See [SyncPlayController.reportReady]. + Future reportReady({bool isPlaying = true, int? positionTicks}) => + controller.reportReady(isPlaying: isPlaying, positionTicks: positionTicks); + + /// Mark local execution of a SyncPlay command for cooldown handling. + void markCommandExecuted([DateTime? at]) => controller.markCommandExecuted(at); + + /// Update buffering/reloading status inside SyncPlay state. + void setPlayerBufferingState(bool isBuffering) => controller.setPlayerBufferingState(isBuffering); + + /// Reset correction state and timers. + void resetCorrectionState({ + String reason = 'manual', + bool syncEnabled = true, + }) => + controller.resetCorrectionState( + reason: reason, + syncEnabled: syncEnabled, + ); + + /// Update playback drift using current local position ticks. + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + }) => + controller.updatePlaybackDrift( + currentPositionTicks: currentPositionTicks, + at: at, + ); + + /// Estimate the group's current playhead position in ticks. See + /// [SyncPlayController.estimateCurrentGroupPositionTicks]. + int estimateCurrentGroupPositionTicks() => controller.estimateCurrentGroupPositionTicks(); + + /// Returns a Future that completes the next time `_startPlayback` + /// finishes (success or failure). Used by the loader UX. + Future awaitNextStartPlayback({ + Duration timeout = const Duration(seconds: 20), + }) => + controller.awaitNextStartPlayback(timeout: timeout); + + /// Re-attach to the currently playing group item from outside the + /// player route ("Resume playback" button). + Future rejoinPlayback() => controller.rejoinPlayback(); + + /// Run [body] while suppressing `Buffering`/`Ready` reports so the + /// rest of the group is not paused (used for audio/subtitle reload). + Future runLocalOnly(Future Function() body) => controller.runLocalOnly(body); + + /// Set a new queue/playlist. Returns `true` when the request was + /// actually sent to the server, `false` if it was suppressed. + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + int startPositionTicks = 0, + }) => + controller.setNewQueue( + itemIds: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + + /// Register player callbacks + void registerPlayer({ + required Future Function() onPlay, + required Future Function() onPause, + required Future Function(int positionTicks) onSeek, + required Future Function() onStop, + required Future Function(double speed) onSetSpeed, + required int Function() getPositionTicks, + required bool Function() isPlaying, + required bool Function() isBuffering, + required bool Function() hasPlaybackRate, + Future Function(int positionTicks)? onSeekRequested, + }) { + controller.onPlay = onPlay; + controller.onPause = onPause; + controller.onSeek = onSeek; + controller.onStop = onStop; + controller.onSetSpeed = onSetSpeed; + controller.getPositionTicks = getPositionTicks; + controller.isPlaying = isPlaying; + controller.isBuffering = isBuffering; + controller.hasPlaybackRate = hasPlaybackRate; + controller.onSeekRequested = onSeekRequested; + // Wire up reportReady callback so command handler can report ready after seek + controller.onReportReady = () => controller.reportReady(); + } + + /// Unregister player callbacks + void unregisterPlayer() { + controller.onPlay = null; + controller.onPause = null; + controller.onSeek = null; + controller.onStop = null; + controller.onSetSpeed = null; + controller.getPositionTicks = null; + controller.isPlaying = null; + controller.isBuffering = null; + controller.hasPlaybackRate = null; + controller.onSeekRequested = null; + controller.onReportReady = null; + } +} + +/// Provider to check if currently in a SyncPlay session +@riverpod +bool isSyncPlayActive(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.isActive)); +} + +/// Provider for current SyncPlay group name +@riverpod +String? syncPlayGroupName(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupName)); +} + +/// Provider for SyncPlay group state +@riverpod +SyncPlayGroupState syncPlayGroupState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupState)); +} + +/// Provider for SyncPlay correction runtime state (UI + diagnostics). +@riverpod +SyncCorrectionState syncCorrectionState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState)); +} + +/// Provider for active correction strategy. +@riverpod +SyncCorrectionStrategy syncCorrectionStrategy(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState.activeStrategy)); +} + +/// True when a SyncPlay-driven `_startPlayback` is currently in flight +/// (initial play, episode switch, rejoin). UI can use this to display +/// a loading indicator while the local player is being prepared. +@riverpod +bool syncPlayStartPlaybackInProgress(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.startPlaybackInProgress)); +} + +/// True when the group has an active item the local user could +/// resume from outside the player route. +@riverpod +bool syncPlayHasActivePlayback(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.hasActivePlayback)); +} + +/// Immutable state for the SyncPlay groups list (used by group sheet). +/// Lists are stored unmodifiable so the state cannot be mutated. +@Freezed(copyWith: true) +abstract class SyncPlayGroupsState with _$SyncPlayGroupsState { + const factory SyncPlayGroupsState({ + List? groups, + @Default(false) bool isLoading, + String? error, + }) = _SyncPlayGroupsState; +} + +/// Provider for the list of SyncPlay groups (load/refresh from sheet). +@Riverpod(keepAlive: false) +class SyncPlayGroups extends _$SyncPlayGroups { + @override + SyncPlayGroupsState build() => const SyncPlayGroupsState(isLoading: true); + + Future loadGroups() async { + state = state.copyWith(isLoading: true, error: null); + try { + await ref.read(syncPlayProvider.notifier).connect(); + final groups = await ref.read(syncPlayProvider.notifier).listGroups(); + state = state.copyWith( + groups: List.unmodifiable(groups), + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + void setLoading(bool isLoading) { + state = state.copyWith(isLoading: isLoading); + } +} diff --git a/lib/providers/syncplay/syncplay_provider.freezed.dart b/lib/providers/syncplay/syncplay_provider.freezed.dart new file mode 100644 index 000000000..40457a1a4 --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.freezed.dart @@ -0,0 +1,327 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'syncplay_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SyncPlayGroupsState implements DiagnosticableTreeMixin { + List? get groups; + bool get isLoading; + String? get error; + + /// Create a copy of SyncPlayGroupsState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SyncPlayGroupsStateCopyWith get copyWith => + _$SyncPlayGroupsStateCopyWithImpl(this as SyncPlayGroupsState, _$identity); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'SyncPlayGroupsState')) + ..add(DiagnosticsProperty('groups', groups)) + ..add(DiagnosticsProperty('isLoading', isLoading)) + ..add(DiagnosticsProperty('error', error)); + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'SyncPlayGroupsState(groups: $groups, isLoading: $isLoading, error: $error)'; + } +} + +/// @nodoc +abstract mixin class $SyncPlayGroupsStateCopyWith<$Res> { + factory $SyncPlayGroupsStateCopyWith(SyncPlayGroupsState value, $Res Function(SyncPlayGroupsState) _then) = + _$SyncPlayGroupsStateCopyWithImpl; + @useResult + $Res call({List? groups, bool isLoading, String? error}); +} + +/// @nodoc +class _$SyncPlayGroupsStateCopyWithImpl<$Res> implements $SyncPlayGroupsStateCopyWith<$Res> { + _$SyncPlayGroupsStateCopyWithImpl(this._self, this._then); + + final SyncPlayGroupsState _self; + final $Res Function(SyncPlayGroupsState) _then; + + /// Create a copy of SyncPlayGroupsState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? groups = freezed, + Object? isLoading = null, + Object? error = freezed, + }) { + return _then(_self.copyWith( + groups: freezed == groups + ? _self.groups + : groups // ignore: cast_nullable_to_non_nullable + as List?, + isLoading: null == isLoading + ? _self.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error + ? _self.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// Adds pattern-matching-related methods to [SyncPlayGroupsState]. +extension SyncPlayGroupsStatePatterns on SyncPlayGroupsState { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SyncPlayGroupsState value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_SyncPlayGroupsState value) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SyncPlayGroupsState value)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(List? groups, bool isLoading, String? error)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState() when $default != null: + return $default(_that.groups, _that.isLoading, _that.error); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(List? groups, bool isLoading, String? error) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState(): + return $default(_that.groups, _that.isLoading, _that.error); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(List? groups, bool isLoading, String? error)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayGroupsState() when $default != null: + return $default(_that.groups, _that.isLoading, _that.error); + case _: + return null; + } + } +} + +/// @nodoc + +class _SyncPlayGroupsState with DiagnosticableTreeMixin implements SyncPlayGroupsState { + const _SyncPlayGroupsState({final List? groups, this.isLoading = false, this.error}) : _groups = groups; + + final List? _groups; + @override + List? get groups { + final value = _groups; + if (value == null) return null; + if (_groups is EqualUnmodifiableListView) return _groups; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + @JsonKey() + final bool isLoading; + @override + final String? error; + + /// Create a copy of SyncPlayGroupsState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SyncPlayGroupsStateCopyWith<_SyncPlayGroupsState> get copyWith => + __$SyncPlayGroupsStateCopyWithImpl<_SyncPlayGroupsState>(this, _$identity); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'SyncPlayGroupsState')) + ..add(DiagnosticsProperty('groups', groups)) + ..add(DiagnosticsProperty('isLoading', isLoading)) + ..add(DiagnosticsProperty('error', error)); + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'SyncPlayGroupsState(groups: $groups, isLoading: $isLoading, error: $error)'; + } +} + +/// @nodoc +abstract mixin class _$SyncPlayGroupsStateCopyWith<$Res> implements $SyncPlayGroupsStateCopyWith<$Res> { + factory _$SyncPlayGroupsStateCopyWith(_SyncPlayGroupsState value, $Res Function(_SyncPlayGroupsState) _then) = + __$SyncPlayGroupsStateCopyWithImpl; + @override + @useResult + $Res call({List? groups, bool isLoading, String? error}); +} + +/// @nodoc +class __$SyncPlayGroupsStateCopyWithImpl<$Res> implements _$SyncPlayGroupsStateCopyWith<$Res> { + __$SyncPlayGroupsStateCopyWithImpl(this._self, this._then); + + final _SyncPlayGroupsState _self; + final $Res Function(_SyncPlayGroupsState) _then; + + /// Create a copy of SyncPlayGroupsState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? groups = freezed, + Object? isLoading = null, + Object? error = freezed, + }) { + return _then(_SyncPlayGroupsState( + groups: freezed == groups + ? _self._groups + : groups // ignore: cast_nullable_to_non_nullable + as List?, + isLoading: null == isLoading + ? _self.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error + ? _self.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +// dart format on diff --git a/lib/providers/syncplay/syncplay_provider.g.dart b/lib/providers/syncplay/syncplay_provider.g.dart new file mode 100644 index 000000000..794c28b81 --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.g.dart @@ -0,0 +1,163 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'syncplay_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$isSyncPlayActiveHash() => r'bf9cda97aa9130fed8fc6558481c02f10f815f99'; + +/// Provider to check if currently in a SyncPlay session +/// +/// Copied from [isSyncPlayActive]. +@ProviderFor(isSyncPlayActive) +final isSyncPlayActiveProvider = AutoDisposeProvider.internal( + isSyncPlayActive, + name: r'isSyncPlayActiveProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$isSyncPlayActiveHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IsSyncPlayActiveRef = AutoDisposeProviderRef; +String _$syncPlayGroupNameHash() => r'f73f243808920efbfbfa467d1ba1234fec622283'; + +/// Provider for current SyncPlay group name +/// +/// Copied from [syncPlayGroupName]. +@ProviderFor(syncPlayGroupName) +final syncPlayGroupNameProvider = AutoDisposeProvider.internal( + syncPlayGroupName, + name: r'syncPlayGroupNameProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayGroupNameHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncPlayGroupNameRef = AutoDisposeProviderRef; +String _$syncPlayGroupStateHash() => r'dff5dba3297066e06ff5ed1b9b273ee19bc27878'; + +/// Provider for SyncPlay group state +/// +/// Copied from [syncPlayGroupState]. +@ProviderFor(syncPlayGroupState) +final syncPlayGroupStateProvider = AutoDisposeProvider.internal( + syncPlayGroupState, + name: r'syncPlayGroupStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayGroupStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncPlayGroupStateRef = AutoDisposeProviderRef; +String _$syncCorrectionStateHash() => r'0c623c5a3e9b99b5dc09c14b50d4cbf120151af9'; + +/// Provider for SyncPlay correction runtime state (UI + diagnostics). +/// +/// Copied from [syncCorrectionState]. +@ProviderFor(syncCorrectionState) +final syncCorrectionStateProvider = AutoDisposeProvider.internal( + syncCorrectionState, + name: r'syncCorrectionStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncCorrectionStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncCorrectionStateRef = AutoDisposeProviderRef; +String _$syncCorrectionStrategyHash() => r'eaa4de3db8e9d9155b6f41465462f087833744e0'; + +/// Provider for active correction strategy. +/// +/// Copied from [syncCorrectionStrategy]. +@ProviderFor(syncCorrectionStrategy) +final syncCorrectionStrategyProvider = AutoDisposeProvider.internal( + syncCorrectionStrategy, + name: r'syncCorrectionStrategyProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncCorrectionStrategyHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncCorrectionStrategyRef = AutoDisposeProviderRef; +String _$syncPlayStartPlaybackInProgressHash() => r'883e5426c30e568f8374656112a2de902a98f5dc'; + +/// True when a SyncPlay-driven `_startPlayback` is currently in flight +/// (initial play, episode switch, rejoin). UI can use this to display +/// a loading indicator while the local player is being prepared. +/// +/// Copied from [syncPlayStartPlaybackInProgress]. +@ProviderFor(syncPlayStartPlaybackInProgress) +final syncPlayStartPlaybackInProgressProvider = AutoDisposeProvider.internal( + syncPlayStartPlaybackInProgress, + name: r'syncPlayStartPlaybackInProgressProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayStartPlaybackInProgressHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncPlayStartPlaybackInProgressRef = AutoDisposeProviderRef; +String _$syncPlayHasActivePlaybackHash() => r'007d108b36b600d13f83e6e04f7c47e3123f3a79'; + +/// True when the group has an active item the local user could +/// resume from outside the player route. +/// +/// Copied from [syncPlayHasActivePlayback]. +@ProviderFor(syncPlayHasActivePlayback) +final syncPlayHasActivePlaybackProvider = AutoDisposeProvider.internal( + syncPlayHasActivePlayback, + name: r'syncPlayHasActivePlaybackProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayHasActivePlaybackHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SyncPlayHasActivePlaybackRef = AutoDisposeProviderRef; +String _$syncPlayHash() => r'1bcc0ba8a76233295e39d3cb0ebd243fe3acc44d'; + +/// Provider for SyncPlay controller instance +/// +/// Copied from [SyncPlay]. +@ProviderFor(SyncPlay) +final syncPlayProvider = NotifierProvider.internal( + SyncPlay.new, + name: r'syncPlayProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncPlay = Notifier; +String _$syncPlayGroupsHash() => r'7f17436df1b0afb4c77cd21128e03b1ed0875939'; + +/// Provider for the list of SyncPlay groups (load/refresh from sheet). +/// +/// Copied from [SyncPlayGroups]. +@ProviderFor(SyncPlayGroups) +final syncPlayGroupsProvider = AutoDisposeNotifierProvider.internal( + SyncPlayGroups.new, + name: r'syncPlayGroupsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayGroupsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncPlayGroups = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/providers/syncplay/time_sync_service.dart b/lib/providers/syncplay/time_sync_service.dart new file mode 100644 index 000000000..907aab095 --- /dev/null +++ b/lib/providers/syncplay/time_sync_service.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; + +/// Service for synchronizing client clock with Jellyfin server using NTP-like algorithm +class TimeSyncService { + TimeSyncService(this._api); + + final JellyfinOpenApi _api; + + final List _measurements = []; + static const int _maxMeasurements = 8; + + Timer? _pollingTimer; + int _pingCount = 0; + bool _isActive = false; + + // Polling intervals + static const Duration _greedyInterval = Duration(seconds: 1); + static const Duration _lowProfileInterval = Duration(seconds: 60); + static const int _greedyPingCount = 3; + + // Staleness threshold + static const Duration _staleThreshold = Duration(seconds: 30); + DateTime? _lastMeasurementTime; + + /// Current best offset estimate + Duration get offset { + if (_measurements.isEmpty) { + return Duration.zero; + } + // Use measurement with minimum delay (least network jitter) + final best = _measurements.reduce( + (a, b) => a.delay < b.delay ? a : b, + ); + return best.offset; + } + + /// Current ping estimate (from best measurement) + Duration get ping { + if (_measurements.isEmpty) { + return Duration.zero; + } + final best = _measurements.reduce( + (a, b) => a.delay < b.delay ? a : b, + ); + return best.ping; + } + + /// Whether time sync is stale and needs refresh + bool get isStale { + if (_lastMeasurementTime == null) { + return true; + } + return DateTime.now().difference(_lastMeasurementTime!) > _staleThreshold; + } + + /// Convert server time to local time + DateTime remoteDateToLocal(DateTime serverTime) { + return serverTime.subtract(offset); + } + + /// Convert local time to server time + DateTime localDateToRemote(DateTime localTime) { + return localTime.add(offset); + } + + /// Start time synchronization + void start() { + if (_isActive) { + return; + } + _isActive = true; + _pingCount = 0; + _poll(); + } + + /// Stop time synchronization + void stop() { + _isActive = false; + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + /// Force an immediate sync update + Future forceUpdate() async { + await _requestPing(); + } + + /// Force update and wait for completion + Future forceUpdateAndWait() async { + await _requestPing(); + } + + void _poll() { + if (!_isActive) { + return; + } + + _requestPing().then((_) { + if (!_isActive) { + return; + } + + _pingCount++; + final interval = _pingCount <= _greedyPingCount ? _greedyInterval : _lowProfileInterval; + + _pollingTimer?.cancel(); + _pollingTimer = Timer(interval, _poll); + }); + } + + Future _requestPing() async { + try { + // T1: Record local time before request + final requestSent = DateTime.now().toUtc(); + + // Make request to Jellyfin TimeSync API + final response = await _api.getUtcTimeGet(); + + // T4: Record local time after response + final responseReceived = DateTime.now().toUtc(); + + final data = response.body; + if (data == null) { + log('Time sync: No response body'); + return; + } + + // T2 and T3 from server + final requestReceived = data.requestReceptionTime; + final responseSent = data.responseTransmissionTime; + + if (requestReceived == null || responseSent == null) { + log('Time sync: Missing server timestamps'); + return; + } + + final measurement = TimeSyncMeasurement( + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived, + ); + + _addMeasurement(measurement); + _lastMeasurementTime = DateTime.now(); + + log('Time sync: offset=${offset.inMilliseconds}ms, ping=${ping.inMilliseconds}ms'); + } catch (e) { + log('Time sync failed: $e'); + } + } + + void _addMeasurement(TimeSyncMeasurement measurement) { + _measurements.add(measurement); + // Keep only the last N measurements + while (_measurements.length > _maxMeasurements) { + _measurements.removeAt(0); + } + } + + /// Clear all measurements + void clear() { + _measurements.clear(); + _lastMeasurementTime = null; + _pingCount = 0; + } + + /// Dispose resources + void dispose() { + stop(); + clear(); + } +} diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 904bc6484..3e3539ca8 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,23 +1,27 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'dart:io'; -import 'package:flutter/material.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path/path.dart' as p; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_queue_state.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource, SyncPlayCommandType; import 'package:fladder/wrappers/media_control_wrapper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; final mediaPlaybackProvider = StateProvider((ref) => MediaPlaybackModel()); final playBackModel = StateProvider((ref) => null); +final isVideoPlayerRouteOpenProvider = StateProvider((ref) => false); + final videoPlayerProvider = StateNotifierProvider((ref) { final videoPlayer = VideoPlayerNotifier(ref); videoPlayer.init(); @@ -35,6 +39,36 @@ class VideoPlayerNotifier extends StateNotifier { MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider); + /// Flag to indicate if the current action is initiated by SyncPlay + bool _syncPlayAction = false; + + /// True while [loadPlaybackItem] is loading new media on behalf of a + /// SyncPlay-driven flow (initial play or queue change). The buffering + /// listener must not auto-report Ready/Buffering during this window: + /// media-kit on web doesn't reliably emit `playing=true` synchronously + /// with `buffering=false`, and the listener would race [loadPlaybackItem] + /// with a stale `isPlaying: false` Ready that overrides the explicit + /// `Ready(isPlaying: true)` we send when the load is complete. + bool _isLoadingForSyncPlay = false; + + /// Cooldown period after SyncPlay command during which we don't auto-report ready + static const _syncPlayCooldown = Duration(milliseconds: 500); + + /// Check if SyncPlay is active + bool get _isSyncPlayActive => ref.read(isSyncPlayActiveProvider); + + /// Whether player is reloading/buffering from SyncPlay perspective. + bool get _isReloading => ref.read(syncPlayProvider.select((s) => s.correctionState.playerIsBuffering)); + + /// Check if we're in the SyncPlay cooldown period + bool get _inSyncPlayCooldown { + final lastCommandTime = ref.read(syncPlayProvider.select((s) => s.lastCommandTime)); + if (lastCommandTime == null) { + return false; + } + return DateTime.now().toUtc().difference(lastCommandTime) < _syncPlayCooldown; + } + Future init() async { await state.stop(); await state.dispose(); @@ -45,6 +79,19 @@ class VideoPlayerNotifier extends StateNotifier { } final subscription = state.stateStream.listen((value) { + // Infer SyncPlay user actions from native player state stream (reviewer request). + if (value.changeSource == PlaybackChangeSource.user) { + final prev = playbackState; + if (value.playing != prev.playing) { + if (value.playing) { + userPlay(); + } else { + userPause(); + } + } else if ((value.position - prev.position).inSeconds.abs() > 2) { + userSeek(value.position); + } + } updateBuffering(value.buffering); updateBuffer(value.buffer); updatePlaying(value.playing); @@ -53,10 +100,150 @@ class VideoPlayerNotifier extends StateNotifier { }); subscriptions.add(subscription); + + // Register player callbacks with SyncPlay + _registerSyncPlayCallbacks(); + + // Listen to SyncPlay state changes for native player overlay + _setupSyncPlayStateListener(); } - Future updateBuffering(bool event) async => - mediaState.update((state) => state.buffering == event ? state : state.copyWith(buffering: event)); + /// Set up listener to forward SyncPlay command state to native player + void _setupSyncPlayStateListener() { + ref.listen( + syncPlayProvider, + (previous, next) { + // Only forward to native player if it's active + if (state.isNativePlayerActive) { + // Check if the relevant state changed + if (previous?.isProcessingCommand != next.isProcessingCommand || + previous?.processingCommandType != next.processingCommandType) { + state.updateSyncPlayCommandState( + next.isProcessingCommand, + _toSyncPlayCommandType(next.processingCommandType), + ); + } + } + }, + ); + } + + SyncPlayCommandType _toSyncPlayCommandType(SyncPlayCommand? commandType) { + return switch (commandType) { + SyncPlayCommand.pause => SyncPlayCommandType.pause, + SyncPlayCommand.unpause => SyncPlayCommandType.unpause, + SyncPlayCommand.seek => SyncPlayCommandType.seek, + SyncPlayCommand.stop => SyncPlayCommandType.stop, + null => SyncPlayCommandType.none, + }; + } + + /// Manually set the reloading state (e.g. before fetching new PlaybackInfo) + void setReloading( + bool value, { + bool reportToSyncPlay = true, + }) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(value); + if (value && _isSyncPlayActive && reportToSyncPlay) { + ref.read(syncPlayProvider.notifier).reportBuffering(); + } + } + + /// Register player callbacks with SyncPlay controller + void _registerSyncPlayCallbacks() { + ref.read(syncPlayProvider.notifier).registerPlayer( + onPlay: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.play(); + _syncPlayAction = false; + }, + onPause: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.pause(); + _syncPlayAction = false; + }, + onSeek: (positionTicks) async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + final position = Duration(microseconds: positionTicks ~/ 10); + await state.seek(position); + _syncPlayAction = false; + }, + onSeekRequested: (positionTicks) async { + // Another user requested a seek. Report buffering to SyncPlay + // without forcing local buffering state, otherwise the command + // handler can get stuck waiting and suppress Ready/Unpause. + ref.read(syncPlayProvider.notifier).reportBuffering(); + }, + onStop: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.stop(); + ref.read(syncPlayProvider.notifier).resetCorrectionState( + reason: 'stop_command', + ); + _syncPlayAction = false; + }, + onSetSpeed: (speed) async { + await state.setSpeed(speed); + }, + getPositionTicks: () { + final position = playbackState.position; + return secondsToTicks(position.inMilliseconds / 1000); + }, + isPlaying: () => playbackState.playing, + isBuffering: () => _isReloading || playbackState.buffering, + // Native player (ExoPlayer) supports setPlaybackSpeed; surfacing it + // here lets SyncPlay drift correction pick SpeedToSync (rate nudge, + // no buffering) instead of falling back to SkipToSync, which on + // ExoPlayer triggers STATE_BUFFERING and amplifies into a + // post-Unpause buffer-cycle on Android-TV. + hasPlaybackRate: () => true, + ); + } + + /// True while a SyncPlay command is being scheduled/executed. The + /// command handler owns the Buffering/Ready exchange in that window + /// and we must not race it with our own reports — for a Seek command + /// in particular, sending Ready(isPlaying: false) here (because the + /// command paused the local player) overrides the command handler's + /// Ready(isPlaying: true) and the server then keeps the group paused + /// instead of broadcasting Unpause. + bool get _isSyncPlayCommandInFlight => ref.read(syncPlayProvider.select((s) => s.isProcessingCommand)); + + Future updateBuffering(bool event) async { + final oldState = playbackState; + if (oldState.buffering == event) { + return; + } + + mediaState.update((state) => state.copyWith(buffering: event)); + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(event); + } + + // Report buffering state to SyncPlay if active + // Skip if we're in the cooldown period after a SyncPlay command to prevent feedback loops + // Also skip if we are currently reloading (we'll report manually when done) + // Also skip while a command is being processed — the command + // handler owns the Ready signal then. + if (_isSyncPlayActive && + !_syncPlayAction && + !_inSyncPlayCooldown && + !_isReloading && + !_isSyncPlayCommandInFlight && + !_isLoadingForSyncPlay) { + if (event) { + // Started buffering + ref.read(syncPlayProvider.notifier).reportBuffering(); + } else { + // Finished buffering - ready + ref.read(syncPlayProvider.notifier).reportReady(isPlaying: playbackState.playing); + } + } + } Future updateBuffer(Duration buffer) async { mediaState.update( @@ -89,13 +276,19 @@ class VideoPlayerNotifier extends StateNotifier { } Future updatePosition(Duration event) async { - if (!state.hasPlayer) return; - if (playbackState.playing == false) return; + if (!state.hasPlayer) { + return; + } + if (playbackState.playing == false) { + return; + } final currentState = playbackState; if (currentState.state == VideoPlayerState.disposed) return; final currentPosition = currentState.position; - if ((currentPosition - event).inSeconds.abs() < 1) return; + if ((currentPosition - event).inSeconds.abs() < 1) { + return; + } final position = event; @@ -113,35 +306,91 @@ class VideoPlayerNotifier extends StateNotifier { position: event, )); } + + // Feed time updates into SyncPlay drift estimation. + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).updatePlaybackDrift( + currentPositionTicks: secondsToTicks( + event.inMilliseconds / 1000, + ), + at: DateTime.now().toUtc(), + ); + } } - Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { - ref.read(playBackModel)?.dispose(); - await state.stop(); - ref.read(playbackRateProvider.notifier).state = 1.0; + Future loadPlaybackItem( + PlaybackModel model, + Duration startPosition, { + bool waitForSyncPlayCommand = true, + }) async { + final oldPlaybackModel = ref.read(playBackModel); + + if (_isSyncPlayActive) { + // Null the old playback model BEFORE state.stop() so its + // 1-second-delayed POST /Sessions/Playing/Stopped is suppressed + // (state.stop() exits early when playBackModel is null). That + // POST is a session-lifecycle event Jellyfin broadcasts to the + // SyncPlay group, which causes other clients (and ourselves via + // the "pause locally on Buffer" handler) to pause. media-kit's + // open() in loadVideo replaces the current media in place — no + // explicit stop is needed for an in-route reload (track switch, + // queue change while route is already open). + ref.read(playBackModel.notifier).update((_) => null); + } + oldPlaybackModel?.dispose(); + + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(true); + + final reportingForSyncPlay = _isSyncPlayActive && waitForSyncPlayCommand; + // Position we're loading at — the local player's position is 0 + // here (the player just got reset), so we must pass this + // explicitly to the SyncPlay reports. Otherwise the server reads + // 0 from the buffering/ready payloads and broadcasts it as the + // group's position, resetting every other client to the start. + final loadPositionTicks = startPosition.inMicroseconds * 10; + if (reportingForSyncPlay) { + _isLoadingForSyncPlay = true; + ref.read(syncPlayProvider.notifier).reportBuffering(positionTicks: loadPositionTicks); + } final useMinimizedPlayer = model.item.type == FladderItemType.audio || model.mediaStreams?.videoStreams.isEmpty == true; - mediaState.update((state) => state.copyWith( - state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, - fullScreen: !useMinimizedPlayer, - buffering: true, - errorPlaying: false, - skippedSegments: {}, - )); + try { + await state.stop(); + ref.read(playbackRateProvider.notifier).state = 1.0; + mediaState.update((state) => state.copyWith( + state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, + fullScreen: !useMinimizedPlayer, + buffering: true, + errorPlaying: false, + skippedSegments: {}, + )); - final media = model.media; - PlaybackModel? newPlaybackModel = model; - final effectiveStartPosition = await model.resolvedStartPosition(startPosition); + final media = model.media; + PlaybackModel? newPlaybackModel = model; + final effectiveStartPosition = await model.resolvedStartPosition(startPosition); - if (media != null) { - ref.read(playBackModel.notifier).update((state) => newPlaybackModel); - await state.loadVideo(model, effectiveStartPosition, true); + if (media == null) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); + mediaState.update((state) => state.copyWith(errorPlaying: true)); + if (reportingForSyncPlay) { + unawaited(ref.read(syncPlayProvider.notifier).reportReady(isPlaying: false)); + } + return false; + } + + // Don't auto-play during a SyncPlay-driven load. The server's + // Unpause command (broadcast after all clients report Ready) is + // what drives playback for the group; auto-playing here races + // the protocol and produces a stale isPlaying:false Ready (see + // _isLoadingForSyncPlay docstring above). + await state.loadVideo(model, effectiveStartPosition, !reportingForSyncPlay); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); await state.setAudioTrack(null, model); await state.setSubtitleTrack(null, model); + ref.read(playBackModel.notifier).update((state) => newPlaybackModel); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith( state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, @@ -150,12 +399,35 @@ class VideoPlayerNotifier extends StateNotifier { skippedSegments: {}, )); - await state.play(); + if (!reportingForSyncPlay) { + await state.play(); + } else { + // Tell the server we're loaded and intend to play. The + // buffering listener stayed silent thanks to + // _isLoadingForSyncPlay, so this is the only Ready that + // reaches the server for this load — server broadcasts + // Unpause and onPlay drives the actual playback. We send + // the load position explicitly so the server knows where + // we'll be when playback resumes. + await ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: true, + positionTicks: loadPositionTicks, + ); + } return true; + } catch (e, stackTrace) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); + mediaState.update((state) => state.copyWith(errorPlaying: true, buffering: false)); + // Tell the group we recovered (with isPlaying:false) so the server + // doesn't keep everyone else paused waiting on us. + if (reportingForSyncPlay) { + unawaited(ref.read(syncPlayProvider.notifier).reportReady(isPlaying: false)); + } + developer.log('loadPlaybackItem failed: $e\n$stackTrace'); + return false; + } finally { + _isLoadingForSyncPlay = false; } - - mediaState.update((state) => state.copyWith(errorPlaying: true)); - return false; } Future loadAudioPlaybackItem( @@ -289,4 +561,64 @@ class VideoPlayerNotifier extends StateNotifier { return false; } + + // ============================================ + // User-initiated actions (go through SyncPlay if active) + // ============================================ + + /// User-initiated play - routes through SyncPlay if active + Future userPlay() async { + if (_isSyncPlayActive) { + // Just request unpause. The server will put the group in Waiting state, + // and our buffering listener will report Ready(isPlaying: false) when appropriate. + await ref.read(syncPlayProvider.notifier).requestUnpause(); + } else { + await state.play(); + } + } + + /// User-initiated pause - routes through SyncPlay if active + Future userPause() async { + if (_isSyncPlayActive) { + await ref.read(syncPlayProvider.notifier).requestPause(); + } else { + await state.pause(); + } + } + + /// User-initiated seek - routes through SyncPlay if active + Future userSeek(Duration position) async { + final wasPlaying = playbackState.playing; + if (_isSyncPlayActive) { + // Apply the seek locally immediately so the UI/slider does not snap + // back to the previous position while we wait for the server to + // broadcast the Seek command. _syncPlayAction prevents the player + // state stream from re-triggering userSeek for our own action. + _syncPlayAction = true; + try { + await state.seek(position); + if (wasPlaying && !playbackState.playing) { + await state.play(); + } + } finally { + _syncPlayAction = false; + } + final positionTicks = secondsToTicks(position.inMilliseconds / 1000); + await ref.read(syncPlayProvider.notifier).requestSeek(positionTicks); + } else { + await state.seek(position); + if (wasPlaying && !playbackState.playing) { + await state.play(); + } + } + } + + /// User-initiated play/pause toggle - routes through SyncPlay if active + Future userPlayOrPause() async { + if (playbackState.playing) { + await userPause(); + } else { + await userPlay(); + } + } } diff --git a/lib/providers/websocket/jellyfin_websocket.dart b/lib/providers/websocket/jellyfin_websocket.dart new file mode 100644 index 000000000..5b00c293b --- /dev/null +++ b/lib/providers/websocket/jellyfin_websocket.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// WebSocket connection state. +/// +/// Moved here from `lib/models/syncplay/syncplay_models.dart` so the +/// socket layer no longer depends on SyncPlay models. +enum WebSocketConnectionState { + disconnected, + connecting, + connected, + reconnecting, +} + +/// Pure platform classification for the lifecycle gate. +/// +/// A "phone" (Android/iOS handheld, not Android-TV/leanback) is the only +/// platform that gets the background/resume disconnect-reconnect cycle. +/// Desktop, Web, and Android-TV/leanback stay always-alive. +/// +/// Kept as a free function with no Flutter-binding dependency so it is +/// unit-testable without `TestWidgetsFlutterBinding`. +bool isPhonePlatform({ + required bool isWeb, + required TargetPlatform platform, + required bool leanBackMode, +}) { + if (isWeb) { + return false; + } + final isAndroidOrIos = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + return isAndroidOrIos && !leanBackMode; +} + +/// Manages a single WebSocket connection to the Jellyfin server. +/// +/// App-level shared connection (formerly `WebSocketManager`, owned by +/// SyncPlay). Connection / keep-alive / reconnect logic is unchanged. +class JellyfinWebSocket { + JellyfinWebSocket({ + required this.serverUrl, + required this.token, + required this.deviceId, + }); + + final String serverUrl; + final String token; + final String deviceId; + + WebSocketChannel? _channel; + Timer? _keepAliveTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static const Duration _baseReconnectDelay = Duration(seconds: 2); + + final _connectionStateController = StreamController.broadcast(); + final _messageController = StreamController>.broadcast(); + + Stream get connectionState => _connectionStateController.stream; + Stream> get messages => _messageController.stream; + + WebSocketConnectionState _currentState = WebSocketConnectionState.disconnected; + WebSocketConnectionState get currentState => _currentState; + + /// Build WebSocket URL for Jellyfin + Uri get _webSocketUri { + final baseUri = Uri.parse(serverUrl); + final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; + final basePath = baseUri.path.replaceAll(RegExp(r'/+$'), ''); + return Uri( + scheme: scheme, + host: baseUri.host, + port: baseUri.port, + path: '$basePath/socket', + queryParameters: { + 'api_key': token, + 'deviceId': deviceId, + }, + ); + } + + /// Connect to WebSocket + Future connect() async { + if (_currentState == WebSocketConnectionState.connected || _currentState == WebSocketConnectionState.connecting) { + return; + } + + _updateState(WebSocketConnectionState.connecting); + + try { + log('WebSocket: Connecting to ${_webSocketUri.toString().replaceAll(RegExp(r'api_key=[^&]+'), 'api_key=***')}'); + _channel = WebSocketChannel.connect(_webSocketUri); + await _channel!.ready; + + _updateState(WebSocketConnectionState.connected); + _reconnectAttempts = 0; + + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDone, + ); + } catch (e) { + log('WebSocket connection failed: $e'); + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + } + + /// Disconnect from WebSocket + Future disconnect() async { + _reconnectTimer?.cancel(); + _keepAliveTimer?.cancel(); + _reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect + + await _channel?.sink.close(); + _channel = null; + _updateState(WebSocketConnectionState.disconnected); + } + + /// Force reconnect (e.g., after app resume) + /// Resets attempt counter and immediately reconnects + Future forceReconnect() async { + _reconnectTimer?.cancel(); + _keepAliveTimer?.cancel(); + await _channel?.sink.close(); + _channel = null; + _reconnectAttempts = 0; + _updateState(WebSocketConnectionState.disconnected); + await connect(); + } + + /// Send a message through WebSocket + void send(Map message) { + if (_currentState != WebSocketConnectionState.connected) { + log('Cannot send message: WebSocket not connected'); + return; + } + + try { + _channel?.sink.add(json.encode(message)); + } catch (e) { + log('Failed to send WebSocket message: $e'); + } + } + + /// Send keep-alive message + void _sendKeepAlive() { + send({'MessageType': 'KeepAlive'}); + } + + void _handleMessage(dynamic data) { + try { + final message = json.decode(data as String) as Map; + final messageType = message['MessageType'] as String?; + + // Log all received messages for debugging (except KeepAlive spam) + if (messageType != 'KeepAlive') { + log('WebSocket: Received message: $message'); + } + + // Handle ForceKeepAlive to set up keep-alive interval + if (messageType == 'ForceKeepAlive') { + final timeoutSeconds = message['Data'] as int? ?? 60; + _setupKeepAlive(timeoutSeconds); + } + + // Forward message to listeners + _messageController.add(message); + } catch (e) { + log('Failed to parse WebSocket message: $e\nRaw data: $data'); + } + } + + void _handleError(dynamic error) { + log('WebSocket error: $error'); + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + + void _handleDone() { + log('WebSocket connection closed'); + _keepAliveTimer?.cancel(); + + if (_currentState != WebSocketConnectionState.disconnected) { + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + } + + void _setupKeepAlive(int timeoutSeconds) { + _keepAliveTimer?.cancel(); + // Send keep-alive at half the timeout interval + final interval = Duration(seconds: (timeoutSeconds * 0.5).round()); + _keepAliveTimer = Timer.periodic(interval, (_) => _sendKeepAlive()); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + log('Max reconnect attempts reached'); + return; + } + + _reconnectTimer?.cancel(); + _updateState(WebSocketConnectionState.reconnecting); + + // Exponential backoff + final delay = _baseReconnectDelay * (1 << _reconnectAttempts); + _reconnectAttempts++; + + log('Scheduling reconnect in ${delay.inSeconds}s (attempt $_reconnectAttempts)'); + _reconnectTimer = Timer(delay, connect); + } + + void _updateState(WebSocketConnectionState state) { + _currentState = state; + _connectionStateController.add(state); + } + + /// Dispose resources + Future dispose() async { + await disconnect(); + await _connectionStateController.close(); + await _messageController.close(); + } +} diff --git a/lib/providers/websocket/jellyfin_websocket_provider.dart b/lib/providers/websocket/jellyfin_websocket_provider.dart new file mode 100644 index 000000000..4a8354888 --- /dev/null +++ b/lib/providers/websocket/jellyfin_websocket_provider.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:fladder/models/account_model.dart'; +import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/arguments_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; + +part 'jellyfin_websocket_provider.g.dart'; + +/// Phone-only lifecycle observer: forces a clean WebSocket reconnect when +/// the app returns to the foreground. Registered only on phones (see +/// [isPhonePlatform]); desktop / web / Android-TV stay always-alive. +class _WebSocketLifecycleObserver with WidgetsBindingObserver { + _WebSocketLifecycleObserver(this._controller); + + final JellyfinWebSocketController _controller; + bool _wasConnected = false; + + void register() => WidgetsBinding.instance.addObserver(this); + void unregister() => WidgetsBinding.instance.removeObserver(this); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + _wasConnected = _controller.currentState == WebSocketConnectionState.connected; + log('JellyfinWebSocket: app paused, wasConnected=$_wasConnected'); + break; + case AppLifecycleState.resumed: + if (_wasConnected) { + log('JellyfinWebSocket: app resumed, forcing reconnect'); + unawaited(_controller.forceReconnectSocket()); + } + break; + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + break; + } + } +} + +/// App-level shared Jellyfin WebSocket. +/// +/// Owns a single [JellyfinWebSocket], connects when a user is +/// authenticated, and re-broadcasts the socket's streams through +/// long-lived controllers so consumers stay subscribed transparently +/// across account switches / socket rebuilds. +@Riverpod(keepAlive: true) +class JellyfinWebSocketController extends _$JellyfinWebSocketController { + JellyfinWebSocket? _socket; + StreamSubscription? _socketStateSub; + StreamSubscription>? _socketMessageSub; + _WebSocketLifecycleObserver? _observer; + + // Serializes socket teardown/rebuild so two quick auth changes + // (e.g. token refresh then account switch) can't run concurrently + // and leak or duplicate a socket. + Future _socketOps = Future.value(); + + // Long-lived re-broadcast controllers. Consumers subscribe to these, + // never to a JellyfinWebSocket instance directly, so a socket rebuild + // (e.g. account switch) is invisible to them. + final _stateController = StreamController.broadcast(); + final _messageController = StreamController>.broadcast(); + + /// Connection-state transitions (re-broadcast). + Stream get connectionState => _stateController.stream; + + /// Raw inbound messages (re-broadcast). Consumers filter by + /// `MessageType` themselves. + Stream> get messages => _messageController.stream; + + /// Current connection state (disconnected if no socket). + WebSocketConnectionState get currentState => _socket?.currentState ?? WebSocketConnectionState.disconnected; + + /// Send a message through the shared socket (no-op if not connected). + void send(Map message) => _socket?.send(message); + + /// Force a clean reconnect of the underlying socket. + Future forceReconnectSocket() async => _socket?.forceReconnect(); + + bool get _isPhone => isPhonePlatform( + isWeb: kIsWeb, + platform: defaultTargetPlatform, + leanBackMode: ref.read(argumentsStateProvider).leanBackMode, + ); + + @override + WebSocketConnectionState build() { + // Drive connect/disconnect off auth. fireImmediately handles the + // case where a user is already logged in when this provider is + // first activated (e.g. by base_app_wrapper after a relaunch). + ref.listen( + userProvider, + (previous, next) => _handleUserChange(previous, next), + fireImmediately: true, + ); + + if (_isPhone) { + _observer = _WebSocketLifecycleObserver(this)..register(); + } + + ref.onDispose(_disposeAll); + return WebSocketConnectionState.disconnected; + } + + /// Chain a socket mutation onto the serial queue so teardown/rebuild + /// never overlap. Failures are logged, not propagated, so one bad op + /// doesn't wedge the queue. + void _enqueueSocketOp(Future Function() op) { + _socketOps = _socketOps.then((_) => op()).catchError((Object e, StackTrace s) { + log('JellyfinWebSocket: socket op failed: $e'); + }); + } + + void _handleUserChange(AccountModel? previous, AccountModel? next) { + if (next == null) { + log('JellyfinWebSocket: user signed out, tearing down socket'); + _enqueueSocketOp(_teardownSocket); + return; + } + + final serverUrl = ref.read(serverUrlProvider); + if (serverUrl == null || serverUrl.isEmpty) { + log('JellyfinWebSocket: no server URL yet, deferring connect'); + return; + } + + final token = next.credentials.token; + final deviceId = next.credentials.deviceId; + + _enqueueSocketOp(() async { + // Re-evaluate against the live socket at execution time (a prior + // queued op may have changed it). + final existing = _socket; + if (existing != null && + existing.serverUrl == serverUrl && + existing.token == token && + existing.deviceId == deviceId) { + // Same credentials/server — just ensure it is up (connect() is a + // no-op when already connected/connecting). + await existing.connect(); + return; + } + + log('JellyfinWebSocket: (re)building socket for $serverUrl'); + await _rebuildSocket(serverUrl, token, deviceId); + }); + } + + Future _rebuildSocket(String serverUrl, String token, String deviceId) async { + await _teardownSocket(); + final socket = JellyfinWebSocket( + serverUrl: serverUrl, + token: token, + deviceId: deviceId, + ); + _socket = socket; + _socketStateSub = socket.connectionState.listen((s) { + if (!_stateController.isClosed) { + _stateController.add(s); + } + state = s; + }); + _socketMessageSub = socket.messages.listen((m) { + if (!_messageController.isClosed) { + _messageController.add(m); + } + }); + await socket.connect(); + } + + Future _teardownSocket() async { + await _socketStateSub?.cancel(); + await _socketMessageSub?.cancel(); + _socketStateSub = null; + _socketMessageSub = null; + await _socket?.dispose(); + _socket = null; + if (!_stateController.isClosed) { + _stateController.add(WebSocketConnectionState.disconnected); + } + state = WebSocketConnectionState.disconnected; + } + + Future _disposeAll() async { + _observer?.unregister(); + _observer = null; + await _teardownSocket(); + await _stateController.close(); + await _messageController.close(); + } +} diff --git a/lib/providers/websocket/jellyfin_websocket_provider.g.dart b/lib/providers/websocket/jellyfin_websocket_provider.g.dart new file mode 100644 index 000000000..07a9bffeb --- /dev/null +++ b/lib/providers/websocket/jellyfin_websocket_provider.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'jellyfin_websocket_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$jellyfinWebSocketControllerHash() => r'00896a5d9767b6479efff0b4a21465e7dc402b8d'; + +/// App-level shared Jellyfin WebSocket. +/// +/// Owns a single [JellyfinWebSocket], connects when a user is +/// authenticated, and re-broadcasts the socket's streams through +/// long-lived controllers so consumers stay subscribed transparently +/// across account switches / socket rebuilds. +/// +/// Copied from [JellyfinWebSocketController]. +@ProviderFor(JellyfinWebSocketController) +final jellyfinWebSocketControllerProvider = + NotifierProvider.internal( + JellyfinWebSocketController.new, + name: r'jellyfinWebSocketControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$jellyfinWebSocketControllerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$JellyfinWebSocketController = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package From 4103d45c4d5a7ae8308a726a6bcbf25df88b2508 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:05:13 +0200 Subject: [PATCH 03/14] feat(syncplay): integrate SyncPlay with the video player and media wrappers --- .../syncplay_command_indicator.dart | 145 ++++++++++++ .../components/video_player_next_wrapper.dart | 19 +- .../video_player_options_sheet.dart | 39 +++- .../video_player_screenshot_indicator.dart | 10 +- .../components/video_progress_bar.dart | 14 +- .../video_player/tv_player_controls.dart | 36 +-- lib/screens/video_player/video_player.dart | 44 +++- .../video_player/video_player_controls.dart | 67 ++++-- .../item_base_model/play_item_helpers.dart | 210 ++++++++++++++++-- lib/wrappers/media_control_wrapper.dart | 52 ++++- lib/wrappers/players/native_player.dart | 6 +- lib/wrappers/players/player_states.dart | 9 + 12 files changed, 562 insertions(+), 89 deletions(-) create mode 100644 lib/screens/video_player/components/syncplay_command_indicator.dart diff --git a/lib/screens/video_player/components/syncplay_command_indicator.dart b/lib/screens/video_player/components/syncplay_command_indicator.dart new file mode 100644 index 000000000..07cbd8377 --- /dev/null +++ b/lib/screens/video_player/components/syncplay_command_indicator.dart @@ -0,0 +1,145 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +/// Centered overlay showing SyncPlay command being processed, sync drift +/// correction in progress, or a next-episode-style queue switch. +class SyncPlayCommandIndicator extends ConsumerWidget { + const SyncPlayCommandIndicator({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final commandType = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); + final strategy = ref.watch(syncCorrectionStrategyProvider); + final isSwitching = ref.watch(syncPlayStartPlaybackInProgressProvider); + + final hasCorrection = strategy != SyncCorrectionStrategy.none; + final showCommand = isProcessing && commandType != null; + final visible = isActive && (showCommand || hasCorrection || isSwitching); + + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: visible ? 1 : 0, + child: Center( + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: visible ? 1.0 : 0.8, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CommandIcon( + commandType: commandType, + strategy: strategy, + isSwitching: isSwitching, + ), + const SizedBox(height: 12), + Text( + _label(context, isSwitching, showCommand, commandType, strategy), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + context.localized.syncPlaySyncingWithGroup, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + String _label( + BuildContext context, + bool isSwitching, + bool showCommand, + SyncPlayCommand? commandType, + SyncCorrectionStrategy strategy, + ) { + if (isSwitching) { + return context.localized.syncPlaySwitchingItem; + } + if (showCommand) { + return commandType.syncPlayCommandOverlayLabel(context); + } + return strategy.label(context); + } +} + +class _CommandIcon extends StatelessWidget { + final SyncPlayCommand? commandType; + final SyncCorrectionStrategy strategy; + final bool isSwitching; + + const _CommandIcon({ + required this.commandType, + required this.strategy, + required this.isSwitching, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (IconData icon, Color color) = isSwitching + ? (IconsaxPlusBold.refresh, scheme.primary) + : (commandType != null ? commandType.syncPlayCommandIconAndColor(context) : strategy.iconAndColor(context)); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 48, + color: color, + ), + ); + } +} diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 529a467e4..0faa6194f 100644 --- a/lib/screens/video_player/components/video_player_next_wrapper.dart +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -1,10 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:screen_brightness/screen_brightness.dart'; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/media_playback_model.dart'; @@ -23,11 +16,17 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; import 'package:fladder/widgets/navigation_scaffold/components/shared/player_bar_shared.dart'; import 'package:fladder/widgets/shared/progress_floating_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class VideoPlayerNextWrapper extends ConsumerStatefulWidget { final Widget video; final Widget controls; final List overlays; + const VideoPlayerNextWrapper({ required this.video, required this.controls, @@ -123,6 +122,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState Future closePlayer() async { clearOverlaySettings(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; ref.read(videoPlayerProvider).stop(); Navigator.of(context).pop(); } @@ -346,6 +346,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState class _NextUpInformation extends StatelessWidget { final ItemBaseModel item; + const _NextUpInformation({ required this.item, }); @@ -436,19 +437,19 @@ class _NextUpInformation extends StatelessWidget { class _SimpleControls extends ConsumerWidget { final Function()? skip; + const _SimpleControls({ this.skip, }); @override Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(videoPlayerProvider); final isPlaying = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton.filledTonal( - onPressed: () => player.playOrPause(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlayOrPause(), icon: Icon(isPlaying ? IconsaxPlusBold.pause : IconsaxPlusBold.play), ), if (skip != null) diff --git a/lib/screens/video_player/components/video_player_options_sheet.dart b/lib/screens/video_player/components/video_player_options_sheet.dart index 12d9658d2..274841473 100644 --- a/lib/screens/video_player/components/video_player_options_sheet.dart +++ b/lib/screens/video_player/components/video_player_options_sheet.dart @@ -14,6 +14,7 @@ import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/collections/add_to_collection.dart'; @@ -429,10 +430,21 @@ Future showSubSelection(BuildContext context) { ? Opacity(opacity: 0.6, child: Text(subModel.language.capitalize())) : null, onTap: () async { - final newModel = await playbackModel.setSubtitle(subModel, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); + Future doSwitch() async { + final newModel = await playbackModel.setSubtitle(subModel, player); + ref.read(playBackModel.notifier).update((state) => newModel); + if (newModel != null) { + await ref.read(playbackModelHelper).shouldReload( + newModel, + isLocalTrackSwitch: true, + ); + } + } + + if (ref.read(isSyncPlayActiveProvider)) { + await ref.read(syncPlayProvider.notifier).runLocalOnly(doSwitch); + } else { + await doSwitch(); } }, ); @@ -470,10 +482,21 @@ Future showAudioSelection(BuildContext context) { ? Opacity(opacity: 0.6, child: Text(audioStream.language.capitalize())) : null, onTap: () async { - final newModel = await playbackModel.setAudio(audioStream, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); + Future doSwitch() async { + final newModel = await playbackModel.setAudio(audioStream, player); + ref.read(playBackModel.notifier).update((state) => newModel); + if (newModel != null) { + await ref.read(playbackModelHelper).shouldReload( + newModel, + isLocalTrackSwitch: true, + ); + } + } + + if (ref.read(isSyncPlayActiveProvider)) { + await ref.read(syncPlayProvider.notifier).runLocalOnly(doSwitch); + } else { + await doSwitch(); } }); }, diff --git a/lib/screens/video_player/components/video_player_screenshot_indicator.dart b/lib/screens/video_player/components/video_player_screenshot_indicator.dart index 0ae67f7df..7b60556c1 100644 --- a/lib/screens/video_player/components/video_player_screenshot_indicator.dart +++ b/lib/screens/video_player/components/video_player_screenshot_indicator.dart @@ -46,7 +46,10 @@ class VideoPlayerScreenshotIndicatorState extends ConsumerState noSubsModel); if (noSubsModel != null) { - await ref.read(playbackModelHelper).shouldReload(noSubsModel); + await ref.read(playbackModelHelper).shouldReload( + noSubsModel, + isLocalTrackSwitch: true, + ); } result = await ref.read(videoPlayerProvider.notifier).takeScreenshot(); @@ -55,7 +58,10 @@ class VideoPlayerScreenshotIndicatorState extends ConsumerState restoredModel); if (restoredModel != null) { - await ref.read(playbackModelHelper).shouldReload(restoredModel); + await ref.read(playbackModelHelper).shouldReload( + restoredModel, + isLocalTrackSwitch: true, + ); } } else { result = await ref.read(videoPlayerProvider.notifier).takeScreenshot(); diff --git a/lib/screens/video_player/components/video_progress_bar.dart b/lib/screens/video_player/components/video_progress_bar.dart index 7c7989fe6..f4eae3604 100644 --- a/lib/screens/video_player/components/video_progress_bar.dart +++ b/lib/screens/video_player/components/video_progress_bar.dart @@ -1,9 +1,5 @@ import 'dart:math' as math; -import 'package:flutter/material.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/providers/video_player_provider.dart'; @@ -13,6 +9,8 @@ import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/gapped_container_shape.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; import 'package:fladder/widgets/shared/trick_play_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; class VideoProgressBar extends ConsumerStatefulWidget { final Function(bool value) wasPlayingChanged; @@ -107,10 +105,13 @@ class _ChapterProgressSliderState extends ConsumerState { ), onChangeEnd: (e) async { currentDuration = Duration(milliseconds: e.toInt()); + // Route seek through SyncPlay if active + widget.onPositionChanged(Duration(milliseconds: e.toInt())); widget.onPositionChanged.call(Duration(milliseconds: e.toInt())); await Future.delayed(const Duration(milliseconds: 250)); if (widget.wasPlaying) { - player.play(); + // Route play through SyncPlay if active + ref.read(videoPlayerProvider.notifier).userPlay(); } widget.timerReset.call(); setState(() { @@ -122,7 +123,8 @@ class _ChapterProgressSliderState extends ConsumerState { onHoverStart = true; }); widget.wasPlayingChanged.call(player.lastState?.playing ?? false); - player.pause(); + // Route pause through SyncPlay if active + ref.read(videoPlayerProvider.notifier).userPause(); }, onChanged: (e) { currentDuration = Duration(milliseconds: e.toInt()); diff --git a/lib/screens/video_player/tv_player_controls.dart b/lib/screens/video_player/tv_player_controls.dart index abf380490..b93003f6c 100644 --- a/lib/screens/video_player/tv_player_controls.dart +++ b/lib/screens/video_player/tv_player_controls.dart @@ -1,15 +1,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:async/async.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:screen_brightness/screen_brightness.dart'; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/media_playback_model.dart'; @@ -22,6 +13,7 @@ import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/screens/shared/media/components/item_logo.dart'; +import 'package:fladder/screens/video_player/components/syncplay_command_indicator.dart'; import 'package:fladder/screens/video_player/components/video_playback_information.dart'; import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart'; import 'package:fladder/screens/video_player/components/video_player_quality_controls.dart'; @@ -35,9 +27,18 @@ import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/input_handler.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_badge.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class TvPlayerControls extends ConsumerStatefulWidget { final Function(bool value) showGuide; + const TvPlayerControls({ required this.showGuide, super.key, @@ -75,7 +76,6 @@ class _TvPlayerControlsState extends ConsumerState { @override Widget build(BuildContext context) { - final player = ref.watch(videoPlayerProvider); return Listener( onPointerSignal: setVolume, child: InputHandler( @@ -98,7 +98,9 @@ class _TvPlayerControlsState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: initInputDevice == InputDevice.pointer ? () => player.playOrPause() : () => toggleOverlay(), + onTap: initInputDevice == InputDevice.pointer + ? () => ref.read(videoPlayerProvider.notifier).userPlayOrPause() + : () => toggleOverlay(), onDoubleTap: initInputDevice == InputDevice.pointer ? () => fullScreenHelper.toggleFullScreen(ref) : null, ), @@ -126,6 +128,7 @@ class _TvPlayerControlsState extends ConsumerState { const VideoPlayerSeekIndicator(), const VideoPlayerVolumeIndicator(), const VideoPlayerScreenshotIndicator(), + const SyncPlayCommandIndicator(), ], ), ), @@ -205,6 +208,7 @@ class _TvPlayerControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -277,7 +281,7 @@ class _TvPlayerControlsState extends ConsumerState { IconButton.filledTonal( iconSize: 38, onPressed: () { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); }, icon: Icon( mediaPlayback.playing ? IconsaxPlusBold.pause : IconsaxPlusBold.play, @@ -629,7 +633,10 @@ class _TvPlayerControlsState extends ConsumerState { void minimizePlayer(BuildContext context) { clearOverlaySettings(); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.minimized)); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); Navigator.of(context).pop(); } @@ -637,6 +644,7 @@ class _TvPlayerControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; ref.read(videoPlayerProvider).stop(); Navigator.of(context).pop(); } @@ -683,7 +691,7 @@ class _TvPlayerControlsState extends ConsumerState { switch (value) { case VideoHotKeys.playPause: - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index b6091af35..8fdf564b7 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -44,6 +44,32 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb } } + @override + void deactivate() { + // Capture the notifier synchronously while the consumer element is + // still alive, then defer the mutation to the next microtask to + // avoid "Tried to modify a provider while the widget tree was + // building" when deactivate runs inside a parent rebuild. + try { + final notifier = ref.read(mediaPlaybackProvider.notifier); + final currentPlaybackState = ref.read(mediaPlaybackProvider).state; + if (currentPlaybackState == VideoPlayerState.fullScreen) { + Future.microtask(() { + try { + notifier.update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); + } catch (_) { + // ProviderContainer may already be torn down. + } + }); + } + } catch (_) { + // ProviderContainer may already be torn down (app shutdown). + } + super.deactivate(); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -56,6 +82,7 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb super.initState(); WidgetsBinding.instance.addObserver(this); Future.microtask(() { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = true; ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen)); final orientations = ref.read(videoPlayerSettingsProvider.select((value) => value.allowedOrientations)); SystemChrome.setPreferredOrientations( @@ -72,11 +99,17 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb final playerController = ref.watch(videoPlayerProvider.select((value) => value)); - //Watch playbackModel type changes to switch between normal players + // Watch playbackModel type changes to switch between normal + // players. Guard with `mounted`: this listener can fire from an + // async callback (e.g. media-kit's loadVideo Future) that resolves + // after the player route has been popped/disposed - calling + // setState then triggers a "_lifecycleState != defunct" assertion. ref.listen( playBackModel, (previous, next) { - if (next == null) return; + if (!mounted || next == null) { + return; + } if (previous.runtimeType != next.runtimeType) { setState(() { currentPlaybackModel = next; @@ -89,9 +122,12 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb ref.listen( videoPlayerSettingsProvider.select((value) => value.allowedOrientations), (previous, next) { - if (previous != next) { - SystemChrome.setPreferredOrientations(next?.isNotEmpty == true ? next!.toList() : DeviceOrientation.values); + if (!mounted || previous == next) { + return; } + SystemChrome.setPreferredOrientations( + next?.isNotEmpty == true ? next!.toList() : DeviceOrientation.values, + ); }, ); diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 6a556751f..2b1a6be7e 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -1,10 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:async/async.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; @@ -20,10 +15,12 @@ import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/pip_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/screens/shared/media/components/item_logo.dart'; +import 'package:fladder/screens/video_player/components/syncplay_command_indicator.dart'; import 'package:fladder/screens/video_player/components/video_playback_information.dart'; import 'package:fladder/screens/video_player/components/video_player_brightness_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_controls_extras.dart'; @@ -42,7 +39,12 @@ import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_badge.dart'; import 'package:fladder/wrappers/pip_manager.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class DesktopControls extends ConsumerStatefulWidget { const DesktopControls({super.key}); @@ -145,7 +147,9 @@ class _DesktopControlsState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: initInputDevice == InputDevice.pointer ? null : () => toggleOverlay(), + onTap: initInputDevice == InputDevice.pointer + ? () => ref.read(videoPlayerProvider.notifier).userPlayOrPause() + : () => toggleOverlay(), onDoubleTapDown: initInputDevice == InputDevice.touch ? _handleDoubleTapDown : null, onDoubleTap: initInputDevice == InputDevice.pointer ? () => fullScreenHelper.toggleFullScreen(ref) @@ -186,6 +190,7 @@ class _DesktopControlsState extends ConsumerState { const VideoPlayerBrightnessIndicator(), const VideoPlayerSpeedIndicator(), const VideoPlayerScreenshotIndicator(), + const SyncPlayCommandIndicator(), Consumer( builder: (context, ref, child) { final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); @@ -245,7 +250,7 @@ class _DesktopControlsState extends ConsumerState { : 1, duration: const Duration(milliseconds: 250), child: IconButton.outlined( - onPressed: () => ref.read(videoPlayerProvider).play(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlay(), isSelected: true, iconSize: 65, tooltip: "Resume video", @@ -311,6 +316,7 @@ class _DesktopControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -433,7 +439,7 @@ class _DesktopControlsState extends ConsumerState { IconButton.filledTonal( iconSize: 38, onPressed: () { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); }, icon: Icon( playing ? IconsaxPlusBold.pause : IconsaxPlusBold.play, @@ -513,9 +519,10 @@ class _DesktopControlsState extends ConsumerState { ), const Spacer(), if (playbackModel != null) - InkWell( - onTap: () => showVideoPlaybackInformation(context), - child: Card( + Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => showVideoPlaybackInformation(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text( @@ -547,7 +554,7 @@ class _DesktopControlsState extends ConsumerState { buffer: mediaPlayback.buffer, buffering: mediaPlayback.buffering, timerReset: () => timer.reset(), - onPositionChanged: (position) => ref.read(videoPlayerProvider).seek(position), + onPositionChanged: (position) => ref.read(videoPlayerProvider.notifier).userSeek(position), ), ), const SizedBox(height: 4), @@ -690,7 +697,7 @@ class _DesktopControlsState extends ConsumerState { final end = mediaSegment?.end; if (end != null) { resetTimer(); - ref.read(videoPlayerProvider).seek(end); + ref.read(videoPlayerProvider.notifier).userSeek(end); if (segmentId != null) { Future(() { @@ -717,7 +724,7 @@ class _DesktopControlsState extends ConsumerState { final mediaPlayback = ref.read(mediaPlaybackProvider); resetTimer(); final newPosition = (mediaPlayback.position.inSeconds + seconds).clamp(0, mediaPlayback.duration.inSeconds); - ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition)); + ref.read(videoPlayerProvider.notifier).userSeek(Duration(seconds: newPosition)); } void stepBack(WidgetRef ref) { @@ -769,7 +776,10 @@ class _DesktopControlsState extends ConsumerState { void minimizePlayer(BuildContext context) { clearOverlaySettings(); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.minimized)); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); Navigator.of(context).pop(); } @@ -777,7 +787,24 @@ class _DesktopControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); + // Mark the route as closed immediately so that a SyncPlay + // _startPlayback call arriving during the pop animation knows + // it must push a new route. + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + // Fire-and-forget the stop. The wrapper's stop() chain reports the + // session to the server (POST /Sessions/Playing/Stopped, ~2s) and + // we don't want the user staring at a black player with live + // controls during that time. Awaiting it would also reach `ref` + // after the widget is disposed by the route pop, throwing. ref.read(videoPlayerProvider).stop(); + if (ref.read(isSyncPlayActiveProvider)) { + // In SyncPlay we previously only paused, which left the floating + // mini-player visible and let a server-broadcast Unpause resume + // local playback in the background. Null out the playback model + // so the mini-player disappears; the user can re-attach via the + // SyncPlay sheet's "Resume Playback" button. + ref.read(playBackModel.notifier).update((_) => null); + } Navigator.of(context).pop(); } @@ -851,7 +878,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { _deactivateSpeedBoost(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } return KeyEventResult.handled; } @@ -880,7 +907,7 @@ class _DesktopControlsState extends ConsumerState { } else if (tapX > zoneThird * 2) { seekForwardWithIndicator(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } _doubleTapPosition = null; } @@ -1014,7 +1041,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { return false; } - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); @@ -1079,10 +1106,10 @@ class _DesktopControlsState extends ConsumerState { seekBack(ref, seconds: seekBackSeconds); return true; case VideoHotKeys.stepForward: - playing ? ref.read(videoPlayerProvider).playOrPause() : stepForward(ref); + playing ? ref.read(videoPlayerProvider.notifier).userPlayOrPause() : stepForward(ref); return true; case VideoHotKeys.stepBack: - playing ? ref.read(videoPlayerProvider).playOrPause() : stepBack(ref); + playing ? ref.read(videoPlayerProvider.notifier).userPlayOrPause() : stepBack(ref); return true; default: return false; diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index c3435370f..41e6bb835 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -1,16 +1,9 @@ import 'dart:developer'; import 'dart:math' show Random, min; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:async/async.dart'; import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:square_progress_indicator/square_progress_indicator.dart'; - import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/book_model.dart'; import 'package:fladder/models/item_base_model.dart'; @@ -18,14 +11,18 @@ import 'package:fladder/models/items/album_model.dart'; import 'package:fladder/models/items/artist_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/channel_model.dart'; +import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/models/items/playlist_model.dart'; +import 'package:fladder/models/items/season_model.dart'; +import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/tv_playback_model.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/book_viewer_provider.dart'; import 'package:fladder/providers/items/book_details_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/book_viewer/book_viewer_screen.dart'; @@ -38,6 +35,13 @@ import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:square_progress_indicator/square_progress_indicator.dart'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; part 'play_playlist_helpers.dart'; @@ -98,6 +102,8 @@ extension PhotoAlbumExtension on PhotoAlbumModel? { final getChildItems = await op.valueOrCancellation(null); if (op.isCanceled || getChildItems == null) { if (!op.isCanceled) { + log('unableToPlayMedia [PhotoAlbumModel.play]: ' + 'getChildItems was null for album=${albumModel.id}'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -155,6 +161,8 @@ extension ChannelModelExtension on ChannelModel? { if (op.isCanceled || model == null) { if (!op.isCanceled) { + log('unableToPlayMedia [ChannelModel.play]: ' + 'createPlaybackModel returned null for channel=${this!.id}'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -631,6 +639,17 @@ extension ItemBaseModelExtensions on ItemBaseModel? { }) async { if (itemModel == null) return; + // When SyncPlay is active, delegate to SyncPlay queue management. + // _startPlayback (triggered by the server's PlayQueue response) + // handles player init and route opening. + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + if (isSyncPlayActive) { + await _playSyncPlay(context, itemModel, ref, startPosition: startPosition); + return; + } + + await ref.read(videoPlayerProvider.notifier).init(); + final op = CancelableOperation.fromFuture(ref.read(playbackModelHelper).createPlaybackModel( context, itemModel, @@ -649,6 +668,8 @@ extension ItemBaseModelExtensions on ItemBaseModel? { log('Error closing loading dialog: $e'); } if (!showPlaybackOption) { + log('unableToPlayMedia [ItemBaseModel._default]: ' + 'createPlaybackModel returned null for item=${itemModel.id}'); FladderSnack.show(context.localized.unableToPlayMedia, context: context); } } @@ -661,6 +682,98 @@ extension ItemBaseModelExtensions on ItemBaseModel? { } } +/// Play item through SyncPlay - sets the queue and lets SyncPlay +/// handle synchronized playback. Mirrors the local playback path by +/// showing the same loader dialog so the user knows the request was +/// accepted while the server distributes the new queue and our own +/// `_startPlayback` runs. +Future _playSyncPlay( + BuildContext context, + ItemBaseModel itemModel, + WidgetRef ref, { + Duration? startPosition, +}) async { + // Build the full play queue so episodes are sent to the group WITH their + // series context. The local playback path builds this via + // createPlaybackModel -> collectQueue; the SyncPlay path must match it. + // A lone single-episode queue is not started by the official Jellyfin + // clients (e.g. the webOS TV app) even though movies — naturally + // single-item — work fine. Movies return an empty seriesQueue and fall + // back to a single item. + final helper = ref.read(playbackModelHelper); + final List seriesQueue = await helper.collectQueue(itemModel); + + // Series/Season tiles resolve to their next-up episode (mirrors + // createPlaybackModel's firstItemToPlay switch). + ItemBaseModel target = itemModel; + if (itemModel is SeriesModel || itemModel is SeasonModel) { + final resolved = seriesQueue.whereType().toList().nextUp; + if (resolved != null) target = resolved; + } + + final List itemIds = seriesQueue.isNotEmpty ? seriesQueue.map((e) => e.id).toList() : [target.id]; + final int playingItemPosition = + seriesQueue.isNotEmpty ? seriesQueue.indexWhere((e) => e.id == target.id).clamp(0, itemIds.length - 1) : 0; + + // Fall back to the resolved item's saved resume position (mirrors the local + // path's `model.startDuration()`) so "Continue Watching" resumes mid-item + // for the whole group instead of restarting from 0. Previously, every play + // that didn't pass an explicit startPosition sent startPositionTicks: 0. + final effectiveStart = startPosition ?? target.userData.playBackPosition; + final startPositionTicks = secondsToTicks(effectiveStart.inMilliseconds / 1000); + + final notifier = ref.read(syncPlayProvider.notifier); + final pending = notifier.awaitNextStartPlayback( + timeout: const Duration(seconds: 20), + ); + final op = CancelableOperation.fromFuture(pending); + + _showLoadingIndicator(context, itemModel, op, autoCloseOnComplete: true); + + final queueAccepted = await notifier.setNewQueue( + itemIds: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + + // setNewQueue is debounced server-side to avoid two participants + // racing the same request. When suppressed there is no PlayQueue + // broadcast and no `_startPlayback`, so awaiting it would always time + // out 20s later with a misleading "unable to play" snack. Cancel the + // pending wait and treat it as a successful no-op (the playback is + // already in flight from another participant or our own previous + // click). + if (!queueAccepted) { + log('SyncPlay: _playSyncPlay short-circuited - setNewQueue debounced ' + 'or rejected for item=${itemModel.id}'); + if (!op.isCanceled) { + await op.cancel(); + } + // Op is cancelled so the auto-close listener on the dialog won't + // fire (CancelableOperation.value never completes after cancel). + // No player route has been pushed yet (no PlayQueue → no + // _startPlayback), so popping the root navigator here safely closes + // just the loader dialog. + if (context.mounted) { + try { + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) {} + } + return; + } + + final ok = await op.valueOrCancellation(false) ?? false; + // Loading dialog auto-closes via _LoadIndicatorCancelable when [op] + // completes; do not pop the root navigator manually here, otherwise we + // may pop the player route that `_startPlayback` pushed on top of the + // dialog. + if (!op.isCanceled && !ok && context.mounted) { + log('unableToPlayMedia [_playSyncPlay]: ' + 'awaitNextStartPlayback returned false for item=${itemModel.id}'); + FladderSnack.show(context.localized.unableToPlayMedia, context: context); + } +} + extension ItemBaseModelsBooleans on List { Future playLibraryItems(BuildContext context, WidgetRef ref, {bool shuffle = false}) async { if (isEmpty) return; @@ -684,6 +797,18 @@ extension ItemBaseModelsBooleans on List { expandedList.shuffle(); } + // If in SyncPlay group, set the queue via SyncPlay + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + if (isSyncPlayActive) { + Navigator.of(context, rootNavigator: true).pop(); // Pop loading indicator + await ref.read(syncPlayProvider.notifier).setNewQueue( + itemIds: expandedList.map((e) => e.id).toList(), + playingItemPosition: 0, + startPositionTicks: 0, + ); + return (null, expandedList); + } + PlaybackModel? model = await ref.read(playbackModelHelper).createPlaybackModel( context, expandedList.firstOrNull, @@ -698,6 +823,8 @@ extension ItemBaseModelsBooleans on List { final result = await op.valueOrCancellation(null); if (op.isCanceled || result == null) { if (!op.isCanceled) { + log('unableToPlayMedia [playLibraryItems]: ' + 'aggregated playback result was null (items=$length)'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -711,6 +838,14 @@ extension ItemBaseModelsBooleans on List { final PlaybackModel? model = result.$1; final List expandedList = result.$2; + // SyncPlay path: queue was set via setNewQueue, no local PlaybackModel + if (model == null && expandedList.isNotEmpty) { + if (context.mounted) { + RefreshState.maybeOf(context)?.refresh(); + } + return; + } + if (context.mounted) { await _playVideo(context, ref: ref, queue: expandedList, current: model, cancelOperation: op); if (context.mounted) { @@ -803,19 +938,60 @@ extension ItemBaseModelsBooleans on List { } } -Future _showLoadingIndicator(BuildContext context, ItemBaseModel? item, CancelableOperation op) async { +Future _showLoadingIndicator( + BuildContext context, + ItemBaseModel? item, + CancelableOperation op, { + bool autoCloseOnComplete = false, +}) async { return showDialog( barrierDismissible: false, useRootNavigator: true, context: context, - builder: (context) => _LoadIndicatorCancelable(op: op, item: item), + builder: (context) => _LoadIndicatorCancelable( + op: op, + item: item, + autoCloseOnComplete: autoCloseOnComplete, + ), ); } -class _LoadIndicatorCancelable extends StatelessWidget { +class _LoadIndicatorCancelable extends StatefulWidget { final ItemBaseModel? item; final CancelableOperation op; - const _LoadIndicatorCancelable({required this.op, this.item}); + final bool autoCloseOnComplete; + const _LoadIndicatorCancelable({ + required this.op, + this.item, + this.autoCloseOnComplete = false, + }); + + @override + State<_LoadIndicatorCancelable> createState() => _LoadIndicatorCancelableState(); +} + +class _LoadIndicatorCancelableState extends State<_LoadIndicatorCancelable> { + @override + void initState() { + super.initState(); + if (!widget.autoCloseOnComplete) { + return; + } + // Auto-close as soon as the underlying operation finishes. Used by + // the SyncPlay flow where `_startPlayback` pushes the player route + // on top of this dialog after the server's PlayQueue update; if the + // caller popped the root navigator manually after awaiting the op, + // it would pop the player route instead of this dialog, which would + // minimize the player and surface a spurious "unable to play" snack. + widget.op.value.whenComplete(() { + if (!mounted) { + return; + } + try { + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) {} + }); + } @override Widget build(BuildContext context) { @@ -837,7 +1013,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { child: Row( spacing: 16, children: [ - if (item != null) + if (widget.item != null) Flexible( child: Container( decoration: FladderTheme.defaultPosterDecoration, @@ -862,7 +1038,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { ), clipBehavior: Clip.hardEdge, child: FladderImage( - image: item!.getPosters?.primary, + image: widget.item!.getPosters?.primary, fit: BoxFit.cover, ), ), @@ -886,9 +1062,9 @@ class _LoadIndicatorCancelable extends StatelessWidget { context.localized.loading, style: Theme.of(context).textTheme.titleLarge, ), - if (item != null) ...[ + if (widget.item != null) ...[ Text( - item!.title, + widget.item!.title, style: Theme.of(context).textTheme.bodyMedium, ), ], @@ -903,7 +1079,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { tooltip: context.localized.close, onPressed: () { try { - op.cancel(); + widget.op.cancel(); } catch (_) {} Navigator.of(context, rootNavigator: true).pop(); }, @@ -932,6 +1108,8 @@ Future _playVideo( } catch (e) { log('Error closing loading dialog: $e'); } + log('unableToPlayMedia [_playVideo]: ' + 'current PlaybackModel was null (queue=${queue?.length ?? 0})'); FladderSnack.show(context.localized.unableToPlayMedia, context: context); } return; diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 4aa0eb7b9..17e3eedcf 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -2,15 +2,8 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/channel_model.dart'; @@ -38,6 +31,11 @@ import 'package:fladder/wrappers/players/lib_mdk.dart' import 'package:fladder/wrappers/players/lib_mpv.dart'; import 'package:fladder/wrappers/players/native_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; part 'audio_queue_handler.dart'; @@ -62,6 +60,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) => _player?.subtitles(showOverlay, controlsKey: controlsKey); + Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit); final Ref ref; @@ -193,6 +192,19 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro _previousPlayer = null; } + /// Check if the native Android player is currently active + bool get isNativePlayerActive => _player is NativePlayer; + + /// Update SyncPlay command state for the native player overlay + Future updateSyncPlayCommandState( + bool processing, + SyncPlayCommandType commandType, + ) async { + if (_player is NativePlayer) { + await (_player as NativePlayer).player.setSyncPlayCommandState(processing, commandType); + } + } + Future openPlayer(BuildContext context) async => _player?.open(context); Future _updatePositionWithRetry(PlaybackModel model, Duration position, bool isPlaying) async { @@ -635,7 +647,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro playbackModel.audioStreams?.firstWhere((element) => element.index == value), this); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); + await ref.read(playbackModelHelper).shouldReload( + newModel, + isLocalTrackSwitch: true, + ); } } @@ -646,7 +661,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro playbackModel.subStreams?.firstWhere((element) => element.index == value), this); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); + await ref.read(playbackModelHelper).shouldReload( + newModel, + isLocalTrackSwitch: true, + ); } } @@ -683,6 +701,22 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro .toList(); } + // SyncPlay-aware user actions from native player + @override + void onUserPlay() { + ref.read(videoPlayerProvider.notifier).userPlay(); + } + + @override + void onUserPause() { + ref.read(videoPlayerProvider.notifier).userPause(); + } + + @override + void onUserSeek(int positionMs) { + ref.read(videoPlayerProvider.notifier).userSeek(Duration(milliseconds: positionMs)); + } + Future takeScreenshot() { final player = _player; diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index ffa249336..3e1c56570 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -78,7 +78,9 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { } @override - Future setSpeed(double speed) async {} + Future setSpeed(double speed) async { + await player.setPlaybackSpeed(speed); + } @override Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async { @@ -114,6 +116,8 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { position: Duration(milliseconds: state.position), buffer: Duration(milliseconds: state.buffered), buffering: state.buffering, + changeSource: state.changeSource, + updateChangeSource: true, ); _stateController.add(lastState); } diff --git a/lib/wrappers/players/player_states.dart b/lib/wrappers/players/player_states.dart index a73e4f640..4bf3a4049 100644 --- a/lib/wrappers/players/player_states.dart +++ b/lib/wrappers/players/player_states.dart @@ -1,3 +1,5 @@ +import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource; + class PlayerState { bool playing; bool completed; @@ -8,6 +10,9 @@ class PlayerState { bool buffering; Duration buffer; + /// Set when state came from native player (for SyncPlay: infer user actions from stream). + PlaybackChangeSource? changeSource; + PlayerState({ this.playing = false, this.completed = false, @@ -17,6 +22,7 @@ class PlayerState { this.rate = 1.0, this.buffering = true, this.buffer = Duration.zero, + this.changeSource, }); PlayerState update({ @@ -28,6 +34,8 @@ class PlayerState { double? volume, double? rate, Duration? buffer, + PlaybackChangeSource? changeSource, + bool updateChangeSource = false, }) { if (playing != null) this.playing = playing; if (completed != null) this.completed = completed; @@ -37,6 +45,7 @@ class PlayerState { if (volume != null) this.volume = volume; if (rate != null) this.rate = rate; if (buffer != null) this.buffer = buffer; + if (updateChangeSource) this.changeSource = changeSource; return this; } } From c8e3bcce7bc0b98431d70f57ae037163d0e06676 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:05:14 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat(syncplay):=20add=20SyncPlay=20UI=20?= =?UTF-8?q?=E2=80=94=20badges,=20FABs,=20group=20sheet=20and=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/home_screen.dart | 9 +- lib/screens/syncing/sync_item_details.dart | 3 +- .../components/destination_model.dart | 7 + .../components/side_navigation_bar.dart | 21 +- .../components/video_player_bar_content.dart | 2 +- .../navigation_scaffold.dart | 2 +- lib/widgets/syncplay/dashboard_fabs.dart | 92 ++++ lib/widgets/syncplay/syncplay_badge.dart | 131 +++++ lib/widgets/syncplay/syncplay_extensions.dart | 103 ++++ lib/widgets/syncplay/syncplay_fab.dart | 47 ++ .../syncplay/syncplay_group_sheet.dart | 480 ++++++++++++++++++ lib/widgets/syncplay/syncplay_utils.dart | 14 + 12 files changed, 899 insertions(+), 12 deletions(-) create mode 100644 lib/widgets/syncplay/dashboard_fabs.dart create mode 100644 lib/widgets/syncplay/syncplay_badge.dart create mode 100644 lib/widgets/syncplay/syncplay_extensions.dart create mode 100644 lib/widgets/syncplay/syncplay_fab.dart create mode 100644 lib/widgets/syncplay/syncplay_group_sheet.dart create mode 100644 lib/widgets/syncplay/syncplay_utils.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 882c2be5e..f334c4c05 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -21,6 +21,7 @@ import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; import 'package:fladder/widgets/navigation_scaffold/navigation_scaffold.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; +import 'package:fladder/widgets/syncplay/dashboard_fabs.dart'; enum HomeTabs { dashboard, @@ -132,13 +133,7 @@ class HomeScreen extends ConsumerWidget { action: () => e.navigate(context), onLongPress: () => _showDashboardSwitcher(context, ref), onSecondaryTapDown: (_) => _showDashboardSwitcher(context, ref), - floatingActionButton: AdaptiveFab( - context: context, - title: context.localized.search, - key: Key(e.name.capitalize()), - onPressed: () => context.router.navigate(LibrarySearchRoute()), - child: const Icon(IconsaxPlusLinear.search_normal_1), - ), + customFab: const DashboardFabs(), ); case HomeTabs.favorites: return DestinationModel( diff --git a/lib/screens/syncing/sync_item_details.dart b/lib/screens/syncing/sync_item_details.dart index 0bb370e21..c98ac45b3 100644 --- a/lib/screens/syncing/sync_item_details.dart +++ b/lib/screens/syncing/sync_item_details.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:background_downloader/background_downloader.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; @@ -26,6 +24,7 @@ import 'package:fladder/util/size_formatting.dart'; import 'package:fladder/widgets/shared/alert_content.dart'; import 'package:fladder/widgets/shared/icon_button_await.dart'; import 'package:fladder/widgets/shared/pull_to_refresh.dart'; +import 'package:flutter/material.dart'; Future showSyncItemDetails( BuildContext context, diff --git a/lib/widgets/navigation_scaffold/components/destination_model.dart b/lib/widgets/navigation_scaffold/components/destination_model.dart index f59a22340..ae76365fe 100644 --- a/lib/widgets/navigation_scaffold/components/destination_model.dart +++ b/lib/widgets/navigation_scaffold/components/destination_model.dart @@ -17,6 +17,9 @@ class DestinationModel { final Widget? badge; final AdaptiveFab? floatingActionButton; + /// Custom FAB widget - takes precedence over floatingActionButton if provided + final Widget? customFab; + DestinationModel({ required this.label, this.icon, @@ -28,8 +31,12 @@ class DestinationModel { this.tooltip, this.badge, this.floatingActionButton, + this.customFab, }); + /// Returns the FAB widget to use - prefers customFab over floatingActionButton.normal + Widget? get fabWidget => customFab ?? floatingActionButton?.normal; + /// Converts this [DestinationModel] to a [NavigationRailDestination] used in a [NavigationRail]. NavigationRailDestination toNavigationRailDestination({EdgeInsets? padding}) { return NavigationRailDestination( diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index bc310a2ce..f17b476be 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -181,7 +181,7 @@ class SideNavigationRail extends ConsumerWidget { const EdgeInsets.symmetric(horizontal: 4).copyWith(bottom: expandedSideBar ? 10 : 0), child: AnimatedFadeSize( duration: const Duration(milliseconds: 250), - child: shouldExpand ? actionButton(context).extended : actionButton(context).normal, + child: actionButtonWidget(context, shouldExpand), ), ), ], @@ -236,6 +236,25 @@ class SideNavigationRail extends ConsumerWidget { child: const Icon(IconsaxPlusLinear.search_normal_1), ); } + + Widget actionButtonWidget(BuildContext context, bool expanded) { + final destination = (widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length) + ? widget.destinations[widget.currentIndex] + : null; + + // If there's a custom FAB widget, use it (DashboardFabs already + // includes SyncPlay for the dashboard route). + if (destination?.customFab != null) { + return destination!.customFab!; + } + + // For non-dashboard rails: show only the route's primary action FAB. + // SyncPlay access comes from the dashboard FAB and the SyncPlayBadge + // (a non-FAB indicator that opens the same sheet) — stacking two FABs + // here violates AGENTS.md rule 4. + final fab = actionButton(context); + return expanded ? fab.extended : fab.normal; + } } class _RailTraversalPolicy extends ReadingOrderTraversalPolicy { diff --git a/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart b/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart index d502464b9..146b093e6 100644 --- a/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart +++ b/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart @@ -90,7 +90,7 @@ class VideoFloatingPlayerBarContent extends ConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: IconButton.filledTonal( - onPressed: () => ref.read(videoPlayerProvider).playOrPause(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlayOrPause(), icon: playbackState.playing ? const Icon(Icons.pause_rounded) : const Icon(Icons.play_arrow_rounded), diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index aa1bca04e..b18f84260 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -116,7 +116,7 @@ class _NavigationScaffoldState extends ConsumerState { extendBody: true, floatingActionButton: !showAudioFullScreen && AdaptiveLayout.layoutModeOf(scaffoldContext) == LayoutMode.single && isHomeScreen - ? widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal + ? widget.destinations.elementAtOrNull(currentIndex)?.fabWidget : null, drawer: !showAudioFullScreen && homeRoutes.any((element) => element.name.contains(currentLocation)) ? NestedNavigationDrawer( diff --git a/lib/widgets/syncplay/dashboard_fabs.dart b/lib/widgets/syncplay/dashboard_fabs.dart new file mode 100644 index 000000000..48c114afa --- /dev/null +++ b/lib/widgets/syncplay/dashboard_fabs.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/routes/auto_router.gr.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; +import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; + +/// Combined FAB for dashboard with search and SyncPlay actions +class DashboardFabs extends ConsumerWidget { + const DashboardFabs({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final isDualLayout = AdaptiveLayout.of(context).layoutMode == LayoutMode.dual; + + final children = [ + // SyncPlay FAB + _SyncPlayFabButton(isActive: isActive), + // Search FAB + AdaptiveFab( + context: context, + title: context.localized.search, + key: const Key('dashboard_search'), + onPressed: () => context.router.navigate(LibrarySearchRoute()), + child: const Icon(IconsaxPlusLinear.search_normal_1), + ).normal, + ]; + + return isDualLayout + ? Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ) + : Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ); + } +} + +class _SyncPlayFabButton extends StatelessWidget { + final bool isActive; + + const _SyncPlayFabButton({required this.isActive}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: 'syncplay_fab', + child: IconButton.filledTonal( + iconSize: 26, + tooltip: context.localized.syncPlay, + onPressed: () => showSyncPlaySheet(context), + style: IconButton.styleFrom( + backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, + ), + icon: Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + Icon( + isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_badge.dart b/lib/widgets/syncplay/syncplay_badge.dart new file mode 100644 index 000000000..c668e8db4 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_badge.dart @@ -0,0 +1,131 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +/// Badge widget showing SyncPlay status in the video player +class SyncPlayBadge extends ConsumerWidget { + const SyncPlayBadge({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + if (!isActive) return const SizedBox.shrink(); + + final groupName = ref.watch(syncPlayGroupNameProvider); + final groupState = ref.watch(syncPlayGroupStateProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final processingCommand = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); + final correctionStrategy = ref.watch(syncCorrectionStrategyProvider); + final hasCorrection = correctionStrategy != SyncCorrectionStrategy.none; + + final (icon, color) = groupState.iconAndColor(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: (isProcessing || hasCorrection) + ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.95) + : Theme.of(context).colorScheme.surface.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: (isProcessing || hasCorrection) ? Theme.of(context).colorScheme.primary : color.withValues(alpha: 0.5), + width: (isProcessing || hasCorrection) ? 2 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isProcessing || hasCorrection) ...[ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 6), + Text( + isProcessing ? processingCommand.syncPlayProcessingLabel(context) : correctionStrategy.label(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ] else ...[ + Icon( + IconsaxPlusLinear.people, + size: 14, + color: color, + ), + const SizedBox(width: 6), + Text( + groupName ?? context.localized.syncPlay, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 6), + Icon( + icon, + size: 12, + color: color, + ), + ], + ], + ), + ); + } +} + +/// Compact SyncPlay indicator for tight spaces +class SyncPlayIndicator extends ConsumerWidget { + const SyncPlayIndicator({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + if (!isActive) return const SizedBox.shrink(); + + final groupState = ref.watch(syncPlayGroupStateProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final correctionStrategy = ref.watch(syncCorrectionStrategyProvider); + final hasCorrection = correctionStrategy != SyncCorrectionStrategy.none; + + final color = groupState.color(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: (isProcessing || hasCorrection) + ? Theme.of(context).colorScheme.primaryContainer + : color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: + (isProcessing || hasCorrection) ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) : null, + ), + child: (isProcessing || hasCorrection) + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ) + : Icon( + IconsaxPlusBold.people, + size: 16, + color: color, + ), + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_extensions.dart b/lib/widgets/syncplay/syncplay_extensions.dart new file mode 100644 index 000000000..648b03b29 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_extensions.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/util/localization_helper.dart'; + +/// Extension on [SyncPlayGroupState] for badge/indicator icon and color. +extension SyncPlayGroupStateExtension on SyncPlayGroupState { + /// Returns (icon, color) for the current group state. + (IconData, Color) iconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncPlayGroupState.idle => ( + IconsaxPlusLinear.pause_circle, + scheme.onSurfaceVariant, + ), + SyncPlayGroupState.waiting => ( + IconsaxPlusLinear.timer_1, + scheme.tertiary, + ), + SyncPlayGroupState.paused => ( + IconsaxPlusLinear.pause, + scheme.secondary, + ), + SyncPlayGroupState.playing => ( + IconsaxPlusLinear.play, + scheme.primary, + ), + }; + } + + /// Returns the color only (for compact indicator). + Color color(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncPlayGroupState.idle => scheme.onSurfaceVariant, + SyncPlayGroupState.waiting => scheme.tertiary, + SyncPlayGroupState.paused => scheme.secondary, + SyncPlayGroupState.playing => scheme.primary, + }; + } +} + +/// Extension for localized SyncPlay command processing label (typed +/// against [SyncPlayCommand] instead of raw strings - see AGENTS.md +/// SyncPlay rule 6 about centralizing repeated display logic). +extension SyncPlayCommandLabelExtension on SyncPlayCommand? { + /// Returns the localized "Syncing..." text for this command type. + String syncPlayProcessingLabel(BuildContext context) { + return switch (this) { + SyncPlayCommand.pause => context.localized.syncPlaySyncingPause, + SyncPlayCommand.unpause => context.localized.syncPlaySyncingPlay, + SyncPlayCommand.seek => context.localized.syncPlaySyncingSeek, + SyncPlayCommand.stop => context.localized.syncPlayStopping, + null => context.localized.syncPlaySyncing, + }; + } + + /// Returns the short command label for overlay (e.g. "Pausing"). + String syncPlayCommandOverlayLabel(BuildContext context) { + return switch (this) { + SyncPlayCommand.pause => context.localized.syncPlayCommandPausing, + SyncPlayCommand.unpause => context.localized.syncPlayCommandPlaying, + SyncPlayCommand.seek => context.localized.syncPlayCommandSeeking, + SyncPlayCommand.stop => context.localized.syncPlayCommandStopping, + null => context.localized.syncPlayCommandSyncing, + }; + } + + /// Returns (icon, color) for the command overlay. + (IconData, Color) syncPlayCommandIconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncPlayCommand.pause => (IconsaxPlusBold.pause, scheme.secondary), + SyncPlayCommand.unpause => (IconsaxPlusBold.play, scheme.primary), + SyncPlayCommand.seek => (IconsaxPlusBold.forward, scheme.tertiary), + SyncPlayCommand.stop => (IconsaxPlusBold.stop, scheme.error), + null => (IconsaxPlusBold.refresh, scheme.primary), + }; + } +} + +/// Extension for correction strategy UI mapping. +extension SyncCorrectionStrategyExtension on SyncCorrectionStrategy { + /// Returns short label for active correction strategy. + String label(BuildContext context) { + return switch (this) { + SyncCorrectionStrategy.none => context.localized.syncPlaySyncing, + SyncCorrectionStrategy.speedToSync => 'SpeedToSync', + SyncCorrectionStrategy.skipToSync => 'SkipToSync', + }; + } + + /// Returns icon and color for active correction strategy. + (IconData, Color) iconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncCorrectionStrategy.none => (IconsaxPlusBold.refresh, scheme.primary), + SyncCorrectionStrategy.speedToSync => (IconsaxPlusBold.flash_1, scheme.primary), + SyncCorrectionStrategy.skipToSync => (IconsaxPlusBold.forward, scheme.tertiary), + }; + } +} diff --git a/lib/widgets/syncplay/syncplay_fab.dart b/lib/widgets/syncplay/syncplay_fab.dart new file mode 100644 index 000000000..9df83c6cc --- /dev/null +++ b/lib/widgets/syncplay/syncplay_fab.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; +import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; + +/// FAB for accessing SyncPlay from the home screen +class SyncPlayFab extends ConsumerWidget { + const SyncPlayFab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + return AdaptiveFab( + context: context, + title: context.localized.syncPlay, + heroTag: 'syncplay_fab', + backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, + onPressed: () => showSyncPlaySheet(context), + child: Stack( + children: [ + Icon( + isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ).normal; + } +} diff --git a/lib/widgets/syncplay/syncplay_group_sheet.dart b/lib/widgets/syncplay/syncplay_group_sheet.dart new file mode 100644 index 000000000..d1def7d75 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_group_sheet.dart @@ -0,0 +1,480 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; + +/// Bottom sheet for managing SyncPlay groups +class SyncPlayGroupSheet extends ConsumerStatefulWidget { + const SyncPlayGroupSheet({super.key}); + + @override + ConsumerState createState() => _SyncPlayGroupSheetState(); +} + +class _SyncPlayGroupSheetState extends ConsumerState { + @override + void initState() { + super.initState(); + // Defer so we don't modify a provider during the widget lifecycle (Riverpod disallows this). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).loadGroups(); + } + }); + } + + Future _createGroup() async { + final name = await _showCreateGroupDialog(); + if (name == null || name.isEmpty) return; + + ref.read(syncPlayGroupsProvider.notifier).setLoading(true); + + final group = await ref.read(syncPlayProvider.notifier).createGroup(name); + if (group != null && mounted) { + FladderSnack.show(context.localized.syncPlayCreatedGroup(group.groupName ?? ''), context: context); + Navigator.of(context).pop(); + } else { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).setLoading(false); + FladderSnack.show(context.localized.syncPlayFailedToCreateGroup, context: context); + } + } + } + + Future _showCreateGroupDialog() async { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.localized.syncPlayCreateGroup), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: context.localized.syncPlayGroupName, + hintText: context.localized.syncPlayGroupNameHint, + ), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.localized.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: Text(context.localized.create), + ), + ], + ), + ); + } + + Future _joinGroup(GroupInfoDto group) async { + ref.read(syncPlayGroupsProvider.notifier).setLoading(true); + + final success = await ref.read(syncPlayProvider.notifier).joinGroup(group.groupId ?? ''); + if (success && mounted) { + FladderSnack.show(context.localized.syncPlayJoinedGroup(group.groupName ?? ''), context: context); + Navigator.of(context).pop(); + } else { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).setLoading(false); + FladderSnack.show(context.localized.syncPlayFailedToJoinGroup, context: context); + } + } + } + + Future _leaveGroup() async { + await ref.read(syncPlayProvider.notifier).leaveGroup(); + if (mounted) { + FladderSnack.show(context.localized.syncPlayLeftGroup, context: context); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final syncPlayState = ref.watch(syncPlayProvider); + final groupsState = ref.watch(syncPlayGroupsProvider); + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8).add(MediaQuery.paddingOf(context)), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: FladderTheme.largeShape.borderRadius, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Container( + height: 8, + width: 35, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: FladderTheme.largeShape.borderRadius, + ), + ), + ) + else + const SizedBox(height: 8), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + IconsaxPlusLinear.people, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.localized.syncPlay, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (syncPlayState.isInGroup) + TextButton.icon( + onPressed: _leaveGroup, + icon: const Icon(IconsaxPlusLinear.logout), + label: Text(context.localized.leave), + ) + else + IconButton( + onPressed: _createGroup, + icon: const Icon(IconsaxPlusLinear.add), + tooltip: context.localized.create, + ), + ], + ), + ), + + const Divider(), + + // Content + Flexible( + child: _SyncPlaySheetContent( + syncPlayState: syncPlayState, + groupsState: groupsState, + onCreateGroup: _createGroup, + onJoinGroup: _joinGroup, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Content area of the SyncPlay group sheet (loading, error, empty, list, or active group). +class _SyncPlaySheetContent extends ConsumerWidget { + const _SyncPlaySheetContent({ + required this.syncPlayState, + required this.groupsState, + required this.onCreateGroup, + required this.onJoinGroup, + }); + + final SyncPlayState syncPlayState; + final SyncPlayGroupsState groupsState; + final VoidCallback onCreateGroup; + final void Function(GroupInfoDto) onJoinGroup; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (syncPlayState.isInGroup) { + return _ActiveGroupView(state: syncPlayState); + } + if (groupsState.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } + if (groupsState.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.warning_2, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayFailedToLoadGroups, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(syncPlayGroupsProvider.notifier).loadGroups(), + child: Text(context.localized.retry), + ), + ], + ), + ), + ); + } + if (groupsState.groups == null || groupsState.groups!.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.people, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayNoActiveGroups, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + context.localized.syncPlayCreateGroupHint, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + autofocus: true, + onPressed: onCreateGroup, + icon: const Icon(IconsaxPlusLinear.add), + label: Text(context.localized.syncPlayCreateGroupButton), + ), + ], + ), + ), + ); + } + final groups = groupsState.groups!; + return ListView.builder( + shrinkWrap: true, + itemCount: groups.length, + padding: const EdgeInsets.only(bottom: 16), + itemBuilder: (context, index) { + final group = groups[index]; + return _GroupListTile( + group: group, + onTap: () => onJoinGroup(group), + autofocus: index == 0, + ); + }, + ); + } +} + +class _ActiveGroupView extends ConsumerWidget { + const _ActiveGroupView({required this.state}); + + final SyncPlayState state; + + Future _onResumePlayback(BuildContext context, WidgetRef ref) async { + final ok = await ref.read(syncPlayProvider.notifier).rejoinPlayback(); + if (!context.mounted) { + return; + } + if (!ok) { + log('unableToPlayMedia [_ActiveGroupView._onResumePlayback]: ' + 'rejoinPlayback returned false'); + FladderSnack.show( + context.localized.unableToPlayMedia, + context: context, + ); + return; + } + Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasActivePlayback = state.hasActivePlayback; + final playerRouteOpen = ref.watch(isVideoPlayerRouteOpenProvider); + final showResume = hasActivePlayback && !playerRouteOpen; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Group name + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + IconsaxPlusBold.people, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.groupName ?? context.localized.syncPlayGroupFallback, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + state.participants.isEmpty + ? context.localized.syncPlayParticipants(0) + : state.participants.join(', '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + _StateIndicator(state: state), + if (showResume) ...[ + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _onResumePlayback(context, ref), + icon: const Icon(IconsaxPlusBold.play), + label: Text(context.localized.syncPlayResumePlayback), + ), + ], + const SizedBox(height: 16), + Text( + context.localized.syncPlayInstructions, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _StateIndicator extends StatelessWidget { + const _StateIndicator({required this.state}); + + final SyncPlayState state; + + @override + Widget build(BuildContext context) { + final (icon, label, color) = switch (state.groupState) { + SyncPlayGroupState.idle => ( + IconsaxPlusLinear.pause_circle, + context.localized.syncPlayStateIdle, + Theme.of(context).colorScheme.onSurfaceVariant, + ), + SyncPlayGroupState.waiting => ( + IconsaxPlusLinear.timer_1, + context.localized.syncPlayStateWaiting, + Theme.of(context).colorScheme.tertiary, + ), + SyncPlayGroupState.paused => ( + IconsaxPlusLinear.pause, + context.localized.syncPlayStatePaused, + Theme.of(context).colorScheme.secondary, + ), + SyncPlayGroupState.playing => ( + IconsaxPlusLinear.play, + context.localized.syncPlayStatePlaying, + Theme.of(context).colorScheme.primary, + ), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color), + ), + ], + ), + ); + } +} + +class _GroupListTile extends StatelessWidget { + final GroupInfoDto group; + final VoidCallback onTap; + final bool autofocus; + + const _GroupListTile({ + required this.group, + required this.onTap, + this.autofocus = false, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + autofocus: autofocus, + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + IconsaxPlusLinear.people, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: Text(group.groupName ?? context.localized.syncPlayUnnamedGroup), + subtitle: Text( + (group.participants == null || group.participants!.isEmpty) + ? context.localized.syncPlayParticipants(0) + : group.participants!.join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: const Icon(IconsaxPlusLinear.arrow_right_3), + onTap: onTap, + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_utils.dart b/lib/widgets/syncplay/syncplay_utils.dart new file mode 100644 index 000000000..f733f3808 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_utils.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +import 'package:fladder/widgets/syncplay/syncplay_group_sheet.dart'; + +/// Show the SyncPlay group management bottom sheet +void showSyncPlaySheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (context) => const SyncPlayGroupSheet(), + ); +} From 543f1eeedf2bc5a0c4f91c79868c53c5adbb7f6d Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:05:27 +0200 Subject: [PATCH 05/14] feat(syncplay): add SyncPlay support to the native Android player --- .../composables/controls/ProgressBar.kt | 7 + .../composables/controls/SkipOverlay.kt | 9 +- .../controls/VideoPlayerControls.kt | 25 ++- .../overlays/SyncPlayCommandOverlay.kt | 172 ++++++++++++++++++ .../messengers/VideoPlayerImplementation.kt | 13 ++ .../fladder/objects/VideoPlayerObject.kt | 34 ++++ .../nl/jknaapen/fladder/player/ExoPlayer.kt | 6 +- 7 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt index 88e43de95..3299ea1b6 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.delay +import PlaybackChangeSource import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.Translate import nl.jknaapen.fladder.objects.VideoPlayerObject @@ -450,6 +451,7 @@ internal fun RowScope.SimpleProgressBar( val clickRelativeOffset = offset.x / width.toFloat() val newPosition = effectiveDuration.milliseconds * clickRelativeOffset.toDouble() + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS)) } } @@ -473,6 +475,7 @@ internal fun RowScope.SimpleProgressBar( }, onDragEnd = { onScrubbingChanged(false) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(internalTempPosition) }, onDragCancel = { @@ -655,6 +658,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.pause() } val newPos = max( @@ -676,6 +680,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.pause() } val newPos = min(player.duration.takeIf { it > 0 } ?: 1L, @@ -687,6 +692,7 @@ internal fun RowScope.SimpleProgressBar( Enter, Spacebar, ButtonSelect, DirectionCenter -> { if (scrubbingTimeLine) { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(tempPosition) player.play() onScrubbingChanged(false) @@ -697,6 +703,7 @@ internal fun RowScope.SimpleProgressBar( Escape, Back -> { if (scrubbingTimeLine) { onScrubbingChanged(false) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.play() true } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt index 0a11c08f9..2f46e5670 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -5,6 +5,8 @@ import MediaSegmentType import SegmentSkip import SegmentType import android.os.Build +import PlaybackChangeSource +import nl.jknaapen.fladder.objects.VideoPlayerObject import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background @@ -38,7 +40,6 @@ import androidx.compose.ui.unit.dp import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.Translate -import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.defaultSelected import nl.jknaapen.fladder.utility.leanBackEnabled import kotlin.time.Duration.Companion.milliseconds @@ -69,7 +70,8 @@ internal fun BoxScope.SegmentSkipOverlay( val currentSegmentId = activeSegment?.let { "${it.type}-${it.start}-${it.end}" } fun skipSegment(segment: MediaSegment, segmentId: String) { - player.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.implementation.player?.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) skippedSegments.add(segmentId) } @@ -108,7 +110,8 @@ internal fun BoxScope.SegmentSkipOverlay( enableScaledFocus = true, onClick = { activeSegment?.let { - player.seekTo(it.end) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.implementation.player?.seekTo(it.end.toLong()) } } ) { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index f425790e4..d491afc13 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -70,7 +70,9 @@ import nl.jknaapen.fladder.composables.dialogs.AudioPicker import nl.jknaapen.fladder.composables.dialogs.ChapterSelectionSheet import nl.jknaapen.fladder.composables.dialogs.PlaybackSpeedPicker import nl.jknaapen.fladder.composables.dialogs.SubtitlePicker +import nl.jknaapen.fladder.composables.overlays.SyncPlayCommandOverlay import nl.jknaapen.fladder.composables.shared.CurrentTime +import PlaybackChangeSource import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.ImmersiveSystemBars @@ -147,6 +149,8 @@ fun CustomVideoControls( LaunchedEffect(lastSeekInteraction.longValue) { delay(1.seconds) if (currentSkipTime == 0L) return@LaunchedEffect + // SyncPlay: user action is applied locally; Flutter infers from playback state stream and sends to server. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player?.seekTo(position + currentSkipTime) currentSkipTime = 0L } @@ -172,12 +176,14 @@ fun CustomVideoControls( } Key.MediaPlay -> { - player?.play() + // Route through Flutter for SyncPlay support + VideoPlayerObject.videoPlayerControls?.onUserPlay {} return@keyEvent true } Key.MediaPlayPause -> { player?.let { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) if (it.isPlaying) { it.pause() updateLastInteraction() @@ -186,10 +192,10 @@ fun CustomVideoControls( } } return@keyEvent true - } Key.MediaPause, Key.P -> { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player?.pause() updateLastInteraction() return@keyEvent true @@ -351,6 +357,7 @@ fun CustomVideoControls( } SegmentSkipOverlay() SeekOverlay(value = currentSkipTime) + SyncPlayCommandOverlay() if (buffering && !playing) { CircularProgressIndicator( modifier = Modifier @@ -383,7 +390,8 @@ fun CustomVideoControls( if (showChapterDialog) { ChapterSelectionSheet( onSelected = { - exoPlayer.seekTo(it.time) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + exoPlayer.seekTo(it.time.toLong()) showChapterDialog = false }, onDismiss = { @@ -444,9 +452,8 @@ fun PlaybackButtons( } CustomButton( onClick = { - player.seekTo( - player.currentPosition - backwardSpeed.inWholeMilliseconds - ) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + player.seekTo((player.currentPosition - backwardSpeed.inWholeMilliseconds).coerceAtLeast(0L)) }, ) { Box( @@ -472,6 +479,7 @@ fun PlaybackButtons( .defaultSelected(true), enableScaledFocus = true, onClick = { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) if (player.isPlaying) { player.pause() onPause() @@ -489,9 +497,8 @@ fun PlaybackButtons( if (!isTVMode) { CustomButton( onClick = { - player.seekTo( - player.currentPosition + forwardSpeed.inWholeMilliseconds - ) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + player.seekTo(player.currentPosition + forwardSpeed.inWholeMilliseconds) }, ) { Box( diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt new file mode 100644 index 000000000..01e0d5a15 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt @@ -0,0 +1,172 @@ +package nl.jknaapen.fladder.composables.overlays + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.rabehx.iconsax.Iconsax +import io.github.rabehx.iconsax.filled.Forward +import io.github.rabehx.iconsax.filled.Pause +import io.github.rabehx.iconsax.filled.Play +import io.github.rabehx.iconsax.filled.Refresh +import io.github.rabehx.iconsax.filled.Stop +import SyncPlayCommandType +import nl.jknaapen.fladder.objects.Localized +import nl.jknaapen.fladder.objects.Translate +import nl.jknaapen.fladder.objects.VideoPlayerObject + +/** + * Centered overlay showing SyncPlay command being processed. + * Mirrors the Flutter SyncPlayCommandIndicator design. + */ +@Composable +fun BoxScope.SyncPlayCommandOverlay( + modifier: Modifier = Modifier +) { + val syncPlayState by VideoPlayerObject.syncPlayCommandState.collectAsState() + val visible by remember(syncPlayState) { + derivedStateOf { + syncPlayState.processing && syncPlayState.commandType != SyncPlayCommandType.NONE + } + } + + AnimatedVisibility( + visible = visible, + modifier = modifier.align(Alignment.Center), + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f) + ) { + Box( + modifier = Modifier + .shadow( + elevation = 20.dp, + shape = RoundedCornerShape(20.dp), + ambientColor = Color.Black.copy(alpha = 0.3f), + spotColor = Color.Black.copy(alpha = 0.3f) + ) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(20.dp) + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + shape = RoundedCornerShape(20.dp) + ) + .padding(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CommandIcon(commandType = syncPlayState.commandType) + + Spacer(modifier = Modifier.height(12.dp)) + + Translate( + callback = { cb -> + when (syncPlayState.commandType) { + SyncPlayCommandType.PAUSE -> Localized.syncPlayCommandPausing(cb) + SyncPlayCommandType.UNPAUSE -> Localized.syncPlayCommandPlaying(cb) + SyncPlayCommandType.SEEK -> Localized.syncPlayCommandSeeking(cb) + SyncPlayCommandType.STOP -> Localized.syncPlayCommandStopping(cb) + else -> Localized.syncPlayCommandSyncing(cb) + } + }, + key = syncPlayState.commandType + ) { label -> + Text( + text = label, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Translate({ Localized.syncPlaySyncingWithGroup(it) }) { syncingText -> + Text( + text = syncingText, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + } +} + +@Composable +private fun CommandIcon(commandType: SyncPlayCommandType) { + val (icon, color) = when (commandType) { + SyncPlayCommandType.PAUSE -> Pair(Iconsax.Filled.Pause, MaterialTheme.colorScheme.secondary) + SyncPlayCommandType.UNPAUSE -> Pair(Iconsax.Filled.Play, MaterialTheme.colorScheme.primary) + SyncPlayCommandType.SEEK -> Pair(Iconsax.Filled.Forward, MaterialTheme.colorScheme.tertiary) + SyncPlayCommandType.STOP -> Pair(Iconsax.Filled.Stop, MaterialTheme.colorScheme.error) + else -> Pair(Iconsax.Filled.Refresh, MaterialTheme.colorScheme.primary) + } + + Box( + modifier = Modifier + .background( + color = color.copy(alpha = 0.15f), + shape = CircleShape + ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = commandType.name, + modifier = Modifier.size(48.dp), + tint = color + ) + } +} + diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index 88f0ed170..67f60121e 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -1,7 +1,10 @@ package nl.jknaapen.fladder.messengers +import PlaybackChangeSource +import PlaybackType import PlayableData import SubtitleSettings +import SyncPlayCommandType import TVGuideModel import VideoPlayerApi import android.os.Handler @@ -140,14 +143,17 @@ class VideoPlayerImplementation( } override fun play() { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.play() } override fun pause() { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.pause() } override fun seekTo(position: Long) { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.seekTo(position) } @@ -155,6 +161,13 @@ class VideoPlayerImplementation( player?.stop() } + override fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) { + VideoPlayerObject.setSyncPlayCommandState( + processing = processing, + commandType = commandType + ) + } + fun init(exoPlayer: ExoPlayer?) { player = exoPlayer subsInitialized = false diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt index 1f335ae51..6472203be 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -1,6 +1,8 @@ package nl.jknaapen.fladder.objects +import PlaybackChangeSource import PlaybackState +import SyncPlayCommandType import TVGuideModel import VideoPlayerControlsCallback import VideoPlayerListenerCallback @@ -15,6 +17,11 @@ import nl.jknaapen.fladder.messengers.VideoPlayerImplementation import nl.jknaapen.fladder.utility.InternalTrack object VideoPlayerObject { + data class SyncPlayCommandUiState( + val processing: Boolean, + val commandType: SyncPlayCommandType + ) + val implementation: VideoPlayerImplementation = VideoPlayerImplementation() private var _currentState = MutableStateFlow(null) @@ -104,5 +111,32 @@ object VideoPlayerObject { guideVisible.value = !guideVisible.value } + // SyncPlay command state for overlay (Pigeon-generated type) + val syncPlayCommandState = MutableStateFlow( + SyncPlayCommandUiState(false, SyncPlayCommandType.NONE) + ) + + fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) { + syncPlayCommandState.value = SyncPlayCommandUiState( + processing = processing, + commandType = commandType + ) + } + + /** Set before updating player so the next PlaybackState sent to Flutter is tagged (for SyncPlay inference). */ + @Volatile + private var pendingPlaybackChangeSource: PlaybackChangeSource? = null + + fun setPendingPlaybackChangeSource(source: PlaybackChangeSource) { + pendingPlaybackChangeSource = source + } + + /** Consumed when building PlaybackState in ExoPlayer; clears after read. */ + fun getAndClearPendingPlaybackChangeSource(): PlaybackChangeSource? { + val r = pendingPlaybackChangeSource + pendingPlaybackChangeSource = null + return r + } + var currentActivity: VideoPlayerActivity? = null } \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 44bb2a8d5..542ab6ffb 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -131,7 +131,8 @@ internal fun ExoPlayer( playing = exoPlayer.isPlaying, buffering = exoPlayer.playbackState == Player.STATE_BUFFERING, completed = exoPlayer.playbackState == Player.STATE_ENDED, - failed = exoPlayer.playbackState == Player.STATE_IDLE + failed = exoPlayer.playbackState == Player.STATE_IDLE, + changeSource = videoHost.getAndClearPendingPlaybackChangeSource() ) ) } @@ -167,7 +168,8 @@ internal fun ExoPlayer( playing = exoPlayer.isPlaying, buffering = playbackState == Player.STATE_BUFFERING, completed = playbackState == Player.STATE_ENDED, - failed = playbackState == Player.STATE_IDLE + failed = playbackState == Player.STATE_IDLE, + changeSource = videoHost.getAndClearPendingPlaybackChangeSource() ) ) } From 5efb1ad6ddbb36a2577d666b1e81678ee295c8d4 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sun, 28 Jun 2026 15:05:28 +0200 Subject: [PATCH 06/14] feat(syncplay): localization, web_socket_channel dependency, tests and docs --- docs/syncplay-implementation.md | 1489 +++++++++++++++++ lib/l10n/app_en.arb | 112 ++ lib/l10n/app_fr.arb | 109 ++ lib/util/localization_helper.dart | 19 + pubspec.lock | 2 +- pubspec.yaml | 1 + .../syncplay/syncplay_correction_test.dart | 299 ++++ .../syncplay/syncplay_lifecycle_test.dart | 118 ++ .../jellyfin_websocket_platform_test.dart | 45 + 9 files changed, 2193 insertions(+), 1 deletion(-) create mode 100644 docs/syncplay-implementation.md create mode 100644 test/providers/syncplay/syncplay_correction_test.dart create mode 100644 test/providers/syncplay/syncplay_lifecycle_test.dart create mode 100644 test/providers/websocket/jellyfin_websocket_platform_test.dart diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md new file mode 100644 index 000000000..f56b6c0dc --- /dev/null +++ b/docs/syncplay-implementation.md @@ -0,0 +1,1489 @@ +# SyncPlay Implementation Guide + +A comprehensive technical specification for implementing Jellyfin SyncPlay synchronized playback in client applications. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Communication Protocols](#communication-protocols) +4. [Time Synchronization](#time-synchronization) +5. [Group Management](#group-management) +6. [Playback Control](#playback-control) +7. [State Machine](#state-machine) +8. [Command Scheduling](#command-scheduling) +9. [Player Interface](#player-interface) +10. [Message Types Reference](#message-types-reference) +11. [Edge Cases & Error Handling](#edge-cases--error-handling) +12. [Implementation Checklist](#implementation-checklist) + +--- + +## Overview + +SyncPlay enables multiple clients to watch media together in perfect synchronization. The system coordinates playback across devices with different network latencies by: + +1. Using a central server (Jellyfin) as the source of truth for group state +2. Synchronizing client clocks with the server via ping measurements +3. Scheduling playback commands to execute at precise server-defined timestamps +4. Managing a shared queue/playlist across all participants + +### Key Principles + +- **Server Authority**: The Jellyfin server owns the group state. Clients request changes, server broadcasts commands. +- **Time-based Coordination**: Commands include a `When` timestamp indicating exact execution time. +- **Buffering Awareness**: Clients report their buffering state; playback only resumes when ALL clients are ready. +- **Dual Protocol**: REST API for state-changing requests, WebSocket for real-time event delivery. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ JELLYFIN SERVER │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ SyncPlay API │ │ Group Manager │ │ WebSocket Broadcaster │ │ +│ │ (REST) │◄──►│ (State) │◄──►│ (Events) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ▲ │ + │ REST API │ WebSocket + │ (Requests) │ (Commands/Updates) + │ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT APPLICATION │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ REST Client │ │ SyncPlay │ │ WebSocket Manager │ │ +│ │ (Actions) │◄──►│ Controller │◄──►│ (Connection/Messages) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ Time Sync │ │ Command │ │ Player Interface │ │ +│ │ (Clock Offset) │◄──►│ Handler │◄──►│ (Video Control) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| Component | Responsibility | +|-----------|----------------| +| **REST Client** | Sends state-change requests (pause, seek, ready, buffering) | +| **WebSocket Manager** | Maintains persistent connection, handles keep-alive, routes messages. As of 2026-05, this is the app-level shared `JellyfinWebSocket` (`lib/providers/websocket/`), owned by `JellyfinWebSocketController` and connected on login — not a SyncPlay-private socket. SyncPlay is one consumer of it. | +| **Time Sync** | Calculates clock offset between client and server | +| **Command Handler** | Schedules commands for future execution, handles duplicates | +| **Player Interface** | Abstraction layer between SyncPlay and actual video player | +| **SyncPlay Controller** | Orchestrates all components, manages group state | + +--- + +## Communication Protocols + +SyncPlay uses two complementary communication channels: + +### REST API (Client → Server) + +Used for **requesting** state changes. The server processes these and broadcasts commands to all clients. + +| Endpoint | Purpose | +|----------|---------| +| `GET /SyncPlay/List` | List available groups | +| `POST /SyncPlay/New` | Create a new group | +| `POST /SyncPlay/Join` | Join an existing group | +| `POST /SyncPlay/Leave` | Leave current group | +| `POST /SyncPlay/Pause` | Request pause | +| `POST /SyncPlay/Unpause` | Request unpause/play | +| `POST /SyncPlay/Seek` | Request seek to position | +| `POST /SyncPlay/Stop` | Request stop | +| `POST /SyncPlay/Buffering` | Report buffering state | +| `POST /SyncPlay/Ready` | Report ready state | +| `POST /SyncPlay/SetNewQueue` | Set a new playlist | +| `POST /SyncPlay/Queue` | Add items to queue | +| `POST /SyncPlay/Ping` | Report ping measurement | +| `GET /GetUtcTime` | Get server timestamps for time sync (T2, T3) | + +### WebSocket (Server → Client) + +Used for **receiving** commands and state updates. Connect to: + +``` +wss://{server}/socket?api_key={token}&deviceId={deviceId} +``` + +Message types received: +- `SyncPlayCommand` - Playback control commands (pause, unpause, seek, stop) +- `SyncPlayGroupUpdate` - Group state changes (join, leave, playlist, state) +- `ForceKeepAlive` - Keep-alive configuration +- `KeepAlive` - Keep-alive acknowledgment + +--- + +## Time Synchronization + +**Critical for accurate synchronization.** Clients must maintain an accurate estimate of the offset between their local clock and the server's clock. + +### Algorithm (NTP-like) + +``` +┌────────┐ ┌────────┐ +│ CLIENT │ │ SERVER │ +└────┬───┘ └────┬───┘ + │ │ + │ requestSent (T1) │ + │──────────────────────────────────────►│ + │ │ requestReceived (T2) + │ │ + │ │ responseSent (T3) + │◄──────────────────────────────────────│ + │ responseReceived (T4) │ + │ │ +``` + +**Offset Calculation:** + +``` +offset = ((T2 - T1) + (T3 - T4)) / 2 +``` + +**Round-trip Delay:** + +``` +delay = (T4 - T1) - (T3 - T2) +ping = delay / 2 +``` + +### Server Time API + +Jellyfin provides a dedicated endpoint for time synchronization that returns server-side timestamps: + +**Endpoint:** +```http +GET /GetUtcTime +``` + +**Response:** +```json +{ + "RequestReceptionTime": "2024-01-15T12:00:00.0000000Z", + "ResponseTransmissionTime": "2024-01-15T12:00:00.0010000Z" +} +``` + +| Field | Description | +|-------|-------------| +| `RequestReceptionTime` | When the server received the request (T2) | +| `ResponseTransmissionTime` | When the server sent the response (T3) | + +The client records T1 (before sending) and T4 (after receiving) locally. + +**Client-side Implementation:** + +```typescript +async function requestPing(): Promise<{ + requestSent: Date; + requestReceived: Date; + responseSent: Date; + responseReceived: Date; +}> { + // T1: Record local time before request + const requestSent = new Date(); + + // Make request to Jellyfin TimeSync API + const response = await fetch(`${serverUrl}/GetUtcTime`, { + headers: { 'Authorization': `MediaBrowser Token="${accessToken}"` } + }); + const data = await response.json(); + + // T4: Record local time after response + const responseReceived = new Date(); + + // T2 and T3 come from server response + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); + + return { + requestSent, // T1 - local + requestReceived, // T2 - from server + responseSent, // T3 - from server + responseReceived // T4 - local + }; +} +``` + +**Flutter/Dart equivalent:** + +```dart +Future requestPing() async { + // T1: Record local time before request + final requestSent = DateTime.now().toUtc(); + + // Make request to Jellyfin TimeSync API + final response = await http.get( + Uri.parse('$serverUrl/GetUtcTime'), + headers: {'Authorization': 'MediaBrowser Token="$accessToken"'}, + ); + + // T4: Record local time after response + final responseReceived = DateTime.now().toUtc(); + + final data = jsonDecode(response.body); + + // T2 and T3 from server + final requestReceived = DateTime.parse(data['RequestReceptionTime']); + final responseSent = DateTime.parse(data['ResponseTransmissionTime']); + + return TimeSyncMeasurement( + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived, + ); +} +``` + +### Implementation Details + +```typescript +class Measurement { + requestSent: number; // T1 - when client sent request + requestReceived: number; // T2 - when server received request + responseSent: number; // T3 - when server sent response + responseReceived: number; // T4 - when client received response + + getOffset(): number { + return ((this.requestReceived - this.requestSent) + + (this.responseSent - this.responseReceived)) / 2; + } + + getDelay(): number { + return (this.responseReceived - this.requestSent) - + (this.responseSent - this.requestReceived); + } + + getPing(): number { + return this.getDelay() / 2; + } +} +``` + +### Measurement Strategy + +| Phase | Interval | Purpose | +|-------|----------|---------| +| **Greedy** (first 3 pings) | 1 second | Quick initial synchronization | +| **Low Profile** (subsequent) | 60 seconds | Maintain sync without network overhead | + +**Best Measurement Selection:** +- Keep last 8 measurements +- Use measurement with **minimum delay** (least network jitter) + +### Time Conversion Functions + +```typescript +// Convert server time to local time +function remoteDateToLocal(serverTime: Date): Date { + return new Date(serverTime.getTime() - offset); +} + +// Convert local time to server time +function localDateToRemote(localTime: Date): Date { + return new Date(localTime.getTime() + offset); +} +``` + +### Staleness Detection + +Time sync becomes unreliable over time. Mark as stale after 30 seconds and force a refresh before critical operations. + +```typescript +function isStale(): boolean { + return (Date.now() - lastMeasurement.timestamp) > 30000; +} +``` + +--- + +## Group Management + +### Group Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> NoGroup: Initial State + NoGroup --> Creating: createGroup() + NoGroup --> Joining: joinGroup(id) + Creating --> InGroup: GroupJoined message + Joining --> InGroup: GroupJoined message + InGroup --> NoGroup: leaveGroup() + InGroup --> NoGroup: GroupDoesNotExist message +``` + +### Create Group + +**Request:** +```http +POST /SyncPlay/New +Content-Type: application/json + +{ + "GroupName": "Movie Night" +} +``` + +**Response:** Group is created and you automatically join. Wait for `GroupJoined` WebSocket message. + +### Join Group + +**Request:** +```http +POST /SyncPlay/Join +Content-Type: application/json + +{ + "GroupId": "abc-123-def" +} +``` + +### Leave Group + +**Request:** +```http +POST /SyncPlay/Leave +``` + +### Group State Structure + +```typescript +interface SyncPlayGroup { + GroupId: string; + GroupName: string; + State: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; + StateReason?: string; + Participants: string[]; + PlayingItemId?: string; + PositionTicks: number; + IsPaused: boolean; // derived: State === 'Paused' || State === 'Waiting' +} +``` + +--- + +## Playback Control + +### Position Units + +Jellyfin uses **ticks** for time positions: +- **1 tick = 100 nanoseconds** +- **10,000,000 ticks = 1 second** + +```typescript +const TICKS_PER_SECOND = 10_000_000; + +function secondsToTicks(seconds: number): number { + return Math.floor(seconds * TICKS_PER_SECOND); +} + +function ticksToSeconds(ticks: number): number { + return ticks / TICKS_PER_SECOND; +} +``` + +### Pause Request + +```http +POST /SyncPlay/Pause +``` + +No body required. Server broadcasts `Pause` command to all clients. + +### Unpause Request + +```http +POST /SyncPlay/Unpause +``` + +No body required. Server transitions group to `Waiting` state, waits for all clients to report ready, then broadcasts `Unpause` command. + +### Seek Request + +```http +POST /SyncPlay/Seek +Content-Type: application/json + +{ + "PositionTicks": 300000000 // 30 seconds +} +``` + +### Ready State + +Report when video is ready to play (buffering complete): + +```http +POST /SyncPlay/Ready +Content-Type: application/json + +{ + "When": "2024-01-15T12:00:00.000Z", + "PositionTicks": 300000000, + "IsPlaying": true, + "PlaylistItemId": "playlist-item-uuid" +} +``` + +### Buffering State + +Report when video starts buffering: + +```http +POST /SyncPlay/Buffering +Content-Type: application/json + +{ + "When": "2024-01-15T12:00:00.000Z", + "PositionTicks": 300000000, + "IsPlaying": false, + "PlaylistItemId": "playlist-item-uuid" +} +``` + +### Ping Reporting + +Report your measured ping to the server (helps with latency compensation): + +```http +POST /SyncPlay/Ping +Content-Type: application/json + +{ + "Ping": 45 // milliseconds, integer +} +``` + +--- + +## State Machine + +### Group States + +```mermaid +stateDiagram-v2 + [*] --> Idle: Group Created + + Idle --> Waiting: SetNewQueue / Play Request + + Waiting --> Playing: All Clients Ready + Waiting --> Paused: Pause Request + + Playing --> Waiting: Client Buffering + Playing --> Waiting: Seek Request + Playing --> Paused: Pause Request + + Paused --> Waiting: Unpause Request + Paused --> Waiting: Seek Request + + note right of Waiting + Group waits here until ALL clients + report Ready state + end note +``` + +### State Definitions + +| State | Description | +|-------|-------------| +| **Idle** | No media playing, group is empty or inactive | +| **Waiting** | Waiting for all clients to buffer and report ready | +| **Playing** | All clients are playing in sync | +| **Paused** | Playback paused for all clients | + +### State Reasons + +The `StateReason` field indicates why the group entered its current state: + +| Reason | Meaning | +|--------|---------| +| `NewPlaylist` | A new queue was set | +| `SetCurrentItem` | Current item changed | +| `Unpause` | User requested unpause | +| `Pause` | User requested pause | +| `Seek` | User requested seek | +| `Buffer` | A client started buffering | +| `Ready` | All clients reported ready | + +--- + +## Command Scheduling + +Commands include a `When` timestamp indicating when they should execute. This is critical for synchronization. + +### Command Flow + +```mermaid +sequenceDiagram + participant C1 as Client 1 + participant S as Server + participant C2 as Client 2 + + C1->>S: Unpause Request + Note over S: Calculate When = Now + buffer + S->>C1: SyncPlayCommand (Unpause, When=T) + S->>C2: SyncPlayCommand (Unpause, When=T) + + Note over C1: Wait until local time = T + Note over C2: Wait until local time = T + + C1->>C1: Execute Play at T + C2->>C2: Execute Play at T +``` + +### Scheduling Algorithm + +```typescript +async function scheduleCommand(command: SyncPlayCommand): Promise { + const serverTime = new Date(command.When); + const localTime = new Date(); + + // Convert server time to local time using time sync + const commandTime = timeSync.remoteDateToLocal(serverTime); + + // Calculate delay + let delay = commandTime.getTime() - localTime.getTime(); + + if (delay < 0) { + // Command is in the past - execute immediately + executeCommand(command); + return; + } + + if (delay > 5000) { + // Suspiciously large delay - might indicate time sync issue + console.warn(`Large delay detected: ${delay}ms`); + // Optionally force time sync update + } + + // Schedule for future execution + setTimeout(() => { + executeCommand(command); + }, delay); +} +``` + +### Command Execution Order + +Different commands require different execution sequences: + +#### Pause Command +1. Pause the player +2. Wait for pause to complete +3. Seek to position if provided (and significantly different from current) + +#### Unpause Command +1. Seek to position if significantly different from current +2. Wait for seek to complete (video can play) +3. Start playback + +#### Seek Command +1. Start playback (unpause) +2. Seek to target position +3. Wait for seek to complete +4. Pause the player +5. Send Ready state to server + +```typescript +async function executeCommand( + type: 'pause' | 'unpause' | 'seek' | 'stop', + positionTicks?: number +): Promise { + const timeInSeconds = positionTicks ? ticksToSeconds(positionTicks) : 0; + + switch (type) { + case 'pause': + player.pause(); + await waitForPause(); + if (positionTicks && Math.abs(timeInSeconds - player.currentTime) > 0.5) { + player.seek(timeInSeconds); + } + break; + + case 'unpause': + if (positionTicks && Math.abs(timeInSeconds - player.currentTime) > 0.5) { + player.seek(timeInSeconds); + await player.waitForCanPlay(); + } + player.play(); + break; + + case 'seek': + player.play(); + player.seek(timeInSeconds); + await player.waitForCanPlay(); + player.pause(); + sendReady(true, player.positionTicks); + break; + + case 'stop': + player.pause(); + player.seek(0); + break; + } +} +``` + +### Handling Late Commands (estimateCurrentTicks) + +When a command's `When` timestamp is in the past (command arrived late), you must estimate where playback *should* be now: + +```typescript +/** + * Estimates current position given a past state. + * @param ticks - Position at the time of the command + * @param when - Server time when position was valid + * @param currentTime - Current local time (optional) + */ +function estimateCurrentTicks( + ticks: number, + when: Date, + currentTime: Date = new Date() +): number { + const remoteTime = timeSync.localDateToRemote(currentTime); + const elapsedMs = remoteTime.getTime() - when.getTime(); + return ticks + (elapsedMs * TICKS_PER_MILLISECOND); +} +``` + +**Usage in scheduleUnpause:** + +```typescript +async function scheduleUnpause(playAtTime: Date, positionTicks: number): Promise { + const currentTime = new Date(); + const playAtTimeLocal = timeSync.remoteDateToLocal(playAtTime); + + if (playAtTimeLocal > currentTime) { + // Future command - schedule it + const delay = playAtTimeLocal.getTime() - currentTime.getTime(); + setTimeout(() => { + player.play(); + }, delay); + } else { + // Late command - estimate where playback should be NOW + const serverPositionTicks = estimateCurrentTicks(positionTicks, playAtTime); + player.seek(ticksToSeconds(serverPositionTicks)); + player.play(); + } +} +``` + +### Playback Sync Correction (SpeedToSync / SkipToSync) + +During playback, clients may drift out of sync. The official Jellyfin client implements two correction strategies: + +#### Strategy 1: SpeedToSync + +Adjusts playback rate temporarily to catch up without visible jumps: + +```typescript +// Constants +const MIN_DELAY_SPEED_TO_SYNC = 60; // ms - minimum delay to trigger +const MAX_DELAY_SPEED_TO_SYNC = 3000; // ms - maximum delay (use SkipToSync above this) +const SPEED_TO_SYNC_DURATION = 1000; // ms - how long to speed up + +function syncPlaybackTime(currentPosition: number, currentTime: Date): void { + if (!lastCommand || lastCommand.Command !== 'Unpause') return; + + const currentPositionTicks = currentPosition * TICKS_PER_MILLISECOND; + const serverPositionTicks = estimateCurrentTicks( + lastCommand.PositionTicks, + lastCommand.When, + currentTime + ); + + const diffMs = (serverPositionTicks - currentPositionTicks) / TICKS_PER_MILLISECOND; + const absDiffMs = Math.abs(diffMs); + + if (absDiffMs >= MIN_DELAY_SPEED_TO_SYNC && absDiffMs < MAX_DELAY_SPEED_TO_SYNC) { + // Calculate speed to catch up within SPEED_TO_SYNC_DURATION + const speed = 1 + (diffMs / SPEED_TO_SYNC_DURATION); + + player.setPlaybackRate(speed); + + setTimeout(() => { + player.setPlaybackRate(1.0); + }, SPEED_TO_SYNC_DURATION); + } +} +``` + +#### Strategy 2: SkipToSync + +For larger delays, seek directly to the correct position: + +```typescript +const MIN_DELAY_SKIP_TO_SYNC = 400; // ms + +if (absDiffMs >= MIN_DELAY_SKIP_TO_SYNC) { + player.seek(ticksToSeconds(serverPositionTicks)); +} +``` + +#### Sync Correction Flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Playback Diff Detection │ +├─────────────────────────────────────────────────────────────────────┤ +│ Calculate: diffMs = serverPosition - clientPosition │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ if (|diffMs| < 60ms) │ +│ → In sync, do nothing │ +│ │ +│ else if (60ms <= |diffMs| < 3000ms) │ +│ → SpeedToSync: adjust playback rate │ +│ → speed = 1 + (diffMs / 1000) │ +│ → Reset rate after 1 second │ +│ │ +│ else if (|diffMs| >= 400ms && SpeedToSync not applicable) │ +│ → SkipToSync: seek to correct position │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### When to Run Sync Correction + +- Only when `syncEnabled` is true (after initial unpause settles) +- Only during active playback (`Command === 'Unpause'`) +- Throttle to avoid overloading (e.g., check every 500ms-1s) +- Disable during buffering + +### Duplicate Command Detection + +The server may send the same command multiple times (network retries, state synchronization). Track recent commands to avoid re-execution: + +```typescript +interface LastCommand { + when: string; + positionTicks: number; + command: string; + playlistItemId: string; +} + +function isDuplicate(command: SyncPlayCommand, lastCommand: LastCommand): boolean { + return ( + lastCommand.when === command.When && + lastCommand.positionTicks === command.PositionTicks && + lastCommand.command === command.Command && + lastCommand.playlistItemId === command.PlaylistItemId + ); +} +``` + +--- + +## Player Interface + +Define a clean abstraction between SyncPlay and your video player. This enables SyncPlay to control any player implementation. + +### Interface Definition + +```typescript +interface SyncPlayPlayerInterface { + // State Queries + getCurrentTime(): number; // Current position in seconds + getPositionTicks(): number; // Current position in ticks + isPaused(): boolean; // Is player paused + isReady(): boolean; // Can video play (not buffering) + isPlaying(): boolean; // Is actively playing (not paused, not buffering) + + // Controls + play(): void; + pause(): void; + seek(timeInSeconds: number): void; + seekToTicks(positionTicks: number): void; + + // Playback Rate (for SpeedToSync) + hasPlaybackRate(): boolean; // Does player support playback rate? + getPlaybackRate(): number; // Current playback rate (1.0 = normal) + setPlaybackRate(rate: number): void; // Set playback rate + + // Event Handling + on(event: PlayerEvent, handler: Function): void; + off(event: PlayerEvent, handler: Function): void; + once(event: PlayerEvent): Promise; +} + +type PlayerEvent = + | 'userPlay' // User initiated play (not SyncPlay) + | 'userPause' // User initiated pause + | 'userSeek' // User initiated seek + | 'videoCanPlay' // Buffering complete, ready to play + | 'videoBuffering'// Started buffering + | 'videoSeeked' // Seek operation complete + | 'timeUpdate'; // Periodic position update (for sync correction) +``` + +**Dart/Flutter equivalent:** + +```dart +abstract class SyncPlayPlayerInterface { + // State Queries + double getCurrentTime(); // seconds + int getPositionTicks(); // ticks + bool isPaused(); + bool isReady(); + bool isPlaying(); + + // Controls + void play(); + void pause(); + void seek(double timeInSeconds); + void seekToTicks(int positionTicks); + + // Playback Rate (for SpeedToSync) + bool hasPlaybackRate(); + double getPlaybackRate(); + void setPlaybackRate(double rate); + + // Event Streams + Stream get onUserPlay; + Stream get onUserPause; + Stream get onUserSeek; + Stream get onVideoCanPlay; + Stream get onVideoBuffering; + Stream get onTimeUpdate; +} +``` + +### Event Flow: User Actions + +When a user interacts with the player directly (not through SyncPlay commands), the player should emit events: + +```mermaid +sequenceDiagram + participant User + participant Player + participant SyncPlay + participant Server + + User->>Player: Clicks Pause + Player->>SyncPlay: 'userPause' event + SyncPlay->>Server: POST /SyncPlay/Pause + Server->>SyncPlay: SyncPlayCommand (Pause) + SyncPlay->>Player: pause() +``` + +### Distinguishing User vs SyncPlay Actions + +You must differentiate between: +- **User actions**: Should trigger REST API requests to server +- **SyncPlay commands**: Should control player without triggering API requests + +Common approaches: +1. Set a flag before executing SyncPlay commands, check it in event handlers +2. Use separate code paths for user controls vs SyncPlay controls +3. Temporarily unsubscribe from events during SyncPlay operations + +--- + +## Message Types Reference + +### SyncPlayCommand + +```typescript +interface SyncPlayCommandMessage { + MessageId: string; + MessageType: 'SyncPlayCommand'; + Data: { + GroupId: string; + PlaylistItemId: string; + When: string; // ISO 8601 timestamp for execution + PositionTicks: number; + Command: 'Unpause' | 'Pause' | 'Seek' | 'Stop'; + EmittedAt: string; // When server sent this command + }; +} +``` + +### SyncPlayGroupUpdate + +```typescript +interface SyncPlayGroupUpdateMessage { + MessageId: string; + MessageType: 'SyncPlayGroupUpdate'; + Data: { + GroupId: string; + Type: 'GroupJoined' | 'UserJoined' | 'UserLeft' | + 'PlayQueue' | 'StateUpdate' | 'GroupDoesNotExist'; + Data: GroupJoinedData | string | PlayQueueData | StateUpdateData; + }; +} +``` + +### GroupJoined Data + +```typescript +interface GroupJoinedData { + GroupId: string; + GroupName: string; + State: string; + Participants: string[]; + LastUpdatedAt: string; + PlayingItemId?: string; + PositionTicks?: number; +} +``` + +### PlayQueue Data + +```typescript +interface PlayQueueData { + Reason: 'NewPlaylist' | 'SetCurrentItem' | 'Queue' | 'RemoveFromPlaylist'; + LastUpdate: string; + Playlist: Array<{ + ItemId: string; + PlaylistItemId: string; + }>; + PlayingItemIndex: number; + StartPositionTicks: number; + IsPlaying: boolean; + ShuffleMode: string; + RepeatMode: string; +} +``` + +### StateUpdate Data + +```typescript +interface StateUpdateData { + State: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; + Reason: string; + PositionTicks: number; +} +``` + +### ForceKeepAlive + +```typescript +interface ForceKeepAliveMessage { + MessageType: 'ForceKeepAlive'; + Data: number; // Timeout in seconds +} +``` + +Handle by sending `KeepAlive` messages at half the timeout interval: + +```typescript +function handleForceKeepAlive(timeout: number): void { + const intervalMs = timeout * 1000 * 0.5; + + setInterval(() => { + websocket.send(JSON.stringify({ MessageType: 'KeepAlive' })); + }, intervalMs); +} +``` + +--- + +## Edge Cases & Error Handling + +### Message Deduplication + +WebSocket messages may be received multiple times. Track recent message IDs: + +```typescript +const recentMessageIds: string[] = []; +const MAX_TRACKED_IDS = 10; + +function handleMessage(message: any): void { + const messageId = message.MessageId; + + if (messageId && recentMessageIds.includes(messageId)) { + // Duplicate - ignore + return; + } + + if (messageId) { + recentMessageIds.push(messageId); + if (recentMessageIds.length > MAX_TRACKED_IDS) { + recentMessageIds.shift(); + } + } + + // Process message... +} +``` + +### Network Disconnection + +When WebSocket disconnects: +1. Update connection status to `disconnected` +2. Clear keep-alive interval +3. Attempt reconnection with exponential backoff +4. On reconnect, the server will send current group state + +### Group No Longer Exists + +Handle `GroupDoesNotExist` message: + +```typescript +function handleGroupDoesNotExist(): void { + currentGroup = null; + isEnabled = false; + disconnect(); + showNotification('Group no longer exists'); +} +``` + +### Stale Time Sync + +Before executing critical commands, check if time sync is stale: + +```typescript +async function executeTimeSensitiveCommand(command: Command): Promise { + if (timeSync.isStale()) { + await timeSync.forceUpdateAndWait(); + } + + // Now safe to schedule command + await scheduleCommand(command); +} +``` + +### Player Not Ready + +When receiving commands before player is initialized: + +```typescript +async function handleCommand(command: SyncPlayCommand): Promise { + if (!player) { + console.warn('Command received but player not registered'); + return; + } + + // If command requires ready state, wait for it + if (!player.isReady()) { + await player.once('videoCanPlay'); + } + + await executeCommand(command); +} +``` + +### State Transition: Waiting → Playing + +When group transitions from `Waiting` to `Playing`, ensure your client is actually playing: + +```typescript +function handleStateUpdate(previousState: string, newState: string): void { + if (newState === 'Playing' && previousState === 'Waiting') { + // Double-check player is playing + if (player.isPaused()) { + player.play(); + } + } +} +``` + +### Handling Waiting State with Different Reasons + +```typescript +async function handleWaitingState(reason: string, positionTicks: number): Promise { + switch (reason) { + case 'Ready': + // All clients ready - unpause will follow + await requestUnpause(); + break; + + case 'Buffer': + // Another client is buffering + // Wait for our player to be ready, then report + if (!player.isReady()) { + await player.once('videoCanPlay'); + } + await sendReady(true, positionTicks); + break; + + case 'Unpause': + // Unpause requested, waiting for all clients + await sendReady(true, positionTicks); + break; + + case 'Seek': + // Seek was processed, now waiting + // Ready state should be sent after seek command completes + break; + } +} +``` + +### Large Delay Detection + +If calculated command delay is suspiciously large (>5 seconds), time sync may be off: + +```typescript +async function scheduleWithValidation(command: SyncPlayCommand): Promise { + const delay = calculateDelay(command); + + if (delay > 5000) { + // Force time sync update + await timeSync.forceUpdateAndWait(); + + // Recalculate delay + const newDelay = calculateDelay(command); + + if (newDelay > 5000) { + // Still too large - log warning but proceed + console.warn(`Executing command with large delay: ${newDelay}ms`); + } + } + + // Continue with scheduling... +} +``` + +--- + +## Implementation Checklist + +Use this checklist when implementing SyncPlay in a new client: + +### Core Infrastructure + +- [ ] WebSocket connection manager with auto-reconnect +- [ ] Keep-alive message handling +- [ ] REST API client for all SyncPlay endpoints +- [ ] Message routing by type + +### Time Synchronization + +- [ ] Implement `GET /GetUtcTime` API call +- [ ] Record T1 (local) before request, T4 (local) after response +- [ ] Parse T2 (`RequestReceptionTime`) and T3 (`ResponseTransmissionTime`) from server +- [ ] Offset calculation using NTP-like algorithm +- [ ] Storage of last N measurements (recommend 8) +- [ ] Best measurement selection (minimum delay) +- [ ] Greedy → low-profile polling transition +- [ ] Staleness detection (>30s) +- [ ] Force update capability +- [ ] Local ↔ remote time conversion + +### Group Management + +- [ ] Create group +- [ ] List available groups +- [ ] Join group +- [ ] Leave group +- [ ] Handle GroupJoined message +- [ ] Handle UserJoined/UserLeft messages +- [ ] Handle GroupDoesNotExist message +- [ ] Track current group state + +### Playback Control + +- [ ] Send pause request +- [ ] Send unpause request +- [ ] Send seek request +- [ ] Send stop request +- [ ] Send buffering state +- [ ] Send ready state +- [ ] Send ping measurements +- [ ] Set new queue +- [ ] Queue additional items + +### Command Processing + +- [ ] Parse SyncPlayCommand messages +- [ ] Convert server time to local time +- [ ] Calculate execution delay +- [ ] Schedule commands for future execution +- [ ] Execute immediately if delay < 0 with `estimateCurrentTicks()` +- [ ] Handle large delay warnings +- [ ] Duplicate command detection +- [ ] Command-specific execution sequences (pause, unpause, seek, stop) + +### Sync Correction (Optional but Recommended) + +- [ ] Implement `estimateCurrentTicks()` for late command handling +- [ ] Track playback diff during playback +- [ ] Implement SpeedToSync (playback rate adjustment) +- [ ] Implement SkipToSync (seek to correct position) +- [ ] Throttle sync checks (every 500ms-1s) +- [ ] Disable sync during buffering + +### Player Integration + +- [ ] Define player interface abstraction +- [ ] Register player with SyncPlay controller +- [ ] Subscribe to player events +- [ ] Distinguish user actions from SyncPlay commands +- [ ] Handle videoCanPlay event → send ready +- [ ] Handle videoBuffering event → send buffering +- [ ] Implement once() for async event waiting +- [ ] Support playback rate control (for SpeedToSync) +- [ ] Implement timeUpdate event (for sync correction) + +### State Management + +- [ ] Track group state (Idle, Waiting, Paused, Playing) +- [ ] Track state reason +- [ ] Track current playlist +- [ ] Track current playing item ID +- [ ] Track position in ticks +- [ ] Handle StateUpdate messages +- [ ] Handle PlayQueue messages + +### Error Handling + +- [ ] Message deduplication +- [ ] WebSocket disconnection recovery +- [ ] Stale time sync detection +- [ ] Player not ready handling +- [ ] API error handling with user feedback + +--- + +## Appendix: Sequence Diagrams + +### Full Unpause Flow + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Initiator) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Paused + + C1->>S: POST /SyncPlay/Unpause + S->>S: State → Waiting (Unpause) + S->>C1: SyncPlayGroupUpdate (StateUpdate: Waiting) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Waiting) + + C1->>S: POST /SyncPlay/Ready (IsPlaying: true) + C2->>S: POST /SyncPlay/Ready (IsPlaying: true) + + S->>S: All clients ready + S->>S: Calculate When = Now + latency buffer + + S->>C1: SyncPlayCommand (Unpause, When=T, Position=P) + S->>C2: SyncPlayCommand (Unpause, When=T, Position=P) + + Note over C1: Wait until local time = T + Note over C2: Wait until local time = T + + C1->>C1: Seek to P if needed, then Play + C2->>C2: Seek to P if needed, then Play + + S->>S: State → Playing + S->>C1: SyncPlayGroupUpdate (StateUpdate: Playing) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Playing) +``` + +### Client Buffering During Playback + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Buffering) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Playing + + C1->>C1: Network slow, starts buffering + C1->>S: POST /SyncPlay/Buffering (IsPlaying: false) + + S->>S: State → Waiting (Buffer) + S->>C1: SyncPlayGroupUpdate (StateUpdate: Waiting, Buffer) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Waiting, Buffer) + + C2->>C2: Pause playback locally + C2->>S: POST /SyncPlay/Ready (IsPlaying: true) + + Note over C1: Buffering completes + C1->>S: POST /SyncPlay/Ready (IsPlaying: true) + + S->>S: All clients ready, resume + S->>C1: SyncPlayCommand (Unpause, When=T) + S->>C2: SyncPlayCommand (Unpause, When=T) + + S->>S: State → Playing +``` + +### Seek Operation + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Seeker) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Playing + + C1->>S: POST /SyncPlay/Seek (Position: 5min) + + S->>C1: SyncPlayCommand (Seek, When=T, Position=5min) + S->>C2: SyncPlayCommand (Seek, When=T, Position=5min) + + C1->>C1: Unpause → Seek → Wait → Pause + C2->>C2: Unpause → Seek → Wait → Pause + + C1->>S: POST /SyncPlay/Ready + C2->>S: POST /SyncPlay/Ready + + S->>S: All ready after seek + S->>C1: SyncPlayCommand (Unpause) + S->>C2: SyncPlayCommand (Unpause) +``` + +--- + +## Appendix: Validation Against Official Jellyfin Web Client + +This documentation was validated against the [official Jellyfin web client](https://github.com/jellyfin/jellyfin-web/tree/master/src/plugins/syncPlay) (as of January 2025). + +### ✅ Correctly Documented + +| Component | Source File | Status | +|-----------|-------------|--------| +| Time Sync Algorithm | `TimeSync.js` | ✅ Same constants and algorithm | +| Server Time API | `TimeSyncServer.js` | ✅ Uses `getServerTime()` → same endpoint | +| Offset Calculation | `TimeSync.js` | ✅ `((T2-T1) + (T3-T4)) / 2` | +| Measurement Selection | `TimeSync.js` | ✅ Picks minimum delay | +| Polling Strategy | `TimeSync.js` | ✅ 1s greedy (3x), then 60s | +| Command Scheduling | `PlaybackCore.js` | ✅ setTimeout with time conversion | +| Pause Sequence | `PlaybackCore.js` | ✅ Pause → wait → seek | +| Unpause Sequence | `PlaybackCore.js` | ✅ Seek → wait → play (or estimate if late) | +| Seek Sequence | `PlaybackCore.js` | ✅ Play → seek → wait ready → pause → send ready | +| Duplicate Detection | `PlaybackCore.js` | ✅ Same 4-field comparison | +| Buffering/Ready | `PlaybackCore.js` | ✅ Same payload structure | +| Queue Management | `QueueCore.js` | ✅ Same reason handling | +| Player Interface | `GenericPlayer.js` | ✅ Same abstraction pattern | +| WebSocket Keep-Alive | External | ✅ Half-timeout interval | + +### ⚠️ Advanced Features (Optional) + +These features are in the official client but may be omitted for simpler implementations: + +| Feature | Source File | Notes | +|---------|-------------|-------| +| SpeedToSync | `PlaybackCore.js` | Adjusts playback rate to catch up | +| SkipToSync | `PlaybackCore.js` | Seeks to correct position for large drifts | +| estimateCurrentTicks | `PlaybackCore.js` | Estimates position for late commands | +| extraTimeOffset | `TimeSyncCore.js` | Manual time offset adjustment setting | +| Repeat/Shuffle Mode | `QueueCore.js` | Queue mode synchronization | +| Player Factory | `PlayerFactory.js` | Multiple player type support | + +### Key Differences from Official Client + +1. **Sync Correction**: Official client continuously monitors playback position and corrects drift using SpeedToSync (60-3000ms drift) or SkipToSync (>400ms drift). + +2. **Late Command Handling**: Official client uses `estimateCurrentTicks()` to calculate where playback *should* be when a command arrives after its scheduled time. + +3. **Event Waiting**: Official uses `waitForEventOnce()` with timeout and reject events for robust async handling. + +4. **Settings**: Official has configurable thresholds (`minDelaySpeedToSync`, `maxDelaySpeedToSync`, `speedToSyncDuration`, etc.). + +--- + +## Regression Scenarios (AGENTS.md rule 10) + +These scenarios MUST be manually verified before any PR that touches +the SyncPlay subsystem. Each scenario maps to a known prior bug that +escaped automated coverage; treat the list as a release blocker per +AGENTS.md #10. + +### Group lifecycle + +- [ ] **Rejoin within 1 second:** Leave a group, immediately join another + and start playback. Confirm the first play click is not silently + dropped (was: setNewQueue debounce leaked across leave). +- [ ] **Leave during start:** Click Leave while a SyncPlay-initiated + playback is still loading the new media. Confirm the player route + does not pop into view after leave and no abandoned media plays. +- [ ] **Auto-load on join:** Browser/tab B joins a group while A is + playing. Confirm B's player auto-opens with the in-progress item; + group state stays Playing (not stuck Waiting). +- [ ] **Shared socket — phone resume:** On a phone (Android/iOS, not + leanback), background the app mid-group, then resume. Confirm the + socket reconnects, the group rejoins, and playback re-syncs. +- [ ] **Shared socket — desktop/web always-alive:** On desktop or web, + blur/minimize the window for >30s during a group. Confirm the + socket stays connected (no forced reconnect) and SyncPlay keeps + working. +- [ ] **Shared socket — Android-TV always-alive:** On Android-TV / + leanback, background the app. Confirm the socket is NOT force- + reconnected on resume (lifecycle gate excludes leanback). +- [ ] **Shared socket — account switch:** While the socket is open, + switch accounts. Confirm the old socket closes, a new one + connects for the new credentials, and SyncPlay is still usable. +- [ ] **Shared socket — logout:** Log out. Confirm the socket closes + and no reconnect attempts are logged. + +### Stuck Waiting/Paused recovery + +- [ ] **Buffer-reason local pause:** Two browsers; throttle one's + network to force buffering during playback. Confirm the other + browser pauses locally for the duration of the buffer event, + then resumes when the slow client recovers. +- [ ] **Loading failure recovery:** Force a load failure (e.g. invalid + media URL via DevTools throttling/blocking). Confirm the group + does not stick in Waiting; the failed client reports ready + (isPlaying:false) and the group returns to Paused. + +### Next-Episode flow + +- [ ] **NextItem advance:** With at least two episodes in a series queue, + click Next Video while in a SyncPlay group. Confirm both clients + switch within ~1-2s and the queue context is preserved (Previous + Video still works). +- [ ] **Rapid Next clicks:** Click Next twice within 1 second. Confirm + the second click is NOT silently dropped (was: setNewQueue + debounce). +- [ ] **Sync feedback overlay:** Click Next Video. Confirm a "Switching + item…" overlay is visible while the load is in progress. +- [ ] **Auto-advance:** Let the "Next Up" wrapper auto-advance time out. + Confirm both clients advance and the overlay shows. + +### Track switching + +- [ ] **Audio switch position:** In a SyncPlay group with a transcoded + stream, switch the audio track from the player options sheet. + Confirm playback resumes near the prior position with no large + forward jump (was: SkipToSync after stale-position reload). +- [ ] **Subtitle switch direct stream:** Switch subtitle track on a + direct-stream item. Confirm no group-level pause and no visible + desync in the other client. + +### Initiate playback (cross-client interop) + +- [ ] **Episode start propagates to official clients:** In a group with an + official Jellyfin client (e.g. webOS/Android TV) as a second + participant, start a TV episode from Fladder. Confirm the other + client actually begins playing the episode (was: `_playSyncPlay` sent + a single-item queue, which official clients do not start; movies, + being single-item by nature, masked the bug and worked). +- [ ] **Resume position on initiate:** Press "Continue Watching" on a + partially-watched item while in a group. Confirm every participant + resumes near the saved position, not from 0:00 (was: the SyncPlay + initiate path always sent startPositionTicks: 0). +- [ ] **Series/Season start:** Press play on a series or season (not a + specific episode) in a group. Confirm it resolves to the next-up + episode and all participants start there. + +### UI placement + +- [ ] **Side rail FAB count:** With a side navigation rail visible, each + rail destination shows exactly one FAB. SyncPlay is accessible via + the dashboard FAB or the SyncPlayBadge (AGENTS.md rule 4). + +--- + +## References + +- [Jellyfin SyncPlay API Documentation](https://api.jellyfin.org/#tag/SyncPlay) +- [Jellyfin Web Client SyncPlay Implementation](https://github.com/jellyfin/jellyfin-web/tree/master/src/plugins/syncPlay) +- [Jellyfin Web Client Source - PlaybackCore.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/PlaybackCore.js) +- [Jellyfin Web Client Source - TimeSync.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/timeSync/TimeSync.js) +- [NTP Clock Synchronization Algorithm](https://en.wikipedia.org/wiki/Network_Time_Protocol#Clock_synchronization_algorithm) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 76c077ff5..81643c63c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1089,6 +1089,116 @@ "@switchUser": {}, "sync": "Sync", "@sync": {}, + "syncPlay": "SyncPlay", + "@syncPlay": { + "description": "SyncPlay - synchronized playback feature" + }, + "syncPlayCreateGroup": "Create SyncPlay Group", + "@syncPlayCreateGroup": {}, + "syncPlayGroupName": "Group Name", + "@syncPlayGroupName": {}, + "syncPlayGroupNameHint": "Movie Night", + "@syncPlayGroupNameHint": {}, + "syncPlayCreatedGroup": "Created group \"{groupName}\"", + "@syncPlayCreatedGroup": { + "placeholders": { + "groupName": { + "type": "String" + } + } + }, + "syncPlayFailedToCreateGroup": "Failed to create group", + "@syncPlayFailedToCreateGroup": {}, + "syncPlayJoinedGroup": "Joined \"{groupName}\"", + "@syncPlayJoinedGroup": { + "placeholders": { + "groupName": { + "type": "String" + } + } + }, + "syncPlayFailedToJoinGroup": "Failed to join group", + "@syncPlayFailedToJoinGroup": {}, + "syncPlayLeftGroup": "Left SyncPlay group", + "@syncPlayLeftGroup": {}, + "syncPlayFailedToLoadGroups": "Failed to load groups", + "@syncPlayFailedToLoadGroups": {}, + "syncPlayNoActiveGroups": "No active groups", + "@syncPlayNoActiveGroups": {}, + "syncPlayCreateGroupHint": "Create a group to watch together", + "@syncPlayCreateGroupHint": {}, + "syncPlayCreateGroupButton": "Create Group", + "@syncPlayCreateGroupButton": {}, + "syncPlayGroupFallback": "SyncPlay Group", + "@syncPlayGroupFallback": {}, + "syncPlayParticipants": "{count, plural, =1{1 participant} other{{count} participants}}", + "@syncPlayParticipants": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncPlayInstructions": "Browse your library and start playing something to sync with the group.", + "@syncPlayInstructions": {}, + "syncPlayUnnamedGroup": "Unnamed Group", + "@syncPlayUnnamedGroup": {}, + "syncPlayStateIdle": "Idle", + "@syncPlayStateIdle": {}, + "syncPlayStateWaiting": "Waiting for others...", + "@syncPlayStateWaiting": {}, + "syncPlayStatePaused": "Paused", + "@syncPlayStatePaused": {}, + "syncPlayStatePlaying": "Playing", + "@syncPlayStatePlaying": {}, + "syncPlaySyncingPause": "Syncing pause...", + "@syncPlaySyncingPause": {}, + "syncPlaySyncingPlay": "Syncing play...", + "@syncPlaySyncingPlay": {}, + "syncPlaySyncingSeek": "Syncing seek...", + "@syncPlaySyncingSeek": {}, + "syncPlayStopping": "Stopping...", + "@syncPlayStopping": {}, + "syncPlaySyncing": "Syncing...", + "@syncPlaySyncing": {}, + "syncPlaySwitchingItem": "Switching item…", + "@syncPlaySwitchingItem": { + "description": "In-player overlay shown after the user advances to the next/previous SyncPlay episode while the group is reloading." + }, + "syncPlayCommandPausing": "Pausing", + "@syncPlayCommandPausing": {}, + "syncPlayCommandPlaying": "Playing", + "@syncPlayCommandPlaying": {}, + "syncPlayCommandSeeking": "Seeking", + "@syncPlayCommandSeeking": {}, + "syncPlayCommandStopping": "Stopping", + "@syncPlayCommandStopping": {}, + "syncPlayCommandSyncing": "Syncing", + "@syncPlayCommandSyncing": {}, + "syncPlaySyncingWithGroup": "Syncing with group...", + "@syncPlaySyncingWithGroup": {}, + "syncPlayUserJoined": "{userName} joined the group", + "@syncPlayUserJoined": { + "placeholders": { + "userName": { + "type": "String" + } + } + }, + "syncPlayUserLeft": "{userName} left the group", + "@syncPlayUserLeft": { + "placeholders": { + "userName": { + "type": "String" + } + } + }, + "syncPlayResumePlayback": "Resume group playback", + "@syncPlayResumePlayback": {}, + "syncPlayKickedFromGroup": "You were removed from the SyncPlay group", + "@syncPlayKickedFromGroup": {}, + "syncPlayGroupNoLongerExists": "The SyncPlay group is no longer available", + "@syncPlayGroupNoLongerExists": {}, "syncDeleteItemDesc": "Delete all synced data for {item}?", "@syncDeleteItemDesc": { "description": "Sync delete item pop-up window", @@ -1556,6 +1666,8 @@ "hasLikedDirector": "Has liked director", "hasLikedActor": "Has liked actor", "latest": "Latest", + "leave": "Leave", + "@leave": {}, "recommended": "Recommended", "playbackType": "Playback type", "playbackTypeDirect": "Direct", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cfa2f9504..649a9f598 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -567,6 +567,115 @@ "subtitles": "Sous-titres", "switchUser": "Changer d'utilisateur", "sync": "Synchroniser", + "@sync": {}, + "syncPlay": "SyncPlay", + "@syncPlay": { + "description": "SyncPlay - fonctionnalité de lecture synchronisée" + }, + "syncPlayCreateGroup": "Créer un groupe SyncPlay", + "@syncPlayCreateGroup": {}, + "syncPlayGroupName": "Nom du groupe", + "@syncPlayGroupName": {}, + "syncPlayGroupNameHint": "Soirée cinéma", + "@syncPlayGroupNameHint": {}, + "syncPlayCreatedGroup": "Groupe \"{groupName}\" créé", + "@syncPlayCreatedGroup": { + "placeholders": { + "groupName": { + "type": "String" + } + } + }, + "syncPlayFailedToCreateGroup": "Échec de la création du groupe", + "@syncPlayFailedToCreateGroup": {}, + "syncPlayJoinedGroup": "Rejoint \"{groupName}\"", + "@syncPlayJoinedGroup": { + "placeholders": { + "groupName": { + "type": "String" + } + } + }, + "syncPlayFailedToJoinGroup": "Échec de la connexion au groupe", + "@syncPlayFailedToJoinGroup": {}, + "syncPlayLeftGroup": "Groupe SyncPlay quitté", + "@syncPlayLeftGroup": {}, + "syncPlayFailedToLoadGroups": "Échec du chargement des groupes", + "@syncPlayFailedToLoadGroups": {}, + "syncPlayNoActiveGroups": "Aucun groupe actif", + "@syncPlayNoActiveGroups": {}, + "syncPlayCreateGroupHint": "Créez un groupe pour regarder ensemble", + "@syncPlayCreateGroupHint": {}, + "syncPlayCreateGroupButton": "Créer un groupe", + "@syncPlayCreateGroupButton": {}, + "syncPlayGroupFallback": "Groupe SyncPlay", + "@syncPlayGroupFallback": {}, + "syncPlayParticipants": "{count, plural, =1{1 participant} other{{count} participants}}", + "@syncPlayParticipants": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncPlayInstructions": "Parcourez votre bibliothèque et lancez une lecture pour synchroniser avec le groupe.", + "@syncPlayInstructions": {}, + "syncPlayUnnamedGroup": "Groupe sans nom", + "@syncPlayUnnamedGroup": {}, + "syncPlayStateIdle": "Inactif", + "@syncPlayStateIdle": {}, + "syncPlayStateWaiting": "En attente des autres...", + "@syncPlayStateWaiting": {}, + "syncPlayStatePaused": "En pause", + "@syncPlayStatePaused": {}, + "syncPlayStatePlaying": "Lecture en cours", + "@syncPlayStatePlaying": {}, + "syncPlaySyncingPause": "Synchronisation pause...", + "@syncPlaySyncingPause": {}, + "syncPlaySyncingPlay": "Synchronisation lecture...", + "@syncPlaySyncingPlay": {}, + "syncPlaySyncingSeek": "Synchronisation position...", + "@syncPlaySyncingSeek": {}, + "syncPlayStopping": "Arrêt...", + "@syncPlayStopping": {}, + "syncPlaySyncing": "Synchronisation...", + "@syncPlaySyncing": {}, + "syncPlaySwitchingItem": "Changement d'élément…", + "@syncPlaySwitchingItem": {}, + "syncPlayCommandPausing": "Mise en pause", + "@syncPlayCommandPausing": {}, + "syncPlayCommandPlaying": "Lecture", + "@syncPlayCommandPlaying": {}, + "syncPlayCommandSeeking": "Recherche", + "@syncPlayCommandSeeking": {}, + "syncPlayCommandStopping": "Arrêt", + "@syncPlayCommandStopping": {}, + "syncPlayCommandSyncing": "Synchronisation", + "@syncPlayCommandSyncing": {}, + "syncPlaySyncingWithGroup": "Synchronisation avec le groupe...", + "@syncPlaySyncingWithGroup": {}, + "syncPlayUserJoined": "{userName} a rejoint le groupe", + "@syncPlayUserJoined": { + "placeholders": { + "userName": { + "type": "String" + } + } + }, + "syncPlayUserLeft": "{userName} a quitté le groupe", + "@syncPlayUserLeft": { + "placeholders": { + "userName": { + "type": "String" + } + } + }, + "syncPlayResumePlayback": "Reprendre la lecture du groupe", + "@syncPlayResumePlayback": {}, + "syncPlayKickedFromGroup": "Vous avez été retiré du groupe SyncPlay", + "@syncPlayKickedFromGroup": {}, + "syncPlayGroupNoLongerExists": "Le groupe SyncPlay n'est plus disponible", + "@syncPlayGroupNoLongerExists": {}, "syncDeleteItemDesc": "Supprimer toutes les données synchronisées pour {item} ?", "@syncDeleteItemDesc": { "description": "Fenêtre contextuelle de suppression d'élément synchronisé", diff --git a/lib/util/localization_helper.dart b/lib/util/localization_helper.dart index c34d74408..e5c2438a9 100644 --- a/lib/util/localization_helper.dart +++ b/lib/util/localization_helper.dart @@ -129,4 +129,23 @@ class _TranslationsMessgener extends messenger.TranslationsPigeon { @override String watch() => context.localized.watch; + + // SyncPlay overlay strings + @override + String syncPlaySyncingWithGroup() => context.localized.syncPlaySyncingWithGroup; + + @override + String syncPlayCommandPausing() => context.localized.syncPlayCommandPausing; + + @override + String syncPlayCommandPlaying() => context.localized.syncPlayCommandPlaying; + + @override + String syncPlayCommandSeeking() => context.localized.syncPlayCommandSeeking; + + @override + String syncPlayCommandStopping() => context.localized.syncPlayCommandStopping; + + @override + String syncPlayCommandSyncing() => context.localized.syncPlayCommandSyncing; } diff --git a/pubspec.lock b/pubspec.lock index 9b34e3550..5449d8f19 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2452,7 +2452,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 diff --git a/pubspec.yaml b/pubspec.yaml index 98ee4aa4b..74dc2c547 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flutter_cache_manager: ^3.4.1 connectivity_plus: ^7.0.0 punycoder: ^0.2.2 + web_socket_channel: ^3.0.3 # State Management flutter_riverpod: ^2.6.1 diff --git a/test/providers/syncplay/syncplay_correction_test.dart b/test/providers/syncplay/syncplay_correction_test.dart new file mode 100644 index 000000000..186867dee --- /dev/null +++ b/test/providers/syncplay/syncplay_correction_test.dart @@ -0,0 +1,299 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('selectSyncCorrectionStrategy', () { + test('selects SpeedToSync in medium drift window', () { + final strategy = selectSyncCorrectionStrategy( + config: const SyncCorrectionConfig(), + state: const SyncCorrectionState( + syncEnabled: true, + activeStrategy: SyncCorrectionStrategy.none, + ), + diffMillis: 500, + hasPlaybackRate: true, + ); + + expect(strategy, SyncCorrectionStrategy.speedToSync); + }); + + test('falls back to SkipToSync when playback rate unsupported', () { + final strategy = selectSyncCorrectionStrategy( + config: const SyncCorrectionConfig(), + state: const SyncCorrectionState( + syncEnabled: true, + activeStrategy: SyncCorrectionStrategy.none, + ), + diffMillis: 500, + hasPlaybackRate: false, + ); + + expect(strategy, SyncCorrectionStrategy.skipToSync); + }); + + test('selects SkipToSync for very large drift', () { + final strategy = selectSyncCorrectionStrategy( + config: const SyncCorrectionConfig(), + state: const SyncCorrectionState( + syncEnabled: true, + activeStrategy: SyncCorrectionStrategy.none, + ), + diffMillis: 3500, + hasPlaybackRate: true, + ); + + expect(strategy, SyncCorrectionStrategy.skipToSync); + }); + }); + + group('SyncPlayState helpers', () { + test('hasActivePlayback false when no playing item', () { + final state = SyncPlayState(isInGroup: true); + expect(state.hasActivePlayback, isFalse); + }); + + test('hasActivePlayback true with playing item and non-idle state', () { + final state = SyncPlayState( + isInGroup: true, + playingItemId: 'item-1', + groupState: SyncPlayGroupState.playing, + ); + expect(state.hasActivePlayback, isTrue); + }); + + test('hasActivePlayback false when group state is idle', () { + final state = SyncPlayState( + isInGroup: true, + playingItemId: 'item-1', + ); + expect(state.hasActivePlayback, isFalse); + }); + + test('isInLocalOnlyMode mirrors localOnlyOperationCount', () { + expect(SyncPlayState().isInLocalOnlyMode, isFalse); + expect( + SyncPlayState(localOnlyOperationCount: 1).isInLocalOnlyMode, + isTrue, + ); + expect( + SyncPlayState(localOnlyOperationCount: 3).isInLocalOnlyMode, + isTrue, + ); + }); + }); + + group('SyncPlayCommandHandler', () { + test('ignores duplicate command', () async { + var pauseCalls = 0; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (_) {}, + ) + ..onPause = () async { + pauseCalls++; + } + ..getPositionTicks = () => 0; + + final now = DateTime.now().toUtc().toIso8601String(); + final commandData = { + 'Command': 'Pause', + 'When': now, + 'PositionTicks': 0, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + handler.handleCommand(commandData, SyncPlayState()); + + expect(pauseCalls, 1); + }); + + test('executes Unpause as seek then play', () async { + final order = []; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (_) {}, + ) + ..onSeek = (ticks) async { + order.add('seek'); + } + ..onPlay = () async { + order.add('play'); + } + ..getPositionTicks = () => 0; + + final commandData = { + 'Command': 'Unpause', + 'When': DateTime.now().toUtc().toIso8601String(), + 'PositionTicks': ticksPerSecond * 2, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + await Future.delayed(const Duration(milliseconds: 5)); + + expect(order, ['seek', 'play']); + }); + + test('Unpause is not deduped when player is paused', () async { + var playCalls = 0; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (_) {}, + ) + ..onPlay = () async { + playCalls++; + } + ..onSeek = ((_) async {}) + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + final commandData = { + 'Command': 'Unpause', + 'When': DateTime.now().toUtc().toIso8601String(), + 'PositionTicks': 0, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + await Future.delayed(const Duration(milliseconds: 5)); + handler.handleCommand(commandData, SyncPlayState()); + await Future.delayed(const Duration(milliseconds: 5)); + + expect(playCalls, 2); + }); + + test('Seek reports ready only when not buffering', () async { + var readyCalls = 0; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (_) {}, + ) + ..onPause = () async {} + ..onSeek = (ticks) async {} + ..onReportReady = () async { + readyCalls++; + } + ..isBuffering = () => false; + + final commandData = { + 'Command': 'Seek', + 'When': DateTime.now().toUtc().toIso8601String(), + 'PositionTicks': ticksPerSecond, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + await Future.delayed(const Duration(milliseconds: 5)); + expect(readyCalls, 1); + + handler.isBuffering = () => true; + handler.handleCommand( + { + ...commandData, + 'When': DateTime.now().toUtc().toIso8601String(), + }, + SyncPlayState(), + ); + await Future.delayed(const Duration(milliseconds: 5)); + expect(readyCalls, 1); + }); + + test('Unpause defers final state-clear until player stops buffering', () async { + var buffering = true; + var stateClearFired = false; + + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) { + // The finally block in _executeCommand calls + // state.copyWith(isProcessingCommand: false, processingCommandType: null) + // — apply the updater to a sentinel and detect that transition. + final after = updater(SyncPlayState(isProcessingCommand: true)); + if (after.isProcessingCommand == false) { + stateClearFired = true; + } + }, + ) + ..onSeek = (_) async {} + ..onPlay = () async {} + ..getPositionTicks = (() => 0) + ..isBuffering = (() => buffering); + + final commandData = { + 'Command': 'Unpause', + 'When': DateTime.now().toUtc().toIso8601String(), + 'PositionTicks': ticksPerSecond * 2, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + + // While player is buffering the finally must not have fired. + await Future.delayed(const Duration(milliseconds: 250)); + expect( + stateClearFired, + isFalse, + reason: 'should not clear isProcessingCommand while player is still buffering', + ); + + // Player finishes buffering; the wait loop polls every 100 ms and + // the finally block should fire shortly after. + buffering = false; + await Future.delayed(const Duration(milliseconds: 250)); + expect( + stateClearFired, + isTrue, + reason: 'should clear isProcessingCommand once buffering ends', + ); + }); + + test('Pause-with-seek defers final state-clear until player stops buffering', () async { + var buffering = true; + var stateClearFired = false; + + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) { + final after = updater(SyncPlayState(isProcessingCommand: true)); + if (after.isProcessingCommand == false) { + stateClearFired = true; + } + }, + ) + ..onPause = () async {} + ..onSeek = (_) async {} + // Force a position correction by pretending the local player is far + // from the requested Pause position — handler will call onSeek. + ..getPositionTicks = (() => 0) + ..isBuffering = (() => buffering); + + final commandData = { + 'Command': 'Pause', + 'When': DateTime.now().toUtc().toIso8601String(), + 'PositionTicks': ticksPerSecond * 5, + 'PlaylistItemId': 'playlist-item-1', + }; + + handler.handleCommand(commandData, SyncPlayState()); + + // While player is buffering after the correction seek, the finally + // block must not have fired. + await Future.delayed(const Duration(milliseconds: 250)); + expect( + stateClearFired, + isFalse, + reason: 'should not clear isProcessingCommand while seek-induced buffering is active', + ); + + buffering = false; + await Future.delayed(const Duration(milliseconds: 250)); + expect( + stateClearFired, + isTrue, + reason: 'should clear isProcessingCommand once buffering ends', + ); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_lifecycle_test.dart b/test/providers/syncplay/syncplay_lifecycle_test.dart new file mode 100644 index 000000000..d26fc27db --- /dev/null +++ b/test/providers/syncplay/syncplay_lifecycle_test.dart @@ -0,0 +1,118 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// We deliberately avoid spinning up the full SyncPlayController here: +// it depends on a Riverpod Ref + Chopper + WebSocket. Instead we cover the +// state-flag invariants that downstream tests rely on. +// +// Lifecycle reset is verified through the controller via integration tests +// gated behind a manual test plan (see docs/syncplay-implementation.md +// "Regression scenarios"). The unit-level coverage here proves that +// SyncPlayState resets cleanly via copyWith — the controller's leaveGroup +// path uses the same pattern. + +void main() { + group('SyncPlayState lifecycle reset', () { + test('copyWith clears all in-flight playback flags', () { + final mid = SyncPlayState( + isInGroup: true, + groupId: 'g1', + groupName: 'movie night', + groupState: SyncPlayGroupState.playing, + playingItemId: 'item-1', + playlistItemId: 'plist-1', + positionTicks: 1234, + startPlaybackInProgress: true, + startingPlaylistItemId: 'plist-1', + isProcessingCommand: true, + processingCommandType: SyncPlayCommand.unpause, + ); + + final cleared = mid.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: const [], + isProcessingCommand: false, + processingCommandType: null, + positionTicks: 0, + playingItemId: null, + playlistItemId: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + ); + + expect(cleared.isInGroup, isFalse); + expect(cleared.groupId, isNull); + expect(cleared.startPlaybackInProgress, isFalse); + expect(cleared.startingPlaylistItemId, isNull); + expect(cleared.processingCommandType, isNull); + expect(cleared.playingItemId, isNull); + }); + }); + + group('SyncPlayMessageHandler Waiting state', () { + test('Buffer reason invokes a local pause callback before reporting ready', () async { + final readyCalls = []; + var pauseCalls = 0; + final handler = SyncPlayMessageHandler( + onStateUpdate: (_) {}, + reportReady: ({bool isPlaying = true}) async { + readyCalls.add(isPlaying); + }, + startPlayback: (id, ticks) async {}, + isBuffering: () => false, + getContext: () => null, + onGroupJoined: () {}, + onGroupJoinFailed: () {}, + onLocalPauseForBuffer: () async { + pauseCalls++; + }, + ); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': { + 'State': 'Waiting', + 'Reason': 'Buffer', + 'PositionTicks': 0, + }, + }, SyncPlayState(isInGroup: true)); + + await Future.delayed(const Duration(milliseconds: 5)); + + expect(pauseCalls, 1, reason: 'should pause locally on Buffer reason'); + expect(readyCalls, [true], reason: 'should still report ready (true) after pausing'); + }); + + test('Unpause reason does not invoke local pause', () async { + var pauseCalls = 0; + final handler = SyncPlayMessageHandler( + onStateUpdate: (_) {}, + reportReady: ({bool isPlaying = true}) async {}, + startPlayback: (id, ticks) async {}, + isBuffering: () => false, + getContext: () => null, + onGroupJoined: () {}, + onGroupJoinFailed: () {}, + onLocalPauseForBuffer: () async { + pauseCalls++; + }, + ); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': { + 'State': 'Waiting', + 'Reason': 'Unpause', + 'PositionTicks': 0, + }, + }, SyncPlayState(isInGroup: true)); + + await Future.delayed(const Duration(milliseconds: 5)); + expect(pauseCalls, 0); + }); + }); +} diff --git a/test/providers/websocket/jellyfin_websocket_platform_test.dart b/test/providers/websocket/jellyfin_websocket_platform_test.dart new file mode 100644 index 000000000..126f0e5f5 --- /dev/null +++ b/test/providers/websocket/jellyfin_websocket_platform_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isPhonePlatform', () { + test('Android handheld (not leanback) is a phone', () { + expect( + isPhonePlatform(isWeb: false, platform: TargetPlatform.android, leanBackMode: false), + isTrue, + ); + }); + + test('iOS handheld is a phone', () { + expect( + isPhonePlatform(isWeb: false, platform: TargetPlatform.iOS, leanBackMode: false), + isTrue, + ); + }); + + test('Android-TV / leanback is NOT a phone (always-alive)', () { + expect( + isPhonePlatform(isWeb: false, platform: TargetPlatform.android, leanBackMode: true), + isFalse, + ); + }); + + test('Web is never a phone', () { + expect( + isPhonePlatform(isWeb: true, platform: TargetPlatform.android, leanBackMode: false), + isFalse, + ); + }); + + test('Desktop platforms are not phones', () { + for (final p in [TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux]) { + expect( + isPhonePlatform(isWeb: false, platform: p, leanBackMode: false), + isFalse, + reason: '$p should not be a phone', + ); + } + }); + }); +} From b925d184837543eff4caf79d2515dc8023b2cbcf Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 10:42:23 +0200 Subject: [PATCH 07/14] chore(syncplay): rebase onto develop Adapt the navigation rail FAB helper to upstream's ConsumerWidget refactor of SideNavigationRail (the widget.* accessors no longer exist). --- .../navigation_scaffold/components/side_navigation_bar.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index f17b476be..ceb26e36c 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -238,8 +238,8 @@ class SideNavigationRail extends ConsumerWidget { } Widget actionButtonWidget(BuildContext context, bool expanded) { - final destination = (widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length) - ? widget.destinations[widget.currentIndex] + final destination = (currentIndex >= 0 && currentIndex < destinations.length) + ? destinations[currentIndex] : null; // If there's a custom FAB widget, use it (DashboardFabs already From 886557889e6dc416571ac933d74b48c1c037ce93 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 14:15:55 +0200 Subject: [PATCH 08/14] fix(websocket): reconnect forever with capped backoff and channel-safe close handling - Exponential backoff (2 s base, 30 s cap, +/-20 % jitter) that never gives up; one reconnect timer at a time. - Close and error events are matched to the channel that raised them, and the stream subscription is stored and cancelled on disconnect, so a forceReconnect() can no longer null out its successor or open a third socket. A handshake that loses the race with disconnect() closes itself, and a synchronous constructor failure drops to disconnected instead of wedging in connecting. - App resume only checks the socket (KeepAlive when healthy, reconnect otherwise); coming back online forces a reconnect. --- .../websocket/jellyfin_websocket.dart | 157 +++++++++++------- .../jellyfin_websocket_provider.dart | 84 +++++----- .../jellyfin_websocket_platform_test.dart | 36 ++++ 3 files changed, 176 insertions(+), 101 deletions(-) diff --git a/lib/providers/websocket/jellyfin_websocket.dart b/lib/providers/websocket/jellyfin_websocket.dart index 5b00c293b..f07251b7d 100644 --- a/lib/providers/websocket/jellyfin_websocket.dart +++ b/lib/providers/websocket/jellyfin_websocket.dart @@ -1,14 +1,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'dart:math' show Random, min, pow; import 'package:flutter/foundation.dart' show TargetPlatform; import 'package:web_socket_channel/web_socket_channel.dart'; -/// WebSocket connection state. -/// -/// Moved here from `lib/models/syncplay/syncplay_models.dart` so the -/// socket layer no longer depends on SyncPlay models. enum WebSocketConnectionState { disconnected, connecting, @@ -16,14 +13,8 @@ enum WebSocketConnectionState { reconnecting, } -/// Pure platform classification for the lifecycle gate. -/// -/// A "phone" (Android/iOS handheld, not Android-TV/leanback) is the only -/// platform that gets the background/resume disconnect-reconnect cycle. -/// Desktop, Web, and Android-TV/leanback stay always-alive. -/// -/// Kept as a free function with no Flutter-binding dependency so it is -/// unit-testable without `TestWidgetsFlutterBinding`. +/// Only a phone (Android/iOS handheld, not leanback) gets the resume check; desktop, web and TV stay +/// always-alive. Free of Flutter bindings so it is unit-testable. bool isPhonePlatform({ required bool isWeb, required TargetPlatform platform, @@ -36,10 +27,25 @@ bool isPhonePlatform({ return isAndroidOrIos && !leanBackMode; } -/// Manages a single WebSocket connection to the Jellyfin server. -/// -/// App-level shared connection (formerly `WebSocketManager`, owned by -/// SyncPlay). Connection / keep-alive / reconnect logic is unchanged. +/// First reconnect delay; doubles on every failed attempt. +const Duration reconnectBaseDelay = Duration(seconds: 2); + +/// Backoff ceiling; a SyncPlay session dying while the socket is down needs a short one. +const Duration reconnectMaxDelay = Duration(seconds: 30); + +/// Random spread so many clients behind the same router do not reconnect in lockstep. +const double reconnectJitterFraction = 0.2; + +/// Exponential with a hard cap and jitter; never gives up. +Duration reconnectDelay(int attempt, {Random? random}) { + final exponent = attempt.clamp(0, 30); + final rawMs = reconnectBaseDelay.inMilliseconds * pow(2, exponent); + final cappedMs = min(rawMs, reconnectMaxDelay.inMilliseconds.toDouble()); + final jitter = ((random ?? Random()).nextDouble() * 2 - 1) * reconnectJitterFraction; + return Duration(milliseconds: (cappedMs * (1 + jitter)).round()); +} + +/// App-level shared connection; reconnects forever with capped backoff until [disconnect]. class JellyfinWebSocket { JellyfinWebSocket({ required this.serverUrl, @@ -52,11 +58,13 @@ class JellyfinWebSocket { final String deviceId; WebSocketChannel? _channel; + StreamSubscription? _subscription; Timer? _keepAliveTimer; Timer? _reconnectTimer; int _reconnectAttempts = 0; - static const int _maxReconnectAttempts = 5; - static const Duration _baseReconnectDelay = Duration(seconds: 2); + + /// Set by [disconnect] until the next [connect]; suppresses automatic reconnects. + bool _manuallyClosed = false; final _connectionStateController = StreamController.broadcast(); final _messageController = StreamController>.broadcast(); @@ -67,7 +75,6 @@ class JellyfinWebSocket { WebSocketConnectionState _currentState = WebSocketConnectionState.disconnected; WebSocketConnectionState get currentState => _currentState; - /// Build WebSocket URL for Jellyfin Uri get _webSocketUri { final baseUri = Uri.parse(serverUrl); final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; @@ -84,58 +91,74 @@ class JellyfinWebSocket { ); } - /// Connect to WebSocket Future connect() async { if (_currentState == WebSocketConnectionState.connected || _currentState == WebSocketConnectionState.connecting) { return; } + _manuallyClosed = false; _updateState(WebSocketConnectionState.connecting); + WebSocketChannel? opened; try { log('WebSocket: Connecting to ${_webSocketUri.toString().replaceAll(RegExp(r'api_key=[^&]+'), 'api_key=***')}'); - _channel = WebSocketChannel.connect(_webSocketUri); - await _channel!.ready; + final channel = WebSocketChannel.connect(_webSocketUri); + opened = channel; + _channel = channel; + await channel.ready; + + if (!identical(_channel, channel)) { + // `disconnect()` ran while the handshake was in flight. + await channel.sink.close(); + return; + } _updateState(WebSocketConnectionState.connected); _reconnectAttempts = 0; - _channel!.stream.listen( + _subscription = channel.stream.listen( _handleMessage, - onError: _handleError, - onDone: _handleDone, + onError: (Object error) => _handleError(channel, error), + onDone: () => _handleDone(channel), ); } catch (e) { log('WebSocket connection failed: $e'); - _updateState(WebSocketConnectionState.disconnected); - _scheduleReconnect(); + if (opened != null) { + if (identical(_channel, opened)) { + _onSocketClosed(opened); + } + } else if (!_manuallyClosed) { + // Channel never created (sync constructor failure); staying `connecting` would make later connect() a no-op. + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } } } - /// Disconnect from WebSocket + /// Disconnect from WebSocket and stop reconnecting. Future disconnect() async { + _manuallyClosed = true; _reconnectTimer?.cancel(); + _reconnectTimer = null; _keepAliveTimer?.cancel(); - _reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect + _keepAliveTimer = null; - await _channel?.sink.close(); + // Detach first so the old channel's onDone/onError can never reach _onSocketClosed after a rebuild. + final channel = _channel; _channel = null; + await _subscription?.cancel(); + _subscription = null; + await channel?.sink.close(); _updateState(WebSocketConnectionState.disconnected); } - /// Force reconnect (e.g., after app resume) - /// Resets attempt counter and immediately reconnects + /// Resets the attempt counter and reconnects immediately (e.g. after app resume with a dead socket). Future forceReconnect() async { - _reconnectTimer?.cancel(); - _keepAliveTimer?.cancel(); - await _channel?.sink.close(); - _channel = null; + await disconnect(); _reconnectAttempts = 0; - _updateState(WebSocketConnectionState.disconnected); await connect(); } - /// Send a message through WebSocket void send(Map message) { if (_currentState != WebSocketConnectionState.connected) { log('Cannot send message: WebSocket not connected'); @@ -149,7 +172,6 @@ class JellyfinWebSocket { } } - /// Send keep-alive message void _sendKeepAlive() { send({'MessageType': 'KeepAlive'}); } @@ -159,70 +181,85 @@ class JellyfinWebSocket { final message = json.decode(data as String) as Map; final messageType = message['MessageType'] as String?; - // Log all received messages for debugging (except KeepAlive spam) if (messageType != 'KeepAlive') { log('WebSocket: Received message: $message'); } - // Handle ForceKeepAlive to set up keep-alive interval if (messageType == 'ForceKeepAlive') { final timeoutSeconds = message['Data'] as int? ?? 60; _setupKeepAlive(timeoutSeconds); } - // Forward message to listeners _messageController.add(message); } catch (e) { log('Failed to parse WebSocket message: $e\nRaw data: $data'); } } - void _handleError(dynamic error) { + void _handleError(WebSocketChannel channel, Object error) { log('WebSocket error: $error'); - _updateState(WebSocketConnectionState.disconnected); - _scheduleReconnect(); + // onDone follows an error on the channel stream; scheduling is idempotent so handling both is harmless. + _onSocketClosed(channel); } - void _handleDone() { + void _handleDone(WebSocketChannel channel) { log('WebSocket connection closed'); - _keepAliveTimer?.cancel(); + _onSocketClosed(channel); + } - if (_currentState != WebSocketConnectionState.disconnected) { + /// Common exit path for errors, remote closes and failed connects. Events from a channel that is no + /// longer current are ignored, or they would null out the live channel and arm a third socket. + void _onSocketClosed(WebSocketChannel channel) { + if (!identical(_channel, channel)) { + return; + } + _keepAliveTimer?.cancel(); + _keepAliveTimer = null; + _channel = null; + _subscription = null; + if (_manuallyClosed) { _updateState(WebSocketConnectionState.disconnected); - _scheduleReconnect(); + return; } + _scheduleReconnect(); } void _setupKeepAlive(int timeoutSeconds) { _keepAliveTimer?.cancel(); - // Send keep-alive at half the timeout interval final interval = Duration(seconds: (timeoutSeconds * 0.5).round()); _keepAliveTimer = Timer.periodic(interval, (_) => _sendKeepAlive()); } + /// Arms a reconnect timer unless one is already pending; never gives up. void _scheduleReconnect() { - if (_reconnectAttempts >= _maxReconnectAttempts) { - log('Max reconnect attempts reached'); + if (_manuallyClosed) { + return; + } + if (_reconnectTimer?.isActive == true) { return; } - _reconnectTimer?.cancel(); - _updateState(WebSocketConnectionState.reconnecting); + if (_currentState != WebSocketConnectionState.reconnecting) { + _updateState(WebSocketConnectionState.reconnecting); + } - // Exponential backoff - final delay = _baseReconnectDelay * (1 << _reconnectAttempts); + final delay = reconnectDelay(_reconnectAttempts); _reconnectAttempts++; - log('Scheduling reconnect in ${delay.inSeconds}s (attempt $_reconnectAttempts)'); - _reconnectTimer = Timer(delay, connect); + log('Scheduling reconnect in ${delay.inMilliseconds}ms (attempt $_reconnectAttempts)'); + _reconnectTimer = Timer(delay, () { + _reconnectTimer = null; + unawaited(connect()); + }); } void _updateState(WebSocketConnectionState state) { _currentState = state; - _connectionStateController.add(state); + if (!_connectionStateController.isClosed) { + _connectionStateController.add(state); + } } - /// Dispose resources Future dispose() async { await disconnect(); await _connectionStateController.close(); diff --git a/lib/providers/websocket/jellyfin_websocket_provider.dart b/lib/providers/websocket/jellyfin_websocket_provider.dart index 4a8354888..36a3db203 100644 --- a/lib/providers/websocket/jellyfin_websocket_provider.dart +++ b/lib/providers/websocket/jellyfin_websocket_provider.dart @@ -8,19 +8,18 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:fladder/models/account_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/arguments_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart' as connectivity; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; part 'jellyfin_websocket_provider.g.dart'; -/// Phone-only lifecycle observer: forces a clean WebSocket reconnect when -/// the app returns to the foreground. Registered only on phones (see -/// [isPhonePlatform]); desktop / web / Android-TV stay always-alive. +/// Phone-only: on foreground it reconnects only if the socket died in the background. `inactive` is +/// ignored because Android raises it for the notification shade, dialogs, PiP and call banners. class _WebSocketLifecycleObserver with WidgetsBindingObserver { _WebSocketLifecycleObserver(this._controller); final JellyfinWebSocketController _controller; - bool _wasConnected = false; void register() => WidgetsBinding.instance.addObserver(this); void unregister() => WidgetsBinding.instance.removeObserver(this); @@ -28,17 +27,11 @@ class _WebSocketLifecycleObserver with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { - case AppLifecycleState.paused: - case AppLifecycleState.inactive: - _wasConnected = _controller.currentState == WebSocketConnectionState.connected; - log('JellyfinWebSocket: app paused, wasConnected=$_wasConnected'); - break; case AppLifecycleState.resumed: - if (_wasConnected) { - log('JellyfinWebSocket: app resumed, forcing reconnect'); - unawaited(_controller.forceReconnectSocket()); - } + unawaited(_controller.ensureConnected(reason: 'app resumed')); break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: case AppLifecycleState.detached: case AppLifecycleState.hidden: break; @@ -46,12 +39,8 @@ class _WebSocketLifecycleObserver with WidgetsBindingObserver { } } -/// App-level shared Jellyfin WebSocket. -/// -/// Owns a single [JellyfinWebSocket], connects when a user is -/// authenticated, and re-broadcasts the socket's streams through -/// long-lived controllers so consumers stay subscribed transparently -/// across account switches / socket rebuilds. +/// Owns a single [JellyfinWebSocket], connects when a user is authenticated, and re-broadcasts its +/// streams through long-lived controllers so consumers survive account switches / socket rebuilds. @Riverpod(keepAlive: true) class JellyfinWebSocketController extends _$JellyfinWebSocketController { JellyfinWebSocket? _socket; @@ -59,33 +48,40 @@ class JellyfinWebSocketController extends _$JellyfinWebSocketController { StreamSubscription>? _socketMessageSub; _WebSocketLifecycleObserver? _observer; - // Serializes socket teardown/rebuild so two quick auth changes - // (e.g. token refresh then account switch) can't run concurrently - // and leak or duplicate a socket. + // Serializes socket teardown/rebuild so two quick auth changes cannot leak or duplicate a socket. Future _socketOps = Future.value(); - // Long-lived re-broadcast controllers. Consumers subscribe to these, - // never to a JellyfinWebSocket instance directly, so a socket rebuild - // (e.g. account switch) is invisible to them. + // Long-lived re-broadcast controllers; consumers never subscribe to a JellyfinWebSocket directly. final _stateController = StreamController.broadcast(); final _messageController = StreamController>.broadcast(); - /// Connection-state transitions (re-broadcast). Stream get connectionState => _stateController.stream; - /// Raw inbound messages (re-broadcast). Consumers filter by - /// `MessageType` themselves. + /// Consumers filter by `MessageType` themselves. Stream> get messages => _messageController.stream; - /// Current connection state (disconnected if no socket). WebSocketConnectionState get currentState => _socket?.currentState ?? WebSocketConnectionState.disconnected; - /// Send a message through the shared socket (no-op if not connected). void send(Map message) => _socket?.send(message); - /// Force a clean reconnect of the underlying socket. Future forceReconnectSocket() async => _socket?.forceReconnect(); + /// A healthy socket only gets a KeepAlive so the server refreshes its liveness timer; a dead one is + /// rebuilt immediately instead of waiting for the backoff timer. + Future ensureConnected({required String reason}) async { + final socket = _socket; + if (socket == null) { + return; + } + if (socket.currentState == WebSocketConnectionState.connected) { + log('JellyfinWebSocket: $reason, socket healthy, sending KeepAlive'); + socket.send({'MessageType': 'KeepAlive'}); + return; + } + log('JellyfinWebSocket: $reason, socket ${socket.currentState.name}, reconnecting now'); + await socket.forceReconnect(); + } + bool get _isPhone => isPhonePlatform( isWeb: kIsWeb, platform: defaultTargetPlatform, @@ -94,9 +90,7 @@ class JellyfinWebSocketController extends _$JellyfinWebSocketController { @override WebSocketConnectionState build() { - // Drive connect/disconnect off auth. fireImmediately handles the - // case where a user is already logged in when this provider is - // first activated (e.g. by base_app_wrapper after a relaunch). + // fireImmediately covers a user already logged in when this provider is first activated. ref.listen( userProvider, (previous, next) => _handleUserChange(previous, next), @@ -107,13 +101,23 @@ class JellyfinWebSocketController extends _$JellyfinWebSocketController { _observer = _WebSocketLifecycleObserver(this)..register(); } + // Coming back online is worth an immediate attempt; do not wait for the backoff timer. + ref.listen( + connectivity.connectivityStatusProvider, + (previous, next) { + final wasOffline = previous == connectivity.ConnectionState.offline; + final isOnline = next != connectivity.ConnectionState.offline; + if (wasOffline && isOnline) { + unawaited(ensureConnected(reason: 'connectivity restored')); + } + }, + ); + ref.onDispose(_disposeAll); return WebSocketConnectionState.disconnected; } - /// Chain a socket mutation onto the serial queue so teardown/rebuild - /// never overlap. Failures are logged, not propagated, so one bad op - /// doesn't wedge the queue. + /// Failures are logged, not propagated, so one bad op doesn't wedge the serial queue. void _enqueueSocketOp(Future Function() op) { _socketOps = _socketOps.then((_) => op()).catchError((Object e, StackTrace s) { log('JellyfinWebSocket: socket op failed: $e'); @@ -137,15 +141,13 @@ class JellyfinWebSocketController extends _$JellyfinWebSocketController { final deviceId = next.credentials.deviceId; _enqueueSocketOp(() async { - // Re-evaluate against the live socket at execution time (a prior - // queued op may have changed it). + // Re-evaluate against the live socket: a prior queued op may have changed it. final existing = _socket; if (existing != null && existing.serverUrl == serverUrl && existing.token == token && existing.deviceId == deviceId) { - // Same credentials/server — just ensure it is up (connect() is a - // no-op when already connected/connecting). + // Same credentials/server: connect() is a no-op when already connected/connecting. await existing.connect(); return; } diff --git a/test/providers/websocket/jellyfin_websocket_platform_test.dart b/test/providers/websocket/jellyfin_websocket_platform_test.dart index 126f0e5f5..3cd499ce1 100644 --- a/test/providers/websocket/jellyfin_websocket_platform_test.dart +++ b/test/providers/websocket/jellyfin_websocket_platform_test.dart @@ -1,8 +1,32 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart' show TargetPlatform; import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('reconnectDelay', () { + test('first attempt is the base delay within jitter', () { + final delay = reconnectDelay(0, random: Random(1)); + expect(delay.inMilliseconds, inInclusiveRange(1600, 2400)); + }); + + test('doubles per attempt until the cap', () { + final second = reconnectDelay(1, random: _NoJitter()); + final third = reconnectDelay(2, random: _NoJitter()); + expect(second, const Duration(seconds: 4)); + expect(third, const Duration(seconds: 8)); + }); + + test('is capped and never gives up', () { + for (final attempt in [4, 10, 100, 1 << 20]) { + final delay = reconnectDelay(attempt, random: Random(attempt)); + expect(delay.inMilliseconds, lessThanOrEqualTo(36000), reason: 'attempt $attempt'); + expect(delay.inMilliseconds, greaterThanOrEqualTo(24000), reason: 'attempt $attempt'); + } + }); + }); + group('isPhonePlatform', () { test('Android handheld (not leanback) is a phone', () { expect( @@ -43,3 +67,15 @@ void main() { }); }); } + +/// Random that always yields 0.5 so the jitter term is exactly zero. +class _NoJitter implements Random { + @override + bool nextBool() => false; + + @override + double nextDouble() => 0.5; + + @override + int nextInt(int max) => 0; +} From bf1c53e7c6e2cddf10a6a47713d9089cbda52c3b Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 14:15:55 +0200 Subject: [PATCH 09/14] feat(syncplay): split native user-action tags and forward the group waiting state - PlaybackChangeSource.user becomes userPlayPause / userSeek so Flutter never has to guess what a paused-while-buffering frame means. ExoPlayer consumes a seek tag on the very next frame and a play/pause tag only when isPlaying actually flips, so the periodic poll can no longer spend the tag on a no-op frame. - The unused onUserPlay/onUserPause/onUserSeek Pigeon callbacks are gone; MediaPlay on the remote tags and plays like the other keys. - SyncPlayCommandType.waiting and TranslationsPigeon.syncPlayStateWaiting let the native overlay show "Waiting for others..." like the Flutter one. - New localized strings for halt/resume, not-following, locked speed, library access denied, disabled access and the drift-correction setting. --- .../fladder/api/TranslationsPigeon.g.kt | 20 ++++ .../fladder/api/VideoPlayerHelper.g.kt | 80 +++------------- .../composables/controls/ProgressBar.kt | 12 +-- .../composables/controls/SkipOverlay.kt | 4 +- .../controls/VideoPlayerControls.kt | 23 +++-- .../overlays/SyncPlayCommandOverlay.kt | 8 +- .../fladder/objects/VideoPlayerObject.kt | 4 +- .../nl/jknaapen/fladder/player/ExoPlayer.kt | 59 +++++++----- lib/l10n/app_en.arb | 14 +++ lib/l10n/app_fr.arb | 14 +++ lib/src/translations_pigeon.g.dart | 22 +++++ lib/src/video_player_helper.g.dart | 93 +++---------------- lib/util/localization_helper.dart | 3 + pigeons/translations_pigeon.dart | 1 + pigeons/video_player.dart | 27 +++--- 15 files changed, 170 insertions(+), 214 deletions(-) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt index 83d7d356a..a823ebe7f 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt @@ -454,4 +454,24 @@ class TranslationsPigeon(private val binaryMessenger: BinaryMessenger, private v } } } + fun syncPlayStateWaiting(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayStateWaiting$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index 367e0a680..54822a100 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -98,7 +98,9 @@ enum class SyncPlayCommandType(val raw: Int) { PAUSE(1), UNPAUSE(2), SEEK(3), - STOP(4); + STOP(4), + /** The group is waiting for a participant to buffer; shown so every device reports the same state. */ + WAITING(5); companion object { fun ofRaw(raw: Int): SyncPlayCommandType? { @@ -121,14 +123,19 @@ enum class MediaSegmentType(val raw: Int) { } } -/** Source of the last playback state change (for SyncPlay: infer user actions from stream). */ +/** Who caused the last playback state change; Flutter infers SyncPlay requests from it. */ enum class PlaybackChangeSource(val raw: Int) { /** No specific source (e.g. periodic update, buffering). */ NONE(0), - /** User tapped play/pause/seek on native; Flutter should send SyncPlay if active. */ - USER(1), + /** User pressed play or pause on native; Flutter turns it into the SyncPlay request. */ + USER_PLAY_PAUSE(1), + /** + * User seeked on native; the frame carries the new position even when the seek drops the player into + * buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + */ + USER_SEEK(2), /** Change was caused by applying a SyncPlay command; do not send again. */ - SYNCPLAY(2); + SYNCPLAY(3); companion object { fun ofRaw(raw: Int): PlaybackChangeSource? { @@ -993,11 +1000,7 @@ interface VideoPlayerApi { fun seekTo(position: Long) fun stop() fun setSubtitleSettings(settings: SubtitleSettings) - /** - * Sets the SyncPlay command state for the native player overlay. - * [processing] indicates if a SyncPlay command is being processed. - * [commandType] is the type of command. - */ + /** Drives the native player's SyncPlay overlay. */ fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) companion object { @@ -1386,61 +1389,4 @@ class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, } } } - /** User-initiated play action from native player (for SyncPlay integration) */ - fun onUserPlay(callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(null) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) - } - } - } - /** User-initiated pause action from native player (for SyncPlay integration) */ - fun onUserPause(callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(null) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) - } - } - } - /** - * User-initiated seek action from native player (for SyncPlay integration) - * Position is in milliseconds - */ - fun onUserSeek(positionMsArg: Long, callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(positionMsArg)) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) - } - } - } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt index 3299ea1b6..630138b62 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -451,7 +451,7 @@ internal fun RowScope.SimpleProgressBar( val clickRelativeOffset = offset.x / width.toFloat() val newPosition = effectiveDuration.milliseconds * clickRelativeOffset.toDouble() - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS)) } } @@ -475,7 +475,7 @@ internal fun RowScope.SimpleProgressBar( }, onDragEnd = { onScrubbingChanged(false) - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo(internalTempPosition) }, onDragCancel = { @@ -658,7 +658,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player.pause() } val newPos = max( @@ -680,7 +680,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player.pause() } val newPos = min(player.duration.takeIf { it > 0 } ?: 1L, @@ -692,7 +692,7 @@ internal fun RowScope.SimpleProgressBar( Enter, Spacebar, ButtonSelect, DirectionCenter -> { if (scrubbingTimeLine) { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo(tempPosition) player.play() onScrubbingChanged(false) @@ -703,7 +703,7 @@ internal fun RowScope.SimpleProgressBar( Escape, Back -> { if (scrubbingTimeLine) { onScrubbingChanged(false) - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player.play() true } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt index 2f46e5670..f028e1ace 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -70,7 +70,7 @@ internal fun BoxScope.SegmentSkipOverlay( val currentSegmentId = activeSegment?.let { "${it.type}-${it.start}-${it.end}" } fun skipSegment(segment: MediaSegment, segmentId: String) { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) VideoPlayerObject.implementation.player?.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) skippedSegments.add(segmentId) } @@ -110,7 +110,7 @@ internal fun BoxScope.SegmentSkipOverlay( enableScaledFocus = true, onClick = { activeSegment?.let { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) VideoPlayerObject.implementation.player?.seekTo(it.end.toLong()) } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index d491afc13..d63dd6d47 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -82,7 +82,6 @@ import nl.jknaapen.fladder.utility.leanBackEnabled import nl.jknaapen.fladder.utility.visible import kotlin.time.Duration.Companion.seconds - @OptIn(UnstableApi::class) @Composable fun CustomVideoControls( @@ -149,8 +148,8 @@ fun CustomVideoControls( LaunchedEffect(lastSeekInteraction.longValue) { delay(1.seconds) if (currentSkipTime == 0L) return@LaunchedEffect - // SyncPlay: user action is applied locally; Flutter infers from playback state stream and sends to server. - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + // Tagged as a user action; Flutter infers the SyncPlay request from the state stream. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player?.seekTo(position + currentSkipTime) currentSkipTime = 0L } @@ -176,14 +175,15 @@ fun CustomVideoControls( } Key.MediaPlay -> { - // Route through Flutter for SyncPlay support - VideoPlayerObject.videoPlayerControls?.onUserPlay {} + // Tagged as a user action; Flutter infers the SyncPlay request from the state stream. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) + player?.play() return@keyEvent true } Key.MediaPlayPause -> { player?.let { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) if (it.isPlaying) { it.pause() updateLastInteraction() @@ -195,7 +195,7 @@ fun CustomVideoControls( } Key.MediaPause, Key.P -> { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player?.pause() updateLastInteraction() return@keyEvent true @@ -390,7 +390,7 @@ fun CustomVideoControls( if (showChapterDialog) { ChapterSelectionSheet( onSelected = { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) exoPlayer.seekTo(it.time.toLong()) showChapterDialog = false }, @@ -452,7 +452,7 @@ fun PlaybackButtons( } CustomButton( onClick = { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo((player.currentPosition - backwardSpeed.inWholeMilliseconds).coerceAtLeast(0L)) }, ) { @@ -479,7 +479,7 @@ fun PlaybackButtons( .defaultSelected(true), enableScaledFocus = true, onClick = { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) if (player.isPlaying) { player.pause() onPause() @@ -497,7 +497,7 @@ fun PlaybackButtons( if (!isTVMode) { CustomButton( onClick = { - VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo(player.currentPosition + forwardSpeed.inWholeMilliseconds) }, ) { @@ -541,7 +541,6 @@ internal fun RowScope.LeftButtons( val chapters by VideoPlayerObject.chapters.collectAsState(emptyList()) val isTVMode by VideoPlayerObject.implementation.isTVMode.collectAsState(false) - Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt index 01e0d5a15..f582e1f5d 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt @@ -40,15 +40,13 @@ import io.github.rabehx.iconsax.filled.Pause import io.github.rabehx.iconsax.filled.Play import io.github.rabehx.iconsax.filled.Refresh import io.github.rabehx.iconsax.filled.Stop +import io.github.rabehx.iconsax.filled.Timer1 import SyncPlayCommandType import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.Translate import nl.jknaapen.fladder.objects.VideoPlayerObject -/** - * Centered overlay showing SyncPlay command being processed. - * Mirrors the Flutter SyncPlayCommandIndicator design. - */ +/** Mirrors the Flutter SyncPlayCommandIndicator. */ @Composable fun BoxScope.SyncPlayCommandOverlay( modifier: Modifier = Modifier @@ -100,6 +98,7 @@ fun BoxScope.SyncPlayCommandOverlay( SyncPlayCommandType.UNPAUSE -> Localized.syncPlayCommandPlaying(cb) SyncPlayCommandType.SEEK -> Localized.syncPlayCommandSeeking(cb) SyncPlayCommandType.STOP -> Localized.syncPlayCommandStopping(cb) + SyncPlayCommandType.WAITING -> Localized.syncPlayStateWaiting(cb) else -> Localized.syncPlayCommandSyncing(cb) } }, @@ -149,6 +148,7 @@ private fun CommandIcon(commandType: SyncPlayCommandType) { SyncPlayCommandType.UNPAUSE -> Pair(Iconsax.Filled.Play, MaterialTheme.colorScheme.primary) SyncPlayCommandType.SEEK -> Pair(Iconsax.Filled.Forward, MaterialTheme.colorScheme.tertiary) SyncPlayCommandType.STOP -> Pair(Iconsax.Filled.Stop, MaterialTheme.colorScheme.error) + SyncPlayCommandType.WAITING -> Pair(Iconsax.Filled.Timer1, MaterialTheme.colorScheme.tertiary) else -> Pair(Iconsax.Filled.Refresh, MaterialTheme.colorScheme.primary) } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt index 6472203be..8a22e2500 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -111,7 +111,6 @@ object VideoPlayerObject { guideVisible.value = !guideVisible.value } - // SyncPlay command state for overlay (Pigeon-generated type) val syncPlayCommandState = MutableStateFlow( SyncPlayCommandUiState(false, SyncPlayCommandType.NONE) ) @@ -131,6 +130,9 @@ object VideoPlayerObject { pendingPlaybackChangeSource = source } + /** Read without consuming; ExoPlayer decides per frame whether the tag belongs to it. */ + fun peekPendingPlaybackChangeSource(): PlaybackChangeSource? = pendingPlaybackChangeSource + /** Consumed when building PlaybackState in ExoPlayer; clears after read. */ fun getAndClearPendingPlaybackChangeSource(): PlaybackChangeSource? { val r = pendingPlaybackChangeSource diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 542ab6ffb..a5bcdfdb1 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -1,5 +1,6 @@ package nl.jknaapen.fladder.player +import kotlin.math.abs import PlaybackState import android.app.ActivityManager import android.os.Handler @@ -122,21 +123,39 @@ internal fun ExoPlayer( ) } - fun updatePlaybackState() { - videoHost.setPlaybackState( - PlaybackState( - position = exoPlayer.currentPosition, - buffered = exoPlayer.bufferedPosition, - duration = exoPlayer.duration, - playing = exoPlayer.isPlaying, - buffering = exoPlayer.playbackState == Player.STATE_BUFFERING, - completed = exoPlayer.playbackState == Player.STATE_ENDED, - failed = exoPlayer.playbackState == Player.STATE_IDLE, - changeSource = videoHost.getAndClearPendingPlaybackChangeSource() - ) + // The user-action tag must only be consumed by the frame carrying the transition it announced; the + // periodic poll would otherwise spend it on a no-op frame and the pause/seek would never reach SyncPlay. + val emitted = remember { EmittedPlaybackState(exoPlayer.isPlaying, exoPlayer.currentPosition) } + + fun buildPlaybackState(playbackState: Int): PlaybackState { + val playing = exoPlayer.isPlaying + val position = exoPlayer.currentPosition + val consume = when (videoHost.peekPendingPlaybackChangeSource()) { + null -> false + // seekTo moves currentPosition synchronously, so the very next frame carries the new position. + PlaybackChangeSource.USER_SEEK -> true + // Play/pause announces itself once isPlaying flips or the position jumps; buffering frames keep the tag. + else -> playing != emitted.playing || abs(position - emitted.position) > 2000L + } + val changeSource = if (consume) videoHost.getAndClearPendingPlaybackChangeSource() else null + emitted.playing = playing + emitted.position = position + return PlaybackState( + position = position, + buffered = exoPlayer.bufferedPosition, + duration = exoPlayer.duration, + playing = playing, + buffering = playbackState == Player.STATE_BUFFERING, + completed = playbackState == Player.STATE_ENDED, + failed = playbackState == Player.STATE_IDLE, + changeSource = changeSource ) } + fun updatePlaybackState() { + videoHost.setPlaybackState(buildPlaybackState(exoPlayer.playbackState)) + } + LaunchedEffect(exoPlayer) { while (true) { updatePlaybackState() @@ -160,18 +179,7 @@ internal fun ExoPlayer( } override fun onPlaybackStateChanged(playbackState: Int) { - videoHost.setPlaybackState( - PlaybackState( - position = exoPlayer.currentPosition, - buffered = exoPlayer.bufferedPosition, - duration = exoPlayer.duration, - playing = exoPlayer.isPlaying, - buffering = playbackState == Player.STATE_BUFFERING, - completed = playbackState == Player.STATE_ENDED, - failed = playbackState == Player.STATE_IDLE, - changeSource = videoHost.getAndClearPendingPlaybackChangeSource() - ) - ) + videoHost.setPlaybackState(buildPlaybackState(playbackState)) } override fun onEvents(player: Player, events: Player.Events) { @@ -313,3 +321,6 @@ internal fun ExoPlayer( } } } + +/** Last playing flag and position sent to Flutter; a frame that differs from it is a transition. */ +class EmittedPlaybackState(var playing: Boolean, var position: Long) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 81643c63c..9ba0291f6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1199,6 +1199,20 @@ "@syncPlayKickedFromGroup": {}, "syncPlayGroupNoLongerExists": "The SyncPlay group is no longer available", "@syncPlayGroupNoLongerExists": {}, + "syncPlayHaltPlayback": "Stop group playback on this device", + "@syncPlayHaltPlayback": {}, + "syncPlayNotFollowing": "Not following group playback", + "@syncPlayNotFollowing": {}, + "syncPlaySpeedLocked": "Playback speed is managed by SyncPlay", + "@syncPlaySpeedLocked": {}, + "syncPlayLibraryAccessDenied": "You don't have access to some items in this group's queue", + "@syncPlayLibraryAccessDenied": {}, + "syncPlayAccessDisabled": "SyncPlay is not enabled for your account", + "@syncPlayAccessDisabled": {}, + "syncPlayEnableCorrection": "Drift correction", + "@syncPlayEnableCorrection": {}, + "syncPlayEnableCorrectionDesc": "Speed up or skip playback slightly to stay in sync with the group", + "@syncPlayEnableCorrectionDesc": {}, "syncDeleteItemDesc": "Delete all synced data for {item}?", "@syncDeleteItemDesc": { "description": "Sync delete item pop-up window", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 649a9f598..c2dfca79c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -676,6 +676,20 @@ "@syncPlayKickedFromGroup": {}, "syncPlayGroupNoLongerExists": "Le groupe SyncPlay n'est plus disponible", "@syncPlayGroupNoLongerExists": {}, + "syncPlayHaltPlayback": "Arrêter la lecture de groupe sur cet appareil", + "@syncPlayHaltPlayback": {}, + "syncPlayNotFollowing": "Lecture de groupe suspendue sur cet appareil", + "@syncPlayNotFollowing": {}, + "syncPlaySpeedLocked": "La vitesse de lecture est gérée par SyncPlay", + "@syncPlaySpeedLocked": {}, + "syncPlayLibraryAccessDenied": "Vous n'avez pas accès à certains éléments de la file de ce groupe", + "@syncPlayLibraryAccessDenied": {}, + "syncPlayAccessDisabled": "SyncPlay n'est pas activé pour votre compte", + "@syncPlayAccessDisabled": {}, + "syncPlayEnableCorrection": "Correction de la dérive", + "@syncPlayEnableCorrection": {}, + "syncPlayEnableCorrectionDesc": "Accélère ou saute légèrement la lecture pour rester synchronisé avec le groupe", + "@syncPlayEnableCorrectionDesc": {}, "syncDeleteItemDesc": "Supprimer toutes les données synchronisées pour {item} ?", "@syncDeleteItemDesc": { "description": "Fenêtre contextuelle de suppression d'élément synchronisé", diff --git a/lib/src/translations_pigeon.g.dart b/lib/src/translations_pigeon.g.dart index 82de1f927..c4b16ea7a 100644 --- a/lib/src/translations_pigeon.g.dart +++ b/lib/src/translations_pigeon.g.dart @@ -84,6 +84,8 @@ abstract class TranslationsPigeon { String syncPlayCommandSyncing(); + String syncPlayStateWaiting(); + static void setUp( TranslationsPigeon? api, { BinaryMessenger? binaryMessenger, @@ -549,5 +551,25 @@ abstract class TranslationsPigeon { }); } } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayStateWaiting$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayStateWaiting(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index 7c70a7112..de5f598ba 100644 --- a/lib/src/video_player_helper.g.dart +++ b/lib/src/video_player_helper.g.dart @@ -50,6 +50,9 @@ enum SyncPlayCommandType { unpause, seek, stop, + + /// The group is waiting for a participant to buffer; shown so every device reports the same state. + waiting, } enum MediaSegmentType { @@ -60,13 +63,17 @@ enum MediaSegmentType { outro, } -/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +/// Who caused the last playback state change; Flutter infers SyncPlay requests from it. enum PlaybackChangeSource { /// No specific source (e.g. periodic update, buffering). none, - /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. - user, + /// User pressed play or pause on native; Flutter turns it into the SyncPlay request. + userPlayPause, + + /// User seeked on native; the frame carries the new position even when the seek drops the player into + /// buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + userSeek, /// Change was caused by applying a SyncPlay command; do not send again. syncplay, @@ -1480,9 +1487,7 @@ class VideoPlayerApi { } } - /// Sets the SyncPlay command state for the native player overlay. - /// [processing] indicates if a SyncPlay command is being processed. - /// [commandType] is the type of command. + /// Drives the native player's SyncPlay overlay. Future setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$pigeonVar_messageChannelSuffix'; @@ -1564,16 +1569,6 @@ abstract class VideoPlayerControlsCallback { Future> fetchProgramsForChannel(String channelId); - /// User-initiated play action from native player (for SyncPlay integration) - void onUserPlay(); - - /// User-initiated pause action from native player (for SyncPlay integration) - void onUserPause(); - - /// User-initiated seek action from native player (for SyncPlay integration) - /// Position is in milliseconds - void onUserSeek(int positionMs); - static void setUp( VideoPlayerControlsCallback? api, { BinaryMessenger? binaryMessenger, @@ -1744,71 +1739,5 @@ abstract class VideoPlayerControlsCallback { }); } } - { - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - try { - api.onUserPlay(); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - { - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - try { - api.onUserPause(); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - { - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null.'); - final List args = (message as List?)!; - final int? arg_positionMs = (args[0] as int?); - assert(arg_positionMs != null, - 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null, expected non-null int.'); - try { - api.onUserSeek(arg_positionMs!); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } } } diff --git a/lib/util/localization_helper.dart b/lib/util/localization_helper.dart index e5c2438a9..43fc73624 100644 --- a/lib/util/localization_helper.dart +++ b/lib/util/localization_helper.dart @@ -148,4 +148,7 @@ class _TranslationsMessgener extends messenger.TranslationsPigeon { @override String syncPlayCommandSyncing() => context.localized.syncPlayCommandSyncing; + + @override + String syncPlayStateWaiting() => context.localized.syncPlayStateWaiting; } diff --git a/pigeons/translations_pigeon.dart b/pigeons/translations_pigeon.dart index 4fe214d7f..0aea93026 100644 --- a/pigeons/translations_pigeon.dart +++ b/pigeons/translations_pigeon.dart @@ -42,4 +42,5 @@ abstract class TranslationsPigeon { String syncPlayCommandSeeking(); String syncPlayCommandStopping(); String syncPlayCommandSyncing(); + String syncPlayStateWaiting(); } diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 4840facc1..740e7560d 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -40,6 +40,9 @@ enum SyncPlayCommandType { unpause, seek, stop, + + /// The group is waiting for a participant to buffer; shown so every device reports the same state. + waiting, } class MediaInfo { @@ -225,19 +228,21 @@ abstract class VideoPlayerApi { void setSubtitleSettings(SubtitleSettings settings); - /// Sets the SyncPlay command state for the native player overlay. - /// [processing] indicates if a SyncPlay command is being processed. - /// [commandType] is the type of command. + /// Drives the native player's SyncPlay overlay. void setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType); } -/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +/// Who caused the last playback state change; Flutter infers SyncPlay requests from it. enum PlaybackChangeSource { /// No specific source (e.g. periodic update, buffering). none, - /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. - user, + /// User pressed play or pause on native; Flutter turns it into the SyncPlay request. + userPlayPause, + + /// User seeked on native; the frame carries the new position even when the seek drops the player into + /// buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + userSeek, /// Change was caused by applying a SyncPlay command; do not send again. syncplay, @@ -367,14 +372,4 @@ abstract class VideoPlayerControlsCallback { @async List fetchProgramsForChannel(String channelId); - - /// User-initiated play action from native player (for SyncPlay integration) - void onUserPlay(); - - /// User-initiated pause action from native player (for SyncPlay integration) - void onUserPause(); - - /// User-initiated seek action from native player (for SyncPlay integration) - /// Position is in milliseconds - void onUserSeek(int positionMs); } From 9213b7e9b766dee4e58a104af9d0cd2ab238a624 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 14:15:55 +0200 Subject: [PATCH 10/14] fix(syncplay): align the client with jellyfin-web and the server state machine Audit of the port against jellyfin-web's SyncPlay plugin and the server's group states, plus the fixes from two reviews and device testing. Protocol - StateUpdate frames only update state; they never reply Ready. The server's Unpause command owns the start time; a paused player is only recovered when nothing of ours is armed, queued or executing. - Duplicate commands are correction checks (jellyfin-web applyCommand) against the server's 500 ms tolerance; commands for another playlist item are dropped (Stop excepted); commands wait for the first clock measurement, with a 2 s fail-open. - Positions are reported from the live player; a load and a Seek report the requested position with isPlaying=false. Spontaneous buffering is debounced for 3 s and closed by exactly one Ready. - Halt/resume via SetIgnoreWait: closing the player or "Stop group playback" halts and stops locally; commands received while halted are recorded so a resume starts at the live group position (last command, then PlayQueue timing). A silent rejoin after a socket drop keeps the halt and re-asserts IgnoreWait. Stop stops and closes the route. - Same-item PlayQueue frames attach in place; creating a group while watching seeds it with the local queue; next/previous go through NextItem/PreviousItem/SetPlaylistItem. LibraryAccessDenied is handled; a failed join never stops local playback. Player - User pause and seek are applied locally then requested; seek stays paused until the group's Unpause. play() is re-issued until the backend really plays (media-kit can drop it, and its web element swallows a rejected play promise); the real state comes from BasePlayer.isPlaying, not the wrapper's intent flag. - Track switches on a transcode no longer wait 5 s for an mpv track that a burned-in subtitle or a server-picked audio track cannot have. - Drift correction is throttled to 1.5 s and can be disabled in the player settings (enableSyncPlayCorrection). Tests cover command scheduling, duplicates, the recovery gate, the clock gate, recording while halted, queue navigation, position estimation, buffering debounce, message handling and reconnect backoff. --- lib/models/playback/playback_model.dart | 76 +- .../settings/video_player_settings.dart | 5 + .../video_player_settings.freezed.dart | 111 ++- .../settings/video_player_settings.g.dart | 70 +- lib/models/syncplay/syncplay_models.dart | 207 ++++- .../syncplay/syncplay_models.freezed.dart | 462 ++++++++++- .../video_player_settings_provider.dart | 2 + .../syncplay/buffering_report_debouncer.dart | 54 ++ .../handlers/syncplay_command_handler.dart | 403 +++++++--- .../handlers/syncplay_message_handler.dart | 217 +++-- .../syncplay/syncplay_controller.dart | 758 +++++++++--------- lib/providers/syncplay/syncplay_provider.dart | 64 +- .../syncplay/syncplay_provider.g.dart | 2 +- lib/providers/syncplay/time_sync_service.dart | 42 +- lib/providers/video_player_provider.dart | 323 ++++---- lib/wrappers/media_control_wrapper.dart | 49 +- lib/wrappers/players/base_player.dart | 10 + lib/wrappers/players/lib_mdk.dart | 16 +- lib/wrappers/players/lib_mpv.dart | 29 +- lib/wrappers/players/native_player.dart | 13 +- .../buffering_report_debouncer_test.dart | 91 +++ .../syncplay_command_handler_test.dart | 543 +++++++++++++ .../syncplay/syncplay_correction_test.dart | 8 +- .../syncplay/syncplay_lifecycle_test.dart | 368 +++++++-- .../syncplay/syncplay_queue_test.dart | 163 ++++ 25 files changed, 3031 insertions(+), 1055 deletions(-) create mode 100644 lib/providers/syncplay/buffering_report_debouncer.dart create mode 100644 test/providers/syncplay/buffering_report_debouncer_test.dart create mode 100644 test/providers/syncplay/syncplay_command_handler_test.dart create mode 100644 test/providers/syncplay/syncplay_queue_test.dart diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 7f2b83b6e..d7869477e 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -169,46 +169,54 @@ class PlaybackModelHelper { JellyService get api => ref.read(jellyApiProvider); Future _ensureLocalTrackSwitchAutoplay() async { - // Poll for up to ~3 seconds, calling play() on every iteration the - // player isn't already playing and isn't buffering. media-kit on web - // sometimes drops the first one or two play() calls after a track - // change or transcode reload (the underlying media isn't fully - // ready yet, or the player is mid-transition). One-shot retries - // weren't enough; this keeps re-issuing play until the state - // stream confirms playing=true or we time out. + // media-kit on web sometimes drops the first play() calls after a reload, so re-issue until the backend's + // real state confirms playing. In a SyncPlay group the group state decides whether to play at all. for (var attempt = 0; attempt < 12; attempt++) { - final playbackState = ref.read(mediaPlaybackProvider); - if (playbackState.playing) { + final player = ref.read(videoPlayerProvider); + if (player.isPlayerPlaying) { return; } - if (!playbackState.buffering) { - await ref.read(videoPlayerProvider).play(); + if (ref.read(isSyncPlayActiveProvider) && + ref.read(syncPlayProvider.select((s) => s.groupState)) != SyncPlayGroupState.playing) { + return; } + await player.play(); await Future.delayed(const Duration(milliseconds: 250)); } } Future loadNewVideo(ItemBaseModel newItem) async { - // When SyncPlay is active, route the next/previous episode through - // the group queue using the lightweight NextItem/PreviousItem - // endpoints (matches jellyfin-web). Determine direction from the - // current playback model's queue and fall back to setNewQueue only - // for non-adjacent jumps (e.g. user picked an arbitrary library item). if (ref.read(isSyncPlayActiveProvider)) { - // Use the same setNewQueue flow as initial play in _playSyncPlay. - // It reliably triggers the PlayQueue/NewPlaylist broadcast that - // drives _startPlayback through _handlePlayQueue, so the user - // sees the "Switching item…" overlay (SyncPlayCommandIndicator) - // and then the new media without having to navigate away. - // - // NextItem/PreviousItem would preserve the server-side queue - // context but in practice did not reliably trigger the - // PlayQueue broadcast we rely on; setNewQueue does. - await ref.read(syncPlayProvider.notifier).setNewQueue( - itemIds: [newItem.id], - playingItemPosition: 0, - startPositionTicks: 0, + // Adjacent moves use NextItem/PreviousItem, whose playlist-item guard lets the server drop the duplicate + // requests every participant fires at once; only an item outside the queue replaces it (whole local queue). + final syncPlay = ref.read(syncPlayProvider.notifier); + final syncPlayState = ref.read(syncPlayProvider); + final navigation = resolveQueueNavigation( + playlist: syncPlayState.playlist, + playingItemIndex: syncPlayState.playingItemIndex, + targetItemId: newItem.id, ); + switch (navigation) { + case SyncPlayQueueNavigation.next: + await syncPlay.requestNextItem(); + break; + case SyncPlayQueueNavigation.previous: + await syncPlay.requestPreviousItem(); + break; + case SyncPlayQueueNavigation.setCurrentItem: + final entry = syncPlayState.playlist.firstWhere((entry) => entry.itemId == newItem.id); + await syncPlay.requestSetPlaylistItem(entry.playlistItemId); + break; + case SyncPlayQueueNavigation.newQueue: + final currentQueue = ref.read(playBackModel)?.queue.map((item) => item.id).toList() ?? const []; + final itemIds = currentQueue.contains(newItem.id) ? currentQueue : [newItem.id]; + await syncPlay.setNewQueue( + itemIds: itemIds, + playingItemPosition: itemIds.indexOf(newItem.id), + startPositionTicks: 0, + ); + break; + } return null; } @@ -606,7 +614,6 @@ class PlaybackModelHelper { final userId = ref.read(userProvider)?.id; if (userId?.isEmpty == true) return; - // Check if syncplay is active and get position from syncplay if so final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); final Duration currentPosition; @@ -619,17 +626,12 @@ class PlaybackModelHelper { reportToSyncPlay: shouldReportGroupBuffering, ); - // Estimate the live group position rather than using the stale - // SyncPlayState.positionTicks (which is frozen at the last server - // event). Without this the local player reloads at an old position - // and the drift correction immediately SkipToSyncs forward, producing - // a visible jump after every audio/subtitle switch. + // The stale SyncPlayState.positionTicks would reload at an old position and SkipToSync forward visibly. final positionTicks = ref.read(syncPlayProvider.notifier).estimateCurrentGroupPositionTicks(); currentPosition = Duration(milliseconds: ticksToMilliseconds(positionTicks)); if (shouldReportGroupBuffering) { - // Report buffering BEFORE stop/reload only when this reload should - // affect group flow. + // Report buffering before stop/reload only when this reload should affect group flow. await ref.read(syncPlayProvider.notifier).reportBuffering(); } } else { diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 36259ac02..7851665a5 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -111,6 +111,11 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(true) bool enableCrossfade, @Default(400) int crossfadeDurationMs, @Default(false) bool ambientBlur, + + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + @Default(true) bool enableSyncPlayCorrection, }) = _VideoPlayerSettingsModel; double get volume { diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 61568659c..392c2bf51 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -45,12 +45,18 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { int get crossfadeDurationMs; bool get ambientBlur; + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + bool get enableSyncPlayCorrection; + /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $VideoPlayerSettingsModelCopyWith get copyWith => - _$VideoPlayerSettingsModelCopyWithImpl(this as VideoPlayerSettingsModel, _$identity); + _$VideoPlayerSettingsModelCopyWithImpl( + this as VideoPlayerSettingsModel, _$identity); /// Serializes this VideoPlayerSettingsModel to a JSON map. Map toJson(); @@ -79,28 +85,32 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('enableSpeedBoost', enableSpeedBoost)) ..add(DiagnosticsProperty('speedBoostRate', speedBoostRate)) ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) - ..add(DiagnosticsProperty('enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty( + 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) - ..add(DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) + ..add( + DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) ..add(DiagnosticsProperty('enableReplayGain', enableReplayGain)) ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) - ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); + ..add(DiagnosticsProperty('ambientBlur', ambientBlur)) + ..add(DiagnosticsProperty( + 'enableSyncPlayCorrection', enableSyncPlayCorrection)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur, enableSyncPlayCorrection: $enableSyncPlayCorrection)'; } } /// @nodoc abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { - factory $VideoPlayerSettingsModelCopyWith( - VideoPlayerSettingsModel value, $Res Function(VideoPlayerSettingsModel) _then) = + factory $VideoPlayerSettingsModelCopyWith(VideoPlayerSettingsModel value, + $Res Function(VideoPlayerSettingsModel) _then) = _$VideoPlayerSettingsModelCopyWithImpl; @useResult $Res call( @@ -133,11 +143,13 @@ abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur}); + bool ambientBlur, + bool enableSyncPlayCorrection}); } /// @nodoc -class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettingsModelCopyWith<$Res> { +class _$VideoPlayerSettingsModelCopyWithImpl<$Res> + implements $VideoPlayerSettingsModelCopyWith<$Res> { _$VideoPlayerSettingsModelCopyWithImpl(this._self, this._then); final VideoPlayerSettingsModel _self; @@ -178,6 +190,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin Object? enableCrossfade = null, Object? crossfadeDurationMs = null, Object? ambientBlur = null, + Object? enableSyncPlayCorrection = null, }) { return _then(_self.copyWith( screenBrightness: freezed == screenBrightness @@ -300,6 +313,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable as bool, + enableSyncPlayCorrection: null == enableSyncPlayCorrection + ? _self.enableSyncPlayCorrection + : enableSyncPlayCorrection // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -427,7 +444,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur)? + bool ambientBlur, + bool enableSyncPlayCorrection)? $default, { required TResult orElse(), }) { @@ -464,7 +482,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: return orElse(); } @@ -515,7 +534,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur) + bool ambientBlur, + bool enableSyncPlayCorrection) $default, ) { final _that = this; @@ -551,7 +571,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: throw StateError('Unexpected subclass'); } @@ -601,7 +622,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur)? + bool ambientBlur, + bool enableSyncPlayCorrection)? $default, ) { final _that = this; @@ -637,7 +659,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: return null; } @@ -646,7 +669,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { /// @nodoc @JsonSerializable() -class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with DiagnosticableTreeMixin { +class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel + with DiagnosticableTreeMixin { _VideoPlayerSettingsModel( {this.screenBrightness, this.videoFit = BoxFit.contain, @@ -662,7 +686,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic this.maxHomeBitrate = Bitrate.original, this.maxInternetBitrate = Bitrate.original, this.audioDevice, - final Map segmentSkipSettings = defaultSegmentSkipValues, + final Map segmentSkipSettings = + defaultSegmentSkipValues, final Map hotKeys = const {}, this.screensaver = Screensaver.logo, this.enableSpeedBoost = false, @@ -677,12 +702,14 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic this.enablePlayPauseFade = true, this.enableCrossfade = true, this.crossfadeDurationMs = 400, - this.ambientBlur = false}) + this.ambientBlur = false, + this.enableSyncPlayCorrection = true}) : _allowedOrientations = allowedOrientations, _segmentSkipSettings = segmentSkipSettings, _hotKeys = hotKeys, super._(); - factory _VideoPlayerSettingsModel.fromJson(Map json) => _$VideoPlayerSettingsModelFromJson(json); + factory _VideoPlayerSettingsModel.fromJson(Map json) => + _$VideoPlayerSettingsModelFromJson(json); @override final double? screenBrightness; @@ -714,7 +741,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic Set? get allowedOrientations { final value = _allowedOrientations; if (value == null) return null; - if (_allowedOrientations is EqualUnmodifiableSetView) return _allowedOrientations; + if (_allowedOrientations is EqualUnmodifiableSetView) + return _allowedOrientations; // ignore: implicit_dynamic_type return EqualUnmodifiableSetView(value); } @@ -734,7 +762,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic @override @JsonKey() Map get segmentSkipSettings { - if (_segmentSkipSettings is EqualUnmodifiableMapView) return _segmentSkipSettings; + if (_segmentSkipSettings is EqualUnmodifiableMapView) + return _segmentSkipSettings; // ignore: implicit_dynamic_type return EqualUnmodifiableMapView(_segmentSkipSettings); } @@ -791,13 +820,21 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic @JsonKey() final bool ambientBlur; + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + @override + @JsonKey() + final bool enableSyncPlayCorrection; + /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') _$VideoPlayerSettingsModelCopyWith<_VideoPlayerSettingsModel> get copyWith => - __$VideoPlayerSettingsModelCopyWithImpl<_VideoPlayerSettingsModel>(this, _$identity); + __$VideoPlayerSettingsModelCopyWithImpl<_VideoPlayerSettingsModel>( + this, _$identity); @override Map toJson() { @@ -830,28 +867,33 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic ..add(DiagnosticsProperty('enableSpeedBoost', enableSpeedBoost)) ..add(DiagnosticsProperty('speedBoostRate', speedBoostRate)) ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) - ..add(DiagnosticsProperty('enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty( + 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) - ..add(DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) + ..add( + DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) ..add(DiagnosticsProperty('enableReplayGain', enableReplayGain)) ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) - ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); + ..add(DiagnosticsProperty('ambientBlur', ambientBlur)) + ..add(DiagnosticsProperty( + 'enableSyncPlayCorrection', enableSyncPlayCorrection)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur, enableSyncPlayCorrection: $enableSyncPlayCorrection)'; } } /// @nodoc -abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> implements $VideoPlayerSettingsModelCopyWith<$Res> { - factory _$VideoPlayerSettingsModelCopyWith( - _VideoPlayerSettingsModel value, $Res Function(_VideoPlayerSettingsModel) _then) = +abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> + implements $VideoPlayerSettingsModelCopyWith<$Res> { + factory _$VideoPlayerSettingsModelCopyWith(_VideoPlayerSettingsModel value, + $Res Function(_VideoPlayerSettingsModel) _then) = __$VideoPlayerSettingsModelCopyWithImpl; @override @useResult @@ -885,11 +927,13 @@ abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> implements $VideoP bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur}); + bool ambientBlur, + bool enableSyncPlayCorrection}); } /// @nodoc -class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSettingsModelCopyWith<$Res> { +class __$VideoPlayerSettingsModelCopyWithImpl<$Res> + implements _$VideoPlayerSettingsModelCopyWith<$Res> { __$VideoPlayerSettingsModelCopyWithImpl(this._self, this._then); final _VideoPlayerSettingsModel _self; @@ -930,6 +974,7 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett Object? enableCrossfade = null, Object? crossfadeDurationMs = null, Object? ambientBlur = null, + Object? enableSyncPlayCorrection = null, }) { return _then(_VideoPlayerSettingsModel( screenBrightness: freezed == screenBrightness @@ -1052,6 +1097,10 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable as bool, + enableSyncPlayCorrection: null == enableSyncPlayCorrection + ? _self.enableSyncPlayCorrection + : enableSyncPlayCorrection // ignore: cast_nullable_to_non_nullable + as bool, )); } } diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index ea3c322c1..a4a8ee4a1 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -6,50 +6,71 @@ part of 'video_player_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -_VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson(Map json) => _VideoPlayerSettingsModel( +_VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson( + Map json) => + _VideoPlayerSettingsModel( screenBrightness: (json['screenBrightness'] as num?)?.toDouble(), - videoFit: $enumDecodeNullable(_$BoxFitEnumMap, json['videoFit']) ?? BoxFit.contain, + videoFit: $enumDecodeNullable(_$BoxFitEnumMap, json['videoFit']) ?? + BoxFit.contain, fillScreen: json['fillScreen'] as bool? ?? false, hardwareAccel: json['hardwareAccel'] as bool? ?? true, useLibass: json['useLibass'] as bool? ?? true, enableTunneling: json['enableTunneling'] as bool? ?? false, bufferSize: (json['bufferSize'] as num?)?.toInt() ?? 32, - playerOptions: $enumDecodeNullable(_$PlayerOptionsEnumMap, json['playerOptions']), + playerOptions: + $enumDecodeNullable(_$PlayerOptionsEnumMap, json['playerOptions']), internalVolume: (json['internalVolume'] as num?)?.toDouble() ?? 100, allowedOrientations: (json['allowedOrientations'] as List?) ?.map((e) => $enumDecode(_$DeviceOrientationEnumMap, e)) .toSet(), - nextVideoType: $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? AutoNextType.smart, - maxHomeBitrate: $enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ?? Bitrate.original, - maxInternetBitrate: $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? Bitrate.original, + nextVideoType: + $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? + AutoNextType.smart, + maxHomeBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ?? + Bitrate.original, + maxInternetBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? + Bitrate.original, audioDevice: json['audioDevice'] as String?, - segmentSkipSettings: (json['segmentSkipSettings'] as Map?)?.map( - (k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k), $enumDecode(_$SegmentSkipEnumMap, e)), - ) ?? - defaultSegmentSkipValues, + segmentSkipSettings: + (json['segmentSkipSettings'] as Map?)?.map( + (k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k), + $enumDecode(_$SegmentSkipEnumMap, e)), + ) ?? + defaultSegmentSkipValues, hotKeys: (json['hotKeys'] as Map?)?.map( - (k, e) => - MapEntry($enumDecode(_$VideoHotKeysEnumMap, k), KeyCombination.fromJson(e as Map)), + (k, e) => MapEntry($enumDecode(_$VideoHotKeysEnumMap, k), + KeyCombination.fromJson(e as Map)), ) ?? const {}, - screensaver: $enumDecodeNullable(_$ScreensaverEnumMap, json['screensaver']) ?? Screensaver.logo, + screensaver: + $enumDecodeNullable(_$ScreensaverEnumMap, json['screensaver']) ?? + Screensaver.logo, enableSpeedBoost: json['enableSpeedBoost'] as bool? ?? false, speedBoostRate: (json['speedBoostRate'] as num?)?.toDouble() ?? 2.0, enableDoubleTapSeek: json['enableDoubleTapSeek'] as bool? ?? true, - enableAdvancedVideoOptions: json['enableAdvancedVideoOptions'] as bool? ?? false, + enableAdvancedVideoOptions: + json['enableAdvancedVideoOptions'] as bool? ?? false, enableEdgeGestures: json['enableEdgeGestures'] as bool? ?? true, reverseEdgeGestures: json['reverseEdgeGestures'] as bool? ?? false, enablePictureInPicture: json['enablePictureInPicture'] as bool? ?? true, enableReplayGain: json['enableReplayGain'] as bool? ?? true, - replayGainVolumeLevel: $enumDecodeNullable(_$ReplayGainVolumeLevelEnumMap, json['replayGainVolumeLevel']) ?? + replayGainVolumeLevel: $enumDecodeNullable( + _$ReplayGainVolumeLevelEnumMap, json['replayGainVolumeLevel']) ?? ReplayGainVolumeLevel.quiet, enablePlayPauseFade: json['enablePlayPauseFade'] as bool? ?? true, enableCrossfade: json['enableCrossfade'] as bool? ?? true, - crossfadeDurationMs: (json['crossfadeDurationMs'] as num?)?.toInt() ?? 400, + crossfadeDurationMs: + (json['crossfadeDurationMs'] as num?)?.toInt() ?? 400, ambientBlur: json['ambientBlur'] as bool? ?? false, + enableSyncPlayCorrection: + json['enableSyncPlayCorrection'] as bool? ?? true, ); -Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel instance) => { +Map _$VideoPlayerSettingsModelToJson( + _VideoPlayerSettingsModel instance) => + { 'screenBrightness': instance.screenBrightness, 'videoFit': _$BoxFitEnumMap[instance.videoFit]!, 'fillScreen': instance.fillScreen, @@ -59,14 +80,17 @@ Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel 'bufferSize': instance.bufferSize, 'playerOptions': _$PlayerOptionsEnumMap[instance.playerOptions], 'internalVolume': instance.internalVolume, - 'allowedOrientations': instance.allowedOrientations?.map((e) => _$DeviceOrientationEnumMap[e]!).toList(), + 'allowedOrientations': instance.allowedOrientations + ?.map((e) => _$DeviceOrientationEnumMap[e]!) + .toList(), 'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!, 'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!, 'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!, 'audioDevice': instance.audioDevice, - 'segmentSkipSettings': - instance.segmentSkipSettings.map((k, e) => MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), - 'hotKeys': instance.hotKeys.map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)), + 'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) => + MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), + 'hotKeys': instance.hotKeys + .map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)), 'screensaver': _$ScreensaverEnumMap[instance.screensaver]!, 'enableSpeedBoost': instance.enableSpeedBoost, 'speedBoostRate': instance.speedBoostRate, @@ -76,11 +100,13 @@ Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel 'reverseEdgeGestures': instance.reverseEdgeGestures, 'enablePictureInPicture': instance.enablePictureInPicture, 'enableReplayGain': instance.enableReplayGain, - 'replayGainVolumeLevel': _$ReplayGainVolumeLevelEnumMap[instance.replayGainVolumeLevel]!, + 'replayGainVolumeLevel': + _$ReplayGainVolumeLevelEnumMap[instance.replayGainVolumeLevel]!, 'enablePlayPauseFade': instance.enablePlayPauseFade, 'enableCrossfade': instance.enableCrossfade, 'crossfadeDurationMs': instance.crossfadeDurationMs, 'ambientBlur': instance.ambientBlur, + 'enableSyncPlayCorrection': instance.enableSyncPlayCorrection, }; const _$BoxFitEnumMap = { diff --git a/lib/models/syncplay/syncplay_models.dart b/lib/models/syncplay/syncplay_models.dart index be7e0bc14..c0b993792 100644 --- a/lib/models/syncplay/syncplay_models.dart +++ b/lib/models/syncplay/syncplay_models.dart @@ -2,7 +2,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'syncplay_models.freezed.dart'; -/// Time sync measurement for NTP-like clock synchronization @Freezed(copyWith: true) abstract class TimeSyncMeasurement with _$TimeSyncMeasurement { const TimeSyncMeasurement._(); @@ -14,8 +13,7 @@ abstract class TimeSyncMeasurement with _$TimeSyncMeasurement { required DateTime responseReceived, }) = _TimeSyncMeasurement; - /// Clock offset between client and server - /// Positive = server is ahead of client + /// Positive means the server is ahead of the client. Duration get offset { final t1 = requestSent.millisecondsSinceEpoch; final t2 = requestReceived.millisecondsSinceEpoch; @@ -39,7 +37,6 @@ abstract class TimeSyncMeasurement with _$TimeSyncMeasurement { Duration get ping => Duration(milliseconds: delay.inMilliseconds ~/ 2); } -/// SyncPlay group state enum SyncPlayGroupState { idle, waiting, @@ -47,9 +44,6 @@ enum SyncPlayGroupState { playing, } -/// SyncPlay command type emitted by the server in `SyncPlayCommand` -/// messages. Keeps the cross-platform contract typed instead of -/// passing raw strings (AGENTS.md SyncPlay rule 2). enum SyncPlayCommand { pause('Pause'), unpause('Unpause'), @@ -58,11 +52,9 @@ enum SyncPlayCommand { const SyncPlayCommand(this.wire); - /// Server-side wire identifier (used in REST/WebSocket payloads). final String wire; - /// Parse a wire string from the server. Returns `null` for unknown - /// values so callers can ignore the message instead of crashing. + /// Returns `null` for unknown values so callers can ignore the message instead of crashing. static SyncPlayCommand? fromWire(String? value) { if (value == null) { return null; @@ -104,16 +96,13 @@ enum SyncPlayStateReason { } } -/// Playback correction strategy used to resync local playback with group time. enum SyncCorrectionStrategy { none, speedToSync, skipToSync, } -/// Config values for playback drift correction. -/// -/// Defaults match official Jellyfin SyncPlay thresholds. +/// Defaults match the official Jellyfin SyncPlay thresholds. class SyncCorrectionConfig { const SyncCorrectionConfig({ this.minDelaySpeedToSyncMs = 60, @@ -154,7 +143,6 @@ class SyncCorrectionConfig { } } -/// Runtime state of playback correction logic. class SyncCorrectionState { const SyncCorrectionState({ this.syncEnabled = true, @@ -191,10 +179,7 @@ class SyncCorrectionState { } } -/// Select correction strategy based on current diff and runtime/config state. -/// -/// Precedence intentionally mirrors official behavior: -/// SpeedToSync first, then SkipToSync fallback. +/// SpeedToSync first, then SkipToSync, mirroring the official precedence. SyncCorrectionStrategy selectSyncCorrectionStrategy({ required SyncCorrectionConfig config, required SyncCorrectionState state, @@ -227,7 +212,138 @@ SyncCorrectionStrategy selectSyncCorrectionStrategy({ return SyncCorrectionStrategy.none; } -/// Current SyncPlay session state +/// `playlistItemId` is the server-generated id used by NextItem/PreviousItem/SetPlaylistItem. +class SyncPlayQueueEntry { + const SyncPlayQueueEntry({ + required this.itemId, + required this.playlistItemId, + }); + + final String itemId; + final String playlistItemId; + + @override + bool operator ==(Object other) { + return other is SyncPlayQueueEntry && other.itemId == itemId && other.playlistItemId == playlistItemId; + } + + @override + int get hashCode => Object.hash(itemId, playlistItemId); +} + +/// How a local "play this item" request maps onto the group queue. +enum SyncPlayQueueNavigation { + /// Target is the entry right after the playing one: `NextItem`. + next, + + /// Target is the entry right before the playing one: `PreviousItem`. + previous, + + /// Target is elsewhere in the queue: `SetPlaylistItem`. + setCurrentItem, + + /// Target is not in the group queue: `SetNewQueue`. + newQueue, +} + +/// Using the server-side queue keeps every participant's context intact and lets the server de-duplicate. +SyncPlayQueueNavigation resolveQueueNavigation({ + required List playlist, + required int playingItemIndex, + required String targetItemId, +}) { + final targetIndex = playlist.indexWhere((entry) => entry.itemId == targetItemId); + if (targetIndex < 0) { + return SyncPlayQueueNavigation.newQueue; + } + if (targetIndex == playingItemIndex + 1) { + return SyncPlayQueueNavigation.next; + } + if (targetIndex == playingItemIndex - 1) { + return SyncPlayQueueNavigation.previous; + } + return SyncPlayQueueNavigation.setCurrentItem; +} + +/// Shared by the Flutter player and the native Android activity so every participant sees the same thing. +enum SyncPlayOverlay { + none, + + /// A queue switch is loading the next item. + switching, + + /// A command from the server is being applied on this device. + command, + + /// The server is holding the group for a participant to buffer. + waiting, +} + +SyncPlayOverlay resolveSyncPlayOverlay(SyncPlayState state) { + if (!state.isInGroup || !state.isFollowingGroupPlayback) { + return SyncPlayOverlay.none; + } + if (state.startPlaybackInProgress) { + return SyncPlayOverlay.switching; + } + if (state.isProcessingCommand && state.processingCommandType != null) { + return SyncPlayOverlay.command; + } + if (state.groupState == SyncPlayGroupState.waiting) { + return SyncPlayOverlay.waiting; + } + return SyncPlayOverlay.none; +} + +/// Lets a device that stopped following the group estimate the live position when it resumes. +@Freezed(copyWith: true) +abstract class SyncPlayQueueTiming with _$SyncPlayQueueTiming { + const SyncPlayQueueTiming._(); + + const factory SyncPlayQueueTiming({ + required int startPositionTicks, + DateTime? lastUpdate, + @Default(false) bool isPlaying, + }) = _SyncPlayQueueTiming; + + /// Frozen while paused, extrapolated from [lastUpdate] while playing. + int positionTicksAt(DateTime remoteNow) { + final since = lastUpdate; + if (!isPlaying || since == null) { + return startPositionTicks; + } + return startPositionTicks + millisecondsToTicks(remoteNow.difference(since).inMilliseconds); + } +} + +/// Prefers the last command for the current item (recorded even while not following), then the last +/// `PlayQueue` timing; [fallbackPositionTicks] can be tens of seconds stale. +int estimateGroupPositionTicks({ + required LastSyncPlayCommand? lastCommand, + required String? currentPlaylistItemId, + required SyncPlayQueueTiming? queueTiming, + required int fallbackPositionTicks, + required DateTime remoteNow, +}) { + if (lastCommand != null) { + final forCurrentItem = lastCommand.playlistItemId.isEmpty || + currentPlaylistItemId == null || + lastCommand.playlistItemId == currentPlaylistItemId; + final when = DateTime.tryParse(lastCommand.when); + if (forCurrentItem && when != null) { + // Only Unpause moves the playhead; Pause/Seek leave it at the command's position. + if (lastCommand.command != SyncPlayCommand.unpause) { + return lastCommand.positionTicks; + } + return lastCommand.positionTicks + millisecondsToTicks(remoteNow.difference(when).inMilliseconds); + } + } + if (queueTiming != null) { + return queueTiming.positionTicksAt(remoteNow); + } + return fallbackPositionTicks; +} + @Freezed(copyWith: true) abstract class SyncPlayState with _$SyncPlayState { const SyncPlayState._(); @@ -244,42 +360,52 @@ abstract class SyncPlayState with _$SyncPlayState { String? playlistItemId, @Default(0) int positionTicks, DateTime? lastCommandTime, - - /// Whether a SyncPlay command is currently being processed @Default(false) bool isProcessingCommand, - - /// The type of command being processed (for UI feedback). Typed - /// as [SyncPlayCommand] to keep cross-platform contracts strongly - /// typed (AGENTS.md SyncPlay rule 2). SyncPlayCommand? processingCommandType, - - /// Internal correction configuration and thresholds. @Default(SyncCorrectionConfig()) SyncCorrectionConfig correctionConfig, - - /// Runtime correction status for UI and command logic. @Default(SyncCorrectionState()) SyncCorrectionState correctionState, /// True while a `_startPlayback` call is in flight (loader UX). @Default(false) bool startPlaybackInProgress, - /// PlaylistItemId currently being started (for dedup of concurrent - /// PlayQueue updates that race against each other). + /// Dedup key for concurrent PlayQueue updates racing each other. String? startingPlaylistItemId, - /// Number of nested local-only operations currently active. While - /// > 0, the controller suppresses `reportBuffering`/`reportReady` - /// so audio/subtitle reloads don't pause the rest of the group. + /// While > 0, `reportBuffering`/`reportReady` are suppressed so local reloads don't pause the group. @Default(0) int localOnlyOperationCount, + + /// False after the user halted group playback on this device (`SetIgnoreWait(true)`): commands are + /// ignored until the user resumes or a new playlist is set. + @Default(true) bool isFollowingGroupPlayback, + + /// Local copy of the group play queue from the last `PlayQueue` frame. + @Default([]) List playlist, + + /// Index of the playing entry in [playlist], -1 when nothing plays. + @Default(-1) int playingItemIndex, + + /// Timing of the last `PlayQueue` frame; null until one arrived. + SyncPlayQueueTiming? queueTiming, }) = _SyncPlayState; bool get isActive => isConnected && isInGroup; + /// Entry after the playing one, if any. + SyncPlayQueueEntry? get nextQueueEntry { + final index = playingItemIndex + 1; + return index > 0 && index < playlist.length ? playlist[index] : null; + } + + /// Entry before the playing one, if any. + SyncPlayQueueEntry? get previousQueueEntry { + final index = playingItemIndex - 1; + return index >= 0 && index < playlist.length ? playlist[index] : null; + } + /// True when local-only mode is active (audio/subtitle switch, etc.). bool get isInLocalOnlyMode => localOnlyOperationCount > 0; - /// True when the group has an active item playing/paused/waiting that - /// the local user could re-attach to (used by the "Resume playback" - /// button when the player route is not currently mounted). + /// The group has an item the local user could re-attach to ("Resume playback" outside the player route). bool get hasActivePlayback => isInGroup && playingItemId != null && groupState != SyncPlayGroupState.idle; } @@ -294,18 +420,13 @@ abstract class LastSyncPlayCommand with _$LastSyncPlayCommand { }) = _LastSyncPlayCommand; } -/// Ticks conversion constants const int ticksPerMillisecond = 10000; const int ticksPerSecond = 10000000; -/// Convert seconds to ticks int secondsToTicks(double seconds) => (seconds * ticksPerSecond).round(); -/// Convert ticks to seconds double ticksToSeconds(int ticks) => ticks / ticksPerSecond; -/// Convert milliseconds to ticks int millisecondsToTicks(int ms) => ms * ticksPerMillisecond; -/// Convert ticks to milliseconds int ticksToMilliseconds(int ticks) => ticks ~/ ticksPerMillisecond; diff --git a/lib/models/syncplay/syncplay_models.freezed.dart b/lib/models/syncplay/syncplay_models.freezed.dart index 54be5b4bc..bd26cac33 100644 --- a/lib/models/syncplay/syncplay_models.freezed.dart +++ b/lib/models/syncplay/syncplay_models.freezed.dart @@ -318,6 +318,292 @@ class __$TimeSyncMeasurementCopyWithImpl<$Res> implements _$TimeSyncMeasurementC } } +/// @nodoc +mixin _$SyncPlayQueueTiming { + int get startPositionTicks; + DateTime? get lastUpdate; + bool get isPlaying; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith get copyWith => + _$SyncPlayQueueTimingCopyWithImpl(this as SyncPlayQueueTiming, _$identity); + + @override + String toString() { + return 'SyncPlayQueueTiming(startPositionTicks: $startPositionTicks, lastUpdate: $lastUpdate, isPlaying: $isPlaying)'; + } +} + +/// @nodoc +abstract mixin class $SyncPlayQueueTimingCopyWith<$Res> { + factory $SyncPlayQueueTimingCopyWith(SyncPlayQueueTiming value, $Res Function(SyncPlayQueueTiming) _then) = + _$SyncPlayQueueTimingCopyWithImpl; + @useResult + $Res call({int startPositionTicks, DateTime? lastUpdate, bool isPlaying}); +} + +/// @nodoc +class _$SyncPlayQueueTimingCopyWithImpl<$Res> implements $SyncPlayQueueTimingCopyWith<$Res> { + _$SyncPlayQueueTimingCopyWithImpl(this._self, this._then); + + final SyncPlayQueueTiming _self; + final $Res Function(SyncPlayQueueTiming) _then; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? startPositionTicks = null, + Object? lastUpdate = freezed, + Object? isPlaying = null, + }) { + return _then(_self.copyWith( + startPositionTicks: null == startPositionTicks + ? _self.startPositionTicks + : startPositionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastUpdate: freezed == lastUpdate + ? _self.lastUpdate + : lastUpdate // ignore: cast_nullable_to_non_nullable + as DateTime?, + isPlaying: null == isPlaying + ? _self.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// Adds pattern-matching-related methods to [SyncPlayQueueTiming]. +extension SyncPlayQueueTimingPatterns on SyncPlayQueueTiming { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SyncPlayQueueTiming value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_SyncPlayQueueTiming value) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SyncPlayQueueTiming value)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming(): + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + return null; + } + } +} + +/// @nodoc + +class _SyncPlayQueueTiming extends SyncPlayQueueTiming { + const _SyncPlayQueueTiming({required this.startPositionTicks, this.lastUpdate, this.isPlaying = false}) : super._(); + + @override + final int startPositionTicks; + @override + final DateTime? lastUpdate; + @override + @JsonKey() + final bool isPlaying; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SyncPlayQueueTimingCopyWith<_SyncPlayQueueTiming> get copyWith => + __$SyncPlayQueueTimingCopyWithImpl<_SyncPlayQueueTiming>(this, _$identity); + + @override + String toString() { + return 'SyncPlayQueueTiming(startPositionTicks: $startPositionTicks, lastUpdate: $lastUpdate, isPlaying: $isPlaying)'; + } +} + +/// @nodoc +abstract mixin class _$SyncPlayQueueTimingCopyWith<$Res> implements $SyncPlayQueueTimingCopyWith<$Res> { + factory _$SyncPlayQueueTimingCopyWith(_SyncPlayQueueTiming value, $Res Function(_SyncPlayQueueTiming) _then) = + __$SyncPlayQueueTimingCopyWithImpl; + @override + @useResult + $Res call({int startPositionTicks, DateTime? lastUpdate, bool isPlaying}); +} + +/// @nodoc +class __$SyncPlayQueueTimingCopyWithImpl<$Res> implements _$SyncPlayQueueTimingCopyWith<$Res> { + __$SyncPlayQueueTimingCopyWithImpl(this._self, this._then); + + final _SyncPlayQueueTiming _self; + final $Res Function(_SyncPlayQueueTiming) _then; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? startPositionTicks = null, + Object? lastUpdate = freezed, + Object? isPlaying = null, + }) { + return _then(_SyncPlayQueueTiming( + startPositionTicks: null == startPositionTicks + ? _self.startPositionTicks + : startPositionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastUpdate: freezed == lastUpdate + ? _self.lastUpdate + : lastUpdate // ignore: cast_nullable_to_non_nullable + as DateTime?, + isPlaying: null == isPlaying + ? _self.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + /// @nodoc mixin _$SyncPlayState { bool get isConnected; @@ -358,6 +644,21 @@ mixin _$SyncPlayState { /// so audio/subtitle reloads don't pause the rest of the group. int get localOnlyOperationCount; + /// False after the user halted group playback on this device + /// (`SetIgnoreWait(true)`): the server no longer waits for us and we + /// ignore playback commands until the user resumes or a new + /// playlist is set (jellyfin-web `followingGroupPlayback`). + bool get isFollowingGroupPlayback; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + List get playlist; + + /// Index of the playing entry in [playlist], -1 when nothing plays. + int get playingItemIndex; + + /// Timing of the last `PlayQueue` frame; null until one arrived. + SyncPlayQueueTiming? get queueTiming; + /// Create a copy of SyncPlayState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -367,7 +668,7 @@ mixin _$SyncPlayState { @override String toString() { - return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount)'; + return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount, isFollowingGroupPlayback: $isFollowingGroupPlayback, playlist: $playlist, playingItemIndex: $playingItemIndex, queueTiming: $queueTiming)'; } } @@ -393,7 +694,13 @@ abstract mixin class $SyncPlayStateCopyWith<$Res> { SyncCorrectionState correctionState, bool startPlaybackInProgress, String? startingPlaylistItemId, - int localOnlyOperationCount}); + int localOnlyOperationCount, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming}); + + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming; } /// @nodoc @@ -426,6 +733,10 @@ class _$SyncPlayStateCopyWithImpl<$Res> implements $SyncPlayStateCopyWith<$Res> Object? startPlaybackInProgress = null, Object? startingPlaylistItemId = freezed, Object? localOnlyOperationCount = null, + Object? isFollowingGroupPlayback = null, + Object? playlist = null, + Object? playingItemIndex = null, + Object? queueTiming = freezed, }) { return _then(_self.copyWith( isConnected: null == isConnected @@ -500,8 +811,38 @@ class _$SyncPlayStateCopyWithImpl<$Res> implements $SyncPlayStateCopyWith<$Res> ? _self.localOnlyOperationCount : localOnlyOperationCount // ignore: cast_nullable_to_non_nullable as int, + isFollowingGroupPlayback: null == isFollowingGroupPlayback + ? _self.isFollowingGroupPlayback + : isFollowingGroupPlayback // ignore: cast_nullable_to_non_nullable + as bool, + playlist: null == playlist + ? _self.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as List, + playingItemIndex: null == playingItemIndex + ? _self.playingItemIndex + : playingItemIndex // ignore: cast_nullable_to_non_nullable + as int, + queueTiming: freezed == queueTiming + ? _self.queueTiming + : queueTiming // ignore: cast_nullable_to_non_nullable + as SyncPlayQueueTiming?, )); } + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming { + if (_self.queueTiming == null) { + return null; + } + + return $SyncPlayQueueTimingCopyWith<$Res>(_self.queueTiming!, (value) { + return _then(_self.copyWith(queueTiming: value)); + }); + } } /// Adds pattern-matching-related methods to [SyncPlayState]. @@ -615,7 +956,11 @@ extension SyncPlayStatePatterns on SyncPlayState { SyncCorrectionState correctionState, bool startPlaybackInProgress, String? startingPlaylistItemId, - int localOnlyOperationCount)? + int localOnlyOperationCount, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming)? $default, { required TResult orElse(), }) { @@ -640,7 +985,11 @@ extension SyncPlayStatePatterns on SyncPlayState { _that.correctionState, _that.startPlaybackInProgress, _that.startingPlaylistItemId, - _that.localOnlyOperationCount); + _that.localOnlyOperationCount, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); case _: return orElse(); } @@ -679,7 +1028,11 @@ extension SyncPlayStatePatterns on SyncPlayState { SyncCorrectionState correctionState, bool startPlaybackInProgress, String? startingPlaylistItemId, - int localOnlyOperationCount) + int localOnlyOperationCount, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming) $default, ) { final _that = this; @@ -703,7 +1056,11 @@ extension SyncPlayStatePatterns on SyncPlayState { _that.correctionState, _that.startPlaybackInProgress, _that.startingPlaylistItemId, - _that.localOnlyOperationCount); + _that.localOnlyOperationCount, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); case _: throw StateError('Unexpected subclass'); } @@ -741,7 +1098,11 @@ extension SyncPlayStatePatterns on SyncPlayState { SyncCorrectionState correctionState, bool startPlaybackInProgress, String? startingPlaylistItemId, - int localOnlyOperationCount)? + int localOnlyOperationCount, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming)? $default, ) { final _that = this; @@ -765,7 +1126,11 @@ extension SyncPlayStatePatterns on SyncPlayState { _that.correctionState, _that.startPlaybackInProgress, _that.startingPlaylistItemId, - _that.localOnlyOperationCount); + _that.localOnlyOperationCount, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); case _: return null; } @@ -793,8 +1158,13 @@ class _SyncPlayState extends SyncPlayState { this.correctionState = const SyncCorrectionState(), this.startPlaybackInProgress = false, this.startingPlaylistItemId, - this.localOnlyOperationCount = 0}) + this.localOnlyOperationCount = 0, + this.isFollowingGroupPlayback = true, + final List playlist = const [], + this.playingItemIndex = -1, + this.queueTiming}) : _participants = participants, + _playlist = playlist, super._(); @override @@ -869,6 +1239,35 @@ class _SyncPlayState extends SyncPlayState { @JsonKey() final int localOnlyOperationCount; + /// False after the user halted group playback on this device + /// (`SetIgnoreWait(true)`): the server no longer waits for us and we + /// ignore playback commands until the user resumes or a new + /// playlist is set (jellyfin-web `followingGroupPlayback`). + @override + @JsonKey() + final bool isFollowingGroupPlayback; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + final List _playlist; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + @override + @JsonKey() + List get playlist { + if (_playlist is EqualUnmodifiableListView) return _playlist; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_playlist); + } + + /// Index of the playing entry in [playlist], -1 when nothing plays. + @override + @JsonKey() + final int playingItemIndex; + + /// Timing of the last `PlayQueue` frame; null until one arrived. + @override + final SyncPlayQueueTiming? queueTiming; + /// Create a copy of SyncPlayState /// with the given fields replaced by the non-null parameter values. @override @@ -879,7 +1278,7 @@ class _SyncPlayState extends SyncPlayState { @override String toString() { - return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount)'; + return 'SyncPlayState(isConnected: $isConnected, isInGroup: $isInGroup, groupId: $groupId, groupName: $groupName, groupState: $groupState, stateReason: $stateReason, participants: $participants, playingItemId: $playingItemId, playlistItemId: $playlistItemId, positionTicks: $positionTicks, lastCommandTime: $lastCommandTime, isProcessingCommand: $isProcessingCommand, processingCommandType: $processingCommandType, correctionConfig: $correctionConfig, correctionState: $correctionState, startPlaybackInProgress: $startPlaybackInProgress, startingPlaylistItemId: $startingPlaylistItemId, localOnlyOperationCount: $localOnlyOperationCount, isFollowingGroupPlayback: $isFollowingGroupPlayback, playlist: $playlist, playingItemIndex: $playingItemIndex, queueTiming: $queueTiming)'; } } @@ -907,7 +1306,14 @@ abstract mixin class _$SyncPlayStateCopyWith<$Res> implements $SyncPlayStateCopy SyncCorrectionState correctionState, bool startPlaybackInProgress, String? startingPlaylistItemId, - int localOnlyOperationCount}); + int localOnlyOperationCount, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming}); + + @override + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming; } /// @nodoc @@ -940,6 +1346,10 @@ class __$SyncPlayStateCopyWithImpl<$Res> implements _$SyncPlayStateCopyWith<$Res Object? startPlaybackInProgress = null, Object? startingPlaylistItemId = freezed, Object? localOnlyOperationCount = null, + Object? isFollowingGroupPlayback = null, + Object? playlist = null, + Object? playingItemIndex = null, + Object? queueTiming = freezed, }) { return _then(_SyncPlayState( isConnected: null == isConnected @@ -1014,8 +1424,38 @@ class __$SyncPlayStateCopyWithImpl<$Res> implements _$SyncPlayStateCopyWith<$Res ? _self.localOnlyOperationCount : localOnlyOperationCount // ignore: cast_nullable_to_non_nullable as int, + isFollowingGroupPlayback: null == isFollowingGroupPlayback + ? _self.isFollowingGroupPlayback + : isFollowingGroupPlayback // ignore: cast_nullable_to_non_nullable + as bool, + playlist: null == playlist + ? _self._playlist + : playlist // ignore: cast_nullable_to_non_nullable + as List, + playingItemIndex: null == playingItemIndex + ? _self.playingItemIndex + : playingItemIndex // ignore: cast_nullable_to_non_nullable + as int, + queueTiming: freezed == queueTiming + ? _self.queueTiming + : queueTiming // ignore: cast_nullable_to_non_nullable + as SyncPlayQueueTiming?, )); } + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming { + if (_self.queueTiming == null) { + return null; + } + + return $SyncPlayQueueTimingCopyWith<$Res>(_self.queueTiming!, (value) { + return _then(_self.copyWith(queueTiming: value)); + }); + } } /// @nodoc diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index 4a7e50b90..b188e62e5 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -151,6 +151,8 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier state = state.copyWith(enableDoubleTapSeek: value); + void setEnableSyncPlayCorrection(bool value) => state = state.copyWith(enableSyncPlayCorrection: value); + void setEnableAdvancedVideoOptions(bool value) => state = state.copyWith(enableAdvancedVideoOptions: value); void setEnableEdgeGestures(bool value) => state = state.copyWith(enableEdgeGestures: value); diff --git a/lib/providers/syncplay/buffering_report_debouncer.dart b/lib/providers/syncplay/buffering_report_debouncer.dart new file mode 100644 index 000000000..463c91bfd --- /dev/null +++ b/lib/providers/syncplay/buffering_report_debouncer.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +/// A Buffering report pauses every participant, but libmpv/ExoPlayer flip their flag on every seek and +/// cache dip: stalls shorter than [threshold] never reach the server; a reported one gets exactly one [onReady]. +class BufferingReportDebouncer { + BufferingReportDebouncer({ + required this.onBuffering, + required this.onReady, + this.threshold = const Duration(seconds: 3), + }); + + final void Function() onBuffering; + final void Function() onReady; + final Duration threshold; + + Timer? _timer; + bool _reported = false; + + /// True between a reported Buffering and its closing Ready. + bool get isReported => _reported; + + /// True while a stall is being timed but has not been reported yet. + bool get isPending => _timer?.isActive == true; + + void update(bool buffering) { + if (buffering) { + if (_reported || isPending) { + return; + } + _timer = Timer(threshold, () { + _timer = null; + _reported = true; + onBuffering(); + }); + return; + } + + _timer?.cancel(); + _timer = null; + if (_reported) { + _reported = false; + onReady(); + } + } + + /// Forgets any pending or reported stall without emitting anything (media reload, player teardown). + void reset() { + _timer?.cancel(); + _timer = null; + _reported = false; + } + + void dispose() => reset(); +} diff --git a/lib/providers/syncplay/handlers/syncplay_command_handler.dart b/lib/providers/syncplay/handlers/syncplay_command_handler.dart index 84032fd20..ce843dc5b 100644 --- a/lib/providers/syncplay/handlers/syncplay_command_handler.dart +++ b/lib/providers/syncplay/handlers/syncplay_command_handler.dart @@ -1,39 +1,74 @@ import 'dart:async'; import 'dart:developer'; +import 'dart:math' show Random; import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/syncplay/time_sync_service.dart'; -/// Callback types for player control commands from SyncPlay typedef SyncPlayPlayerCallback = Future Function(); typedef SyncPlaySeekCallback = Future Function(int positionTicks); typedef SyncPlayPositionCallback = int Function(); -typedef SyncPlayReportReadyCallback = Future Function(); +typedef SyncPlayReportReadyCallback = Future Function({required bool isPlaying, required int positionTicks}); typedef SyncPlaySetSpeedCallback = Future Function(double speed); -/// Handles scheduling and execution of SyncPlay commands +class _ParsedCommand { + const _ParsedCommand({ + required this.command, + required this.when, + required this.whenStr, + required this.positionTicks, + required this.playlistItemId, + }); + + final SyncPlayCommand command; + final DateTime when; + final String whenStr; + final int positionTicks; + final String playlistItemId; +} + +class _PendingCommand { + const _PendingCommand(this.command, this.when, this.positionTicks); + + final SyncPlayCommand command; + final DateTime when; + final int positionTicks; +} + class SyncPlayCommandHandler { SyncPlayCommandHandler({ required this.timeSync, required this.onStateUpdate, - }); + Random? random, + this.clockFallback = const Duration(seconds: 2), + }) : _random = random ?? Random(); + + /// How long a command may wait for the first clock measurement before it is applied with the unsynced + /// clock; a broken `/GetUtcTime` must not freeze the group forever. + final Duration clockFallback; + Timer? _clockFallbackTimer; - /// Commands more than this late are dropped on the floor — typical - /// trigger is the server replaying its queued backlog after a long - /// client disconnect (phone locked, app backgrounded). The current - /// `StateUpdate` messages from the server then resync us. + /// Commands later than this are dropped (typically the server replaying its backlog after a long + /// disconnect); the server's `StateUpdate` resyncs us. static const _staleCommandThreshold = Duration(seconds: 30); - final TimeSyncService? Function() timeSync; + /// Matches the server's `MaxPlaybackOffset` (500 ms) so we never sit outside the window it accepts. + static const int positionToleranceTicks = 500 * ticksPerMillisecond; + + /// Jitter applied when re-seeking on a duplicate Seek so the server sees a fresh position. + static const int _duplicateSeekJitterMs = 100; + + final SyncPlayClock? Function() timeSync; final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + final Random _random; - // Last command for duplicate detection LastSyncPlayCommand? _lastCommand; - // Pending command timer Timer? _commandTimer; - // Player callbacks + // Command received before the clock was ready. + _PendingCommand? _queuedCommand; + SyncPlayPlayerCallback? onPlay; SyncPlayPlayerCallback? onPause; SyncPlaySeekCallback? onSeek; @@ -42,21 +77,41 @@ class SyncPlayCommandHandler { bool Function()? isPlaying; bool Function()? isBuffering; - // New callback to signal that a seek has been requested by someone else SyncPlaySeekCallback? onSeekRequested; - // Report ready callback (to tell server we're ready after seek) SyncPlayReportReadyCallback? onReportReady; - // Playback rate callbacks for SpeedToSync SyncPlaySetSpeedCallback? onSetSpeed; bool Function()? hasPlaybackRate; - /// Last accepted command (non-duplicate), exposed for correction logic. LastSyncPlayCommand? get lastCommand => _lastCommand; - /// Handle incoming SyncPlay command from WebSocket - void handleCommand(Map data, SyncPlayState currentState) { + /// True while a command is armed or executing; callers must not drive the player themselves meanwhile. + bool get hasPendingCommand => _commandTimer?.isActive == true || _isExecuting; + + bool _isExecuting = false; + + /// True while a command is parked waiting for the first clock measurement. + bool get hasQueuedCommand => _queuedCommand != null; + + /// Records without executing, used while not following group playback: [lastCommand] must keep + /// describing the group's playhead or a later resume starts from a stale position. + void recordCommand(Map data) { + final parsed = _parseCommand(data); + if (parsed == null) { + return; + } + _lastCommand = LastSyncPlayCommand( + when: parsed.whenStr, + positionTicks: parsed.positionTicks, + command: parsed.command, + playlistItemId: parsed.playlistItemId, + ); + onStateUpdate((state) => state.copyWith(positionTicks: parsed.positionTicks)); + log('SyncPlay: Recorded ${parsed.command.wire} at ${parsed.positionTicks} ticks (not following)'); + } + + _ParsedCommand? _parseCommand(Map data) { final commandWire = data['Command'] as String?; final whenStr = data['When'] as String?; final positionTicks = data['PositionTicks'] as int? ?? 0; @@ -65,12 +120,50 @@ class SyncPlayCommandHandler { final command = SyncPlayCommand.fromWire(commandWire); if (command == null || whenStr == null) { log('SyncPlay: Ignoring unknown command "$commandWire"'); + return null; + } + + final when = DateTime.tryParse(whenStr); + if (when == null) { + log('SyncPlay: Ignoring ${command.wire} with unparsable When "$whenStr"'); + return null; + } + return _ParsedCommand( + command: command, + when: when, + whenStr: whenStr, + positionTicks: positionTicks, + playlistItemId: playlistItemId, + ); + } + + void handleCommand(Map data, SyncPlayState currentState) { + final parsed = _parseCommand(data); + if (parsed == null) { + return; + } + final command = parsed.command; + final when = parsed.when; + final whenStr = parsed.whenStr; + final positionTicks = parsed.positionTicks; + final playlistItemId = parsed.playlistItemId; + + // A late command for the previous item must not be applied to the new one around an item switch. + // Stop is exempt: the server sends it with an empty playlist item. + final currentPlaylistItemId = currentState.playlistItemId; + final isForOtherItem = command != SyncPlayCommand.stop && + playlistItemId.isNotEmpty && + currentPlaylistItemId != null && + currentPlaylistItemId != playlistItemId; + if (isForOtherItem) { + log('SyncPlay: Ignoring ${command.wire} for playlist item $playlistItemId ' + '(current item is $currentPlaylistItemId)'); return; } - // Check for duplicate command - if (_isDuplicateCommand(whenStr, positionTicks, command, playlistItemId)) { - log('SyncPlay: Ignoring duplicate command: ${command.wire}'); + // A resend of the command parked behind the clock gate has not been applied yet; re-queue it. + if (!hasQueuedCommand && _isDuplicateCommand(whenStr, positionTicks, command, playlistItemId)) { + _handleDuplicateCommand(command, when, positionTicks); return; } @@ -81,18 +174,13 @@ class SyncPlayCommandHandler { playlistItemId: playlistItemId, ); - onStateUpdate((state) => state.copyWith( - positionTicks: positionTicks, - playlistItemId: playlistItemId, - )); + onStateUpdate((state) => state.copyWith(positionTicks: positionTicks)); - // If it's a Seek command, notify the player immediately so it can - // report buffering. + // Let the player report buffering right away on Seek. if (command == SyncPlayCommand.seek) { onSeekRequested?.call(positionTicks); } - final when = DateTime.parse(whenStr); _scheduleCommand(command, when, positionTicks); } @@ -102,29 +190,61 @@ class SyncPlayCommandHandler { SyncPlayCommand command, String playlistItemId, ) { - if (_lastCommand == null) { + final last = _lastCommand; + if (last == null) { return false; } + return last.when == when && + last.positionTicks == positionTicks && + last.command == command && + last.playlistItemId == playlistItemId; + } - // For Unpause commands, if we are not currently playing, we should - // NEVER treat it as a duplicate to ensure the player actually - // resumes. - if (command == SyncPlayCommand.unpause && isPlaying?.call() == false) { - return false; + /// The server re-sends a byte-identical command as "you got lost, here is the current state": + /// a duplicate whose time is still in the future is already armed; a past duplicate is a correction check. + void _handleDuplicateCommand(SyncPlayCommand command, DateTime when, int positionTicks) { + final clock = timeSync(); + final localWhen = clock?.remoteDateToLocal(when) ?? when; + if (localWhen.isAfter(DateTime.now().toUtc())) { + log('SyncPlay: Duplicate ${command.wire} is already scheduled'); + return; } - return _lastCommand!.when == when && - _lastCommand!.positionTicks == positionTicks && - _lastCommand!.command == command && - _lastCommand!.playlistItemId == playlistItemId; + final playing = isPlaying?.call() ?? false; + final currentTicks = getPositionTicks?.call() ?? 0; + final offPosition = (currentTicks - positionTicks).abs() > positionToleranceTicks; + + switch (command) { + case SyncPlayCommand.unpause: + if (!playing) { + log('SyncPlay: Duplicate Unpause while paused - resuming'); + _runNow(command, _estimateCurrentTicks(positionTicks, when)); + } + break; + case SyncPlayCommand.pause: + if (playing || offPosition) { + log('SyncPlay: Duplicate Pause while playing/off-position - correcting'); + _runNow(command, positionTicks); + } + break; + case SyncPlayCommand.seek: + if (playing || offPosition) { + final jitterMs = ((_random.nextDouble() - 0.5) * _duplicateSeekJitterMs).round(); + log('SyncPlay: Duplicate Seek while playing/off-position - re-seeking (${jitterMs}ms jitter)'); + _runNow(command, positionTicks + millisecondsToTicks(jitterMs)); + } else { + log('SyncPlay: Duplicate Seek already satisfied - reporting ready'); + unawaited(onReportReady?.call(isPlaying: false, positionTicks: currentTicks)); + } + break; + case SyncPlayCommand.stop: + if (playing) { + _runNow(command, positionTicks); + } + break; + } } - /// Guard rules before any playback correction attempt. - /// - /// Rules: - /// - only after `Unpause` command context - /// - skip while player is buffering/reloading - /// - skip when command playlist item does not match current item bool canAttemptSyncCorrection(SyncPlayState currentState) { final command = _lastCommand; if (command == null) { @@ -146,27 +266,76 @@ class SyncPlayCommandHandler { return true; } + /// The group's word is final once nothing of our own is in the way: no command armed/queued/executing, + /// not buffering, no Pause/Stop as last command, and not before a scheduled Unpause's `When`. + bool shouldRecoverPlayback(DateTime remoteNow) { + if (hasPendingCommand || hasQueuedCommand) { + return false; + } + if (isBuffering?.call() == true) { + return false; + } + final last = _lastCommand; + if (last != null) { + if (last.command == SyncPlayCommand.pause || last.command == SyncPlayCommand.stop) { + return false; + } + final when = DateTime.tryParse(last.when); + if (last.command == SyncPlayCommand.unpause && when != null && remoteNow.isBefore(when)) { + return false; + } + } + return isPlaying?.call() == false; + } + + /// Schedules the command parked while the clock was not ready; [force] applies it with the unsynced + /// clock (fallback timer, or the group already reports Playing). + void flushQueuedCommand({bool force = false}) { + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + final queued = _queuedCommand; + _queuedCommand = null; + if (queued == null) { + return; + } + log('SyncPlay: ${force ? 'Clock wait over' : 'Clock ready'} - applying queued ${queued.command.wire}'); + _scheduleCommand(queued.command, queued.when, queued.positionTicks, requireClockReady: !force); + } + void _scheduleCommand( SyncPlayCommand command, DateTime serverTime, - int positionTicks, - ) { - final timeSyncService = timeSync(); - if (timeSyncService == null) { + int positionTicks, { + bool requireClockReady = true, + }) { + // A newer command always replaces whatever is armed, queued or not. + _commandTimer?.cancel(); + + final clock = timeSync(); + if (clock == null) { log('SyncPlay: Cannot schedule command without time sync'); - _executeCommand(command, positionTicks); + _runNow(command, positionTicks); return; } - final localTime = timeSyncService.remoteDateToLocal(serverTime); + if (requireClockReady && !clock.isReady) { + // Without a measurement the scheduled time would be off by the whole clock skew; park the latest + // command until the first measurement lands, but never longer than [clockFallback]. + log('SyncPlay: Clock not ready - queueing ${command.wire}'); + _queuedCommand = _PendingCommand(command, serverTime, positionTicks); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = Timer(clockFallback, () { + log('SyncPlay: Clock still not ready after ${clockFallback.inMilliseconds}ms - applying unsynced'); + flushQueuedCommand(force: true); + }); + return; + } + + final localTime = clock.remoteDateToLocal(serverTime); final now = DateTime.now().toUtc(); final delay = localTime.difference(now); - _commandTimer?.cancel(); - - // Drop commands too stale to act on. Executing them would - // extrapolate to positions far past EOF and start a buffer - // oscillation while the player chases an unreachable target. + // Executing stale commands extrapolates far past EOF and starts a buffer oscillation. if (delay.isNegative && -delay > _staleCommandThreshold) { log('SyncPlay: Discarding stale ${command.wire} command ' '(${(-delay).inSeconds}s late > ' @@ -175,42 +344,45 @@ class SyncPlayCommandHandler { return; } - // Show processing indicator - onStateUpdate((state) => state.copyWith( - isProcessingCommand: true, - processingCommandType: command, - )); - if (delay.isNegative) { - // Late but within the staleness threshold. Only Unpause should - // extrapolate the requested position by the elapsed delay — the - // group has been *playing* during that window. Pause/Seek/Stop - // are static targets: the original PositionTicks is the - // authoritative value regardless of how late the command - // arrives. Without this, a late Pause or Seek would seek to - // position+elapsed, often past EOF, which on libMPV/ExoPlayer - // triggers a real buffer cycle. + // Only Unpause extrapolates by the elapsed delay (the group kept playing); Pause/Seek/Stop are + // static targets, and extrapolating them would seek past EOF and trigger a real buffer cycle. final ticksToUse = command == SyncPlayCommand.unpause ? _estimateCurrentTicks(positionTicks, serverTime) : positionTicks; log('SyncPlay: Executing late command: ${command.wire} ' '(${delay.inMilliseconds}ms late)'); - _executeCommand(command, ticksToUse); - } else if (delay.inMilliseconds > 5000) { + _runNow(command, ticksToUse); + return; + } + + if (delay.inMilliseconds > 5000) { log('SyncPlay: Warning - large delay: ${delay.inMilliseconds}ms'); - _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); } else { log('SyncPlay: Scheduling command: ${command.wire} ' 'in ${delay.inMilliseconds}ms'); - _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); } + + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } + + void _runNow(SyncPlayCommand command, int positionTicks) { + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + unawaited(_executeCommand(command, positionTicks)); } int _estimateCurrentTicks(int ticks, DateTime when) { - final timeSyncService = timeSync(); - if (timeSyncService == null) { + final clock = timeSync(); + if (clock == null) { return ticks; } - final remoteNow = timeSyncService.localDateToRemote(DateTime.now().toUtc()); + final remoteNow = clock.localDateToRemote(DateTime.now().toUtc()); final elapsedMs = remoteNow.difference(when).inMilliseconds; return ticks + millisecondsToTicks(elapsedMs); } @@ -221,18 +393,17 @@ class SyncPlayCommandHandler { ) async { log('SyncPlay: Executing command: ${command.wire} at $positionTicks ticks'); + _isExecuting = true; try { switch (command) { case SyncPlayCommand.pause: await onPause?.call(); - // Only seek if position is significantly different (>1 sec). + // Correct the position outside the server's tolerance so all participants freeze on the same frame. final currentTicks = getPositionTicks?.call() ?? 0; - final needsCorrectionSeek = (positionTicks - currentTicks).abs() > ticksPerSecond; + final needsCorrectionSeek = (positionTicks - currentTicks).abs() > positionToleranceTicks; if (needsCorrectionSeek) { await onSeek?.call(positionTicks); - // Seek can put native ExoPlayer through STATE_BUFFERING; hold - // isProcessingCommand=true until that clears. Same rationale as - // the Unpause and Seek paths. + // Seek can put native ExoPlayer through STATE_BUFFERING; hold isProcessingCommand until it clears. if (isBuffering?.call() == true) { await _waitUntilNotBuffering(); } @@ -240,20 +411,14 @@ class SyncPlayCommandHandler { break; case SyncPlayCommand.unpause: - // Only seek if position is significantly different (>1 sec). - // Seek first, then play for smoother unpause alignment. + // Seek first, then play; drift correction handles anything inside the tolerance. final currentTicks = getPositionTicks?.call() ?? 0; - if ((positionTicks - currentTicks).abs() > ticksPerSecond) { + if ((positionTicks - currentTicks).abs() > positionToleranceTicks) { await onSeek?.call(positionTicks); } await onPlay?.call(); - // Resuming from pause can put native ExoPlayer (Android-TV / - // leanback) through STATE_BUFFERING for several hundred ms - // while it primes the resumed buffer. Hold isProcessingCommand - // true for that window — otherwise the player-state listener - // leaks a stale Buffering report once the time-based cooldown - // expires, which forms a feedback loop in any SyncPlay group - // containing a TV. + // Native ExoPlayer buffers for a few hundred ms after resume; holding isProcessingCommand prevents + // the player-state listener from leaking a stale Buffering report (a feedback loop with any TV). if (isBuffering?.call() == true) { await _waitUntilNotBuffering(); } @@ -262,40 +427,29 @@ class SyncPlayCommandHandler { case SyncPlayCommand.seek: await onPause?.call(); await onSeek?.call(positionTicks); - // Wait for the seek-induced buffering to clear before - // reporting Ready. The buffering listener in - // video_player_provider is suppressed while - // isProcessingCommand is true, so we own the Ready signal - // here. Without this wait the listener would fire a - // Ready(isPlaying:false) (we paused as part of the seek) - // that overrides the explicit Ready below — server would - // then keep the group paused instead of broadcasting - // Unpause, and the player would not auto-resume. - // - // Cap the wait at 2 s: libMPV (phone/web) keeps - // `paused-for-cache` true conservatively while the player - // is paused — it only flips to false once the cache is - // fully topped up, which can take many seconds even when - // there is plenty already buffered to play. ExoPlayer - // (Android-TV) settles seek-buffering well within 2 s, so - // shortening this timeout doesn't regress the TV path. If - // the cap is reached we still fire onReportReady; the - // server's Unpause then arrives normally and the next - // onPlay flips libMPV to "playing" mode where it emits - // buffering=false immediately. + // We own the Ready signal here (the buffering listener is suppressed while processing). Cap at 2 s: + // libmpv keeps `paused-for-cache` true while paused until the cache is fully topped up. if (isBuffering?.call() == true) { await _waitUntilNotBuffering(timeout: const Duration(seconds: 2)); } - await onReportReady?.call(); + // Report the requested position, not the live one: libmpv can land a hair off on keyframe-bound + // media and a value outside the server's 500 ms window would trigger another corrective Seek. + await onReportReady?.call(isPlaying: false, positionTicks: positionTicks); break; case SyncPlayCommand.stop: - await onPause?.call(); - await onSeek?.call(0); + // The group went idle, playback ends. + final stop = onStop; + if (stop != null) { + await stop(); + } else { + await onPause?.call(); + await onSeek?.call(0); + } break; } } finally { - // Clear processing state after command completes + _isExecuting = false; onStateUpdate((state) => state.copyWith( isProcessingCommand: false, processingCommandType: null, @@ -303,9 +457,6 @@ class SyncPlayCommandHandler { } } - /// Poll the [isBuffering] callback until it returns `false` or the - /// timeout expires. Used by the Seek command handler so the explicit - /// `onReportReady` fires only once the player has finished buffering. Future _waitUntilNotBuffering({ Duration timeout = const Duration(seconds: 10), Duration pollInterval = const Duration(milliseconds: 100), @@ -316,18 +467,26 @@ class SyncPlayCommandHandler { } } - /// Cancel any pending commands void cancelPendingCommands() { _commandTimer?.cancel(); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + _queuedCommand = null; } - /// Clear last command context used for duplicate detection and correction. void clearLastCommand() { _lastCommand = null; } - /// Dispose resources void dispose() { _commandTimer?.cancel(); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + _queuedCommand = null; + } + + /// Runs as an Unpause so the overlay shows it and [hasPendingCommand] owns the player meanwhile. + void recoverPlayback() { + _runNow(SyncPlayCommand.unpause, getPositionTicks?.call() ?? 0); } } diff --git a/lib/providers/syncplay/handlers/syncplay_message_handler.dart b/lib/providers/syncplay/handlers/syncplay_message_handler.dart index 9a7422a25..9facae4e7 100644 --- a/lib/providers/syncplay/handlers/syncplay_message_handler.dart +++ b/lib/providers/syncplay/handlers/syncplay_message_handler.dart @@ -6,58 +6,42 @@ import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:flutter/material.dart'; -/// Callback for reporting ready state after seek -typedef ReportReadyCallback = Future Function({bool isPlaying}); +/// [reason] is the server's `PlayQueueUpdateReason` wire value (`NewPlaylist`, `NextItem`, ...). +typedef StartPlaybackCallback = Future Function(String itemId, int startPositionTicks, String? reason); -/// Callback for starting playback of an item -typedef StartPlaybackCallback = Future Function(String itemId, int startPositionTicks); - -/// Callback that pauses the local player without sending a SyncPlay -/// pause request. Used when the group enters Waiting because another -/// client is buffering — we must mirror the group state locally. -typedef LocalPauseCallback = Future Function(); - -/// Handles SyncPlay group update messages from WebSocket +/// Never answers a `StateUpdate` with a request: the server only needs `Ready` from sessions it flagged +/// as buffering, and replying from here produced an N-client cascade of Ready/Pause exchanges. class SyncPlayMessageHandler { SyncPlayMessageHandler({ required this.onStateUpdate, - required this.reportReady, required this.startPlayback, - required this.isBuffering, required this.getContext, required this.onGroupJoined, required this.onGroupJoinFailed, this.onGroupLeftOrKicked, this.onStateUpdateToPlaying, this.onGroupGone, - this.onLocalPauseForBuffer, }); final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; - final ReportReadyCallback reportReady; final StartPlaybackCallback startPlayback; - final bool Function() isBuffering; final BuildContext? Function() getContext; final void Function() onGroupJoined; final void Function() onGroupJoinFailed; - /// Called when we leave or are kicked so controller can cancel pending commands and clear processing state. final void Function()? onGroupLeftOrKicked; - /// Called when group state becomes Playing so controller can ensure player is actually playing (per docs). + /// Lets the controller run its missed-Unpause recovery check. final void Function()? onStateUpdateToPlaying; - /// Called when the user is no longer part of the group from the - /// server's perspective (kicked, group disposed, etc.) so that the - /// controller can surface a user-visible notification. + /// The server no longer considers us part of the group (kicked, group disposed). final void Function({required bool wasKicked})? onGroupGone; - /// Called when the group enters Waiting because another client is - /// buffering. Mirrors the group state locally before reporting Ready - /// so we don't keep playing while the group is logically paused. - final LocalPauseCallback? onLocalPauseForBuffer; + bool _wasInGroupAtLastUpdate = false; + + /// `LastUpdate` of the most recent applied `PlayQueue` frame; older frames are ignored. + DateTime? _lastQueueUpdate; - /// Handle group update message void handleGroupUpdate(Map data, SyncPlayState currentState) { _wasInGroupAtLastUpdate = currentState.isInGroup; final updateType = data['Type'] as String?; @@ -82,52 +66,51 @@ class SyncPlayMessageHandler { case 'NotInGroup': _handleNotInGroup(); break; + case 'LibraryAccessDenied': + _handleLibraryAccessDenied(); + break; case 'StateUpdate': _handleStateUpdate(updateData as Map); break; case 'PlayQueue': _handlePlayQueue(updateData as Map, currentState); break; + default: + log('SyncPlay: Unhandled group update type: $updateType'); } } + /// The playing item arrives in the `PlayQueue` frame that follows. void _handleGroupJoined(Map data) { final groupId = data['GroupId'] as String?; final groupName = data['GroupName'] as String?; final stateStr = data['State'] as String?; final participants = (data['Participants'] as List?)?.cast() ?? []; - final positionTicks = data['PositionTicks'] as int? ?? 0; - final playingItemId = data['PlayingItemId'] as String?; + // A (re)join's fresh PlayQueue frame may carry the LastUpdate we already applied; accept it. + _lastQueueUpdate = null; + + // `isFollowingGroupPlayback` is left alone: the controller sets it for user-initiated joins, and a + // silent rejoin must not un-halt a device the user halted. onStateUpdate((state) => state.copyWith( isInGroup: true, groupId: groupId, groupName: groupName, groupState: _parseGroupState(stateStr), participants: participants, - positionTicks: positionTicks, - playingItemId: playingItemId ?? state.playingItemId, )); log('SyncPlay: Joined group "$groupName" ($groupId)'); - // Notify controller that group join was confirmed onGroupJoined(); } - /// Note: SyncPlay's `UserJoined` / `UserLeft` payloads carry the - /// participant's display name directly in `Data` (a plain string), - /// not a userId. No `usersUserIdGet` lookup is needed - calling that - /// endpoint with the username returns a 400. + /// `UserJoined` / `UserLeft` carry the participant's display name in `Data`, not a userId. void _handleUserJoined(String? userName, SyncPlayState currentState) { if (userName == null) { return; } - // The server re-broadcasts `UserJoined` on every `Join` POST — - // including reconnects, silent rejoins and retries after a - // false-negative "Failed to join". Appending unconditionally is - // what stacked the same user multiple times. Ignore if already a - // participant. + // The server re-broadcasts `UserJoined` on every `Join` POST (reconnects, silent rejoins, retries). if (currentState.participants.contains(userName)) { log('SyncPlay: Duplicate UserJoined ignored (already a participant): $userName'); return; @@ -150,12 +133,8 @@ class SyncPlayMessageHandler { log('SyncPlay: User left: $userName'); } - /// Render a snackbar through the global notification overlay. We - /// deliberately do NOT pass the navigator-key context here: that - /// context lives under `Navigator` but not under any `Overlay`, so - /// `Overlay.of(context)` throws. `FladderSnack` keeps a stored root - /// context (set by `NotificationManagerInitializer`) that already - /// resolves to the root overlay. + /// Never pass the navigator-key context: it is not under an `Overlay`, so `Overlay.of` throws. + /// `FladderSnack` resolves the root overlay from its own stored context. void _showSnackbar(String Function(AppLocalizations l) builder) { final context = getContext(); if (context != null) { @@ -170,140 +149,140 @@ class SyncPlayMessageHandler { } } + SyncPlayState _clearGroup(SyncPlayState state) { + return state.copyWith( + isInGroup: false, + isFollowingGroupPlayback: true, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + playlist: [], + playingItemIndex: -1, + queueTiming: null, + ); + } + void _handleGroupLeft() { - onStateUpdate((state) => state.copyWith( - isInGroup: false, - groupId: null, - groupName: null, - groupState: SyncPlayGroupState.idle, - participants: [], - isProcessingCommand: false, - processingCommandType: null, - )); + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); onGroupLeftOrKicked?.call(); log('SyncPlay: Left group'); } void _handleGroupDoesNotExist() { final wasInGroup = _wasInGroupAtLastUpdate; - onStateUpdate((state) => state.copyWith( - isInGroup: false, - groupId: null, - groupName: null, - groupState: SyncPlayGroupState.idle, - participants: [], - isProcessingCommand: false, - processingCommandType: null, - )); - onGroupLeftOrKicked?.call(); + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); log('SyncPlay: Group does not exist'); + // A failed join must not stop what the user is watching; only a real member has playback to tear down. if (wasInGroup) { + onGroupLeftOrKicked?.call(); onGroupGone?.call(wasKicked: false); } - // Notify controller that group join failed onGroupJoinFailed(); } void _handleNotInGroup() { final wasInGroup = _wasInGroupAtLastUpdate; - onStateUpdate((state) => state.copyWith( - isInGroup: false, - groupId: null, - groupName: null, - groupState: SyncPlayGroupState.idle, - participants: [], - isProcessingCommand: false, - processingCommandType: null, - )); - onGroupLeftOrKicked?.call(); + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); log('SyncPlay: Not in group - server rejected operation'); if (wasInGroup) { + onGroupLeftOrKicked?.call(); onGroupGone?.call(wasKicked: true); } - // Notify controller that group join failed onGroupJoinFailed(); } - bool _wasInGroupAtLastUpdate = false; + /// Sent instead of `GroupJoined` when the user lacks access to an item in the queue. + void _handleLibraryAccessDenied() { + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); + log('SyncPlay: Join refused - library access denied'); + _showSnackbar((l) => l.syncPlayLibraryAccessDenied); + onGroupJoinFailed(); + } void _handleStateUpdate(Map data) { final stateStr = data['State'] as String?; final reasonStr = data['Reason'] as String?; - final positionTicks = data['PositionTicks'] as int? ?? 0; final newGroupState = _parseGroupState(stateStr); - final reason = SyncPlayStateReason.fromWire(reasonStr); onStateUpdate((state) => state.copyWith( groupState: newGroupState, stateReason: reasonStr, - positionTicks: positionTicks, )); - log('SyncPlay: State update: $stateStr (reason: $reasonStr, positionTicks: $positionTicks)'); - - if (newGroupState == SyncPlayGroupState.waiting) { - _handleWaitingState(reason); - } + log('SyncPlay: State update: $stateStr (reason: $reasonStr)'); - // Per docs: when state becomes Playing, ensure player is actually - // playing (recover if Unpause was missed). + // The controller decides whether a missed Unpause needs recovering (see shouldRecoverPlayback). if (newGroupState == SyncPlayGroupState.playing) { onStateUpdateToPlaying?.call(); } } - void _handleWaitingState(SyncPlayStateReason? reason) { - if (reason == SyncPlayStateReason.buffer) { - // Per spec: another client is buffering — pause locally first, then - // report ready so the server knows we're aligned. - final pauseFuture = onLocalPauseForBuffer?.call() ?? Future.value(); - pauseFuture.then((_) { - if (!isBuffering()) { - reportReady(isPlaying: true); - } - }); - return; - } - if (reason == SyncPlayStateReason.unpause) { - if (!isBuffering()) { - reportReady(isPlaying: true); - } - } - } - void _handlePlayQueue(Map data, SyncPlayState currentState) { final playlist = data['Playlist'] as List? ?? []; - final playingItemIndex = data['PlayingItemIndex'] as int? ?? 0; + final playingItemIndex = data['PlayingItemIndex'] as int? ?? -1; final startPositionTicks = data['StartPositionTicks'] as int? ?? 0; final isPlayingNow = data['IsPlaying'] as bool? ?? false; final reason = data['Reason'] as String?; + final lastUpdate = DateTime.tryParse(data['LastUpdate'] as String? ?? ''); - String? playingItemId; - String? playlistItemId; + final previousUpdate = _lastQueueUpdate; + if (lastUpdate != null && previousUpdate != null && !lastUpdate.isAfter(previousUpdate)) { + log('SyncPlay: Ignoring old PlayQueue update ($reason, $lastUpdate <= $previousUpdate)'); + return; + } + if (lastUpdate != null) { + _lastQueueUpdate = lastUpdate; + } - if (playlist.isNotEmpty && playingItemIndex < playlist.length) { - final item = playlist[playingItemIndex] as Map; - playingItemId = item['ItemId'] as String?; - playlistItemId = item['PlaylistItemId'] as String?; + final entries = []; + for (final raw in playlist) { + if (raw is! Map) { + continue; + } + final itemId = raw['ItemId'] as String?; + final playlistItemId = raw['PlaylistItemId'] as String?; + if (itemId == null || playlistItemId == null) { + continue; + } + entries.add(SyncPlayQueueEntry(itemId: itemId, playlistItemId: playlistItemId)); } + final hasPlayingEntry = playingItemIndex >= 0 && playingItemIndex < entries.length; + final playingEntry = hasPlayingEntry ? entries[playingItemIndex] : null; + final playingItemId = playingEntry?.itemId; + final playlistItemId = playingEntry?.playlistItemId; + final previousItemId = currentState.playingItemId; onStateUpdate((state) => state.copyWith( playingItemId: playingItemId, playlistItemId: playlistItemId, positionTicks: startPositionTicks, + playlist: entries, + playingItemIndex: hasPlayingEntry ? playingItemIndex : -1, + queueTiming: SyncPlayQueueTiming( + startPositionTicks: startPositionTicks, + lastUpdate: lastUpdate, + isPlaying: isPlayingNow, + ), )); - log('SyncPlay: PlayQueue update - playing: $playingItemId (reason: $reason, isPlaying: $isPlayingNow, previousItemId: $previousItemId)'); + log('SyncPlay: PlayQueue update - playing: $playingItemId ' + '(reason: $reason, isPlaying: $isPlayingNow, previousItemId: $previousItemId, ' + 'queue: ${entries.length} items)'); - // Trigger playback for NewPlaylist/SetCurrentItem/NextItem/PreviousItem regardless of - // whether the item changed (the same user who set the queue also receives the update - // and needs to start playing). + // Trigger regardless of whether the item changed: the user who set the queue also receives the update. final shouldTrigger = playingItemId != null && (reason == 'NewPlaylist' || reason == 'SetCurrentItem' || @@ -315,7 +294,7 @@ class SyncPlayMessageHandler { if (shouldTrigger) { log('SyncPlay: Triggering playback for item: $playingItemId'); - startPlayback(playingItemId, startPositionTicks); + startPlayback(playingItemId, startPositionTicks, reason); } } diff --git a/lib/providers/syncplay/syncplay_controller.dart b/lib/providers/syncplay/syncplay_controller.dart index ecf4fc29a..62d0b0ece 100644 --- a/lib/providers/syncplay/syncplay_controller.dart +++ b/lib/providers/syncplay/syncplay_controller.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:developer' as developer; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/router_provider.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; import 'package:fladder/providers/syncplay/time_sync_service.dart'; @@ -15,14 +17,20 @@ import 'package:fladder/providers/websocket/jellyfin_websocket_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/util/localization_helper.dart'; import 'package:flutter/material.dart'; import 'package:fladder/l10n/generated/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Controller for SyncPlay synchronized playback class SyncPlayController { static const bool _verboseSyncPlayLogs = false; + /// Position ticks arrive far more often than corrections should be attempted. + static const Duration _driftCheckInterval = Duration(milliseconds: 1500); + + /// Position window inside which an already-loaded item is attached without a seek. + static const int _attachSeekToleranceTicks = SyncPlayCommandHandler.positionToleranceTicks; + SyncPlayController(this._ref) { _commandHandler = SyncPlayCommandHandler( timeSync: () => _timeSync, @@ -30,38 +38,13 @@ class SyncPlayController { ); _messageHandler = SyncPlayMessageHandler( onStateUpdate: _updateStateWith, - reportReady: ({bool isPlaying = true}) => reportReady(isPlaying: isPlaying), - // Wrap _startPlayback so the loader-UX completer resolves as soon - // as the server's PlayQueue is received (i.e. our queue request - // was accepted and broadcast). The actual local load - // (loadPlaybackItem → media-kit) can then take its time without - // gating the dialog: media-kit on web sometimes leaves - // `state.loadVideo()` hanging which would otherwise let the - // 20s timeout fire and surface a misleading "unable to play - // media format" snack while playback is in fact already running. - startPlayback: (itemId, ticks) async { - final completer = _startPlaybackCompleter; - if (completer != null && !completer.isCompleted) { - log('SyncPlay: PlayQueue accepted - resolving loader ' - 'completer eagerly for item=$itemId'); - completer.complete(true); - } - await _startPlayback(itemId, ticks); - }, - isBuffering: () => _commandHandler.isBuffering?.call() ?? false, + startPlayback: _onQueueStartPlayback, getContext: () => getNavigatorKey(_ref)?.currentContext, onGroupJoined: _onGroupJoined, onGroupJoinFailed: _onGroupJoinFailed, onGroupLeftOrKicked: _onGroupLeftOrKicked, onStateUpdateToPlaying: _onStateUpdateToPlaying, onGroupGone: ({required wasKicked}) => notifyGroupGone(wasKicked: wasKicked), - onLocalPauseForBuffer: () async { - final pause = _commandHandler.onPause; - if (pause != null && _commandHandler.isPlaying?.call() == true) { - log('SyncPlay: Pausing locally because another client is buffering'); - await pause(); - } - }, ); } @@ -71,6 +54,7 @@ class SyncPlayController { StreamSubscription? _wsMessageSubscription; StreamSubscription? _wsStateSubscription; Timer? _syncCorrectionTimer; + DateTime? _lastDriftCheckAt; late final SyncPlayCommandHandler _commandHandler; late final SyncPlayMessageHandler _messageHandler; @@ -82,33 +66,32 @@ class SyncPlayController { SyncPlayState get state => _state; - // Lifecycle state for reconnection String? _lastGroupId; - // Previous WebSocket state — used to detect reconnect transitions in - // `_handleConnectionState` so we can silently rejoin the last group. - // The very first connect is naturally skipped because `_lastGroupId` - // is null until the user joins a group for the first time. + /// Set while a transparent rejoin after a socket drop is in flight; the `PlayQueue` frame that follows + /// its `GroupJoined` must not be mistaken for a user-initiated join on a halted device. + bool _silentRejoinInFlight = false; + DateTime? _silentJoinedAt; + bool _queueFrameFromSilentRejoin = false; + + /// The server replays the queue right after `GroupJoined`; a later frame is a participant's action. + static const Duration _silentRejoinQueueWindow = Duration(seconds: 5); + + // Previous socket state, used to detect reconnects for the silent rejoin. WebSocketConnectionState? _previousWsState; - // Completer for waiting on group join confirmation Completer? _joinGroupCompleter; - // Completer that resolves the next time `_startPlayback` finishes - // (success or failure). Used by the loader UX for both initiator - // and receivers. + // Resolves when the next `_startPlayback` finishes; drives the loader UX. Completer? _startPlaybackCompleter; - // PlaylistItemId currently being started (dedup against concurrent - // PlayQueue updates issued by simultaneous initiators). + // Dedup key against concurrent PlayQueue updates from simultaneous initiators. String? _currentlyStartingPlaylistItemId; Completer? _inFlightStartCompleter; - // Debounce: timestamp of the last `setNewQueue` API call so two - // initiators don't fire two requests in the same second. + // Debounces `setNewQueue` so a double tap does not send two requests. DateTime? _lastSetNewQueueAt; - // Player callbacks (delegated to command handler) set onPlay(SyncPlayPlayerCallback? callback) => _commandHandler.onPlay = callback; set onPause(SyncPlayPlayerCallback? callback) => _commandHandler.onPause = callback; @@ -138,15 +121,13 @@ class SyncPlayController { } } - /// Mark that a SyncPlay command was executed locally. - /// Used by player-side cooldown logic to avoid feedback loops. + /// Stamps the last command time; the player-side cooldown uses it to avoid feedback loops. void markCommandExecuted([DateTime? at]) { _updateStateWith((state) => state.copyWith( lastCommandTime: at ?? DateTime.now().toUtc(), )); } - /// Update buffering/reloading status used by SyncPlay integration. void setPlayerBufferingState(bool isBuffering) { if (isBuffering) { _syncCorrectionTimer?.cancel(); @@ -177,14 +158,16 @@ class SyncPlayController { )); } - /// Reset correction strategy/state when commands are cleared, on stop, - /// or around rejoin flows. + /// [clearLastCommand] is false around halt/resume so the last command keeps describing the group's + /// playhead while this device is not following. void resetCorrectionState({ String reason = 'reset', bool syncEnabled = true, + bool clearLastCommand = true, }) { _syncCorrectionTimer?.cancel(); _syncCorrectionTimer = null; + _lastDriftCheckAt = null; final setSpeed = _commandHandler.onSetSpeed; if (setSpeed != null) { @@ -194,7 +177,9 @@ class SyncPlayController { }), ); } - _commandHandler.clearLastCommand(); + if (clearLastCommand) { + _commandHandler.clearLastCommand(); + } log('SyncPlay: Reset correction state ($reason)'); _updateStateWith((state) => state.copyWith( @@ -207,15 +192,22 @@ class SyncPlayController { )); } - /// Update current playback drift against estimated SyncPlay server time. - /// - /// Drift is computed as: - /// `estimatedServerPositionTicks - currentLocalPositionTicks`. - /// Positive means local player is behind, negative means ahead. + /// Drift = estimated server position - local position (positive: local is behind). + /// Throttled to [_driftCheckInterval] unless [force] (used right after a local-only reload). void updatePlaybackDrift({ required int currentPositionTicks, DateTime? at, + bool force = false, }) { + if (!_state.isFollowingGroupPlayback) { + return; + } + // Throttle before any other work: this runs on every position tick. + final now = (at ?? DateTime.now().toUtc()); + final lastCheck = _lastDriftCheckAt; + if (!force && lastCheck != null && now.difference(lastCheck) < _driftCheckInterval) { + return; + } if (!_commandHandler.canAttemptSyncCorrection(_state)) { return; } @@ -229,15 +221,16 @@ class SyncPlayController { if (when == null) { return; } + _lastDriftCheckAt = now; - final now = (at ?? DateTime.now().toUtc()); final remoteNow = _timeSync?.localDateToRemote(now) ?? now; final elapsedMs = remoteNow.difference(when).inMilliseconds; final estimatedServerTicks = lastCommand.positionTicks + millisecondsToTicks(elapsedMs); final diffTicks = estimatedServerTicks - currentPositionTicks; final diffMillis = ticksToMilliseconds(diffTicks).toDouble(); - final correctionConfig = _state.correctionConfig; + final correctionEnabled = _ref.read(videoPlayerSettingsProvider).enableSyncPlayCorrection; + final correctionConfig = _state.correctionConfig.copyWith(enableSyncCorrection: correctionEnabled); final correctionState = _state.correctionState; final strategy = selectSyncCorrectionStrategy( config: correctionConfig, @@ -273,29 +266,17 @@ class SyncPlayController { )); } - /// Estimate where the group's playhead is right now by extrapolating from - /// the last `Unpause`/`Seek` command timestamp. Falls back to - /// `state.positionTicks` if no command context is available — that value - /// is the position from the most recent state update, which may be tens - /// of seconds stale during continuous playback. + /// Estimates the live group playhead from the last command, then the last `PlayQueue` frame timing; + /// `state.positionTicks` is only a fallback because it can be tens of seconds stale. int estimateCurrentGroupPositionTicks() { - final lastCommand = _commandHandler.lastCommand; - final timeSyncService = _timeSync; - if (lastCommand == null || timeSyncService == null) { - return _state.positionTicks; - } - final when = DateTime.tryParse(lastCommand.when); - if (when == null) { - return _state.positionTicks; - } - // Only extrapolate from Unpause-style commands; Pause leaves the playhead - // frozen at the command's positionTicks. - if (lastCommand.command != SyncPlayCommand.unpause) { - return lastCommand.positionTicks; - } - final remoteNow = timeSyncService.localDateToRemote(DateTime.now().toUtc()); - final elapsedMs = remoteNow.difference(when).inMilliseconds; - return lastCommand.positionTicks + millisecondsToTicks(elapsedMs); + final now = DateTime.now().toUtc(); + return estimateGroupPositionTicks( + lastCommand: _commandHandler.lastCommand, + currentPlaylistItemId: _state.playlistItemId, + queueTiming: _state.queueTiming, + fallbackPositionTicks: _state.positionTicks, + remoteNow: _timeSync?.localDateToRemote(now) ?? now, + ); } void _applySpeedToSync({ @@ -408,11 +389,7 @@ class SyncPlayController { JellyfinOpenApi get _api => _ref.read(jellyApiProvider).api; - /// Subscribe SyncPlay to the shared app-level WebSocket. - /// - /// The socket itself is owned by [JellyfinWebSocketController] and is - /// connected/disconnected off `userProvider`; SyncPlay only attaches - /// its message/state listeners and starts time-sync. + /// Attaches SyncPlay to the app-owned WebSocket (connected off `userProvider`) and starts time-sync. Future connect() async { final user = _ref.read(userProvider); if (user == null) { @@ -420,34 +397,31 @@ class SyncPlayController { return; } - // Activate the shared socket provider and grab its notifier. final ws = _ref.read(jellyfinWebSocketControllerProvider.notifier); - // Idempotent: if we are already subscribed, do nothing. (This path - // is hit every time the SyncPlay sheet re-opens via loadGroups().) + // Idempotent: the sheet calls this every time it re-opens. if (_wsStateSubscription != null) { log('SyncPlay: connect() called but already subscribed; reusing shared socket'); return; } - // Initialize time sync (SyncPlay-owned, not part of the socket). - _timeSync = TimeSyncService(_api); - _timeSync!.start(); + final timeSync = TimeSyncService(_api); + timeSync.onMeasurement = (_, __) { + // The server sizes the group's unpause delay from our ping; also release a clock-gated command. + unawaited(reportPing()); + _commandHandler.flushQueuedCommand(); + }; + _timeSync = timeSync; + timeSync.start(); _wsStateSubscription = ws.connectionState.listen(_handleConnectionState); _wsMessageSubscription = ws.messages.listen(_handleMessage); - // The shared socket is app-owned and is usually already connected by - // the time the user opens SyncPlay. The re-broadcast stream does not - // replay, so seed from the current state — otherwise `isConnected` - // would stay false and `joinGroup` would be blocked. + // The state stream does not replay; seed from the current state or `joinGroup` would stay blocked. _handleConnectionState(ws.currentState); } - /// Detach SyncPlay from the shared WebSocket. - /// - /// Does NOT close the socket — it is app-owned and shared. Leaving a - /// SyncPlay group no longer tears down the connection. + /// Detaches SyncPlay from the shared WebSocket without closing it (the socket is app-owned). Future disconnect() async { resetCorrectionState( reason: 'disconnect', @@ -460,12 +434,12 @@ class SyncPlayController { await _wsStateSubscription?.cancel(); _wsMessageSubscription = null; _wsStateSubscription = null; + _timeSync?.onMeasurement = null; _timeSync?.dispose(); _timeSync = null; _updateState(SyncPlayState()); } - /// List available SyncPlay groups Future> listGroups() async { try { final response = await _api.syncPlayListGet(); @@ -476,47 +450,95 @@ class SyncPlayController { } } - /// Create a new SyncPlay group + /// Creates a group and waits for `GroupJoined`. Local video playback seeds the group through + /// `SetNewQueue`, since the server cannot seed from a `NowPlayingQueue` this client never reports. Future createGroup(String groupName) async { + if (_state.isInGroup) { + log('SyncPlay: Already in a group, leaving before creating a new one'); + await leaveGroup(); + } + + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); + final completer = _joinGroupCompleter = Completer(); try { final response = await _api.syncPlayNewPost( body: NewGroupRequestDto(groupName: groupName), ); - return response.body; + final info = response.body; + if (info == null) { + _completeJoinRequest(false); + return null; + } + + final joined = await completer.future.timeout( + const Duration(seconds: 12), + onTimeout: () => _state.isInGroup && _state.groupId == info.groupId, + ); + if (identical(_joinGroupCompleter, completer)) { + _joinGroupCompleter = null; + } + if (!joined) { + log('SyncPlay: Group ${info.groupId} created but GroupJoined never arrived'); + return null; + } + await _seedQueueFromLocalPlayback(); + return info; } catch (e) { log('SyncPlay: Failed to create group: $e'); + if (identical(_joinGroupCompleter, completer)) { + _completeJoinRequest(false); + } return null; } } - /// Join an existing SyncPlay group - /// Returns true only after receiving GroupJoined confirmation from WebSocket + /// Only an item showing in the video player qualifies: seeding from the audio queue would tear the + /// audio player down and reload the track through the video path. + Future _seedQueueFromLocalPlayback() async { + final model = _ref.read(playBackModel); + if (model == null) { + return; + } + final videoRouteOpen = _ref.read(isVideoPlayerRouteOpenProvider); + if (!videoRouteOpen || model.item.type == FladderItemType.audio) { + log('SyncPlay: Not seeding new group (no video playing locally)'); + return; + } + final queueIds = model.queue.map((item) => item.id).toList(); + final itemIds = queueIds.isNotEmpty ? queueIds : [model.item.id]; + final index = itemIds.indexOf(model.item.id).clamp(0, itemIds.length - 1); + final position = _ref.read(videoPlayerProvider).lastState?.position ?? Duration.zero; + final positionTicks = secondsToTicks(position.inMilliseconds / 1000); + log('SyncPlay: Seeding new group with local playback ' + '(item=${model.item.id}, index=$index, ticks=$positionTicks)'); + await setNewQueue( + itemIds: itemIds, + playingItemPosition: index, + startPositionTicks: positionTicks, + ); + } + + /// Returns true only once the server's `GroupJoined` frame arrived. Future joinGroup(String groupId) async { - // Check if already in a group if (_state.isInGroup) { log('SyncPlay: Already in a group, leaving first...'); await leaveGroup(); } - // Check if WebSocket is connected if (!_state.isConnected) { log('SyncPlay: WebSocket not connected, cannot join group'); return false; } log('SyncPlay: Joining group: $groupId'); + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); final confirmed = await _sendJoinRequest(groupId); - // `_lastGroupId` is stamped in `_onGroupJoined` from the server - // frame (source of truth), so it is correct even if a slow socket - // makes this call reconcile/return before `GroupJoined` lands. + // `_lastGroupId` is stamped in `_onGroupJoined` from the server frame, not from this result. log(confirmed ? 'SyncPlay: Group join confirmed' : 'SyncPlay: Group join not confirmed'); return confirmed; } - /// Send a Join request and wait for the matching `GroupJoined` - /// message via the completer. Caller is responsible for any pre/post - /// state management (e.g. `leaveGroup` first, `_lastGroupId` updates). - /// Used by both [joinGroup] and [_attemptSilentRejoin]. + /// Sends Join and waits for the matching `GroupJoined`; shared by [joinGroup] and [_attemptSilentRejoin]. Future _sendJoinRequest(String groupId) async { final completer = _joinGroupCompleter = Completer(); try { @@ -526,16 +548,8 @@ class SyncPlayController { final confirmed = await completer.future.timeout( const Duration(seconds: 12), onTimeout: () { - // The POST itself succeeded (no exception). Jellyfin keys - // SyncPlay membership by session and `Join` is idempotent, so - // a missing `GroupJoined` inside the window is almost always a - // slow/stalled WebSocket — not a real rejection. Genuine - // rejections arrive promptly as NotInGroup/GroupDoesNotExist - // and complete the completer `false` long before this fires. - // `_handleGroupJoined` also flips `isInGroup` whenever the - // frame eventually lands (or after a silent rejoin), so - // reconcile against the authoritative state instead of - // reporting a false "Failed to join group". + // Join is idempotent and real rejections arrive promptly, so a missing GroupJoined is almost + // always a slow socket: reconcile against the authoritative state instead of reporting failure. final joined = _state.isInGroup && _state.groupId == groupId; log('SyncPlay: GroupJoined not received within timeout; ' 'reconciled isInGroup=$joined for $groupId'); @@ -555,10 +569,7 @@ class SyncPlayController { } } - /// Complete and clear the pending join completer exactly once. Safe to - /// call from the success path, the failure path, the leave/kick reset - /// path, and a late-arriving `GroupJoined` — without ever risking a - /// "Future already completed" or leaking a stale completer reference. + /// Completes and clears the pending join completer exactly once, whichever path reaches it first. void _completeJoinRequest(bool joined) { final completer = _joinGroupCompleter; _joinGroupCompleter = null; @@ -567,71 +578,39 @@ class SyncPlayController { } } - /// Called by message handler when GroupJoined is received. - /// - /// If the group is already playing/waiting/paused with an active item, - /// auto-attach the local player to it. This mirrors `jellyfin-web`'s - /// behavior — the group should not stall in Waiting because a fresh - /// joiner forgot to click "Resume Playback". + /// The `PlayQueue` frame that follows `GroupJoined` drives playback, so nothing is started here. void _onGroupJoined() { resetCorrectionState( reason: 'group_joined', syncEnabled: true, ); - // Stamp `_lastGroupId` from the authoritative server frame, not the - // awaited `joinGroup` bool. A slow socket can make `_sendJoinRequest` - // time out (reconciled false) and only deliver `GroupJoined` after - // `joinGroup` already returned — without this, the reconnect - // silent-rejoin invariant (`_handleConnectionState`) would be lost - // even though we are genuinely in the group. This only fires on a - // real `GroupJoined`, so it is still "only on confirmation". + // Stamp from the server frame, not the awaited `joinGroup` bool: a slow socket can deliver + // `GroupJoined` after `joinGroup` already timed out, and the silent rejoin depends on this. _lastGroupId = _state.groupId ?? _lastGroupId; + final silentRejoin = _silentRejoinInFlight; + _silentJoinedAt = silentRejoin ? DateTime.now().toUtc() : null; + _silentRejoinInFlight = false; _completeJoinRequest(true); - final showSnackbar = _state.groupName != null; + unawaited(_timeSync?.forceUpdate()); + // The server-side IgnoreWait flag died with the evicted session; a halted device must re-set it. + if (!_state.isFollowingGroupPlayback) { + unawaited(setIgnoreWait(true)); + } + // A transparent rejoin after a socket drop is not news to the user. + final showSnackbar = _state.groupName != null && !silentRejoin; if (showSnackbar) { _showGroupSnackbar( (l) => l.syncPlayJoinedGroup(_state.groupName ?? ''), ); } - - final hasActiveItem = _state.playingItemId != null && - (_state.groupState == SyncPlayGroupState.playing || - _state.groupState == SyncPlayGroupState.waiting || - _state.groupState == SyncPlayGroupState.paused); - - if (hasActiveItem) { - // If the local player is already showing this exact item, skip - // the reload. This is the silent-rejoin path (WS dropped during - // a brief doze, user is still mid-playback locally): reloading - // would tear the player back to ticks=0 and the subsequent - // reportReady(positionTicks: 0) inside loadPlaybackItem would - // be broadcast by the server as the new group position, - // resetting every other client. The server's authoritative - // position is preserved server-side; the next Pause/Unpause/Seek - // command will resync our local player without a reload. - final currentPlayback = _ref.read(playBackModel); - final alreadyLoaded = currentPlayback?.item.id == _state.playingItemId; - if (alreadyLoaded) { - log('SyncPlay: Joined group with active item ${_state.playingItemId} ' - 'already loaded locally; skipping reload (silent rejoin)'); - } else { - log('SyncPlay: Joined group with active item ${_state.playingItemId} ' - '(state=${_state.groupState.name}); auto-loading playback'); - unawaited(rejoinPlayback()); - } - } } - /// Called by message handler when NotInGroup/GroupDoesNotExist is received void _onGroupJoinFailed() { _completeJoinRequest(false); } - /// Called when we leave or are kicked; cancel pending commands, - /// clear processing state and stop any local playback that was - /// driven by the previous group. Without the local stop, the player - /// keeps the old media loaded in the background and a later - /// `Unpause` command from a *different* group would resume it. + /// Also stops local playback: otherwise the old media stays loaded in the background and a later + /// `Unpause` from a different group would resume it. void _onGroupLeftOrKicked() { _resetGroupLifecycleState(); _commandHandler.cancelPendingCommands(); @@ -646,50 +625,31 @@ class SyncPlayController { playlistItemId: null, startPlaybackInProgress: false, startingPlaylistItemId: null, + isFollowingGroupPlayback: true, )); _stopLocalPlayback(); } - /// Stop and dispose the local video player & playback model so no - /// leftover media can resume after the SyncPlay session ended. - /// - /// Deferred to a microtask: this method is reached from a synchronous - /// chain that started inside a WebSocket message handler — the - /// `_stateController.add(_state)` from `_updateStateWith` fires the - /// Riverpod-side `state = newState` listener synchronously, which in - /// turn re-enters every `syncPlayProvider` listener (including - /// `videoPlayerProvider`). Reading `videoPlayerProvider` / - /// `playBackModel.notifier` while that re-entrant chain is still on - /// the stack throws `CircularDependencyError`. Yielding to a microtask - /// breaks out of that chain without changing observable behaviour: - /// the player is still stopped, the playback model is still cleared. + /// Deferred to a microtask: this can run inside a WebSocket handler still on the stack of a + /// `syncPlayProvider` listener, and reading `videoPlayerProvider` there throws `CircularDependencyError`. void _stopLocalPlayback() { Future.microtask(() { try { - unawaited(_ref.read(videoPlayerProvider).stop()); - _ref.read(playBackModel.notifier).update((_) => null); - _ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + unawaited(_ref.read(videoPlayerProvider.notifier).stopAndClosePlayer()); } catch (e) { log('SyncPlay: Failed to stop local playback after leave: $e'); } }); } - /// Returns `true` once the user has left or been kicked while a - /// long-running playback start is in progress. Callers must check this - /// between every `await` so they don't push a player route or resume - /// media for a group we no longer belong to. - bool _shouldAbortStartPlayback() => !_state.isInGroup; - - /// Clear all in-memory bookkeeping that is only meaningful while in a - /// group. Called from `leaveGroup`, `_onGroupLeftOrKicked`, and - /// `disconnect` so that a subsequent rejoin starts from a clean slate. - /// - /// In particular: `_lastSetNewQueueAt` was previously leaking past - /// leaveGroup, which silently debounced the first `setNewQueue` after - /// rejoin within 1s. + /// Callers must check this between every `await` so they don't resume media for a group we left. + bool _shouldAbortStartPlayback() => !_state.isInGroup || !_state.isFollowingGroupPlayback; + + /// Clears in-group bookkeeping so a subsequent rejoin starts from a clean slate. void _resetGroupLifecycleState() { _lastSetNewQueueAt = null; + _silentRejoinInFlight = false; + _silentJoinedAt = null; _currentlyStartingPlaylistItemId = null; _inFlightStartCompleter = null; if (_startPlaybackCompleter != null && !_startPlaybackCompleter!.isCompleted) { @@ -699,16 +659,46 @@ class SyncPlayController { _completeJoinRequest(false); } - /// When server reports Playing, ensure player is actually playing (per docs: recover if Unpause command was missed). + /// Must never pre-empt a scheduled Unpause (the command owns the start time); releases a clock-gated + /// Unpause, otherwise recovers a paused player when nothing of our own is in flight. void _onStateUpdateToPlaying() { - if (_commandHandler.isPlaying?.call() != true) { - log('SyncPlay: State is Playing but player not playing, triggering play'); - _commandHandler.onPlay?.call(); + if (!_state.isFollowingGroupPlayback || _state.startPlaybackInProgress) { + return; + } + if (_commandHandler.hasQueuedCommand) { + log('SyncPlay: Group is Playing - applying the command queued behind the clock gate'); + _commandHandler.flushQueuedCommand(force: true); + return; + } + final now = DateTime.now().toUtc(); + final remoteNow = _timeSync?.localDateToRemote(now) ?? now; + if (_commandHandler.shouldRecoverPlayback(remoteNow)) { + log('SyncPlay: Group is Playing but player is paused with nothing pending - recovering'); + _commandHandler.recoverPlayback(); } } - /// Leave the current SyncPlay group. - /// Resets processing state and cancels pending commands so playback is not stuck (per docs). + SyncPlayState _clearGroupState(SyncPlayState state) { + return state.copyWith( + isInGroup: false, + isFollowingGroupPlayback: true, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + positionTicks: 0, + playlistItemId: null, + playingItemId: null, + playlist: [], + playingItemIndex: -1, + queueTiming: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + ); + } + Future leaveGroup() async { if (!_state.isInGroup) { return; @@ -716,54 +706,21 @@ class SyncPlayController { try { await _api.syncPlayLeavePost(); _lastGroupId = null; - _resetGroupLifecycleState(); - _commandHandler.cancelPendingCommands(); - resetCorrectionState( - reason: 'leave_group', - syncEnabled: false, - ); - _updateState(_state.copyWith( - isInGroup: false, - groupId: null, - groupName: null, - groupState: SyncPlayGroupState.idle, - participants: [], - isProcessingCommand: false, - processingCommandType: null, - positionTicks: 0, - playlistItemId: null, - playingItemId: null, - startPlaybackInProgress: false, - startingPlaylistItemId: null, - )); - _stopLocalPlayback(); log('SyncPlay: Left group, state reset'); } catch (e) { log('SyncPlay: Failed to leave group: $e'); + } finally { _resetGroupLifecycleState(); _commandHandler.cancelPendingCommands(); resetCorrectionState( - reason: 'leave_group_failed_local_reset', + reason: 'leave_group', syncEnabled: false, ); - _updateState(_state.copyWith( - isInGroup: false, - groupId: null, - groupName: null, - groupState: SyncPlayGroupState.idle, - participants: [], - isProcessingCommand: false, - processingCommandType: null, - playingItemId: null, - playlistItemId: null, - startPlaybackInProgress: false, - startingPlaylistItemId: null, - )); + _updateState(_clearGroupState(_state)); _stopLocalPlayback(); } } - /// Request pause Future requestPause() async { if (!_state.isInGroup) { return; @@ -775,7 +732,7 @@ class SyncPlayController { } } - /// Request unpause/play (server will move to Waiting until all clients report Ready, then broadcast Unpause). + /// The server moves to Waiting until all clients report Ready, then broadcasts Unpause. Future requestUnpause() async { if (!_state.isInGroup) { return; @@ -788,7 +745,6 @@ class SyncPlayController { } } - /// Request seek Future requestSeek(int positionTicks) async { if (!_state.isInGroup) { return; @@ -802,12 +758,8 @@ class SyncPlayController { } } - /// Advance to the next item in the SyncPlay queue. - /// - /// Mirrors jellyfin-web's lightweight flow: the server only swaps the - /// current playlist index and broadcasts a `PlayQueue` update with - /// reason=`NextItem`. Pass the current `playlistItemId` so the server - /// can reject the request if our view of the queue is stale. + /// Passes the current `playlistItemId` so the server rejects a stale request; that is what + /// de-duplicates simultaneous requests from every participant. Future requestNextItem() async { if (!_state.isInGroup) { log('SyncPlay: Cannot request NextItem - not in group'); @@ -827,8 +779,6 @@ class SyncPlayController { } } - /// Step back to the previous item in the SyncPlay queue. Symmetric with - /// [requestNextItem]. Future requestPreviousItem() async { if (!_state.isInGroup) { log('SyncPlay: Cannot request PreviousItem - not in group'); @@ -848,20 +798,80 @@ class SyncPlayController { } } - /// Report buffering state. - /// - /// No-op while a local-only operation is active (track switch) so - /// changing audio/subtitle locally does not pause the group. - /// Report Buffering to the server. By default the position reported - /// is the local player's current position; pass [positionTicks] to - /// override (e.g. during a rejoin/initial-load where the local player - /// is at 0 but the group is mid-playback — sending 0 there would - /// make the server use it as the group's position and reset every - /// other client to the start). - Future reportBuffering({int? positionTicks}) async { + Future requestSetPlaylistItem(String playlistItemId) async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot request SetPlaylistItem - not in group'); + return; + } + try { + await _api.syncPlaySetPlaylistItemPost( + body: SetPlaylistItemRequestDto(playlistItemId: playlistItemId), + ); + } catch (e) { + log('SyncPlay: Failed to request SetPlaylistItem: $e'); + } + } + + /// `true` while the user halted playback on this device, so group waits skip this session. + Future setIgnoreWait(bool ignoreWait) async { if (!_state.isInGroup) { return; } + try { + await _api.syncPlaySetIgnoreWaitPost( + body: IgnoreWaitRequestDto(ignoreWait: ignoreWait), + ); + } catch (e) { + log('SyncPlay: Failed to set IgnoreWait=$ignoreWait: $e'); + } + } + + /// Stops following group playback on this device while staying in the group; commands are only + /// recorded until [rejoinPlayback]. Local state changes first so `userStop` never waits on the network. + Future haltPlayback({bool stopLocalPlayer = true}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { + return; + } + log('SyncPlay: Halting group playback on this device'); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'halt_playback', + syncEnabled: false, + clearLastCommand: false, + ); + _updateStateWith((s) => s.copyWith( + isFollowingGroupPlayback: false, + isProcessingCommand: false, + processingCommandType: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + if (stopLocalPlayer) { + _stopLocalPlayback(); + } + await setIgnoreWait(true); + } + + Future _followGroupPlayback() async { + if (_state.isFollowingGroupPlayback) { + return; + } + log('SyncPlay: Following group playback again'); + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); + resetCorrectionState( + reason: 'follow_playback', + syncEnabled: true, + clearLastCommand: false, + ); + await setIgnoreWait(false); + } + + /// Pass [positionTicks] during a rejoin/initial load: reporting the local 0 would become the group + /// position and reset every other client. No-op in local-only mode or while halted. + Future reportBuffering({int? positionTicks}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { + return; + } if (_state.isInLocalOnlyMode) { log('SyncPlay: Skipping reportBuffering (local-only mode)'); return; @@ -882,12 +892,10 @@ class SyncPlayController { } } - /// Report ready state (required for server to broadcast Unpause when - /// in Waiting). Suppressed while local-only mode is active. Pass - /// [positionTicks] to override the position sent — same rationale as - /// [reportBuffering]. - Future reportReady({bool isPlaying = true, int? positionTicks}) async { - if (!_state.isInGroup) { + /// [isPlaying] must be the real player state: the server adds elapsed time only for a playing client. + /// [positionTicks] overrides the reported position (see [reportBuffering]). + Future reportReady({required bool isPlaying, int? positionTicks}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { return; } if (_state.isInLocalOnlyMode) { @@ -911,19 +919,9 @@ class SyncPlayController { } } - /// Run [body] as a "local-only" operation. While this runs the - /// controller will not emit `Buffering`/`Ready` to the server and - /// will trigger an immediate drift correction on completion so the - /// local player catches up to the group time after a track reload. - /// - /// If the group is in `Playing` state when the operation finishes, - /// the local player is explicitly resumed: media-kit on web does - /// not reliably auto-play after `loadVideo` + `setAudioTrack` / - /// `setSubtitleTrack`, and since we suppress `Buffering`/`Ready` - /// reports the server never re-issues an `Unpause` command we could - /// piggy-back on. + /// Suppresses Buffering/Ready reports while [body] runs (track switch), then forces a drift check. + /// Resumes explicitly if the group is Playing: media-kit on web does not reliably auto-play after a reload. Future runLocalOnly(Future Function() body) async { - final wasPlaying = _commandHandler.isPlaying?.call() ?? false; _updateStateWith( (state) => state.copyWith( localOnlyOperationCount: state.localOnlyOperationCount + 1, @@ -938,7 +936,7 @@ class SyncPlayController { ), ); - final shouldResume = wasPlaying || _state.groupState == SyncPlayGroupState.playing; + final shouldResume = _state.groupState == SyncPlayGroupState.playing && _state.isFollowingGroupPlayback; if (shouldResume && _state.localOnlyOperationCount == 0 && _commandHandler.isPlaying?.call() == false) { log('SyncPlay: Resuming local playback after local-only switch'); try { @@ -949,11 +947,10 @@ class SyncPlayController { } final ticks = _commandHandler.getPositionTicks?.call() ?? 0; - updatePlaybackDrift(currentPositionTicks: ticks); + updatePlaybackDrift(currentPositionTicks: ticks, force: true); } } - /// Report ping to server Future reportPing() async { if (!_state.isInGroup || _timeSync == null) { return; @@ -967,16 +964,8 @@ class SyncPlayController { } } - /// Set a new queue/playlist. - /// - /// Debounced to 1 second so two participants cannot race the same - /// `setNewQueue` request and crash the player by triggering two - /// concurrent `_startPlayback` flows. - /// Returns `true` when the request was actually sent to the server, - /// `false` when it was suppressed (not in group, or debounced). - /// Callers awaiting the next `_startPlayback` (e.g. the loader UX in - /// `_playSyncPlay`) need this to avoid waiting for a `PlayQueue` - /// broadcast that will never arrive. + /// Debounced to 1s per device. Returns `true` only when the request was sent, so callers awaiting + /// the next `_startPlayback` do not wait for a `PlayQueue` broadcast that will never arrive. Future setNewQueue({ required List itemIds, int playingItemPosition = 0, @@ -1011,11 +1000,7 @@ class SyncPlayController { } } - /// Returns a Future that completes the next time `_startPlayback` - /// finishes. Used by the loader UX (initiator path). - /// - /// Resolves to `true` on successful playback start, `false` on - /// error or timeout. + /// Completes with the outcome of the next `_startPlayback` (`false` on error or timeout). Future awaitNextStartPlayback({ Duration timeout = const Duration(seconds: 20), }) { @@ -1033,23 +1018,16 @@ class SyncPlayController { }); } - /// Re-attach to the currently playing group item from outside the - /// player route. Re-uses [_startPlayback] with the current group - /// position so the local player jumps back into the running session. + /// "Resume playback" from outside the player: follows the group again, then restarts at the live position. Future rejoinPlayback() async { final itemId = _state.playingItemId; if (!_state.isInGroup || itemId == null) { log('SyncPlay: rejoinPlayback called but no active item in group'); return false; } - // Extrapolate the live group position from the last Unpause command - // rather than reading the stale `_state.positionTicks` (which only - // updates on server-broadcast state changes, not continuous - // playback). Reporting the stale value to the server triggers a - // catch-up Unpause that schedules every other client to seek BACK - // to that stale position — visible as "everyone restarted to the - // beginning" from the perspective of clients that never paused. + // Loading at the stale `_state.positionTicks` costs a corrective Seek and a transcode at the wrong offset. final positionTicks = estimateCurrentGroupPositionTicks(); + await _followGroupPlayback(); final pending = awaitNextStartPlayback(); log('SyncPlay: Rejoining playback for item=$itemId, ' 'positionTicks=$positionTicks (estimated live)'); @@ -1063,43 +1041,28 @@ class SyncPlayController { _updateState(_state.copyWith(isConnected: isConnected)); log('SyncPlay: isConnected updated to: $isConnected'); - // Detect a reconnect: previous state was not-connected and we are - // now connected. The initial connect after `connect()` falls into - // this branch too, but the `_lastGroupId != null` guard below makes - // it a no-op until the user has actually been in a group. + // The initial connect lands here too; the `_lastGroupId` guard makes it a no-op until a group was joined. final wasConnected = _previousWsState == WebSocketConnectionState.connected; final isReconnect = isConnected && !wasConnected; _previousWsState = wsState; if (isReconnect) { - // A fresh socket connection (initial or reconnect) may have a - // stale clock offset. Refresh time-sync on every reconnect — a - // safe superset of the old resume-only refresh that used to live - // in the now-deleted _handleAppResume(). + // A fresh socket may carry a stale clock offset. if (_timeSync != null) { _timeSync!.start(); unawaited(_timeSync!.forceUpdate()); } if (_lastGroupId != null) { - // ColorOS / aggressive Android battery savers can drop the - // WebSocket during a brief window-focus loss — without a - // corresponding `AppLifecycleState.paused` — so the lifecycle - // observer can't catch it. Auto-rejoin here covers that case - // and runs even when the app stayed in the foreground. + // The server drops group membership with the socket; rejoin transparently and let it answer. log('SyncPlay: WS reconnected, attempting silent rejoin of $_lastGroupId'); unawaited(_attemptSilentRejoin()); } } } - /// Rejoin the last-known group without going through `joinGroup`'s - /// "leave first" path. Used after a transparent WebSocket reconnect - /// where the local `isInGroup` flag may still be true (we never got - /// a NotInGroup signal during the disconnect window) but the server - /// may have already evicted us. The server is the source of truth: - /// if it accepts the join, GroupJoined fires normally; if not, the - /// existing NotInGroup / GroupDoesNotExist handlers run. + /// Rejoins the last group after a socket reconnect without `joinGroup`'s leave-first path: the local + /// `isInGroup` may still be true although the server already evicted us. Future _attemptSilentRejoin() async { final groupId = _lastGroupId; if (groupId == null) { @@ -1109,11 +1072,13 @@ class SyncPlayController { log('SyncPlay: WS not connected, skipping silent rejoin'); return; } + _silentRejoinInFlight = true; final confirmed = await _sendJoinRequest(groupId); if (confirmed) { log('SyncPlay: Silent rejoin confirmed'); } else { log('SyncPlay: Silent rejoin not confirmed; clearing _lastGroupId'); + _silentRejoinInFlight = false; _lastGroupId = null; } } @@ -1128,28 +1093,87 @@ class SyncPlayController { case 'SyncPlayCommand': final cmd = (data as Map)['Command'] as String?; log('SyncPlay: Received SyncPlayCommand: $cmd'); + if (!_state.isInGroup) { + log('SyncPlay: Ignoring $cmd - not in a group'); + break; + } + if (!_state.isFollowingGroupPlayback) { + // Not applied, but recorded: a later resume needs the group's live position. + _commandHandler.recordCommand(data); + break; + } _commandHandler.handleCommand(data, _state); break; case 'SyncPlayGroupUpdate': log('SyncPlay: GroupUpdate data: $data'); - _messageHandler.handleGroupUpdate(data as Map, _state); + final update = data as Map; + // The first queue frame after a transparent rejoin is a replay; see [_onQueueStartPlayback]. + if (update['Type'] == 'PlayQueue') { + final joinedAt = _silentJoinedAt; + _silentJoinedAt = null; + _queueFrameFromSilentRejoin = + joinedAt != null && DateTime.now().toUtc().difference(joinedAt) < _silentRejoinQueueWindow; + } + _messageHandler.handleGroupUpdate(update, _state); + _queueFrameFromSilentRejoin = false; break; default: - // Log unhandled message types for debugging if (messageType?.startsWith('SyncPlay') == true) { log('SyncPlay: Unhandled SyncPlay message type: $messageType'); } } } - /// Start playback of an item from SyncPlay. - /// - /// Guards against re-entrancy: if a `_startPlayback` is already in - /// flight for the same playlist item, the duplicate call is ignored - /// (this is the crash fix when two participants press play at the - /// same time and the server broadcasts two PlayQueue updates back to - /// back). If a different item is already starting, we wait for it - /// to finish before kicking off the new one. + /// Resolves the loader completer as soon as the frame arrives: media-kit on web can leave `loadVideo()` + /// hanging although playback runs. A halted device only follows again on a brand-new playlist. + Future _onQueueStartPlayback(String itemId, int startPositionTicks, String? reason) async { + final fromSilentRejoin = _queueFrameFromSilentRejoin; + if (!_state.isFollowingGroupPlayback) { + // The replayed queue after a transparent rejoin is not a participant starting a new playlist. + if (reason != 'NewPlaylist' || fromSilentRejoin) { + log('SyncPlay: Ignoring $reason queue update while not following group playback'); + return; + } + await _followGroupPlayback(); + } + final completer = _startPlaybackCompleter; + if (completer != null && !completer.isCompleted) { + log('SyncPlay: PlayQueue accepted - resolving loader completer eagerly for item=$itemId'); + completer.complete(true); + } + await _startPlayback(itemId, startPositionTicks); + } + + /// True when the group's item is already loaded in an open player, so a seek can replace a full reload. + bool _isItemAttachable(String itemId) { + final routeOpen = _ref.read(isVideoPlayerRouteOpenProvider); + if (!routeOpen) { + return false; + } + final player = _ref.read(videoPlayerProvider); + if (!player.hasPlayer) { + return false; + } + final currentModel = _ref.read(playBackModel); + return currentModel != null && currentModel.item.id == itemId; + } + + /// The server flagged us as buffering when it sent the queue frame, so a Ready is owed either way. + Future _attachLoadedItem(int startPositionTicks) async { + final currentTicks = _commandHandler.getPositionTicks?.call() ?? 0; + await _commandHandler.onPause?.call(); + if ((currentTicks - startPositionTicks).abs() > _attachSeekToleranceTicks) { + log('SyncPlay: Attaching loaded item - seeking from $currentTicks to $startPositionTicks'); + await _commandHandler.onSeek?.call(startPositionTicks); + } else { + log('SyncPlay: Attaching loaded item in place (within tolerance)'); + } + setPlayerBufferingState(false); + await reportReady(isPlaying: false, positionTicks: startPositionTicks); + } + + /// Re-entrancy guard: a duplicate start for the same playlist item is ignored (two participants pressing + /// play produce two back-to-back PlayQueue frames); a different item waits for the in-flight start. Future _startPlayback(String itemId, int startPositionTicks) async { final dedupKey = _state.playlistItemId ?? itemId; if (_state.startPlaybackInProgress) { @@ -1175,15 +1199,20 @@ class SyncPlayController { log('SyncPlay: _startPlayback called for item: $itemId, ticks: $startPositionTicks'); var success = false; + var loadAttempted = false; try { + if (_isItemAttachable(itemId)) { + log('SyncPlay: Item $itemId already loaded - attaching without reload'); + await _attachLoadedItem(startPositionTicks); + success = true; + return; + } + final playerRouteAlreadyOpen = _ref.read(isVideoPlayerRouteOpenProvider); log('SyncPlay: Player route already open: $playerRouteAlreadyOpen'); - // Clear the old playback model BEFORE re-initializing. This prevents - // the fire-and-forget stop() inside _initPlayer() from entering a - // 1-second delayed playbackStopped flow that races against the new - // loadPlaybackItem call (which also calls stop()). With playBackModel - // null, every stop() becomes a no-op. + // Clear the playback model before re-initializing so the fire-and-forget stop() inside init() is a + // no-op instead of racing the new loadPlaybackItem through the delayed playbackStopped flow. if (!playerRouteAlreadyOpen) { _ref.read(playBackModel.notifier).update((state) => null); await _ref.read(videoPlayerProvider.notifier).init(); @@ -1193,7 +1222,6 @@ class SyncPlayController { return; } - // Fetch the item from Jellyfin log('SyncPlay: Fetching item from API...'); final api = _ref.read(jellyApiProvider); final itemResponse = await api.usersUserIdItemsItemIdGet(itemId: itemId); @@ -1209,7 +1237,6 @@ class SyncPlayController { } log('SyncPlay: Fetched item: ${itemModel.name}'); - // Create playback model (context is optional - null for SyncPlay auto-play) log('SyncPlay: Creating playback model...'); final playbackHelper = _ref.read(playbackModelHelper); final startPosition = Duration(microseconds: startPositionTicks ~/ 10); @@ -1230,7 +1257,8 @@ class SyncPlayController { } log('SyncPlay: Playback model created successfully'); - // Load and play + // From here on the player reports its own Ready on failure, so the finally block must not send one. + loadAttempted = true; log('SyncPlay: Loading playback item...'); final loadedCorrectly = await _ref.read(videoPlayerProvider.notifier).loadPlaybackItem( playbackModel, @@ -1238,8 +1266,7 @@ class SyncPlayController { ); if (_shouldAbortStartPlayback()) { log('SyncPlay: _startPlayback aborted after loadPlaybackItem (left group)'); - // The player loaded media for a group we no longer belong to — - // tear it back down so we don't display the abandoned video. + // Tear down media loaded for a group we no longer belong to. _stopLocalPlayback(); return; } @@ -1251,28 +1278,20 @@ class SyncPlayController { success = true; log('SyncPlay: Playback item loaded successfully'); - // Set state to fullScreen _ref.read(mediaPlaybackProvider.notifier).update( (state) => state.copyWith(state: VideoPlayerState.fullScreen), ); log('SyncPlay: Set state to fullScreen'); - // Only push the player route when it isn't already on screen. - // When the route is already open (e.g. User B whose player stayed - // open), loadPlaybackItem already swapped the video content in the - // existing player — pushing again would stack duplicate routes. + // When the route is already open, loadPlaybackItem swapped the content in place; pushing would stack routes. if (!playerRouteAlreadyOpen) { final navigatorKey = getNavigatorKey(_ref); final context = navigatorKey?.currentContext; log('SyncPlay: Navigator context: ${context != null ? "exists" : "null"}'); if (context != null && !_shouldAbortStartPlayback()) { - // openPlayer pushes a route via Navigator.push, whose Future - // does not complete until the route is popped (i.e. the user - // closes the player). Awaiting it would hold _startPlayback - // open for as long as the player is visible — and with it - // startPlaybackInProgress and the "Switching item…" overlay. - // Fire-and-forget so we exit the load phase immediately. + // openPlayer's Future only completes when the route is popped; awaiting it would keep + // startPlaybackInProgress (and the "Switching item…" overlay) up while the player is visible. unawaited(_ref.read(videoPlayerProvider.notifier).openPlayer(context)); log('SyncPlay: Pushed player route for $itemId'); } else { @@ -1290,11 +1309,10 @@ class SyncPlayController { startingPlaylistItemId: null, )); if (!success) { - // Failure or aborted-on-leave: clear the buffering flag so the rest - // of the group is not stranded waiting on us. + // Clear the buffering flag so the rest of the group is not stranded waiting on us. setPlayerBufferingState(false); - if (_state.isInGroup) { - unawaited(reportReady(isPlaying: false)); + if (_state.isInGroup && !loadAttempted) { + unawaited(reportReady(isPlaying: false, positionTicks: startPositionTicks)); } } _inFlightStartCompleter?.complete(); @@ -1316,29 +1334,25 @@ class SyncPlayController { _stateController.add(_state); } - /// Display a SyncPlay-related snackbar through the global overlay. - /// We never pass the navigator-key context to `FladderSnack`: that - /// context isn't under any `Overlay`. The notification manager keeps - /// a stored root context (set by `NotificationManagerInitializer`) - /// that resolves to the root overlay. + /// Never pass the navigator-key context to `FladderSnack`: it is not under an `Overlay`. The + /// notification manager resolves the root overlay from its own stored context. void _showGroupSnackbar(String Function(AppLocalizations l) message) { try { - final loc = lookupAppLocalizations(const Locale('en')); + final context = _ref.read(localizationContextProvider); + final loc = context != null ? context.localized : lookupAppLocalizations(const Locale('en')); FladderSnack.show(message(loc)); } catch (_) { // Best effort - ignore if localizations are unavailable. } } - /// Notify listeners (and overlays) that we got kicked out of a group - /// while still believing we belonged to it. + /// Shown when the server evicted us from a group we still believed we belonged to. void notifyGroupGone({bool wasKicked = false}) { _showGroupSnackbar( (l) => wasKicked ? l.syncPlayKickedFromGroup : l.syncPlayGroupNoLongerExists, ); } - /// Dispose resources Future dispose() async { _commandHandler.dispose(); await disconnect(); diff --git a/lib/providers/syncplay/syncplay_provider.dart b/lib/providers/syncplay/syncplay_provider.dart index 9dab6a6af..896b9dd82 100644 --- a/lib/providers/syncplay/syncplay_provider.dart +++ b/lib/providers/syncplay/syncplay_provider.dart @@ -11,7 +11,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'syncplay_provider.freezed.dart'; part 'syncplay_provider.g.dart'; -/// Provider for SyncPlay controller instance @Riverpod(keepAlive: true) class SyncPlay extends _$SyncPlay { SyncPlayController? _controller; @@ -31,62 +30,51 @@ class SyncPlay extends _$SyncPlay { return _controller!; } - /// Initialize and connect to SyncPlay WebSocket + /// Subscribes before connecting: `connect()` seeds `isConnected` synchronously on a broadcast + /// stream, and an event emitted before anyone listens is lost. Future connect() async { - await controller.connect(); - _stateSubscription?.cancel(); - _stateSubscription = controller.stateStream.listen((newState) { + _stateSubscription ??= controller.stateStream.listen((newState) { state = newState; }); + await controller.connect(); } - /// Disconnect from SyncPlay Future disconnect() async { await controller.disconnect(); state = SyncPlayState(); } - /// List available SyncPlay groups Future> listGroups() => controller.listGroups(); - /// Create a new SyncPlay group Future createGroup(String groupName) => controller.createGroup(groupName); - /// Join an existing group Future joinGroup(String groupId) => controller.joinGroup(groupId); - /// Leave current group Future leaveGroup() => controller.leaveGroup(); - /// Request pause Future requestPause() => controller.requestPause(); - /// Request unpause/play Future requestUnpause() async => await controller.requestUnpause(); - /// Request seek Future requestSeek(int positionTicks) => controller.requestSeek(positionTicks); - /// Advance to the next item in the SyncPlay queue. Future requestNextItem() => controller.requestNextItem(); - /// Step back to the previous item in the SyncPlay queue. Future requestPreviousItem() => controller.requestPreviousItem(); - /// Report buffering state. See [SyncPlayController.reportBuffering]. + Future requestSetPlaylistItem(String playlistItemId) => controller.requestSetPlaylistItem(playlistItemId); + + Future haltPlayback({bool stopLocalPlayer = true}) => controller.haltPlayback(stopLocalPlayer: stopLocalPlayer); + Future reportBuffering({int? positionTicks}) => controller.reportBuffering(positionTicks: positionTicks); - /// Report ready state. See [SyncPlayController.reportReady]. - Future reportReady({bool isPlaying = true, int? positionTicks}) => + Future reportReady({required bool isPlaying, int? positionTicks}) => controller.reportReady(isPlaying: isPlaying, positionTicks: positionTicks); - /// Mark local execution of a SyncPlay command for cooldown handling. void markCommandExecuted([DateTime? at]) => controller.markCommandExecuted(at); - /// Update buffering/reloading status inside SyncPlay state. void setPlayerBufferingState(bool isBuffering) => controller.setPlayerBufferingState(isBuffering); - /// Reset correction state and timers. void resetCorrectionState({ String reason = 'manual', bool syncEnabled = true, @@ -96,37 +84,28 @@ class SyncPlay extends _$SyncPlay { syncEnabled: syncEnabled, ); - /// Update playback drift using current local position ticks. void updatePlaybackDrift({ required int currentPositionTicks, DateTime? at, + bool force = false, }) => controller.updatePlaybackDrift( currentPositionTicks: currentPositionTicks, at: at, + force: force, ); - /// Estimate the group's current playhead position in ticks. See - /// [SyncPlayController.estimateCurrentGroupPositionTicks]. int estimateCurrentGroupPositionTicks() => controller.estimateCurrentGroupPositionTicks(); - /// Returns a Future that completes the next time `_startPlayback` - /// finishes (success or failure). Used by the loader UX. Future awaitNextStartPlayback({ Duration timeout = const Duration(seconds: 20), }) => controller.awaitNextStartPlayback(timeout: timeout); - /// Re-attach to the currently playing group item from outside the - /// player route ("Resume playback" button). Future rejoinPlayback() => controller.rejoinPlayback(); - /// Run [body] while suppressing `Buffering`/`Ready` reports so the - /// rest of the group is not paused (used for audio/subtitle reload). Future runLocalOnly(Future Function() body) => controller.runLocalOnly(body); - /// Set a new queue/playlist. Returns `true` when the request was - /// actually sent to the server, `false` if it was suppressed. Future setNewQueue({ required List itemIds, int playingItemPosition = 0, @@ -138,7 +117,6 @@ class SyncPlay extends _$SyncPlay { startPositionTicks: startPositionTicks, ); - /// Register player callbacks void registerPlayer({ required Future Function() onPlay, required Future Function() onPause, @@ -161,11 +139,10 @@ class SyncPlay extends _$SyncPlay { controller.isBuffering = isBuffering; controller.hasPlaybackRate = hasPlaybackRate; controller.onSeekRequested = onSeekRequested; - // Wire up reportReady callback so command handler can report ready after seek - controller.onReportReady = () => controller.reportReady(); + controller.onReportReady = ({required bool isPlaying, required int positionTicks}) => + controller.reportReady(isPlaying: isPlaying, positionTicks: positionTicks); } - /// Unregister player callbacks void unregisterPlayer() { controller.onPlay = null; controller.onPause = null; @@ -181,53 +158,43 @@ class SyncPlay extends _$SyncPlay { } } -/// Provider to check if currently in a SyncPlay session @riverpod bool isSyncPlayActive(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.isActive)); } -/// Provider for current SyncPlay group name @riverpod String? syncPlayGroupName(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.groupName)); } -/// Provider for SyncPlay group state @riverpod SyncPlayGroupState syncPlayGroupState(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.groupState)); } -/// Provider for SyncPlay correction runtime state (UI + diagnostics). @riverpod SyncCorrectionState syncCorrectionState(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.correctionState)); } -/// Provider for active correction strategy. @riverpod SyncCorrectionStrategy syncCorrectionStrategy(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.correctionState.activeStrategy)); } -/// True when a SyncPlay-driven `_startPlayback` is currently in flight -/// (initial play, episode switch, rejoin). UI can use this to display -/// a loading indicator while the local player is being prepared. +/// True while a SyncPlay-driven `_startPlayback` is in flight (initial play, episode switch, rejoin). @riverpod bool syncPlayStartPlaybackInProgress(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.startPlaybackInProgress)); } -/// True when the group has an active item the local user could -/// resume from outside the player route. +/// True when the group has an active item the local user could resume from outside the player route. @riverpod bool syncPlayHasActivePlayback(Ref ref) { return ref.watch(syncPlayProvider.select((s) => s.hasActivePlayback)); } -/// Immutable state for the SyncPlay groups list (used by group sheet). -/// Lists are stored unmodifiable so the state cannot be mutated. @Freezed(copyWith: true) abstract class SyncPlayGroupsState with _$SyncPlayGroupsState { const factory SyncPlayGroupsState({ @@ -237,7 +204,6 @@ abstract class SyncPlayGroupsState with _$SyncPlayGroupsState { }) = _SyncPlayGroupsState; } -/// Provider for the list of SyncPlay groups (load/refresh from sheet). @Riverpod(keepAlive: false) class SyncPlayGroups extends _$SyncPlayGroups { @override diff --git a/lib/providers/syncplay/syncplay_provider.g.dart b/lib/providers/syncplay/syncplay_provider.g.dart index 794c28b81..926d35dbc 100644 --- a/lib/providers/syncplay/syncplay_provider.g.dart +++ b/lib/providers/syncplay/syncplay_provider.g.dart @@ -129,7 +129,7 @@ final syncPlayHasActivePlaybackProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SyncPlayHasActivePlaybackRef = AutoDisposeProviderRef; -String _$syncPlayHash() => r'1bcc0ba8a76233295e39d3cb0ebd243fe3acc44d'; +String _$syncPlayHash() => r'ffaacb6512f6389f68d693c76ab1e656a5606928'; /// Provider for SyncPlay controller instance /// diff --git a/lib/providers/syncplay/time_sync_service.dart b/lib/providers/syncplay/time_sync_service.dart index 907aab095..73a0861a1 100644 --- a/lib/providers/syncplay/time_sync_service.dart +++ b/lib/providers/syncplay/time_sync_service.dart @@ -4,12 +4,31 @@ import 'dart:developer'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/syncplay/syncplay_models.dart'; -/// Service for synchronizing client clock with Jellyfin server using NTP-like algorithm -class TimeSyncService { +/// Minimal clock contract so handler tests can substitute a fake clock without a Chopper client. +abstract class SyncPlayClock { + /// True once at least one measurement has been taken; commands must not be scheduled before this. + bool get isReady; + + /// One-way ping estimate from the best measurement. + Duration get ping; + + DateTime remoteDateToLocal(DateTime serverTime); + + DateTime localDateToRemote(DateTime localTime); +} + +/// NTP-style clock sync against the server's `/GetUtcTime`. +class TimeSyncService implements SyncPlayClock { TimeSyncService(this._api); final JellyfinOpenApi _api; + /// Invoked after every successful measurement with the current best offset and ping. + void Function(Duration offset, Duration ping)? onMeasurement; + + @override + bool get isReady => _measurements.isNotEmpty; + final List _measurements = []; static const int _maxMeasurements = 8; @@ -17,16 +36,13 @@ class TimeSyncService { int _pingCount = 0; bool _isActive = false; - // Polling intervals static const Duration _greedyInterval = Duration(seconds: 1); static const Duration _lowProfileInterval = Duration(seconds: 60); static const int _greedyPingCount = 3; - // Staleness threshold static const Duration _staleThreshold = Duration(seconds: 30); DateTime? _lastMeasurementTime; - /// Current best offset estimate Duration get offset { if (_measurements.isEmpty) { return Duration.zero; @@ -38,7 +54,7 @@ class TimeSyncService { return best.offset; } - /// Current ping estimate (from best measurement) + @override Duration get ping { if (_measurements.isEmpty) { return Duration.zero; @@ -49,7 +65,6 @@ class TimeSyncService { return best.ping; } - /// Whether time sync is stale and needs refresh bool get isStale { if (_lastMeasurementTime == null) { return true; @@ -57,17 +72,16 @@ class TimeSyncService { return DateTime.now().difference(_lastMeasurementTime!) > _staleThreshold; } - /// Convert server time to local time + @override DateTime remoteDateToLocal(DateTime serverTime) { return serverTime.subtract(offset); } - /// Convert local time to server time + @override DateTime localDateToRemote(DateTime localTime) { return localTime.add(offset); } - /// Start time synchronization void start() { if (_isActive) { return; @@ -77,19 +91,16 @@ class TimeSyncService { _poll(); } - /// Stop time synchronization void stop() { _isActive = false; _pollingTimer?.cancel(); _pollingTimer = null; } - /// Force an immediate sync update Future forceUpdate() async { await _requestPing(); } - /// Force update and wait for completion Future forceUpdateAndWait() async { await _requestPing(); } @@ -117,7 +128,6 @@ class TimeSyncService { // T1: Record local time before request final requestSent = DateTime.now().toUtc(); - // Make request to Jellyfin TimeSync API final response = await _api.getUtcTimeGet(); // T4: Record local time after response @@ -149,6 +159,7 @@ class TimeSyncService { _lastMeasurementTime = DateTime.now(); log('Time sync: offset=${offset.inMilliseconds}ms, ping=${ping.inMilliseconds}ms'); + onMeasurement?.call(offset, ping); } catch (e) { log('Time sync failed: $e'); } @@ -156,20 +167,17 @@ class TimeSyncService { void _addMeasurement(TimeSyncMeasurement measurement) { _measurements.add(measurement); - // Keep only the last N measurements while (_measurements.length > _maxMeasurements) { _measurements.removeAt(0); } } - /// Clear all measurements void clear() { _measurements.clear(); _lastMeasurementTime = null; _pingCount = 0; } - /// Dispose resources void dispose() { stop(); clear(); diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 3e3539ca8..23400fbd5 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -9,6 +9,7 @@ import 'package:fladder/models/playback/playback_queue_state.dart'; import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/buffering_report_debouncer.dart'; import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource, SyncPlayCommandType; import 'package:fladder/wrappers/media_control_wrapper.dart'; @@ -39,28 +40,29 @@ class VideoPlayerNotifier extends StateNotifier { MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider); - /// Flag to indicate if the current action is initiated by SyncPlay bool _syncPlayAction = false; - /// True while [loadPlaybackItem] is loading new media on behalf of a - /// SyncPlay-driven flow (initial play or queue change). The buffering - /// listener must not auto-report Ready/Buffering during this window: - /// media-kit on web doesn't reliably emit `playing=true` synchronously - /// with `buffering=false`, and the listener would race [loadPlaybackItem] - /// with a stale `isPlaying: false` Ready that overrides the explicit - /// `Ready(isPlaying: true)` we send when the load is complete. + /// True while [loadPlaybackItem] loads media for a SyncPlay flow; the buffering listener must not + /// auto-report then, because the load itself owes the server exactly one Ready. bool _isLoadingForSyncPlay = false; /// Cooldown period after SyncPlay command during which we don't auto-report ready static const _syncPlayCooldown = Duration(milliseconds: 500); - /// Check if SyncPlay is active + /// Debounces spontaneous buffering before it is reported to the group; recreated on every [init]. + BufferingReportDebouncer? _bufferingDebouncer; + + /// Kept so a re-[init] does not stack native-overlay listeners. + ProviderSubscription? _syncPlayStateSubscription; + SyncPlayCommandType _lastNativeOverlayType = SyncPlayCommandType.none; + + /// Bumped by everything that pauses or stops the player so a running [_playUntilPlaying] loop gives up. + int _playGeneration = 0; + bool get _isSyncPlayActive => ref.read(isSyncPlayActiveProvider); - /// Whether player is reloading/buffering from SyncPlay perspective. bool get _isReloading => ref.read(syncPlayProvider.select((s) => s.correctionState.playerIsBuffering)); - /// Check if we're in the SyncPlay cooldown period bool get _inSyncPlayCooldown { final lastCommandTime = ref.read(syncPlayProvider.select((s) => s.lastCommandTime)); if (lastCommandTime == null) { @@ -69,7 +71,30 @@ class VideoPlayerNotifier extends StateNotifier { return DateTime.now().toUtc().difference(lastCommandTime) < _syncPlayCooldown; } + /// The wrapper's last frame, never the throttled provider copy (1 s steps, frozen while paused): + /// SyncPlay reports must sit inside the server's 500 ms tolerance. + Duration get _livePosition => state.lastState?.position ?? playbackState.position; + + /// The backend's real playing state: on media-kit the wrapper's `playing` flag is only the requested one. + bool get _livePlaying => state.isPlayerPlaying; + + /// media-kit sometimes drops the first `play()` after a paused `open()` or a reload, and in a group the + /// server's Unpause is the only thing that starts this device, so re-issue until the backend really plays. + Future _playUntilPlaying() async { + final generation = ++_playGeneration; + await state.play(); + for (var attempt = 0; attempt < 12; attempt++) { + await Future.delayed(const Duration(milliseconds: 250)); + if (generation != _playGeneration || !state.hasPlayer || _livePlaying) { + return; + } + await state.play(); + } + developer.log('SyncPlay: player still not playing after repeated play() calls'); + } + Future init() async { + _playGeneration++; await state.stop(); await state.dispose(); await state.init(); @@ -78,56 +103,81 @@ class VideoPlayerNotifier extends StateNotifier { s.cancel(); } + _bufferingDebouncer?.dispose(); + _bufferingDebouncer = BufferingReportDebouncer( + onBuffering: () => ref.read(syncPlayProvider.notifier).reportBuffering(), + onReady: () => ref.read(syncPlayProvider.notifier).reportReady(isPlaying: _livePlaying), + ); + final subscription = state.stateStream.listen((value) { - // Infer SyncPlay user actions from native player state stream (reviewer request). - if (value.changeSource == PlaybackChangeSource.user) { - final prev = playbackState; - if (value.playing != prev.playing) { - if (value.playing) { - userPlay(); - } else { - userPause(); + // The native tag says which action the frame carries, so a seek that drops ExoPlayer into buffering + // (playing -> false on the same frame) is never mistaken for a pause. + switch (value.changeSource) { + case PlaybackChangeSource.userPlayPause: + if (value.playing != playbackState.playing) { + if (value.playing) { + userPlay(); + } else { + userPause(); + } } - } else if ((value.position - prev.position).inSeconds.abs() > 2) { + break; + case PlaybackChangeSource.userSeek: userSeek(value.position); - } + break; + case PlaybackChangeSource.syncplay: + case PlaybackChangeSource.none: + case null: + break; } + // Playing before buffering: the Ready sent when a stall ends must carry the current playing flag. + updatePlaying(value.playing); updateBuffering(value.buffering); updateBuffer(value.buffer); - updatePlaying(value.playing); updatePosition(value.position); updateDuration(value.duration); }); subscriptions.add(subscription); - // Register player callbacks with SyncPlay _registerSyncPlayCallbacks(); - // Listen to SyncPlay state changes for native player overlay _setupSyncPlayStateListener(); } - /// Set up listener to forward SyncPlay command state to native player void _setupSyncPlayStateListener() { - ref.listen( + _syncPlayStateSubscription?.close(); + _lastNativeOverlayType = SyncPlayCommandType.none; + _syncPlayStateSubscription = ref.listen( syncPlayProvider, - (previous, next) { - // Only forward to native player if it's active - if (state.isNativePlayerActive) { - // Check if the relevant state changed - if (previous?.isProcessingCommand != next.isProcessingCommand || - previous?.processingCommandType != next.processingCommandType) { - state.updateSyncPlayCommandState( - next.isProcessingCommand, - _toSyncPlayCommandType(next.processingCommandType), - ); - } - } - }, + (previous, next) => _forwardNativeOverlay(next), ); } + /// Pushed right before the native activity opens so it does not wait for the next state change. + void refreshNativeOverlay() { + _lastNativeOverlayType = SyncPlayCommandType.none; + _forwardNativeOverlay(ref.read(syncPlayProvider), force: true); + } + + /// Same resolution as the Flutter overlay, so the native activity shows the group's Waiting state too. + void _forwardNativeOverlay(SyncPlayState syncState, {bool force = false}) { + if (!state.isNativePlayerActive) { + _lastNativeOverlayType = SyncPlayCommandType.none; + return; + } + final type = switch (resolveSyncPlayOverlay(syncState)) { + SyncPlayOverlay.command => _toSyncPlayCommandType(syncState.processingCommandType), + SyncPlayOverlay.waiting => SyncPlayCommandType.waiting, + SyncPlayOverlay.switching || SyncPlayOverlay.none => SyncPlayCommandType.none, + }; + if (!force && type == _lastNativeOverlayType) { + return; + } + _lastNativeOverlayType = type; + state.updateSyncPlayCommandState(type != SyncPlayCommandType.none, type); + } + SyncPlayCommandType _toSyncPlayCommandType(SyncPlayCommand? commandType) { return switch (commandType) { SyncPlayCommand.pause => SyncPlayCommandType.pause, @@ -138,7 +188,6 @@ class VideoPlayerNotifier extends StateNotifier { }; } - /// Manually set the reloading state (e.g. before fetching new PlaybackInfo) void setReloading( bool value, { bool reportToSyncPlay = true, @@ -149,17 +198,20 @@ class VideoPlayerNotifier extends StateNotifier { } } - /// Register player callbacks with SyncPlay controller void _registerSyncPlayCallbacks() { ref.read(syncPlayProvider.notifier).registerPlayer( onPlay: () async { _syncPlayAction = true; ref.read(syncPlayProvider.notifier).markCommandExecuted(); - await state.play(); - _syncPlayAction = false; + try { + await _playUntilPlaying(); + } finally { + _syncPlayAction = false; + } }, onPause: () async { _syncPlayAction = true; + _playGeneration++; ref.read(syncPlayProvider.notifier).markCommandExecuted(); await state.pause(); _syncPlayAction = false; @@ -172,15 +224,14 @@ class VideoPlayerNotifier extends StateNotifier { _syncPlayAction = false; }, onSeekRequested: (positionTicks) async { - // Another user requested a seek. Report buffering to SyncPlay - // without forcing local buffering state, otherwise the command - // handler can get stuck waiting and suppress Ready/Unpause. + // Report buffering without forcing local buffering state, or the command handler can get stuck waiting. ref.read(syncPlayProvider.notifier).reportBuffering(); }, onStop: () async { + // Group went idle: end playback and leave the player screen. _syncPlayAction = true; ref.read(syncPlayProvider.notifier).markCommandExecuted(); - await state.stop(); + await stopAndClosePlayer(); ref.read(syncPlayProvider.notifier).resetCorrectionState( reason: 'stop_command', ); @@ -189,28 +240,16 @@ class VideoPlayerNotifier extends StateNotifier { onSetSpeed: (speed) async { await state.setSpeed(speed); }, - getPositionTicks: () { - final position = playbackState.position; - return secondsToTicks(position.inMilliseconds / 1000); - }, - isPlaying: () => playbackState.playing, + getPositionTicks: () => secondsToTicks(_livePosition.inMilliseconds / 1000), + isPlaying: () => _livePlaying, isBuffering: () => _isReloading || playbackState.buffering, - // Native player (ExoPlayer) supports setPlaybackSpeed; surfacing it - // here lets SyncPlay drift correction pick SpeedToSync (rate nudge, - // no buffering) instead of falling back to SkipToSync, which on - // ExoPlayer triggers STATE_BUFFERING and amplifies into a - // post-Unpause buffer-cycle on Android-TV. + // ExoPlayer supports setPlaybackSpeed; SpeedToSync avoids SkipToSync, which triggers STATE_BUFFERING + // and amplifies into a post-Unpause buffer cycle on Android-TV. hasPlaybackRate: () => true, ); } - /// True while a SyncPlay command is being scheduled/executed. The - /// command handler owns the Buffering/Ready exchange in that window - /// and we must not race it with our own reports — for a Seek command - /// in particular, sending Ready(isPlaying: false) here (because the - /// command paused the local player) overrides the command handler's - /// Ready(isPlaying: true) and the server then keeps the group paused - /// instead of broadcasting Unpause. + /// The command handler owns the Buffering/Ready exchange while a command is in flight. bool get _isSyncPlayCommandInFlight => ref.read(syncPlayProvider.select((s) => s.isProcessingCommand)); Future updateBuffering(bool event) async { @@ -220,28 +259,21 @@ class VideoPlayerNotifier extends StateNotifier { } mediaState.update((state) => state.copyWith(buffering: event)); - if (_isSyncPlayActive) { - ref.read(syncPlayProvider.notifier).setPlayerBufferingState(event); - } - // Report buffering state to SyncPlay if active - // Skip if we're in the cooldown period after a SyncPlay command to prevent feedback loops - // Also skip if we are currently reloading (we'll report manually when done) - // Also skip while a command is being processed — the command - // handler owns the Ready signal then. - if (_isSyncPlayActive && - !_syncPlayAction && - !_inSyncPlayCooldown && - !_isReloading && - !_isSyncPlayCommandInFlight && - !_isLoadingForSyncPlay) { - if (event) { - // Started buffering - ref.read(syncPlayProvider.notifier).reportBuffering(); - } else { - // Finished buffering - ready - ref.read(syncPlayProvider.notifier).reportReady(isPlaying: playbackState.playing); + // A stall is only fed to the debouncer when nothing else owns the Buffering/Ready exchange; the end + // of a stall is always fed. Read the reload flag before `setPlayerBufferingState` mutates the state. + if (!_isSyncPlayActive) { + _bufferingDebouncer?.reset(); + return; + } + final ownedElsewhere = _syncPlayAction || _inSyncPlayCooldown || _isReloading || _isSyncPlayCommandInFlight; + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(event); + if (event) { + if (!ownedElsewhere && !_isLoadingForSyncPlay) { + _bufferingDebouncer?.update(true); } + } else { + _bufferingDebouncer?.update(false); } } @@ -284,6 +316,15 @@ class VideoPlayerNotifier extends StateNotifier { } final currentState = playbackState; if (currentState.state == VideoPlayerState.disposed) return; + + // Every tick feeds drift estimation; the controller throttles the actual corrections. + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).updatePlaybackDrift( + currentPositionTicks: secondsToTicks(event.inMilliseconds / 1000), + at: DateTime.now().toUtc(), + ); + } + final currentPosition = currentState.position; if ((currentPosition - event).inSeconds.abs() < 1) { @@ -306,16 +347,6 @@ class VideoPlayerNotifier extends StateNotifier { position: event, )); } - - // Feed time updates into SyncPlay drift estimation. - if (_isSyncPlayActive) { - ref.read(syncPlayProvider.notifier).updatePlaybackDrift( - currentPositionTicks: secondsToTicks( - event.inMilliseconds / 1000, - ), - at: DateTime.now().toUtc(), - ); - } } Future loadPlaybackItem( @@ -323,30 +354,23 @@ class VideoPlayerNotifier extends StateNotifier { Duration startPosition, { bool waitForSyncPlayCommand = true, }) async { + // A play loop started for the previous item must not start the new one ahead of the group's Unpause. + _playGeneration++; final oldPlaybackModel = ref.read(playBackModel); if (_isSyncPlayActive) { - // Null the old playback model BEFORE state.stop() so its - // 1-second-delayed POST /Sessions/Playing/Stopped is suppressed - // (state.stop() exits early when playBackModel is null). That - // POST is a session-lifecycle event Jellyfin broadcasts to the - // SyncPlay group, which causes other clients (and ourselves via - // the "pause locally on Buffer" handler) to pause. media-kit's - // open() in loadVideo replaces the current media in place — no - // explicit stop is needed for an in-route reload (track switch, - // queue change while route is already open). + // Null the model before state.stop() so its delayed Playing/Stopped report is suppressed; media-kit's + // open() replaces the media in place, so an in-route reload needs no explicit stop. ref.read(playBackModel.notifier).update((_) => null); } oldPlaybackModel?.dispose(); + _bufferingDebouncer?.reset(); ref.read(syncPlayProvider.notifier).setPlayerBufferingState(true); final reportingForSyncPlay = _isSyncPlayActive && waitForSyncPlayCommand; - // Position we're loading at — the local player's position is 0 - // here (the player just got reset), so we must pass this - // explicitly to the SyncPlay reports. Otherwise the server reads - // 0 from the buffering/ready payloads and broadcasts it as the - // group's position, resetting every other client to the start. + // The local player is at 0 here, so pass the load position to the SyncPlay reports explicitly or the + // server finds us outside its 500 ms tolerance and answers with a corrective Seek. final loadPositionTicks = startPosition.inMicroseconds * 10; if (reportingForSyncPlay) { _isLoadingForSyncPlay = true; @@ -375,16 +399,15 @@ class VideoPlayerNotifier extends StateNotifier { ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); mediaState.update((state) => state.copyWith(errorPlaying: true)); if (reportingForSyncPlay) { - unawaited(ref.read(syncPlayProvider.notifier).reportReady(isPlaying: false)); + unawaited(ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: false, + positionTicks: loadPositionTicks, + )); } return false; } - // Don't auto-play during a SyncPlay-driven load. The server's - // Unpause command (broadcast after all clients report Ready) is - // what drives playback for the group; auto-playing here races - // the protocol and produces a stale isPlaying:false Ready (see - // _isLoadingForSyncPlay docstring above). + // No auto-play during a SyncPlay load: the server's Unpause drives playback for the group. await state.loadVideo(model, effectiveStartPosition, !reportingForSyncPlay); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); @@ -402,15 +425,10 @@ class VideoPlayerNotifier extends StateNotifier { if (!reportingForSyncPlay) { await state.play(); } else { - // Tell the server we're loaded and intend to play. The - // buffering listener stayed silent thanks to - // _isLoadingForSyncPlay, so this is the only Ready that - // reaches the server for this load — server broadcasts - // Unpause and onPlay drives the actual playback. We send - // the load position explicitly so the server knows where - // we'll be when playback resumes. + // Report the requested position with isPlaying=false: the server only corrects a paused client whose + // position is off by more than 500 ms, which keeps keyframe-bound media inside the window. await ref.read(syncPlayProvider.notifier).reportReady( - isPlaying: true, + isPlaying: false, positionTicks: loadPositionTicks, ); } @@ -418,10 +436,12 @@ class VideoPlayerNotifier extends StateNotifier { } catch (e, stackTrace) { ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); mediaState.update((state) => state.copyWith(errorPlaying: true, buffering: false)); - // Tell the group we recovered (with isPlaying:false) so the server - // doesn't keep everyone else paused waiting on us. + // Tell the group we recovered so the server doesn't keep everyone paused waiting on us. if (reportingForSyncPlay) { - unawaited(ref.read(syncPlayProvider.notifier).reportReady(isPlaying: false)); + unawaited(ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: false, + positionTicks: loadPositionTicks, + )); } developer.log('loadPlaybackItem failed: $e\n$stackTrace'); return false; @@ -447,6 +467,7 @@ class VideoPlayerNotifier extends StateNotifier { repeatMode: playbackSettings.repeatMode, ); final queuedModel = model.updatePlaybackQueue(initializedQueueState); + _playGeneration++; final effectiveStartPosition = await queuedModel.resolvedStartPosition(startPosition); ref.read(playBackModel.notifier).update((state) => queuedModel); @@ -507,6 +528,19 @@ class VideoPlayerNotifier extends StateNotifier { Future openPlayer(BuildContext context) async => state.openPlayer(context); + /// Used by SyncPlay when the group ends, stops or removes us, so no dead player screen is left behind. + Future stopAndClosePlayer({bool closeRoute = true}) async { + _playGeneration++; + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + _bufferingDebouncer?.reset(); + // stop() reads the model synchronously before its first await; its delayed report must not block us. + unawaited(state.stop()); + ref.read(playBackModel.notifier).update((_) => null); + if (closeRoute) { + state.closePlayerRoute(); + } + } + Future takeScreenshot() async { final syncPath = ref.read(clientSettingsProvider).syncPath; // Early return here if we don't have a set/valid path. Skips actually taking the screenshot @@ -562,44 +596,43 @@ class VideoPlayerNotifier extends StateNotifier { return false; } - // ============================================ // User-initiated actions (go through SyncPlay if active) - // ============================================ - /// User-initiated play - routes through SyncPlay if active Future userPlay() async { if (_isSyncPlayActive) { - // Just request unpause. The server will put the group in Waiting state, - // and our buffering listener will report Ready(isPlaying: false) when appropriate. + // Request only: the server answers with a scheduled Unpause for the whole group. await ref.read(syncPlayProvider.notifier).requestUnpause(); } else { await state.play(); } } - /// User-initiated pause - routes through SyncPlay if active + /// Pauses locally at once for responsiveness; the Pause command that follows aligns everyone. Future userPause() async { if (_isSyncPlayActive) { + _playGeneration++; + _syncPlayAction = true; + try { + await state.pause(); + } finally { + _syncPlayAction = false; + } await ref.read(syncPlayProvider.notifier).requestPause(); } else { await state.pause(); } } - /// User-initiated seek - routes through SyncPlay if active + /// In a group the seek is applied locally so the slider does not snap back, but the player stays paused: + /// the server's Seek and the following Unpause resume playback (resuming here gave play-pause-play). Future userSeek(Duration position) async { final wasPlaying = playbackState.playing; if (_isSyncPlayActive) { - // Apply the seek locally immediately so the UI/slider does not snap - // back to the previous position while we wait for the server to - // broadcast the Seek command. _syncPlayAction prevents the player - // state stream from re-triggering userSeek for our own action. + _playGeneration++; _syncPlayAction = true; try { + await state.pause(); await state.seek(position); - if (wasPlaying && !playbackState.playing) { - await state.play(); - } } finally { _syncPlayAction = false; } @@ -613,7 +646,17 @@ class VideoPlayerNotifier extends StateNotifier { } } - /// User-initiated play/pause toggle - routes through SyncPlay if active + /// In a group this halts group playback on this device (`SetIgnoreWait`); the caller pops the route. + /// Local flags flip and the player stops before the request, so a slow server never keeps audio playing. + Future userStop() async { + Future? halt; + if (_isSyncPlayActive) { + halt = ref.read(syncPlayProvider.notifier).haltPlayback(stopLocalPlayer: false); + } + await stopAndClosePlayer(closeRoute: false); + await halt; + } + Future userPlayOrPause() async { if (playbackState.playing) { await userPause(); diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 17e3eedcf..3b8d4077e 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -58,6 +58,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Stream get stateStream => _stateController.stream; PlayerState? get lastState => _player?.lastState; + /// The backend's real playing state, see [BasePlayer.isPlaying]. + bool get isPlayerPlaying => _player?.isPlaying ?? false; + Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) => _player?.subtitles(showOverlay, controlsKey: controlsKey); @@ -124,6 +127,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } finally { _player?.dispose(); } + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + } } Future setup(BasePlayer newPlayer) async { @@ -192,10 +198,8 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro _previousPlayer = null; } - /// Check if the native Android player is currently active bool get isNativePlayerActive => _player is NativePlayer; - /// Update SyncPlay command state for the native player overlay Future updateSyncPlayCommandState( bool processing, SyncPlayCommandType commandType, @@ -205,7 +209,24 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } } - Future openPlayer(BuildContext context) async => _player?.open(context); + /// The native activity has no Flutter route, so its route-open flag is maintained here. + Future openPlayer(BuildContext context) async { + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = true; + ref.read(videoPlayerProvider.notifier).refreshNativeOverlay(); + } + return _player?.open(context); + } + + /// Removes the player route wherever it sits in the stack; no-op for the native player (closed by [stop]). + void closePlayerRoute() { + final player = _player; + final route = player?.playerRoute; + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + player?.playerRoute = null; + } Future _updatePositionWithRetry(PlaybackModel model, Duration position, bool isPlaying) async { try { @@ -509,6 +530,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro unawaited(_applyWakelock(false)); _player?.stop(); + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + } ref.read(windowTitleProvider.notifier).setPlayTitle(null); final position = _player?.lastState.position; @@ -637,8 +661,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo); } + /// The native activity was closed by the user: halt group playback on this device before teardown. @override - void onStop() => stop(); + void onStop() => ref.read(videoPlayerProvider.notifier).userStop(); @override void swapAudioTrack(int value) async { @@ -701,22 +726,6 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro .toList(); } - // SyncPlay-aware user actions from native player - @override - void onUserPlay() { - ref.read(videoPlayerProvider.notifier).userPlay(); - } - - @override - void onUserPause() { - ref.read(videoPlayerProvider.notifier).userPause(); - } - - @override - void onUserSeek(int positionMs) { - ref.read(videoPlayerProvider.notifier).userSeek(Duration(milliseconds: positionMs)); - } - Future takeScreenshot() { final player = _player; diff --git a/lib/wrappers/players/base_player.dart b/lib/wrappers/players/base_player.dart index 4adf59a10..2372efbfa 100644 --- a/lib/wrappers/players/base_player.dart +++ b/lib/wrappers/players/base_player.dart @@ -11,10 +11,20 @@ import 'package:fladder/wrappers/players/player_states.dart'; const libassFallbackFont = "assets/mp-font.ttf"; +/// Lets the full-screen player route be found and removed from the navigator (SyncPlay leave/kick/stop). +const String videoPlayerRouteName = 'fladder/video_player'; + abstract class BasePlayer { Stream get stateStream; PlayerState lastState = PlayerState(); + /// Whether the backend is really playing: on mpv [lastState.playing] only means "not paused by the user", + /// so anything that must know whether a `play()` took reads this instead. media-kit overrides it. + bool get isPlaying => lastState.playing; + + /// Route pushed by [open] for Flutter-rendered players; null for the native activity and once popped. + Route? playerRoute; + Future init(VideoPlayerSettingsModel settings); Widget? videoWidget(Key key, BoxFit fit); Widget? subtitles(bool showOverlay, {GlobalKey? controlsKey}); diff --git a/lib/wrappers/players/lib_mdk.dart b/lib/wrappers/players/lib_mdk.dart index bed8097e7..8d621eea4 100644 --- a/lib/wrappers/players/lib_mdk.dart +++ b/lib/wrappers/players/lib_mdk.dart @@ -127,11 +127,17 @@ class LibMDK extends BasePlayer { } @override - Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const video_screen.VideoPlayer(), - ), - ); + Future open(BuildContext context) async { + final route = MaterialPageRoute( + settings: const RouteSettings(name: videoPlayerRouteName), + builder: (context) => const video_screen.VideoPlayer(), + ); + playerRoute = route; + await Navigator.of(context, rootNavigator: true).push(route); + if (identical(playerRoute, route)) { + playerRoute = null; + } + } @override Future pause() async { diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index be48a9c4e..ed6dcf927 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -16,6 +16,7 @@ import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/settings/subtitle_settings_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; @@ -376,11 +377,17 @@ class LibMPV extends BasePlayer { } @override - Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const video_screen.VideoPlayer(), - ), - ); + Future open(BuildContext context) async { + final route = MaterialPageRoute( + settings: const RouteSettings(name: videoPlayerRouteName), + builder: (context) => const video_screen.VideoPlayer(), + ); + playerRoute = route; + await Navigator.of(context, rootNavigator: true).push(route); + if (identical(playerRoute, route)) { + playerRoute = null; + } + } List get subTracks => _player?.state.tracks.subtitle ?? []; mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no(); @@ -458,6 +465,10 @@ class LibMPV extends BasePlayer { @override Future seek(Duration position) async => _player?.seek(position); + /// media-kit's real state; [lastState] only mirrors [_musicPaused]. + @override + bool get isPlaying => _player?.state.playing ?? lastState.playing; + // mpv parses tracks asynchronously after open(); at playback start the list is still empty, so a // positional lookup would miss and leave mpv on its own default pick. Wait (capped) for [index]. Future _awaitTrack(int index, int Function(mpv.Tracks) count) async { @@ -474,6 +485,9 @@ class LibMPV extends BasePlayer { if (wantedAudioStream == null) return -1; if (wantedAudioStream.index == AudioStreamModel.no().index) { await _player?.setAudioTrack(mpv.AudioTrack.no()); + } else if (playbackModel is TranscodePlaybackModel) { + // The server picks the audio track for a transcode; the reload applies it. + return wantedAudioStream.index; } else { final index = (playbackModel.audioStreams?.indexOf(wantedAudioStream) ?? -1) - 1; await _awaitTrack(index, (tracks) => tracks.audio.length); @@ -498,6 +512,11 @@ class LibMPV extends BasePlayer { return -1; } _currentSubtitleCodec = wantedSubtitle.codec; + // A non-external subtitle on a transcode is burned in by the server: there is no selectable track, and + // waiting for one only stalls the caller for the whole `_awaitTrack` timeout before the reload. + if (playbackModel is TranscodePlaybackModel && !wantedSubtitle.isExternal) { + return wantedSubtitle.index; + } final index = playbackModel.subStreams?.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id) ?? -1; if (!wantedSubtitle.isExternal) await _awaitTrack(index, (tracks) => tracks.subtitle.length); final internalTrack = subTracks.getRange(2, subTracks.length).toList(); diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index 3e1c56570..d1a7039f3 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -44,17 +44,26 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { return activity.launchActivity(); } + // Synthetic frames come from Flutter, never a remote gesture, so they must not carry a stale user tag. @override Future pause() async { await player.pause(); - lastState = lastState.update(playing: false); + lastState = lastState.update( + playing: false, + changeSource: PlaybackChangeSource.none, + updateChangeSource: true, + ); _stateController.add(lastState); } @override Future play() async { await player.play(); - lastState = lastState.update(playing: true); + lastState = lastState.update( + playing: true, + changeSource: PlaybackChangeSource.none, + updateChangeSource: true, + ); _stateController.add(lastState); } diff --git a/test/providers/syncplay/buffering_report_debouncer_test.dart b/test/providers/syncplay/buffering_report_debouncer_test.dart new file mode 100644 index 000000000..d8944f934 --- /dev/null +++ b/test/providers/syncplay/buffering_report_debouncer_test.dart @@ -0,0 +1,91 @@ +import 'package:fladder/providers/syncplay/buffering_report_debouncer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Real timers with a short threshold: fake_async is not a direct dependency of the app. +const _threshold = Duration(milliseconds: 200); + +Future _wait(int milliseconds) => Future.delayed(Duration(milliseconds: milliseconds)); + +BufferingReportDebouncer _debouncer(List calls, {Duration threshold = _threshold}) { + return BufferingReportDebouncer( + threshold: threshold, + onBuffering: () => calls.add('buffering'), + onReady: () => calls.add('ready'), + ); +} + +void main() { + group('BufferingReportDebouncer', () { + test('a stall shorter than the threshold is never reported', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + await _wait(20); + debouncer.update(false); + await _wait(320); + + expect(calls, isEmpty); + expect(debouncer.isPending, isFalse); + debouncer.dispose(); + }); + + test('a stall held past the threshold is reported once and closed once', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + debouncer.update(true); // duplicate frames must not restart the timer + await _wait(320); + expect(calls, ['buffering']); + expect(debouncer.isReported, isTrue); + + debouncer.update(false); + debouncer.update(false); + expect(calls, ['buffering', 'ready']); + expect(debouncer.isReported, isFalse); + debouncer.dispose(); + }); + + test('duplicate true frames do not extend the threshold', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + await _wait(120); + debouncer.update(true); + await _wait(120); + + expect(calls, ['buffering'], reason: 'the first frame started the timer; the second must not restart it'); + debouncer.dispose(); + }); + + test('reset drops a pending stall and a reported one silently', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + debouncer.reset(); + await _wait(320); + expect(calls, isEmpty); + + debouncer.update(true); + await _wait(320); + debouncer.reset(); + debouncer.update(false); + expect(calls, ['buffering']); + debouncer.dispose(); + }); + + test('a ready frame with nothing reported is silent', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(false); + debouncer.update(false); + + expect(calls, isEmpty); + debouncer.dispose(); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_command_handler_test.dart b/test/providers/syncplay/syncplay_command_handler_test.dart new file mode 100644 index 000000000..79efc840b --- /dev/null +++ b/test/providers/syncplay/syncplay_command_handler_test.dart @@ -0,0 +1,543 @@ +import 'dart:async'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Zero-offset clock whose readiness the test controls. +class _FakeClock implements SyncPlayClock { + _FakeClock({required this.ready}); + + bool ready; + + @override + bool get isReady => ready; + + @override + Duration get ping => Duration.zero; + + @override + DateTime remoteDateToLocal(DateTime serverTime) => serverTime; + + @override + DateTime localDateToRemote(DateTime localTime) => localTime; +} + +Map _command( + String command, { + required DateTime when, + int positionTicks = 0, + String playlistItemId = 'plist-1', +}) { + return { + 'Command': command, + 'When': when.toIso8601String(), + 'PositionTicks': positionTicks, + 'PlaylistItemId': playlistItemId, + }; +} + +Future _settle() => Future.delayed(const Duration(milliseconds: 10)); + +void main() { + group('recording while not following', () { + test('recordCommand keeps the position bookkeeping without touching the player', () async { + var pauseCalls = 0; + int? writtenTicks; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) => writtenTicks = updater(SyncPlayState()).positionTicks, + )..onPause = () async { + pauseCalls++; + }; + final when = DateTime.now().toUtc(); + + handler.recordCommand(_command('Pause', when: when, positionTicks: 42000)); + await _settle(); + + expect(pauseCalls, 0); + expect(writtenTicks, 42000); + expect(handler.lastCommand?.command, SyncPlayCommand.pause); + expect(handler.lastCommand?.positionTicks, 42000); + expect(handler.lastCommand?.when, when.toIso8601String()); + expect(handler.hasPendingCommand, isFalse); + }); + }); + + group('execution ownership', () { + test('hasPendingCommand stays true while the player callback runs', () async { + final playStarted = Completer(); + final release = Completer(); + final handler = SyncPlayCommandHandler( + timeSync: () => _FakeClock(ready: true), + onStateUpdate: (_) {}, + ) + ..onPlay = () async { + playStarted.complete(); + await release.future; + } + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(milliseconds: 100))), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + await playStarted.future; + + expect(handler.hasPendingCommand, isTrue, reason: 'a running command owns the player'); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + + release.complete(); + await _settle(); + expect(handler.hasPendingCommand, isFalse); + }); + }); + + group('playlist item guard', () { + test('command for another playlist item is ignored', () async { + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + } + ..getPositionTicks = (() => 0); + + handler.handleCommand( + _command('Pause', when: DateTime.now().toUtc(), playlistItemId: 'plist-1'), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-2'), + ); + await _settle(); + + expect(pauseCalls, 0); + }); + + test('Stop is applied even when the playlist item differs', () async { + var stopCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onStop = () async { + stopCalls++; + }; + + handler.handleCommand( + _command('Stop', when: DateTime.now().toUtc(), playlistItemId: ''), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-2'), + ); + await _settle(); + + expect(stopCalls, 1); + }); + + test('command does not overwrite the current playlist item in state', () async { + String? writtenPlaylistItemId = 'unchanged'; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) { + writtenPlaylistItemId = updater(SyncPlayState(playlistItemId: 'unchanged')).playlistItemId; + }, + ) + ..onPause = () async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(writtenPlaylistItemId, 'unchanged'); + }); + }); + + group('duplicate command correction', () { + test('duplicate Pause re-applies when the player is playing again', () async { + var pauseCalls = 0; + var playing = true; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + playing = false; + } + ..onSeek = (_) async {} + ..isPlaying = (() => playing) + ..getPositionTicks = (() => 0); + final cmd = _command('Pause', when: DateTime.now().toUtc().subtract(const Duration(seconds: 1))); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + playing = true; // user resumed locally + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(pauseCalls, 2); + }); + + test('duplicate Pause is ignored when already paused at the right position', () async { + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + } + ..onSeek = (_) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => ticksPerSecond * 10); + final cmd = _command( + 'Pause', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: ticksPerSecond * 10, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(pauseCalls, 1); + }); + + test('duplicate Seek re-seeks with jitter when off position', () async { + final seeks = []; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (ticks) async { + seeks.add(ticks); + } + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => 0); + final target = ticksPerSecond * 30; + final cmd = _command( + 'Seek', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: target, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(seeks.length, 2); + expect(seeks.first, target); + expect((seeks.last - target).abs(), lessThanOrEqualTo(millisecondsToTicks(50))); + }); + + test('duplicate Seek at the right position only reports ready', () async { + final seeks = []; + var readyCalls = 0; + final target = ticksPerSecond * 30; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (ticks) async { + seeks.add(ticks); + } + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async { + readyCalls++; + } + ..isPlaying = (() => false) + ..getPositionTicks = (() => target); + final cmd = _command( + 'Seek', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: target, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(seeks.length, 1); + expect(readyCalls, 2, reason: 'first from the Seek itself, second from the duplicate check'); + }); + + test('duplicate whose time is still in the future is left to the timer', () async { + var playCalls = 0; + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => 0); + final cmd = _command('Unpause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 500))); + + handler.handleCommand(cmd, SyncPlayState()); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + expect(playCalls, 0); + expect(handler.hasPendingCommand, isTrue); + + handler.cancelPendingCommands(); + }); + }); + + group('recovery gate', () { + test('shouldRecoverPlayback is false while an Unpause is still scheduled', () { + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 800))), + SyncPlayState(), + ); + + expect(handler.hasPendingCommand, isTrue); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + handler.cancelPendingCommands(); + }); + + test('shouldRecoverPlayback is true once the Unpause time has passed and the player is paused', () async { + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(seconds: 2))), + SyncPlayState(), + ); + await _settle(); + + expect(handler.hasPendingCommand, isFalse); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is false after a Pause', () async { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + }); + + test('shouldRecoverPlayback is true after a Seek once nothing is pending', () async { + // The Unpause that follows a Seek can be lost or dropped by the + // player; the group saying Playing is then the only signal left. + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand(_command('Seek', when: DateTime.now().toUtc(), positionTicks: 5000), SyncPlayState()); + await _settle(); + + expect(handler.hasPendingCommand, isFalse); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is true with no command context and a paused player', () { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is false while the player is buffering', () { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..isPlaying = (() => false) + ..isBuffering = (() => true); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + }); + }); + + group('clock gate', () { + test('a queued command is applied with the unsynced clock once the fallback expires', () async { + var seekCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler( + timeSync: () => clock, + onStateUpdate: (_) {}, + clockFallback: const Duration(milliseconds: 100), + ) + ..onSeek = (_) async { + seekCalls++; + } + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand( + _command('Seek', when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), positionTicks: 9000), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + await _settle(); + expect(seekCalls, 0); + expect(handler.hasQueuedCommand, isTrue); + + await Future.delayed(const Duration(milliseconds: 250)); + expect(seekCalls, 1, reason: 'a broken time sync must not freeze the group'); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('flushQueuedCommand(force) applies the queued command before the clock is ready', () async { + var playCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(milliseconds: 100))), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + expect(handler.hasQueuedCommand, isTrue); + + handler.flushQueuedCommand(force: true); + await _settle(); + expect(playCalls, 1); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('a resend of the queued command is re-queued, not treated as a correction', () async { + var seekCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onSeek = (_) async { + seekCalls++; + } + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + final when = DateTime.now().toUtc().subtract(const Duration(seconds: 1)); + final state = SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'); + + handler.handleCommand(_command('Seek', when: when, positionTicks: 9000), state); + handler.handleCommand(_command('Seek', when: when, positionTicks: 9000), state); + await _settle(); + expect(seekCalls, 0, reason: 'nothing may run before the first clock measurement'); + expect(handler.hasQueuedCommand, isTrue); + + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + expect(seekCalls, 1); + }); + + test('commands queue until the clock is ready and run on flush', () async { + final clock = _FakeClock(ready: false); + var playCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Unpause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + expect(playCalls, 0); + expect(handler.hasQueuedCommand, isTrue); + + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + expect(playCalls, 1); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('only the latest command is kept while waiting for the clock', () async { + final clock = _FakeClock(ready: false); + final calls = []; + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + calls.add('play'); + } + ..onPause = () async { + calls.add('pause'); + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Unpause', when: DateTime.now().toUtc()), SyncPlayState()); + handler.handleCommand( + _command('Pause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 1))), + SyncPlayState(), + ); + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + + expect(calls, ['pause']); + }); + }); + + group('execution', () { + test('Seek reports ready as paused at the requested position', () async { + bool? reportedPlaying; + int? reportedTicks; + final target = ticksPerSecond * 42; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async {} + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async { + reportedPlaying = isPlaying; + reportedTicks = positionTicks; + } + ..isBuffering = (() => false) + ..getPositionTicks = (() => target - millisecondsToTicks(300)); + + handler.handleCommand( + _command('Seek', when: DateTime.now().toUtc(), positionTicks: target), + SyncPlayState(), + ); + await _settle(); + + expect(reportedPlaying, isFalse); + expect(reportedTicks, target); + }); + + test('Pause does not seek inside the 500 ms tolerance', () async { + var seekCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async { + seekCalls++; + } + ..getPositionTicks = (() => millisecondsToTicks(400)); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(seekCalls, 0); + }); + + test('Stop runs the stop callback', () async { + var stopCalls = 0; + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onStop = () async { + stopCalls++; + } + ..onPause = () async { + pauseCalls++; + }; + + handler.handleCommand(_command('Stop', when: DateTime.now().toUtc(), playlistItemId: ''), SyncPlayState()); + await _settle(); + + expect(stopCalls, 1); + expect(pauseCalls, 0); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_correction_test.dart b/test/providers/syncplay/syncplay_correction_test.dart index 186867dee..8651c8a4c 100644 --- a/test/providers/syncplay/syncplay_correction_test.dart +++ b/test/providers/syncplay/syncplay_correction_test.dart @@ -84,7 +84,7 @@ void main() { }); group('SyncPlayCommandHandler', () { - test('ignores duplicate command', () async { + test('duplicate Pause while already paused at position is a no-op correction check', () async { var pauseCalls = 0; final handler = SyncPlayCommandHandler( timeSync: () => null, @@ -172,7 +172,7 @@ void main() { ) ..onPause = () async {} ..onSeek = (ticks) async {} - ..onReportReady = () async { + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async { readyCalls++; } ..isBuffering = () => false; @@ -207,9 +207,7 @@ void main() { final handler = SyncPlayCommandHandler( timeSync: () => null, onStateUpdate: (updater) { - // The finally block in _executeCommand calls - // state.copyWith(isProcessingCommand: false, processingCommandType: null) - // — apply the updater to a sentinel and detect that transition. + // Detect the finally block's isProcessingCommand=false transition by applying the updater to a sentinel. final after = updater(SyncPlayState(isProcessingCommand: true)); if (after.isProcessingCommand == false) { stateClearFired = true; diff --git a/test/providers/syncplay/syncplay_lifecycle_test.dart b/test/providers/syncplay/syncplay_lifecycle_test.dart index d26fc27db..c2ebb5239 100644 --- a/test/providers/syncplay/syncplay_lifecycle_test.dart +++ b/test/providers/syncplay/syncplay_lifecycle_test.dart @@ -2,15 +2,51 @@ import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; import 'package:flutter_test/flutter_test.dart'; -// We deliberately avoid spinning up the full SyncPlayController here: -// it depends on a Riverpod Ref + Chopper + WebSocket. Instead we cover the -// state-flag invariants that downstream tests rely on. -// -// Lifecycle reset is verified through the controller via integration tests -// gated behind a manual test plan (see docs/syncplay-implementation.md -// "Regression scenarios"). The unit-level coverage here proves that -// SyncPlayState resets cleanly via copyWith — the controller's leaveGroup -// path uses the same pattern. +// The full SyncPlayController needs Ref + Chopper + WebSocket; these cover the invariants it relies on. + +SyncPlayMessageHandler _handler({ + required List calls, + void Function(SyncPlayState Function(SyncPlayState))? onStateUpdate, +}) { + return SyncPlayMessageHandler( + onStateUpdate: onStateUpdate ?? (_) {}, + startPlayback: (id, ticks, reason) async { + calls.add('start:$id:$ticks:$reason'); + }, + getContext: () => null, + onGroupJoined: () => calls.add('joined'), + onGroupJoinFailed: () => calls.add('joinFailed'), + onGroupLeftOrKicked: () => calls.add('leftOrKicked'), + onStateUpdateToPlaying: () => calls.add('toPlaying'), + onGroupGone: ({required bool wasKicked}) => calls.add('gone:$wasKicked'), + ); +} + +Map _playQueue({ + required String reason, + required List<(String, String)> items, + int playingItemIndex = 0, + int startPositionTicks = 0, + bool isPlaying = false, + DateTime? lastUpdate, +}) { + return { + 'Type': 'PlayQueue', + 'Data': { + 'Reason': reason, + 'LastUpdate': (lastUpdate ?? DateTime.now().toUtc()).toIso8601String(), + 'Playlist': [ + for (final (itemId, playlistItemId) in items) + {'ItemId': itemId, 'PlaylistItemId': playlistItemId}, + ], + 'PlayingItemIndex': playingItemIndex, + 'StartPositionTicks': startPositionTicks, + 'IsPlaying': isPlaying, + 'ShuffleMode': 'Sorted', + 'RepeatMode': 'RepeatNone', + }, + }; +} void main() { group('SyncPlayState lifecycle reset', () { @@ -27,6 +63,7 @@ void main() { startingPlaylistItemId: 'plist-1', isProcessingCommand: true, processingCommandType: SyncPlayCommand.unpause, + isFollowingGroupPlayback: false, ); final cleared = mid.copyWith( @@ -42,6 +79,7 @@ void main() { playlistItemId: null, startPlaybackInProgress: false, startingPlaylistItemId: null, + isFollowingGroupPlayback: true, ); expect(cleared.isInGroup, isFalse); @@ -50,69 +88,291 @@ void main() { expect(cleared.startingPlaylistItemId, isNull); expect(cleared.processingCommandType, isNull); expect(cleared.playingItemId, isNull); + expect(cleared.isFollowingGroupPlayback, isTrue); }); }); - group('SyncPlayMessageHandler Waiting state', () { - test('Buffer reason invokes a local pause callback before reporting ready', () async { - final readyCalls = []; - var pauseCalls = 0; - final handler = SyncPlayMessageHandler( - onStateUpdate: (_) {}, - reportReady: ({bool isPlaying = true}) async { - readyCalls.add(isPlaying); - }, - startPlayback: (id, ticks) async {}, - isBuffering: () => false, - getContext: () => null, - onGroupJoined: () {}, - onGroupJoinFailed: () {}, - onLocalPauseForBuffer: () async { - pauseCalls++; - }, + group('resolveSyncPlayOverlay', () { + test('nothing outside a group or while halted', () { + expect(resolveSyncPlayOverlay(SyncPlayState(groupState: SyncPlayGroupState.waiting)), SyncPlayOverlay.none); + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + isFollowingGroupPlayback: false, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.none, + ); + }); + + test('a queue switch wins over a command, a command over the group waiting', () { + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + startPlaybackInProgress: true, + isProcessingCommand: true, + processingCommandType: SyncPlayCommand.seek, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.switching, + ); + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + isProcessingCommand: true, + processingCommandType: SyncPlayCommand.seek, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.command, + ); + }); + + test('the group waiting shows on every device, even one with nothing to do', () { + expect( + resolveSyncPlayOverlay(SyncPlayState(isInGroup: true, groupState: SyncPlayGroupState.waiting)), + SyncPlayOverlay.waiting, + ); + expect( + resolveSyncPlayOverlay(SyncPlayState(isInGroup: true, groupState: SyncPlayGroupState.playing)), + SyncPlayOverlay.none, + ); + }); + }); + + group('SyncPlayMessageHandler state updates', () { + test('Waiting/Buffer only updates state, never replies', () async { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), ); handler.handleGroupUpdate({ 'Type': 'StateUpdate', - 'Data': { - 'State': 'Waiting', - 'Reason': 'Buffer', - 'PositionTicks': 0, - }, + 'Data': {'State': 'Waiting', 'Reason': 'Buffer'}, }, SyncPlayState(isInGroup: true)); + await Future.delayed(const Duration(milliseconds: 5)); + + expect(calls, isEmpty); + expect(written?.groupState, SyncPlayGroupState.waiting); + }); + test('Waiting/Unpause only updates state, never replies', () async { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': {'State': 'Waiting', 'Reason': 'Unpause'}, + }, SyncPlayState(isInGroup: true)); await Future.delayed(const Duration(milliseconds: 5)); - expect(pauseCalls, 1, reason: 'should pause locally on Buffer reason'); - expect(readyCalls, [true], reason: 'should still report ready (true) after pausing'); - }); - - test('Unpause reason does not invoke local pause', () async { - var pauseCalls = 0; - final handler = SyncPlayMessageHandler( - onStateUpdate: (_) {}, - reportReady: ({bool isPlaying = true}) async {}, - startPlayback: (id, ticks) async {}, - isBuffering: () => false, - getContext: () => null, - onGroupJoined: () {}, - onGroupJoinFailed: () {}, - onLocalPauseForBuffer: () async { - pauseCalls++; - }, - ); + expect(calls, isEmpty); + }); + + test('Playing hands recovery to the controller', () { + final calls = []; + final handler = _handler(calls: calls); handler.handleGroupUpdate({ 'Type': 'StateUpdate', + 'Data': {'State': 'Playing', 'Reason': 'Unpause'}, + }, SyncPlayState(isInGroup: true)); + + expect(calls, ['toPlaying']); + }); + }); + + group('SyncPlayMessageHandler membership', () { + test('LibraryAccessDenied fails the join and clears the group', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true, groupId: 'g')), + ); + + handler.handleGroupUpdate({ + 'Type': 'LibraryAccessDenied', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState()); + + expect(calls, ['joinFailed']); + expect(written?.isInGroup, isFalse); + expect(written?.groupId, isNull); + }); + + test('GroupJoined leaves the following flag to the controller', () { + // A silent rejoin after a socket drop must not un-halt a device the + // user halted; user-initiated joins set the flag themselves. + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isFollowingGroupPlayback: false)), + ); + + handler.handleGroupUpdate({ + 'Type': 'GroupJoined', 'Data': { - 'State': 'Waiting', - 'Reason': 'Unpause', - 'PositionTicks': 0, + 'GroupId': 'g', + 'GroupName': 'Movie night', + 'State': 'Paused', + 'Participants': ['alice'], + 'LastUpdatedAt': DateTime.now().toUtc().toIso8601String(), }, + }, SyncPlayState()); + + expect(calls, ['joined']); + expect(written?.isInGroup, isTrue); + expect(written?.isFollowingGroupPlayback, isFalse); + expect(written?.groupState, SyncPlayGroupState.paused); + }); + + test('NotInGroup for a failed join does not stop local playback', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'NotInGroup', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: false)); + + expect(calls, ['joinFailed']); + }); + + test('NotInGroup while in a group tears playback down and reports the kick', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'NotInGroup', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: true, groupId: 'g')); + + expect(calls, ['leftOrKicked', 'gone:true', 'joinFailed']); + }); + + test('GroupDoesNotExist for a stale id does not stop local playback', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'GroupDoesNotExist', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: false)); + + expect(calls, ['joinFailed']); + }); + }); + + group('SyncPlayMessageHandler play queue', () { + test('stores the playlist, playing index and starts playback with the reason', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), + ); + + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: const [('a', 'pa'), ('b', 'pb'), ('c', 'pc')], + playingItemIndex: 1, + startPositionTicks: 5000, + ), + SyncPlayState(isInGroup: true), + ); + + expect(calls, ['start:b:5000:NewPlaylist']); + expect(written?.playlist.length, 3); + expect(written?.playingItemIndex, 1); + expect(written?.playlistItemId, 'pb'); + expect(written?.playingItemId, 'b'); + }); + + test('stores the frame timing for position extrapolation', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), + ); + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: [('item-1', 'plist-1')], + startPositionTicks: 5000, + isPlaying: true, + lastUpdate: at, + ), + SyncPlayState(isInGroup: true), + ); + + final timing = written?.queueTiming; + expect(timing, isNotNull); + expect(timing?.startPositionTicks, 5000); + expect(timing?.isPlaying, isTrue); + expect(timing?.lastUpdate, at); + }); + + test('an older PlayQueue frame is ignored', () { + final calls = []; + final handler = _handler(calls: calls); + final now = DateTime.now().toUtc(); + + handler.handleGroupUpdate( + _playQueue(reason: 'NewPlaylist', items: const [('a', 'pa')], lastUpdate: now), + SyncPlayState(isInGroup: true), + ); + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: const [('z', 'pz')], + lastUpdate: now.subtract(const Duration(seconds: 1)), + ), + SyncPlayState(isInGroup: true, playingItemId: 'a'), + ); + + expect(calls, ['start:a:0:NewPlaylist']); + }); + + test('a rejoin accepts a PlayQueue frame with an unchanged LastUpdate', () { + final calls = []; + final handler = _handler(calls: calls); + final now = DateTime.now().toUtc(); + final frame = _playQueue(reason: 'NewPlaylist', items: const [('a', 'pa')], lastUpdate: now); + + handler.handleGroupUpdate(frame, SyncPlayState(isInGroup: true)); + handler.handleGroupUpdate({ + 'Type': 'GroupJoined', + 'Data': {'GroupId': 'g', 'GroupName': 'n', 'State': 'Waiting', 'Participants': []}, }, SyncPlayState(isInGroup: true)); + handler.handleGroupUpdate(frame, SyncPlayState(isInGroup: true, playingItemId: 'a')); - await Future.delayed(const Duration(milliseconds: 5)); - expect(pauseCalls, 0); + expect(calls, ['start:a:0:NewPlaylist', 'joined', 'start:a:0:NewPlaylist']); + }); + + test('an empty playlist clears the playing item without starting playback', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true, playingItemId: 'a')), + ); + + handler.handleGroupUpdate( + _playQueue(reason: 'NewPlaylist', items: const [], playingItemIndex: -1), + SyncPlayState(isInGroup: true, playingItemId: 'a'), + ); + + expect(calls, isEmpty); + expect(written?.playingItemId, isNull); + expect(written?.playingItemIndex, -1); }); }); } diff --git a/test/providers/syncplay/syncplay_queue_test.dart b/test/providers/syncplay/syncplay_queue_test.dart new file mode 100644 index 000000000..fcae7dd0a --- /dev/null +++ b/test/providers/syncplay/syncplay_queue_test.dart @@ -0,0 +1,163 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const playlist = [ + SyncPlayQueueEntry(itemId: 'a', playlistItemId: 'pa'), + SyncPlayQueueEntry(itemId: 'b', playlistItemId: 'pb'), + SyncPlayQueueEntry(itemId: 'c', playlistItemId: 'pc'), + SyncPlayQueueEntry(itemId: 'd', playlistItemId: 'pd'), + ]; + + group('SyncPlayQueueTiming', () { + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + + test('a paused group stays at the frame position', () { + const timing = SyncPlayQueueTiming(startPositionTicks: 5000); + expect(timing.positionTicksAt(at.add(const Duration(minutes: 5))), 5000); + }); + + test('a playing group is extrapolated from LastUpdate', () { + final timing = SyncPlayQueueTiming(startPositionTicks: 5000, lastUpdate: at, isPlaying: true); + expect( + timing.positionTicksAt(at.add(const Duration(seconds: 3))), + 5000 + millisecondsToTicks(3000), + ); + }); + + test('a playing group without LastUpdate cannot be extrapolated', () { + const timing = SyncPlayQueueTiming(startPositionTicks: 5000, isPlaying: true); + expect(timing.positionTicksAt(at.add(const Duration(seconds: 3))), 5000); + }); + }); + + group('estimateGroupPositionTicks', () { + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + final remoteNow = at.add(const Duration(seconds: 10)); + final timing = SyncPlayQueueTiming(startPositionTicks: 1000, lastUpdate: at, isPlaying: true); + + test('an Unpause for the current item is extrapolated', () { + final unpause = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: SyncPlayCommand.unpause, + playlistItemId: 'pa', + ); + expect( + estimateGroupPositionTicks( + lastCommand: unpause, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 5000 + millisecondsToTicks(10000), + ); + }); + + test('a Pause or Seek freezes the playhead at the command position', () { + for (final command in [SyncPlayCommand.pause, SyncPlayCommand.seek]) { + final last = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: command, + playlistItemId: 'pa', + ); + expect( + estimateGroupPositionTicks( + lastCommand: last, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 5000, + reason: '$command must not extrapolate', + ); + } + }); + + test('a command for another item yields to the queue frame timing', () { + final stale = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: SyncPlayCommand.unpause, + playlistItemId: 'pb', + ); + expect( + estimateGroupPositionTicks( + lastCommand: stale, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 1000 + millisecondsToTicks(10000), + ); + }); + + test('without any context the last state update position is used', () { + expect( + estimateGroupPositionTicks( + lastCommand: null, + currentPlaylistItemId: 'pa', + queueTiming: null, + fallbackPositionTicks: 777, + remoteNow: remoteNow, + ), + 777, + ); + }); + }); + + group('resolveQueueNavigation', () { + test('adjacent following entry is NextItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 1, targetItemId: 'c'), + SyncPlayQueueNavigation.next, + ); + }); + + test('adjacent preceding entry is PreviousItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 1, targetItemId: 'a'), + SyncPlayQueueNavigation.previous, + ); + }); + + test('non-adjacent entry is SetPlaylistItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 0, targetItemId: 'd'), + SyncPlayQueueNavigation.setCurrentItem, + ); + }); + + test('unknown item needs a new queue', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 0, targetItemId: 'z'), + SyncPlayQueueNavigation.newQueue, + ); + }); + + test('empty queue needs a new queue', () { + expect( + resolveQueueNavigation(playlist: const [], playingItemIndex: -1, targetItemId: 'a'), + SyncPlayQueueNavigation.newQueue, + ); + }); + }); + + group('SyncPlayState queue helpers', () { + test('next and previous entries follow the playing index', () { + final state = SyncPlayState(playlist: playlist, playingItemIndex: 1); + expect(state.nextQueueEntry?.itemId, 'c'); + expect(state.previousQueueEntry?.itemId, 'a'); + }); + + test('edges return null', () { + expect(SyncPlayState(playlist: playlist, playingItemIndex: 0).previousQueueEntry, isNull); + expect(SyncPlayState(playlist: playlist, playingItemIndex: 3).nextQueueEntry, isNull); + expect(SyncPlayState().nextQueueEntry, isNull); + }); + }); +} From 1ee77b2e077aa0a1e7687946f763fcb0853bf6fb Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 14:15:55 +0200 Subject: [PATCH 11/14] feat(syncplay): group sheet, overlay and player control follow-ups - Group sheet: create/join gated by the server's SyncPlay access policy, an explicit view when access is disabled, "Stop group playback" and "Resume playback" buttons, a not-following state, no duplicate toasts. The navigation-rail FAB is gone. - Centre overlay shows one thing per device from a shared resolution: queue switch, the command being applied, or the group waiting for a participant. Drift corrections stay in the badge. - Progress bar sends one Seek per release and never pauses the group on drag; the close paths halt group playback before stopping; playback speed controls are locked in a group. - Subtitle/audio picker highlights the tapped entry at once with a spinner while the switch and any reload run. - Player settings gain the SyncPlay drift-correction toggle. --- .../settings/player_settings_page.dart | 9 + .../syncplay_command_indicator.dart | 60 ++--- .../components/video_player_next_wrapper.dart | 3 +- .../video_player_options_sheet.dart | 242 +++++++++++------- .../components/video_progress_bar.dart | 20 +- .../video_player/tv_player_controls.dart | 3 +- .../video_player/video_player_controls.dart | 25 +- lib/widgets/syncplay/syncplay_badge.dart | 2 - lib/widgets/syncplay/syncplay_extensions.dart | 12 - lib/widgets/syncplay/syncplay_fab.dart | 47 ---- .../syncplay/syncplay_group_sheet.dart | 91 +++++-- 11 files changed, 275 insertions(+), 239 deletions(-) delete mode 100644 lib/widgets/syncplay/syncplay_fab.dart diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 1cf040276..0e21f412e 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -262,6 +262,15 @@ class _PlayerSettingsPageState extends ConsumerState { ), ), ], + SettingsListTile( + label: Text(context.localized.syncPlayEnableCorrection), + subLabel: Text(context.localized.syncPlayEnableCorrectionDesc), + onTap: () => provider.setEnableSyncPlayCorrection(!videoSettings.enableSyncPlayCorrection), + trailing: Switch( + value: videoSettings.enableSyncPlayCorrection, + onChanged: (value) => provider.setEnableSyncPlayCorrection(value), + ), + ), SettingsListTile( label: Text(context.localized.enableSpeedBoostTitle), subLabel: Text(context.localized.enableSpeedBoostDesc), diff --git a/lib/screens/video_player/components/syncplay_command_indicator.dart b/lib/screens/video_player/components/syncplay_command_indicator.dart index 07cbd8377..7db89c2d8 100644 --- a/lib/screens/video_player/components/syncplay_command_indicator.dart +++ b/lib/screens/video_player/components/syncplay_command_indicator.dart @@ -6,22 +6,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; -/// Centered overlay showing SyncPlay command being processed, sync drift -/// correction in progress, or a next-episode-style queue switch. +/// Centre overlay driven by [resolveSyncPlayOverlay], shared with the native Android overlay. Drift +/// corrections are deliberately not shown here: they can fire every couple of seconds ([SyncPlayBadge] only). class SyncPlayCommandIndicator extends ConsumerWidget { const SyncPlayCommandIndicator({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final isActive = ref.watch(isSyncPlayActiveProvider); - final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final overlay = ref.watch(syncPlayProvider.select(resolveSyncPlayOverlay)); final commandType = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); - final strategy = ref.watch(syncCorrectionStrategyProvider); - final isSwitching = ref.watch(syncPlayStartPlaybackInProgressProvider); - final hasCorrection = strategy != SyncCorrectionStrategy.none; - final showCommand = isProcessing && commandType != null; - final visible = isActive && (showCommand || hasCorrection || isSwitching); + final visible = isActive && overlay != SyncPlayOverlay.none; return IgnorePointer( child: AnimatedOpacity( @@ -51,14 +47,10 @@ class SyncPlayCommandIndicator extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _CommandIcon( - commandType: commandType, - strategy: strategy, - isSwitching: isSwitching, - ), + _OverlayIcon(overlay: overlay, commandType: commandType), const SizedBox(height: 12), Text( - _label(context, isSwitching, showCommand, commandType, strategy), + _label(context, overlay, commandType), style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -94,40 +86,34 @@ class SyncPlayCommandIndicator extends ConsumerWidget { ); } - String _label( - BuildContext context, - bool isSwitching, - bool showCommand, - SyncPlayCommand? commandType, - SyncCorrectionStrategy strategy, - ) { - if (isSwitching) { - return context.localized.syncPlaySwitchingItem; - } - if (showCommand) { - return commandType.syncPlayCommandOverlayLabel(context); - } - return strategy.label(context); + String _label(BuildContext context, SyncPlayOverlay overlay, SyncPlayCommand? commandType) { + return switch (overlay) { + SyncPlayOverlay.switching => context.localized.syncPlaySwitchingItem, + SyncPlayOverlay.command => commandType.syncPlayCommandOverlayLabel(context), + SyncPlayOverlay.waiting => context.localized.syncPlayStateWaiting, + SyncPlayOverlay.none => context.localized.syncPlayCommandSyncing, + }; } } -class _CommandIcon extends StatelessWidget { +class _OverlayIcon extends StatelessWidget { + final SyncPlayOverlay overlay; final SyncPlayCommand? commandType; - final SyncCorrectionStrategy strategy; - final bool isSwitching; - const _CommandIcon({ + const _OverlayIcon({ + required this.overlay, required this.commandType, - required this.strategy, - required this.isSwitching, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - final (IconData icon, Color color) = isSwitching - ? (IconsaxPlusBold.refresh, scheme.primary) - : (commandType != null ? commandType.syncPlayCommandIconAndColor(context) : strategy.iconAndColor(context)); + final (IconData icon, Color color) = switch (overlay) { + SyncPlayOverlay.switching => (IconsaxPlusBold.refresh, scheme.primary), + SyncPlayOverlay.command => commandType.syncPlayCommandIconAndColor(context), + SyncPlayOverlay.waiting => SyncPlayGroupState.waiting.iconAndColor(context), + SyncPlayOverlay.none => (IconsaxPlusBold.refresh, scheme.primary), + }; return Container( padding: const EdgeInsets.all(16), diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 0faa6194f..4b2e808a3 100644 --- a/lib/screens/video_player/components/video_player_next_wrapper.dart +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -123,7 +123,8 @@ class _VideoPlayerNextWrapperState extends ConsumerState Future closePlayer() async { clearOverlaySettings(); ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; - ref.read(videoPlayerProvider).stop(); + // Halts group playback on this device (SyncPlay) before stopping. + ref.read(videoPlayerProvider.notifier).userStop(); Navigator.of(context).pop(); } diff --git a/lib/screens/video_player/components/video_player_options_sheet.dart b/lib/screens/video_player/components/video_player_options_sheet.dart index 274841473..1d76ede0a 100644 --- a/lib/screens/video_player/components/video_player_options_sheet.dart +++ b/lib/screens/video_player/components/video_player_options_sheet.dart @@ -30,6 +30,7 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/map_bool_helper.dart'; import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/string_extensions.dart'; +import 'package:fladder/wrappers/media_control_wrapper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; @@ -195,6 +196,9 @@ class _VideoOptionsMobileState extends ConsumerState { onTap: () => showOrientationOptions(context, ref), ), ListTile( + // The rate belongs to SyncPlay's drift correction while in a group. + enabled: !ref.watch(isSyncPlayActiveProvider), + subtitle: ref.watch(isSyncPlayActiveProvider) ? Text(context.localized.syncPlaySpeedLocked) : null, onTap: () { Navigator.of(context).pop(); showPlaybackSpeed(context); @@ -398,59 +402,35 @@ Future showSubSelection(BuildContext context) { return showDialog( context: context, builder: (context) { - return Consumer( - builder: (context, ref, child) { - final playbackModel = ref.watch(playBackModel); - final player = ref.watch(videoPlayerProvider); - return SimpleDialog( - contentPadding: const EdgeInsets.only(top: 8, bottom: 24), - title: Row( - children: [ - Text(context.localized.subtitle), - const Spacer(), - if (player.backend == PlayerOptions.libMPV || player.backend == PlayerOptions.libMDK) - IconButton.outlined( - onPressed: () { - Navigator.pop(context); - showSubtitleControls( - context: context, - label: context.localized.subtitleConfiguration, - ); - }, - icon: const Icon(Icons.display_settings_rounded)) - ], - ), - children: playbackModel?.subStreams?.mapIndexed( - (index, subModel) { - final selected = playbackModel.mediaStreams?.defaultSubStreamIndex == subModel.index; - return ListTile( - title: Text(subModel.label(context)), - tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, - subtitle: subModel.language.isNotEmpty - ? Opacity(opacity: 0.6, child: Text(subModel.language.capitalize())) - : null, - onTap: () async { - Future doSwitch() async { - final newModel = await playbackModel.setSubtitle(subModel, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload( - newModel, - isLocalTrackSwitch: true, - ); - } - } - - if (ref.read(isSyncPlayActiveProvider)) { - await ref.read(syncPlayProvider.notifier).runLocalOnly(doSwitch); - } else { - await doSwitch(); - } + return _TrackSelectionDialog( + title: (context, player) => Row( + children: [ + Text(context.localized.subtitle), + const Spacer(), + if (player.backend == PlayerOptions.libMPV || player.backend == PlayerOptions.libMDK) + IconButton.outlined( + onPressed: () { + Navigator.pop(context); + showSubtitleControls( + context: context, + label: context.localized.subtitleConfiguration, + ); }, - ); - }, - ).toList(), - ); + icon: const Icon(Icons.display_settings_rounded)) + ], + ), + tracks: (model) => + model.subStreams + ?.map((sub) => _TrackOption(index: sub.index, label: sub.label(context), language: sub.language)) + .toList() ?? + const [], + currentIndex: (model) => model.mediaStreams?.defaultSubStreamIndex, + apply: (model, player, index) async { + final subModel = model.subStreams?.firstWhereOrNull((sub) => sub.index == index); + if (subModel == null) { + return null; + } + return model.setSubtitle(subModel, player); }, ); }, @@ -461,53 +441,129 @@ Future showAudioSelection(BuildContext context) { return showDialog( context: context, builder: (context) { - return Consumer( - builder: (context, ref, child) { - final playbackModel = ref.watch(playBackModel); - final player = ref.watch(videoPlayerProvider); - return SimpleDialog( - contentPadding: const EdgeInsets.only(top: 8, bottom: 24), - title: Row( - children: [ - Text(context.localized.audio(1)), - ], - ), - children: playbackModel?.audioStreams?.mapIndexed( - (index, audioStream) { - final selected = playbackModel.mediaStreams?.defaultAudioStreamIndex == audioStream.index; - return ListTile( - title: Text(audioStream.label(context)), - tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, - subtitle: audioStream.language.isNotEmpty - ? Opacity(opacity: 0.6, child: Text(audioStream.language.capitalize())) - : null, - onTap: () async { - Future doSwitch() async { - final newModel = await playbackModel.setAudio(audioStream, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload( - newModel, - isLocalTrackSwitch: true, - ); - } - } - - if (ref.read(isSyncPlayActiveProvider)) { - await ref.read(syncPlayProvider.notifier).runLocalOnly(doSwitch); - } else { - await doSwitch(); - } - }); - }, - ).toList(), - ); + return _TrackSelectionDialog( + title: (context, player) => Row( + children: [ + Text(context.localized.audio(1)), + ], + ), + tracks: (model) => + model.audioStreams + ?.map( + (audio) => _TrackOption(index: audio.index, label: audio.label(context), language: audio.language)) + .toList() ?? + const [], + currentIndex: (model) => model.mediaStreams?.defaultAudioStreamIndex, + apply: (model, player, index) async { + final audioStream = model.audioStreams?.firstWhereOrNull((audio) => audio.index == index); + if (audioStream == null) { + return null; + } + return model.setAudio(audioStream, player); }, ); }, ); } +class _TrackOption { + const _TrackOption({required this.index, required this.label, required this.language}); + + final int index; + final String label; + final String language; +} + +/// The tapped entry is highlighted and marked busy at once while the switch and a possible transcode +/// reload run behind it. In a SyncPlay group the switch is a local-only operation. +class _TrackSelectionDialog extends ConsumerStatefulWidget { + const _TrackSelectionDialog({ + required this.title, + required this.tracks, + required this.currentIndex, + required this.apply, + }); + + final Widget Function(BuildContext context, MediaControlsWrapper player) title; + final List<_TrackOption> Function(PlaybackModel model) tracks; + final int? Function(PlaybackModel model) currentIndex; + final Future? Function(PlaybackModel model, MediaControlsWrapper player, int index) apply; + + @override + ConsumerState<_TrackSelectionDialog> createState() => _TrackSelectionDialogState(); +} + +class _TrackSelectionDialogState extends ConsumerState<_TrackSelectionDialog> { + int? _pendingIndex; + + Future _select(PlaybackModel model, MediaControlsWrapper player, int index) async { + if (_pendingIndex != null) { + return; + } + setState(() => _pendingIndex = index); + // Read everything before the first await: the dialog may be dismissed while the reload runs. + final modelNotifier = ref.read(playBackModel.notifier); + final helper = ref.read(playbackModelHelper); + final syncPlay = ref.read(isSyncPlayActiveProvider) ? ref.read(syncPlayProvider.notifier) : null; + Future doSwitch() async { + final newModel = await widget.apply(model, player, index); + modelNotifier.update((state) => newModel ?? state); + if (newModel != null) { + await helper.shouldReload( + newModel, + isLocalTrackSwitch: true, + ); + } + } + + try { + if (syncPlay != null) { + await syncPlay.runLocalOnly(doSwitch); + } else { + await doSwitch(); + } + } finally { + if (mounted) { + setState(() => _pendingIndex = null); + } + } + } + + @override + Widget build(BuildContext context) { + final playbackModel = ref.watch(playBackModel); + final player = ref.watch(videoPlayerProvider); + final currentIndex = playbackModel == null ? null : widget.currentIndex(playbackModel); + final highlighted = _pendingIndex ?? currentIndex; + final tracks = playbackModel == null ? const <_TrackOption>[] : widget.tracks(playbackModel); + + return SimpleDialog( + contentPadding: const EdgeInsets.only(top: 8, bottom: 24), + title: widget.title(context, player), + children: tracks.map( + (track) { + final selected = highlighted == track.index; + final busy = _pendingIndex == track.index && currentIndex != track.index; + return ListTile( + title: Text(track.label), + tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, + subtitle: + track.language.isNotEmpty ? Opacity(opacity: 0.6, child: Text(track.language.capitalize())) : null, + trailing: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: playbackModel == null ? null : () => _select(playbackModel, player, track.index), + ); + }, + ).toList(), + ); + } +} + Future showPlaybackSpeed(BuildContext context) { return showDialog( context: context, diff --git a/lib/screens/video_player/components/video_progress_bar.dart b/lib/screens/video_player/components/video_progress_bar.dart index f4eae3604..8da735069 100644 --- a/lib/screens/video_player/components/video_progress_bar.dart +++ b/lib/screens/video_player/components/video_progress_bar.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/list_padding.dart'; @@ -105,13 +106,14 @@ class _ChapterProgressSliderState extends ConsumerState { ), onChangeEnd: (e) async { currentDuration = Duration(milliseconds: e.toInt()); - // Route seek through SyncPlay if active + final inSyncPlay = ref.read(isSyncPlayActiveProvider); + // One seek per release; in a group the server's Seek and the following Unpause resume everyone. widget.onPositionChanged(Duration(milliseconds: e.toInt())); - widget.onPositionChanged.call(Duration(milliseconds: e.toInt())); - await Future.delayed(const Duration(milliseconds: 250)); - if (widget.wasPlaying) { - // Route play through SyncPlay if active - ref.read(videoPlayerProvider.notifier).userPlay(); + if (!inSyncPlay) { + await Future.delayed(const Duration(milliseconds: 250)); + if (widget.wasPlaying) { + ref.read(videoPlayerProvider.notifier).userPlay(); + } } widget.timerReset.call(); setState(() { @@ -123,8 +125,10 @@ class _ChapterProgressSliderState extends ConsumerState { onHoverStart = true; }); widget.wasPlayingChanged.call(player.lastState?.playing ?? false); - // Route pause through SyncPlay if active - ref.read(videoPlayerProvider.notifier).userPause(); + // In a group the player keeps running while dragging: pausing here would pause everyone. + if (!ref.read(isSyncPlayActiveProvider)) { + ref.read(videoPlayerProvider.notifier).userPause(); + } }, onChanged: (e) { currentDuration = Duration(milliseconds: e.toInt()); diff --git a/lib/screens/video_player/tv_player_controls.dart b/lib/screens/video_player/tv_player_controls.dart index b93003f6c..06bf2efe7 100644 --- a/lib/screens/video_player/tv_player_controls.dart +++ b/lib/screens/video_player/tv_player_controls.dart @@ -645,7 +645,8 @@ class _TvPlayerControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; - ref.read(videoPlayerProvider).stop(); + // Halts group playback on this device (SyncPlay) first; not awaited so the route pops immediately. + ref.read(videoPlayerProvider.notifier).userStop(); Navigator.of(context).pop(); } diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 2b1a6be7e..4730054ac 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -787,24 +787,11 @@ class _DesktopControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); - // Mark the route as closed immediately so that a SyncPlay - // _startPlayback call arriving during the pop animation knows - // it must push a new route. + // Mark the route closed now so a SyncPlay _startPlayback arriving during the pop knows to push a new one. ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; - // Fire-and-forget the stop. The wrapper's stop() chain reports the - // session to the server (POST /Sessions/Playing/Stopped, ~2s) and - // we don't want the user staring at a black player with live - // controls during that time. Awaiting it would also reach `ref` - // after the widget is disposed by the route pop, throwing. - ref.read(videoPlayerProvider).stop(); - if (ref.read(isSyncPlayActiveProvider)) { - // In SyncPlay we previously only paused, which left the floating - // mini-player visible and let a server-broadcast Unpause resume - // local playback in the background. Null out the playback model - // so the mini-player disappears; the user can re-attach via the - // SyncPlay sheet's "Resume Playback" button. - ref.read(playBackModel.notifier).update((_) => null); - } + // Fire-and-forget: userStop() halts group playback first (SetIgnoreWait) and then stops the wrapper, + // whose ~1-2 s session report would reach `ref` after the route pop disposed the widget. + unawaited(ref.read(videoPlayerProvider.notifier).userStop()); Navigator.of(context).pop(); } @@ -842,6 +829,10 @@ class _DesktopControlsState extends ConsumerState { void _activateSpeedBoost() { if (_speedBoostActive) return; + // The rate belongs to SyncPlay's drift correction in a group; a local boost would desynchronise us. + if (ref.read(isSyncPlayActiveProvider)) { + return; + } final settings = ref.read(videoPlayerSettingsProvider); if (!settings.enableSpeedBoost) return; diff --git a/lib/widgets/syncplay/syncplay_badge.dart b/lib/widgets/syncplay/syncplay_badge.dart index c668e8db4..936083942 100644 --- a/lib/widgets/syncplay/syncplay_badge.dart +++ b/lib/widgets/syncplay/syncplay_badge.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; -/// Badge widget showing SyncPlay status in the video player class SyncPlayBadge extends ConsumerWidget { const SyncPlayBadge({super.key}); @@ -84,7 +83,6 @@ class SyncPlayBadge extends ConsumerWidget { } } -/// Compact SyncPlay indicator for tight spaces class SyncPlayIndicator extends ConsumerWidget { const SyncPlayIndicator({super.key}); diff --git a/lib/widgets/syncplay/syncplay_extensions.dart b/lib/widgets/syncplay/syncplay_extensions.dart index 648b03b29..bee3bbafe 100644 --- a/lib/widgets/syncplay/syncplay_extensions.dart +++ b/lib/widgets/syncplay/syncplay_extensions.dart @@ -4,9 +4,7 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/util/localization_helper.dart'; -/// Extension on [SyncPlayGroupState] for badge/indicator icon and color. extension SyncPlayGroupStateExtension on SyncPlayGroupState { - /// Returns (icon, color) for the current group state. (IconData, Color) iconAndColor(BuildContext context) { final scheme = Theme.of(context).colorScheme; return switch (this) { @@ -29,7 +27,6 @@ extension SyncPlayGroupStateExtension on SyncPlayGroupState { }; } - /// Returns the color only (for compact indicator). Color color(BuildContext context) { final scheme = Theme.of(context).colorScheme; return switch (this) { @@ -41,11 +38,7 @@ extension SyncPlayGroupStateExtension on SyncPlayGroupState { } } -/// Extension for localized SyncPlay command processing label (typed -/// against [SyncPlayCommand] instead of raw strings - see AGENTS.md -/// SyncPlay rule 6 about centralizing repeated display logic). extension SyncPlayCommandLabelExtension on SyncPlayCommand? { - /// Returns the localized "Syncing..." text for this command type. String syncPlayProcessingLabel(BuildContext context) { return switch (this) { SyncPlayCommand.pause => context.localized.syncPlaySyncingPause, @@ -56,7 +49,6 @@ extension SyncPlayCommandLabelExtension on SyncPlayCommand? { }; } - /// Returns the short command label for overlay (e.g. "Pausing"). String syncPlayCommandOverlayLabel(BuildContext context) { return switch (this) { SyncPlayCommand.pause => context.localized.syncPlayCommandPausing, @@ -67,7 +59,6 @@ extension SyncPlayCommandLabelExtension on SyncPlayCommand? { }; } - /// Returns (icon, color) for the command overlay. (IconData, Color) syncPlayCommandIconAndColor(BuildContext context) { final scheme = Theme.of(context).colorScheme; return switch (this) { @@ -80,9 +71,7 @@ extension SyncPlayCommandLabelExtension on SyncPlayCommand? { } } -/// Extension for correction strategy UI mapping. extension SyncCorrectionStrategyExtension on SyncCorrectionStrategy { - /// Returns short label for active correction strategy. String label(BuildContext context) { return switch (this) { SyncCorrectionStrategy.none => context.localized.syncPlaySyncing, @@ -91,7 +80,6 @@ extension SyncCorrectionStrategyExtension on SyncCorrectionStrategy { }; } - /// Returns icon and color for active correction strategy. (IconData, Color) iconAndColor(BuildContext context) { final scheme = Theme.of(context).colorScheme; return switch (this) { diff --git a/lib/widgets/syncplay/syncplay_fab.dart b/lib/widgets/syncplay/syncplay_fab.dart deleted file mode 100644 index 9df83c6cc..000000000 --- a/lib/widgets/syncplay/syncplay_fab.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; - -import 'package:fladder/providers/syncplay/syncplay_provider.dart'; -import 'package:fladder/util/localization_helper.dart'; -import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; -import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; - -/// FAB for accessing SyncPlay from the home screen -class SyncPlayFab extends ConsumerWidget { - const SyncPlayFab({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isActive = ref.watch(isSyncPlayActiveProvider); - - return AdaptiveFab( - context: context, - title: context.localized.syncPlay, - heroTag: 'syncplay_fab', - backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, - onPressed: () => showSyncPlaySheet(context), - child: Stack( - children: [ - Icon( - isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, - ), - if (isActive) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ).normal; - } -} diff --git a/lib/widgets/syncplay/syncplay_group_sheet.dart b/lib/widgets/syncplay/syncplay_group_sheet.dart index d1def7d75..d0fa37ee6 100644 --- a/lib/widgets/syncplay/syncplay_group_sheet.dart +++ b/lib/widgets/syncplay/syncplay_group_sheet.dart @@ -8,13 +8,13 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/localization_helper.dart'; -/// Bottom sheet for managing SyncPlay groups class SyncPlayGroupSheet extends ConsumerStatefulWidget { const SyncPlayGroupSheet({super.key}); @@ -42,7 +42,7 @@ class _SyncPlayGroupSheetState extends ConsumerState { final group = await ref.read(syncPlayProvider.notifier).createGroup(name); if (group != null && mounted) { - FladderSnack.show(context.localized.syncPlayCreatedGroup(group.groupName ?? ''), context: context); + // The controller toasts the server's GroupJoined confirmation. Navigator.of(context).pop(); } else { if (mounted) { @@ -86,7 +86,7 @@ class _SyncPlayGroupSheetState extends ConsumerState { final success = await ref.read(syncPlayProvider.notifier).joinGroup(group.groupId ?? ''); if (success && mounted) { - FladderSnack.show(context.localized.syncPlayJoinedGroup(group.groupName ?? ''), context: context); + // The controller toasts the server's GroupJoined confirmation. Navigator.of(context).pop(); } else { if (mounted) { @@ -108,6 +108,10 @@ class _SyncPlayGroupSheetState extends ConsumerState { Widget build(BuildContext context) { final syncPlayState = ref.watch(syncPlayProvider); final groupsState = ref.watch(syncPlayGroupsProvider); + // Mirrors the server policy; an unknown policy (older servers) does not block anything. + final syncPlayAccess = ref.watch(userProvider.select((user) => user?.policy?.syncPlayAccess)); + final canCreate = syncPlayAccess == null || syncPlayAccess == SyncPlayUserAccessType.createandjoingroups; + final canJoin = syncPlayAccess == null || syncPlayAccess != SyncPlayUserAccessType.none; return ConstrainedBox( constraints: BoxConstraints( @@ -122,7 +126,6 @@ class _SyncPlayGroupSheetState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Drag handle if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch) Padding( padding: const EdgeInsets.symmetric(vertical: 16), @@ -137,8 +140,6 @@ class _SyncPlayGroupSheetState extends ConsumerState { ) else const SizedBox(height: 8), - - // Header Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -160,7 +161,7 @@ class _SyncPlayGroupSheetState extends ConsumerState { icon: const Icon(IconsaxPlusLinear.logout), label: Text(context.localized.leave), ) - else + else if (canCreate) IconButton( onPressed: _createGroup, icon: const Icon(IconsaxPlusLinear.add), @@ -169,14 +170,13 @@ class _SyncPlayGroupSheetState extends ConsumerState { ], ), ), - const Divider(), - - // Content Flexible( child: _SyncPlaySheetContent( syncPlayState: syncPlayState, groupsState: groupsState, + canCreate: canCreate, + canJoin: canJoin, onCreateGroup: _createGroup, onJoinGroup: _joinGroup, ), @@ -189,17 +189,20 @@ class _SyncPlayGroupSheetState extends ConsumerState { } } -/// Content area of the SyncPlay group sheet (loading, error, empty, list, or active group). class _SyncPlaySheetContent extends ConsumerWidget { const _SyncPlaySheetContent({ required this.syncPlayState, required this.groupsState, + required this.canCreate, + required this.canJoin, required this.onCreateGroup, required this.onJoinGroup, }); final SyncPlayState syncPlayState; final SyncPlayGroupsState groupsState; + final bool canCreate; + final bool canJoin; final VoidCallback onCreateGroup; final void Function(GroupInfoDto) onJoinGroup; @@ -208,6 +211,29 @@ class _SyncPlaySheetContent extends ConsumerWidget { if (syncPlayState.isInGroup) { return _ActiveGroupView(state: syncPlayState); } + if (!canJoin) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.lock, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayAccessDisabled, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ); + } if (groupsState.isLoading) { return const Center( child: Padding( @@ -267,13 +293,15 @@ class _SyncPlaySheetContent extends ConsumerWidget { color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 16), - FilledButton.icon( - autofocus: true, - onPressed: onCreateGroup, - icon: const Icon(IconsaxPlusLinear.add), - label: Text(context.localized.syncPlayCreateGroupButton), - ), + if (canCreate) ...[ + const SizedBox(height: 16), + FilledButton.icon( + autofocus: true, + onPressed: onCreateGroup, + icon: const Icon(IconsaxPlusLinear.add), + label: Text(context.localized.syncPlayCreateGroupButton), + ), + ], ], ), ), @@ -318,11 +346,21 @@ class _ActiveGroupView extends ConsumerWidget { Navigator.of(context).maybePop(); } + Future _onHaltPlayback(BuildContext context, WidgetRef ref) async { + await ref.read(syncPlayProvider.notifier).haltPlayback(); + if (context.mounted) { + Navigator.of(context).maybePop(); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final hasActivePlayback = state.hasActivePlayback; final playerRouteOpen = ref.watch(isVideoPlayerRouteOpenProvider); - final showResume = hasActivePlayback && !playerRouteOpen; + final following = state.isFollowingGroupPlayback; + // "Resume playback" when the group has content this device is not showing, "Stop" while following. + final showResume = hasActivePlayback && (!playerRouteOpen || !following); + final showHalt = hasActivePlayback && playerRouteOpen && following; return Padding( padding: const EdgeInsets.all(16), @@ -330,7 +368,6 @@ class _ActiveGroupView extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Group name Row( children: [ Container( @@ -368,7 +405,6 @@ class _ActiveGroupView extends ConsumerWidget { ), ], ), - const SizedBox(height: 16), _StateIndicator(state: state), if (showResume) ...[ @@ -379,6 +415,14 @@ class _ActiveGroupView extends ConsumerWidget { label: Text(context.localized.syncPlayResumePlayback), ), ], + if (showHalt) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _onHaltPlayback(context, ref), + icon: const Icon(IconsaxPlusBold.pause_circle), + label: Text(context.localized.syncPlayHaltPlayback), + ), + ], const SizedBox(height: 16), Text( context.localized.syncPlayInstructions, @@ -400,6 +444,11 @@ class _StateIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final (icon, label, color) = switch (state.groupState) { + _ when !state.isFollowingGroupPlayback => ( + IconsaxPlusLinear.pause_circle, + context.localized.syncPlayNotFollowing, + Theme.of(context).colorScheme.onSurfaceVariant, + ), SyncPlayGroupState.idle => ( IconsaxPlusLinear.pause_circle, context.localized.syncPlayStateIdle, From a521eec5fe8d4d29ba86faad250707ed9135354e Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Wed, 2 Sep 2026 15:16:02 +0200 Subject: [PATCH 12/14] style: format side navigation bar after the develop rebase --- .../navigation_scaffold/components/side_navigation_bar.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index ceb26e36c..4e6f9d659 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -238,9 +238,7 @@ class SideNavigationRail extends ConsumerWidget { } Widget actionButtonWidget(BuildContext context, bool expanded) { - final destination = (currentIndex >= 0 && currentIndex < destinations.length) - ? destinations[currentIndex] - : null; + final destination = (currentIndex >= 0 && currentIndex < destinations.length) ? destinations[currentIndex] : null; // If there's a custom FAB widget, use it (DashboardFabs already // includes SyncPlay for the dashboard route). From e01a8b80f3c24c914fbd897cbc0df3b0fa4ddaa4 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Fri, 4 Sep 2026 13:45:02 +0200 Subject: [PATCH 13/14] fix(syncplay): send the socket token as ApiKey Jellyfin reads the socket token from `ApiKey` unconditionally, but from `api_key` only when `EnableLegacyAuthorization` is set, which Jellyfin 12 turns off by default. A socket sending `api_key` alone is refused before the handshake completes, so every SyncPlay create and join timed out on a stock 12 server while the REST API kept working off the Authorization header. Match the rest of the app and send `ApiKey`. Extract the URI builder as a free function so the parameters are covered by a test, matching isPhonePlatform and reconnectDelay in the same file. --- docs/syncplay-implementation.md | 2 +- .../websocket/jellyfin_websocket.dart | 47 ++++++++++------ .../jellyfin_websocket_uri_test.dart | 55 +++++++++++++++++++ 3 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 test/providers/websocket/jellyfin_websocket_uri_test.dart diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md index f56b6c0dc..5ae9c2514 100644 --- a/docs/syncplay-implementation.md +++ b/docs/syncplay-implementation.md @@ -109,7 +109,7 @@ Used for **requesting** state changes. The server processes these and broadcasts Used for **receiving** commands and state updates. Connect to: ``` -wss://{server}/socket?api_key={token}&deviceId={deviceId} +wss://{server}/socket?ApiKey={token}&deviceId={deviceId} ``` Message types received: diff --git a/lib/providers/websocket/jellyfin_websocket.dart b/lib/providers/websocket/jellyfin_websocket.dart index f07251b7d..ac549b554 100644 --- a/lib/providers/websocket/jellyfin_websocket.dart +++ b/lib/providers/websocket/jellyfin_websocket.dart @@ -45,6 +45,32 @@ Duration reconnectDelay(int attempt, {Random? random}) { return Duration(milliseconds: (cappedMs * (1 + jitter)).round()); } +/// The `/socket` URI, carrying the token as the `ApiKey` query parameter (the legacy `api_key` name is +/// only honoured while `EnableLegacyAuthorization` is on, which Jellyfin 12 turns off by default). +Uri buildWebSocketUri({ + required String serverUrl, + required String token, + required String deviceId, +}) { + final baseUri = Uri.parse(serverUrl); + final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; + final basePath = baseUri.path.replaceAll(RegExp(r'/+$'), ''); + return Uri( + scheme: scheme, + host: baseUri.host, + port: baseUri.port, + path: '$basePath/socket', + queryParameters: { + 'ApiKey': token, + 'deviceId': deviceId, + }, + ); +} + +/// [uri] with every token-bearing query parameter masked, for logging. +String redactSocketUri(Uri uri) => + uri.toString().replaceAllMapped(RegExp(r'(ApiKey)=[^&]+'), (match) => '${match[1]}=***'); + /// App-level shared connection; reconnects forever with capped backoff until [disconnect]. class JellyfinWebSocket { JellyfinWebSocket({ @@ -75,21 +101,10 @@ class JellyfinWebSocket { WebSocketConnectionState _currentState = WebSocketConnectionState.disconnected; WebSocketConnectionState get currentState => _currentState; - Uri get _webSocketUri { - final baseUri = Uri.parse(serverUrl); - final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; - final basePath = baseUri.path.replaceAll(RegExp(r'/+$'), ''); - return Uri( - scheme: scheme, - host: baseUri.host, - port: baseUri.port, - path: '$basePath/socket', - queryParameters: { - 'api_key': token, - 'deviceId': deviceId, - }, - ); - } + Uri get _webSocketUri => buildWebSocketUri(serverUrl: serverUrl, token: token, deviceId: deviceId); + + /// The socket URI with every token-bearing parameter masked, for logging. + String get _redactedUri => redactSocketUri(_webSocketUri); Future connect() async { if (_currentState == WebSocketConnectionState.connected || _currentState == WebSocketConnectionState.connecting) { @@ -101,7 +116,7 @@ class JellyfinWebSocket { WebSocketChannel? opened; try { - log('WebSocket: Connecting to ${_webSocketUri.toString().replaceAll(RegExp(r'api_key=[^&]+'), 'api_key=***')}'); + log('WebSocket: Connecting to $_redactedUri'); final channel = WebSocketChannel.connect(_webSocketUri); opened = channel; _channel = channel; diff --git a/test/providers/websocket/jellyfin_websocket_uri_test.dart b/test/providers/websocket/jellyfin_websocket_uri_test.dart new file mode 100644 index 000000000..02d3be955 --- /dev/null +++ b/test/providers/websocket/jellyfin_websocket_uri_test.dart @@ -0,0 +1,55 @@ +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buildWebSocketUri', () { + test('carries the token as ApiKey', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com', + token: 'tok-123', + deviceId: 'dev-abc', + ); + + // Jellyfin 12 ignores the legacy `api_key` name by default, so the socket must send `ApiKey`. + expect(uri.queryParameters['ApiKey'], 'tok-123'); + expect(uri.queryParameters.containsKey('api_key'), isFalse); + expect(uri.queryParameters['deviceId'], 'dev-abc'); + }); + + test('upgrades the scheme and keeps host, port and base path', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com:8920/jellyfin/', + token: 't', + deviceId: 'd', + ); + + expect(uri.scheme, 'wss'); + expect(uri.host, 'jelly.example.com'); + expect(uri.port, 8920); + expect(uri.path, '/jellyfin/socket'); + }); + + test('uses ws for a plain-http server', () { + final uri = buildWebSocketUri(serverUrl: 'http://192.168.1.10:8096', token: 't', deviceId: 'd'); + + expect(uri.scheme, 'ws'); + expect(uri.path, '/socket'); + }); + }); + + group('redactSocketUri', () { + test('masks the token but keeps the rest readable', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com', + token: 'super-secret', + deviceId: 'dev-abc', + ); + + final redacted = redactSocketUri(uri); + + expect(redacted, isNot(contains('super-secret'))); + expect(redacted, contains('ApiKey=***')); + expect(redacted, contains('deviceId=dev-abc')); + }); + }); +} From 79e249ebf99ecc8dc8f0d5d43c3604ace45fbba9 Mon Sep 17 00:00:00 2001 From: Filip Iricanin Date: Sat, 19 Sep 2026 12:33:21 +0200 Subject: [PATCH 14/14] fix(syncplay): switch items with the player open and keep a fast load out of Waiting The player registered `ref.listen(syncPlayProvider)` in init(), which made it a dependent of SyncPlay. The controller reads `videoPlayerProvider` back whenever the player route is open, and Riverpod's debug circular-dependency assertion then aborted every queue switch after the first start. Subscribe to the controller's state stream instead, and cancel it with the buffering debouncer in dispose(). loadPlaybackItem fired its Buffering report without awaiting it. An item that starts at 00:00 loads fast enough for the Ready to reach the server first, so the session was marked buffering again with no Ready to follow and the group stayed in Waiting. Await the report so the server always sees it before Ready. Document both scenarios and the behaviour decisions from the jellyfin-web audit. --- docs/syncplay-implementation.md | 34 ++++++++++++++++++++++++ lib/providers/video_player_provider.dart | 22 +++++++++------ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md index 5ae9c2514..273953e52 100644 --- a/docs/syncplay-implementation.md +++ b/docs/syncplay-implementation.md @@ -1431,6 +1431,10 @@ AGENTS.md #10. media URL via DevTools throttling/blocking). Confirm the group does not stick in Waiting; the failed client reports ready (isPlaying:false) and the group returns to Paused. +- [ ] **Fast load at 00:00 (web):** Two web clients; start an unwatched + episode so the group begins at 0:00. Confirm neither client's + Buffering report lands after its own Ready (the load-time Buffering + is awaited) and the group does not stay in Waiting. ### Next-Episode flow @@ -1438,6 +1442,11 @@ AGENTS.md #10. click Next Video while in a SyncPlay group. Confirm both clients switch within ~1-2s and the queue context is preserved (Previous Video still works). +- [ ] **Switch with the player open (debug build):** In a debug build, + press Next or Previous while the player route is open. Confirm no + `CircularDependencyError` is logged from `_isItemAttachable` and the + new episode loads (the player subscribes to the controller's state + stream instead of `ref.listen(syncPlayProvider)`). - [ ] **Rapid Next clicks:** Click Next twice within 1 second. Confirm the second click is NOT silently dropped (was: setNewQueue debounce). @@ -1487,3 +1496,28 @@ AGENTS.md #10. - [Jellyfin Web Client Source - PlaybackCore.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/PlaybackCore.js) - [Jellyfin Web Client Source - TimeSync.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/timeSync/TimeSync.js) - [NTP Clock Synchronization Algorithm](https://en.wikipedia.org/wiki/Network_Time_Protocol#Clock_synchronization_algorithm) + +--- + +## Behaviour decisions (audit follow-up, 2026-09-02) + +These decisions come from comparing the port against the jellyfin-web plugin and the server's group state machine. They are deliberate and tests pin most of them. + +- **State updates never pre-empt a command, but they are the last word.** `StateUpdate` frames update `SyncPlayState` and drive the overlay. The server sends the `Unpause` command *before* `StateUpdate(Playing)`, and the command carries the `When` every client must honour, so `StateUpdate(Playing)` never starts the player while a command is armed, queued or executing, while the player is buffering, or after a Pause/Stop (`SyncPlayCommandHandler.shouldRecoverPlayback`). With nothing of our own in flight, a paused player is started: the Unpause was lost, dropped by the player, or is still parked behind the clock gate (then it is applied at once with the unsynced clock). +- **`play()` is made to stick.** media-kit sometimes drops the first `play()` after a paused `open()` or a reload; on web the element's play promise is rejected when a load interrupts it and media-kit swallows the rejection. The SyncPlay `onPlay` callback (`_playUntilPlaying`) and `_ensureLocalTrackSwitchAutoplay` re-issue `play()` every 250 ms for up to 3 s until the backend really plays, and stop as soon as anything else pauses or stops the player. "Really plays" is `BasePlayer.isPlaying` (media-kit's own `state.playing`: `!pause` on libmpv, the element's play/pause events on web), never the wrapper's `lastState.playing`, which on mpv only records that the user did not pause and turns true on the first `play()` whether or not it took. That same real state feeds the SyncPlay `isPlaying` callback, so Ready reports and the missed-Unpause recovery are truthful too. +- **Duplicate commands correct state, they are not ignored.** The server re-sends an identical command as "here is the current state". A duplicate whose time is still in the future is already armed; a past duplicate re-applies Unpause when paused, Pause when playing or off position, Seek with a small random offset when off position, Stop when playing. +- **Commands are validated.** A command for a playlist item other than the current one is dropped (Stop excepted); commands are ignored while not in a group; while group playback is halted on this device they are only recorded (`SyncPlayCommandHandler.recordCommand`) so the group's playhead stays known; commands wait for the first clock measurement (`SyncPlayClock.isReady`) and only the latest one is queued, a resend of the queued command is re-queued rather than treated as a correction, and the wait is capped (`SyncPlayCommandHandler.clockFallback`, 2 s) so a broken `/GetUtcTime` never freezes the group. +- **Positions are reported from the live player** (`MediaControlsWrapper.lastState`), never from the throttled provider copy. A load and a Seek report the *requested* position, because libmpv can land a few hundred milliseconds off on keyframe-bound media and the server tolerates only 500 ms. +- **`isPlaying` is reported truthfully.** After a load or a Seek the player is paused and says so; the server ignores elapsed time for a paused client and only sends a corrective Seek when the position is off. +- **Spontaneous buffering is debounced for 3 s** (`BufferingReportDebouncer`) before it pauses the group; a reported stall is always closed by exactly one Ready. +- **Halt / resume.** Stopping the player (the close button on every platform, the Android back gesture out of the native activity, or "Stop group playback" in the group sheet) sets `isFollowingGroupPlayback = false`, stops the local player and sends `SetIgnoreWait(true)`; the flags flip before the request goes out so closing never waits on the network. The group no longer waits for this device. "Resume playback" in the sheet (or a brand-new playlist from another participant) sends `SetIgnoreWait(false)` and reloads at the live group position, estimated from the last recorded command or, failing that, from the last `PlayQueue` frame's `StartPositionTicks` + elapsed (`SyncPlayQueueTiming`). Mirrors jellyfin-web `haltGroupPlayback` / `resumeGroupPlayback` / `QueueCore.startPlayback`. +- **A silent rejoin keeps the halt.** `GroupJoined` never touches `isFollowingGroupPlayback`; only a user-initiated join or create sets it. After a transparent rejoin a halted device re-sends `SetIgnoreWait(true)` (the server-side flag died with the evicted session) and ignores the `PlayQueue` frame the server replays for the rejoin, recognised as the first queue frame within 5 s of that `GroupJoined`. Known gaps of the heuristic, both rare: an idle group replays no queue frame, so a participant's genuine `NewPlaylist` inside that window is ignored once by a halted device; and a `GroupJoined` that lands after the 12 s join timeout is no longer known to be silent, so its replayed frame un-halts a halted device. The rejoin toast is suppressed for silent rejoins. A failed join (`NotInGroup` / `GroupDoesNotExist` while not in a group) never stops what the user is watching. +- **Stop means stop.** A `Stop` command ends playback and closes the player route (jellyfin-web `localStop`). +- **Same item, no reload.** A `PlayQueue` frame for the item already loaded in an open player is satisfied with a pause, a seek if more than 500 ms off, and a Ready. This covers silent rejoin after a socket drop and creating a group while watching. +- **Create while watching seeds the group** with this device's queue and position through `SetNewQueue`, because the server seeds from the session's `NowPlayingQueue`, which this client does not report. Only an item showing in the video player qualifies; music in the audio queue is not attachable and would be reloaded through the video path. A create whose `GroupJoined` never arrives is reported as a failure. Caveat: the server seeds the new group from the session's `FullNowPlayingItem`, which this client provides through its normal playback reports; if that report failed, the group starts Idle and its `SessionJoined` sends a `Stop` that closes the player before the seed's `PlayQueue` reopens it. +- **Next / previous go through the server queue** (`NextItem`, `PreviousItem`, `SetPlaylistItem`); `SetNewQueue` is only used for an item outside the group queue. The server's playlist-item guard drops the duplicate requests every participant's next-up timer fires simultaneously. +- **User gestures.** Pause is applied locally at once and requested; seek is applied locally (paused) and requested, the Unpause after everyone's Ready resumes; the progress bar sends exactly one Seek on release and never pauses the group on drag start. Playback speed controls are locked in a group. +- **Native Android tags say what happened.** The native controls tag the next `PlaybackState` frame with `PlaybackChangeSource.userPlayPause` or `userSeek` (Pigeon enum). ExoPlayer consumes a seek tag on the very next frame (the position moves synchronously, even when the seek drops the player into buffering) and a play/pause tag only when `isPlaying` actually flips, so a seek that rebuffers is never read as a pause and a small skip never leaves a tag behind for an unrelated transition. +- **Socket reconnection never gives up** (exponential backoff capped at 30 s with jitter). On phones only `resumed` is handled, and a healthy socket just gets a KeepAlive. Coming back online forces a reconnect. A reconnect silently rejoins the last group, because the server drops the member when the socket closes. Close events are matched to the channel that raised them, so a channel replaced by `forceReconnect` can never null out its successor or open a third socket. +- **Drift correction** is throttled to one check per 1.5 s and can be turned off in player settings; corrections show in the badge only, not in the centre overlay. +- **One overlay for everyone.** `resolveSyncPlayOverlay` decides what the centre overlay shows, in priority order: a queue switch, a command being applied on this device, then the group's Waiting state ("Waiting for others…"). The same resolution feeds the native Android overlay through `SyncPlayCommandType.waiting`, so while the server holds the group for a buffering participant every device shows the same thing, instead of only the device that happens to be executing a command. diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 097bf6207..c36d9f6d0 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -53,8 +53,10 @@ class VideoPlayerNotifier extends StateNotifier { /// Debounces spontaneous buffering before it is reported to the group; recreated on every [init]. BufferingReportDebouncer? _bufferingDebouncer; - /// Kept so a re-[init] does not stack native-overlay listeners. - ProviderSubscription? _syncPlayStateSubscription; + /// Kept so a re-[init] does not stack native-overlay listeners. A stream subscription, not `ref.listen`: + /// listening to `syncPlayProvider` would make this provider its dependent, and the SyncPlay controller + /// reads `videoPlayerProvider` back, which trips Riverpod's circular-dependency assertion in debug builds. + StreamSubscription? _syncPlayStateSubscription; SyncPlayCommandType _lastNativeOverlayType = SyncPlayCommandType.none; /// Bumped by everything that pauses or stops the player so a running [_playUntilPlaying] loop gives up. @@ -83,6 +85,8 @@ class VideoPlayerNotifier extends StateNotifier { @override void dispose() { settingsChanged?.close(); + _syncPlayStateSubscription?.cancel(); + _bufferingDebouncer?.dispose(); super.dispose(); } @@ -167,12 +171,11 @@ class VideoPlayerNotifier extends StateNotifier { } void _setupSyncPlayStateListener() { - _syncPlayStateSubscription?.close(); + _syncPlayStateSubscription?.cancel(); _lastNativeOverlayType = SyncPlayCommandType.none; - _syncPlayStateSubscription = ref.listen( - syncPlayProvider, - (previous, next) => _forwardNativeOverlay(next), - ); + _syncPlayStateSubscription = ref.read(syncPlayProvider.notifier).controller.stateStream.listen( + _forwardNativeOverlay, + ); } /// Pushed right before the native activity opens so it does not wait for the next state change. @@ -395,7 +398,10 @@ class VideoPlayerNotifier extends StateNotifier { final loadPositionTicks = startPosition.inMicroseconds * 10; if (reportingForSyncPlay) { _isLoadingForSyncPlay = true; - ref.read(syncPlayProvider.notifier).reportBuffering(positionTicks: loadPositionTicks); + // Awaited: the server handles requests in arrival order, and a fast load (an item starting at 00:00) + // let the Ready below overtake this Buffering, leaving the session marked buffering with no Ready to + // follow and the whole group stuck in Waiting. + await ref.read(syncPlayProvider.notifier).reportBuffering(positionTicks: loadPositionTicks); } final useMinimizedPlayer =