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/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() ) ) } 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/bootstrap/platform/base_app_wrapper.dart b/lib/bootstrap/platform/base_app_wrapper.dart index 29242469e..ace97da96 100644 --- a/lib/bootstrap/platform/base_app_wrapper.dart +++ b/lib/bootstrap/platform/base_app_wrapper.dart @@ -16,7 +16,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'; @@ -46,6 +48,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(); @@ -143,6 +151,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/l10n/app_en.arb b/lib/l10n/app_en.arb index 65fc489e4..52cd40a6b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1069,6 +1069,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", @@ -1536,6 +1646,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 cc9f299dc..0388fdb84 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/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 1192ed36a..97828211d 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); @@ -537,7 +587,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; } @@ -547,7 +600,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)), @@ -639,9 +720,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/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 296b49fc0..1186ab997 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.dispose(); await state.init(); @@ -44,6 +78,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); @@ -52,10 +99,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( @@ -88,13 +275,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; @@ -112,35 +305,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, @@ -149,12 +398,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( @@ -288,4 +560,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 diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b6c5ab624..24f7ba1ae 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/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 721e9f37f..4884ba0bb 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(); } @@ -350,6 +350,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState class _NextUpInformation extends StatelessWidget { final ItemBaseModel item; + const _NextUpInformation({ required this.item, }); @@ -440,19 +441,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 f1e7c9c05..d3214f573 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'; @@ -422,10 +423,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(); } }, ); @@ -463,10 +475,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 e751dace7..6927e34d5 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -55,6 +55,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); @@ -67,6 +93,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( @@ -83,11 +110,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; @@ -100,9 +133,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 c5ef094f6..1d647b50c 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_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/items/media_streams_model.dart'; @@ -19,10 +10,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'; @@ -41,7 +34,15 @@ 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'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class DesktopControls extends ConsumerStatefulWidget { const DesktopControls({super.key}); @@ -144,7 +145,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) @@ -185,6 +188,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)); @@ -244,7 +248,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", @@ -310,6 +314,7 @@ class _DesktopControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -432,7 +437,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, @@ -512,9 +517,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( @@ -546,7 +552,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), @@ -689,7 +695,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(() { @@ -716,7 +722,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) { @@ -768,7 +774,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(); } @@ -776,7 +785,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(); } @@ -850,7 +876,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { _deactivateSpeedBoost(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } return KeyEventResult.handled; } @@ -879,7 +905,7 @@ class _DesktopControlsState extends ConsumerState { } else if (tapX > zoneThird * 2) { seekForwardWithIndicator(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } _doubleTapPosition = null; } @@ -1010,7 +1036,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { return false; } - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); @@ -1075,10 +1101,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/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/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index 35d42f9e3..7500d6e7a 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/playlist_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) { @@ -157,6 +163,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) { @@ -611,6 +619,15 @@ 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( @@ -631,6 +648,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); } } @@ -643,6 +662,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; @@ -668,6 +779,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, @@ -682,6 +805,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) { @@ -695,6 +820,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) { @@ -789,19 +922,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) { @@ -823,7 +997,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { child: Row( spacing: 16, children: [ - if (item != null) + if (widget.item != null) Flexible( child: Container( decoration: FladderTheme.defaultPosterDecoration, @@ -848,7 +1022,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { ), clipBehavior: Clip.hardEdge, child: FladderImage( - image: item!.getPosters?.primary, + image: widget.item!.getPosters?.primary, fit: BoxFit.cover, ), ), @@ -872,9 +1046,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, ), ], @@ -889,7 +1063,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { tooltip: context.localized.close, onPressed: () { try { - op.cancel(); + widget.op.cancel(); } catch (_) {} Navigator.of(context, rootNavigator: true).pop(); }, @@ -918,6 +1092,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/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/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 21d40c79e..df170cd5e 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -1,10 +1,5 @@ -import 'package:flutter/material.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:fladder/models/collection_types.dart'; import 'package:fladder/models/settings/client_settings_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; @@ -28,6 +23,9 @@ import 'package:fladder/widgets/shared/custom_tooltip.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/simple_overflow_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; final navBarNode = FocusNode(); @@ -196,7 +194,7 @@ class _SideNavigationRail extends ConsumerState { 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), ), ), ], @@ -439,6 +437,25 @@ class _SideNavigationRail extends ConsumerState { 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 6c56e2dbb..2232cfbf9 100644 --- a/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart +++ b/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart @@ -89,7 +89,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 1768dda46..c28c85f66 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -119,7 +119,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(), + ); +} diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index c9372b07c..2ffa11e6b 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'; @@ -37,6 +30,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'; @@ -61,6 +59,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; @@ -183,6 +182,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 { @@ -582,7 +594,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, + ); } } @@ -593,7 +608,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, + ); } } @@ -630,6 +648,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 6da11dd7a..3f36dcc9c 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -72,7 +72,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 { @@ -108,6 +110,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; } } 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); } diff --git a/pubspec.lock b/pubspec.lock index 914472fba..bf77e2726 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2451,7 +2451,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 14bf8ecab..e84301858 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', + ); + } + }); + }); +}