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..a823ebe7f 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt @@ -334,4 +334,144 @@ 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))) + } + } + } + fun syncPlayStateWaiting(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayStateWaiting$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index 440f8edf4..54822a100 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -93,6 +93,22 @@ enum class PlaybackType(val raw: Int) { } } +enum class SyncPlayCommandType(val raw: Int) { + NONE(0), + PAUSE(1), + UNPAUSE(2), + SEEK(3), + STOP(4), + /** The group is waiting for a participant to buffer; shown so every device reports the same state. */ + WAITING(5); + + companion object { + fun ofRaw(raw: Int): SyncPlayCommandType? { + return values().firstOrNull { it.raw == raw } + } + } +} + enum class MediaSegmentType(val raw: Int) { COMMERCIAL(0), PREVIEW(1), @@ -107,6 +123,27 @@ enum class MediaSegmentType(val raw: Int) { } } +/** Who caused the last playback state change; Flutter infers SyncPlay requests from it. */ +enum class PlaybackChangeSource(val raw: Int) { + /** No specific source (e.g. periodic update, buffering). */ + NONE(0), + /** User pressed play or pause on native; Flutter turns it into the SyncPlay request. */ + USER_PLAY_PAUSE(1), + /** + * User seeked on native; the frame carries the new position even when the seek drops the player into + * buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + */ + USER_SEEK(2), + /** Change was caused by applying a SyncPlay command; do not send again. */ + SYNCPLAY(3); + + 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 +524,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 +538,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 +551,7 @@ data class PlaybackState ( buffering, completed, failed, + changeSource, ) } override fun equals(other: Any?): Boolean { @@ -709,75 +750,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 +842,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 +1000,8 @@ interface VideoPlayerApi { fun seekTo(position: Long) fun stop() fun setSubtitleSettings(settings: SubtitleSettings) + /** Drives the native player's SyncPlay overlay. */ + fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) companion object { /** The codec used by VideoPlayerApi. */ @@ -1150,6 +1211,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) + } + } } } } 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..630138b62 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -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_SEEK) player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS)) } } @@ -473,6 +475,7 @@ internal fun RowScope.SimpleProgressBar( }, onDragEnd = { onScrubbingChanged(false) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player.seekTo(internalTempPosition) }, onDragCancel = { @@ -655,6 +658,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player.pause() } val newPos = max( @@ -676,6 +680,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) 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_SEEK) 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_PLAY_PAUSE) player.play() true } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt index 0a11c08f9..f028e1ace 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -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_SEEK) + 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_SEEK) + VideoPlayerObject.implementation.player?.seekTo(it.end.toLong()) } } ) { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index f425790e4..d63dd6d47 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -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 @@ -80,7 +82,6 @@ import nl.jknaapen.fladder.utility.leanBackEnabled import nl.jknaapen.fladder.utility.visible import kotlin.time.Duration.Companion.seconds - @OptIn(UnstableApi::class) @Composable fun CustomVideoControls( @@ -147,6 +148,8 @@ fun CustomVideoControls( LaunchedEffect(lastSeekInteraction.longValue) { delay(1.seconds) if (currentSkipTime == 0L) return@LaunchedEffect + // Tagged as a user action; Flutter infers the SyncPlay request from the state stream. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_SEEK) player?.seekTo(position + currentSkipTime) currentSkipTime = 0L } @@ -172,12 +175,15 @@ fun CustomVideoControls( } Key.MediaPlay -> { + // Tagged as a user action; Flutter infers the SyncPlay request from the state stream. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) player?.play() return@keyEvent true } Key.MediaPlayPause -> { player?.let { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) if (it.isPlaying) { it.pause() updateLastInteraction() @@ -186,10 +192,10 @@ fun CustomVideoControls( } } return@keyEvent true - } Key.MediaPause, Key.P -> { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) 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_SEEK) + 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_SEEK) + player.seekTo((player.currentPosition - backwardSpeed.inWholeMilliseconds).coerceAtLeast(0L)) }, ) { Box( @@ -472,6 +479,7 @@ fun PlaybackButtons( .defaultSelected(true), enableScaledFocus = true, onClick = { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER_PLAY_PAUSE) 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_SEEK) + player.seekTo(player.currentPosition + forwardSpeed.inWholeMilliseconds) }, ) { Box( @@ -534,7 +541,6 @@ internal fun RowScope.LeftButtons( val chapters by VideoPlayerObject.chapters.collectAsState(emptyList()) val isTVMode by VideoPlayerObject.implementation.isTVMode.collectAsState(false) - Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt new file mode 100644 index 000000000..f582e1f5d --- /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 io.github.rabehx.iconsax.filled.Timer1 +import SyncPlayCommandType +import nl.jknaapen.fladder.objects.Localized +import nl.jknaapen.fladder.objects.Translate +import nl.jknaapen.fladder.objects.VideoPlayerObject + +/** Mirrors the Flutter SyncPlayCommandIndicator. */ +@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) + SyncPlayCommandType.WAITING -> Localized.syncPlayStateWaiting(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) + SyncPlayCommandType.WAITING -> Pair(Iconsax.Filled.Timer1, MaterialTheme.colorScheme.tertiary) + 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..8a22e2500 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -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,34 @@ object VideoPlayerObject { guideVisible.value = !guideVisible.value } + 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 + } + + /** Read without consuming; ExoPlayer decides per frame whether the tag belongs to it. */ + fun peekPendingPlaybackChangeSource(): PlaybackChangeSource? = pendingPlaybackChangeSource + + /** Consumed when building PlaybackState in ExoPlayer; clears after read. */ + fun getAndClearPendingPlaybackChangeSource(): PlaybackChangeSource? { + val r = pendingPlaybackChangeSource + 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..a5bcdfdb1 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -1,5 +1,6 @@ package nl.jknaapen.fladder.player +import kotlin.math.abs import PlaybackState import android.app.ActivityManager import android.os.Handler @@ -122,20 +123,39 @@ internal fun ExoPlayer( ) } - fun updatePlaybackState() { - videoHost.setPlaybackState( - PlaybackState( - position = exoPlayer.currentPosition, - buffered = exoPlayer.bufferedPosition, - duration = exoPlayer.duration, - playing = exoPlayer.isPlaying, - buffering = exoPlayer.playbackState == Player.STATE_BUFFERING, - completed = exoPlayer.playbackState == Player.STATE_ENDED, - failed = exoPlayer.playbackState == Player.STATE_IDLE - ) + // The user-action tag must only be consumed by the frame carrying the transition it announced; the + // periodic poll would otherwise spend it on a no-op frame and the pause/seek would never reach SyncPlay. + val emitted = remember { EmittedPlaybackState(exoPlayer.isPlaying, exoPlayer.currentPosition) } + + fun buildPlaybackState(playbackState: Int): PlaybackState { + val playing = exoPlayer.isPlaying + val position = exoPlayer.currentPosition + val consume = when (videoHost.peekPendingPlaybackChangeSource()) { + null -> false + // seekTo moves currentPosition synchronously, so the very next frame carries the new position. + PlaybackChangeSource.USER_SEEK -> true + // Play/pause announces itself once isPlaying flips or the position jumps; buffering frames keep the tag. + else -> playing != emitted.playing || abs(position - emitted.position) > 2000L + } + val changeSource = if (consume) videoHost.getAndClearPendingPlaybackChangeSource() else null + emitted.playing = playing + emitted.position = position + return PlaybackState( + position = position, + buffered = exoPlayer.bufferedPosition, + duration = exoPlayer.duration, + playing = playing, + buffering = playbackState == Player.STATE_BUFFERING, + completed = playbackState == Player.STATE_ENDED, + failed = playbackState == Player.STATE_IDLE, + changeSource = changeSource ) } + fun updatePlaybackState() { + videoHost.setPlaybackState(buildPlaybackState(exoPlayer.playbackState)) + } + LaunchedEffect(exoPlayer) { while (true) { updatePlaybackState() @@ -159,17 +179,7 @@ internal fun ExoPlayer( } override fun onPlaybackStateChanged(playbackState: Int) { - videoHost.setPlaybackState( - PlaybackState( - position = exoPlayer.currentPosition, - buffered = exoPlayer.bufferedPosition, - duration = exoPlayer.duration, - playing = exoPlayer.isPlaying, - buffering = playbackState == Player.STATE_BUFFERING, - completed = playbackState == Player.STATE_ENDED, - failed = playbackState == Player.STATE_IDLE - ) - ) + videoHost.setPlaybackState(buildPlaybackState(playbackState)) } override fun onEvents(player: Player, events: Player.Events) { @@ -311,3 +321,6 @@ internal fun ExoPlayer( } } } + +/** Last playing flag and position sent to Flutter; a frame that differs from it is a transition. */ +class EmittedPlaybackState(var playing: Boolean, var position: Long) diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md new file mode 100644 index 000000000..273953e52 --- /dev/null +++ b/docs/syncplay-implementation.md @@ -0,0 +1,1523 @@ +# 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?ApiKey={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. +- [ ] **Fast load at 00:00 (web):** Two web clients; start an unwatched + episode so the group begins at 0:00. Confirm neither client's + Buffering report lands after its own Ready (the load-time Buffering + is awaited) and the group does not stay in Waiting. + +### Next-Episode flow + +- [ ] **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). +- [ ] **Switch with the player open (debug build):** In a debug build, + press Next or Previous while the player route is open. Confirm no + `CircularDependencyError` is logged from `_isItemAttachable` and the + new episode loads (the player subscribes to the controller's state + stream instead of `ref.listen(syncPlayProvider)`). +- [ ] **Rapid Next clicks:** Click Next twice within 1 second. Confirm + the second click is NOT silently dropped (was: setNewQueue + debounce). +- [ ] **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) + +--- + +## Behaviour decisions (audit follow-up, 2026-09-02) + +These decisions come from comparing the port against the jellyfin-web plugin and the server's group state machine. They are deliberate and tests pin most of them. + +- **State updates never pre-empt a command, but they are the last word.** `StateUpdate` frames update `SyncPlayState` and drive the overlay. The server sends the `Unpause` command *before* `StateUpdate(Playing)`, and the command carries the `When` every client must honour, so `StateUpdate(Playing)` never starts the player while a command is armed, queued or executing, while the player is buffering, or after a Pause/Stop (`SyncPlayCommandHandler.shouldRecoverPlayback`). With nothing of our own in flight, a paused player is started: the Unpause was lost, dropped by the player, or is still parked behind the clock gate (then it is applied at once with the unsynced clock). +- **`play()` is made to stick.** media-kit sometimes drops the first `play()` after a paused `open()` or a reload; on web the element's play promise is rejected when a load interrupts it and media-kit swallows the rejection. The SyncPlay `onPlay` callback (`_playUntilPlaying`) and `_ensureLocalTrackSwitchAutoplay` re-issue `play()` every 250 ms for up to 3 s until the backend really plays, and stop as soon as anything else pauses or stops the player. "Really plays" is `BasePlayer.isPlaying` (media-kit's own `state.playing`: `!pause` on libmpv, the element's play/pause events on web), never the wrapper's `lastState.playing`, which on mpv only records that the user did not pause and turns true on the first `play()` whether or not it took. That same real state feeds the SyncPlay `isPlaying` callback, so Ready reports and the missed-Unpause recovery are truthful too. +- **Duplicate commands correct state, they are not ignored.** The server re-sends an identical command as "here is the current state". A duplicate whose time is still in the future is already armed; a past duplicate re-applies Unpause when paused, Pause when playing or off position, Seek with a small random offset when off position, Stop when playing. +- **Commands are validated.** A command for a playlist item other than the current one is dropped (Stop excepted); commands are ignored while not in a group; while group playback is halted on this device they are only recorded (`SyncPlayCommandHandler.recordCommand`) so the group's playhead stays known; commands wait for the first clock measurement (`SyncPlayClock.isReady`) and only the latest one is queued, a resend of the queued command is re-queued rather than treated as a correction, and the wait is capped (`SyncPlayCommandHandler.clockFallback`, 2 s) so a broken `/GetUtcTime` never freezes the group. +- **Positions are reported from the live player** (`MediaControlsWrapper.lastState`), never from the throttled provider copy. A load and a Seek report the *requested* position, because libmpv can land a few hundred milliseconds off on keyframe-bound media and the server tolerates only 500 ms. +- **`isPlaying` is reported truthfully.** After a load or a Seek the player is paused and says so; the server ignores elapsed time for a paused client and only sends a corrective Seek when the position is off. +- **Spontaneous buffering is debounced for 3 s** (`BufferingReportDebouncer`) before it pauses the group; a reported stall is always closed by exactly one Ready. +- **Halt / resume.** Stopping the player (the close button on every platform, the Android back gesture out of the native activity, or "Stop group playback" in the group sheet) sets `isFollowingGroupPlayback = false`, stops the local player and sends `SetIgnoreWait(true)`; the flags flip before the request goes out so closing never waits on the network. The group no longer waits for this device. "Resume playback" in the sheet (or a brand-new playlist from another participant) sends `SetIgnoreWait(false)` and reloads at the live group position, estimated from the last recorded command or, failing that, from the last `PlayQueue` frame's `StartPositionTicks` + elapsed (`SyncPlayQueueTiming`). Mirrors jellyfin-web `haltGroupPlayback` / `resumeGroupPlayback` / `QueueCore.startPlayback`. +- **A silent rejoin keeps the halt.** `GroupJoined` never touches `isFollowingGroupPlayback`; only a user-initiated join or create sets it. After a transparent rejoin a halted device re-sends `SetIgnoreWait(true)` (the server-side flag died with the evicted session) and ignores the `PlayQueue` frame the server replays for the rejoin, recognised as the first queue frame within 5 s of that `GroupJoined`. Known gaps of the heuristic, both rare: an idle group replays no queue frame, so a participant's genuine `NewPlaylist` inside that window is ignored once by a halted device; and a `GroupJoined` that lands after the 12 s join timeout is no longer known to be silent, so its replayed frame un-halts a halted device. The rejoin toast is suppressed for silent rejoins. A failed join (`NotInGroup` / `GroupDoesNotExist` while not in a group) never stops what the user is watching. +- **Stop means stop.** A `Stop` command ends playback and closes the player route (jellyfin-web `localStop`). +- **Same item, no reload.** A `PlayQueue` frame for the item already loaded in an open player is satisfied with a pause, a seek if more than 500 ms off, and a Ready. This covers silent rejoin after a socket drop and creating a group while watching. +- **Create while watching seeds the group** with this device's queue and position through `SetNewQueue`, because the server seeds from the session's `NowPlayingQueue`, which this client does not report. Only an item showing in the video player qualifies; music in the audio queue is not attachable and would be reloaded through the video path. A create whose `GroupJoined` never arrives is reported as a failure. Caveat: the server seeds the new group from the session's `FullNowPlayingItem`, which this client provides through its normal playback reports; if that report failed, the group starts Idle and its `SessionJoined` sends a `Stop` that closes the player before the seed's `PlayQueue` reopens it. +- **Next / previous go through the server queue** (`NextItem`, `PreviousItem`, `SetPlaylistItem`); `SetNewQueue` is only used for an item outside the group queue. The server's playlist-item guard drops the duplicate requests every participant's next-up timer fires simultaneously. +- **User gestures.** Pause is applied locally at once and requested; seek is applied locally (paused) and requested, the Unpause after everyone's Ready resumes; the progress bar sends exactly one Seek on release and never pauses the group on drag start. Playback speed controls are locked in a group. +- **Native Android tags say what happened.** The native controls tag the next `PlaybackState` frame with `PlaybackChangeSource.userPlayPause` or `userSeek` (Pigeon enum). ExoPlayer consumes a seek tag on the very next frame (the position moves synchronously, even when the seek drops the player into buffering) and a play/pause tag only when `isPlaying` actually flips, so a seek that rebuffers is never read as a pause and a small skip never leaves a tag behind for an unrelated transition. +- **Socket reconnection never gives up** (exponential backoff capped at 30 s with jitter). On phones only `resumed` is handled, and a healthy socket just gets a KeepAlive. Coming back online forces a reconnect. A reconnect silently rejoins the last group, because the server drops the member when the socket closes. Close events are matched to the channel that raised them, so a channel replaced by `forceReconnect` can never null out its successor or open a third socket. +- **Drift correction** is throttled to one check per 1.5 s and can be turned off in player settings; corrections show in the badge only, not in the centre overlay. +- **One overlay for everyone.** `resolveSyncPlayOverlay` decides what the centre overlay shows, in priority order: a queue switch, a command being applied on this device, then the group's Waiting state ("Waiting for others…"). The same resolution feeds the native Android overlay through `SyncPlayCommandType.waiting`, so while the server holds the group for a buffering participant every device shows the same thing, instead of only the device that happens to be executing a command. diff --git a/lib/bootstrap/platform/base_app_wrapper.dart b/lib/bootstrap/platform/base_app_wrapper.dart index 06d96929d..bdfb41e97 100644 --- a/lib/bootstrap/platform/base_app_wrapper.dart +++ b/lib/bootstrap/platform/base_app_wrapper.dart @@ -17,7 +17,9 @@ import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/update_notifications_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket_provider.dart'; import 'package:fladder/routes/auto_router.dart'; +import 'package:fladder/providers/router_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/login/lock_screen.dart'; import 'package:fladder/services/notification_service.dart'; @@ -47,6 +49,12 @@ abstract class BaseAppWrapperState extends ConsumerSta @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + ref.read(routerProvider.notifier).state = autoRouter; + }); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) async { ref.read(sharedUtilityProvider).loadSettings(); @@ -144,6 +152,10 @@ abstract class BaseAppWrapperState extends ConsumerSta @override Widget build(BuildContext context) { + // Activate the app-level Jellyfin WebSocket for the whole session. + // The provider connects/disconnects itself off userProvider; this + // watch only ensures the keepAlive provider is instantiated. + ref.watch(jellyfinWebSocketControllerProvider); return widget.builder( context, autoRouter, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1892ce825..2e4bce3f3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1091,6 +1091,130 @@ "@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": {}, + "syncPlayHaltPlayback": "Stop group playback on this device", + "@syncPlayHaltPlayback": {}, + "syncPlayNotFollowing": "Not following group playback", + "@syncPlayNotFollowing": {}, + "syncPlaySpeedLocked": "Playback speed is managed by SyncPlay", + "@syncPlaySpeedLocked": {}, + "syncPlayLibraryAccessDenied": "You don't have access to some items in this group's queue", + "@syncPlayLibraryAccessDenied": {}, + "syncPlayAccessDisabled": "SyncPlay is not enabled for your account", + "@syncPlayAccessDisabled": {}, + "syncPlayEnableCorrection": "Drift correction", + "@syncPlayEnableCorrection": {}, + "syncPlayEnableCorrectionDesc": "Speed up or skip playback slightly to stay in sync with the group", + "@syncPlayEnableCorrectionDesc": {}, "syncDeleteItemDesc": "Delete all synced data for {item}?", "@syncDeleteItemDesc": { "description": "Sync delete item pop-up window", @@ -1558,6 +1682,8 @@ "hasLikedDirector": "Has liked director", "hasLikedActor": "Has liked actor", "latest": "Latest", + "leave": "Leave", + "@leave": {}, "recommended": "Recommended", "playbackType": "Playback type", "playbackTypeDirect": "Direct", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cfa2f9504..c2dfca79c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -567,6 +567,129 @@ "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": {}, + "syncPlayHaltPlayback": "Arrêter la lecture de groupe sur cet appareil", + "@syncPlayHaltPlayback": {}, + "syncPlayNotFollowing": "Lecture de groupe suspendue sur cet appareil", + "@syncPlayNotFollowing": {}, + "syncPlaySpeedLocked": "La vitesse de lecture est gérée par SyncPlay", + "@syncPlaySpeedLocked": {}, + "syncPlayLibraryAccessDenied": "Vous n'avez pas accès à certains éléments de la file de ce groupe", + "@syncPlayLibraryAccessDenied": {}, + "syncPlayAccessDisabled": "SyncPlay n'est pas activé pour votre compte", + "@syncPlayAccessDisabled": {}, + "syncPlayEnableCorrection": "Correction de la dérive", + "@syncPlayEnableCorrection": {}, + "syncPlayEnableCorrectionDesc": "Accélère ou saute légèrement la lecture pour rester synchronisé avec le groupe", + "@syncPlayEnableCorrectionDesc": {}, "syncDeleteItemDesc": "Supprimer toutes les données synchronisées pour {item} ?", "@syncDeleteItemDesc": { "description": "Fenêtre contextuelle de suppression d'élément synchronisé", diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 401ec15bd..5afa328c6 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,58 @@ class PlaybackModelHelper { JellyService get api => ref.read(jellyApiProvider); + Future _ensureLocalTrackSwitchAutoplay() async { + // media-kit on web sometimes drops the first play() calls after a reload, so re-issue until the backend's + // real state confirms playing. In a SyncPlay group the group state decides whether to play at all. + for (var attempt = 0; attempt < 12; attempt++) { + final player = ref.read(videoPlayerProvider); + if (player.isPlayerPlaying) { + return; + } + if (ref.read(isSyncPlayActiveProvider) && + ref.read(syncPlayProvider.select((s) => s.groupState)) != SyncPlayGroupState.playing) { + return; + } + await player.play(); + await Future.delayed(const Duration(milliseconds: 250)); + } + } + Future loadNewVideo(ItemBaseModel newItem) async { + if (ref.read(isSyncPlayActiveProvider)) { + // Adjacent moves use NextItem/PreviousItem, whose playlist-item guard lets the server drop the duplicate + // requests every participant fires at once; only an item outside the queue replaces it (whole local queue). + final syncPlay = ref.read(syncPlayProvider.notifier); + final syncPlayState = ref.read(syncPlayProvider); + final navigation = resolveQueueNavigation( + playlist: syncPlayState.playlist, + playingItemIndex: syncPlayState.playingItemIndex, + targetItemId: newItem.id, + ); + switch (navigation) { + case SyncPlayQueueNavigation.next: + await syncPlay.requestNextItem(); + break; + case SyncPlayQueueNavigation.previous: + await syncPlay.requestPreviousItem(); + break; + case SyncPlayQueueNavigation.setCurrentItem: + final entry = syncPlayState.playlist.firstWhere((entry) => entry.itemId == newItem.id); + await syncPlay.requestSetPlaylistItem(entry.playlistItemId); + break; + case SyncPlayQueueNavigation.newQueue: + final currentQueue = ref.read(playBackModel)?.queue.map((item) => item.id).toList() ?? const []; + final itemIds = currentQueue.contains(newItem.id) ? currentQueue : [newItem.id]; + await syncPlay.setNewQueue( + itemIds: itemIds, + playingItemPosition: itemIds.indexOf(newItem.id), + startPositionTicks: 0, + ); + break; + } + return null; + } + ref.read(videoPlayerProvider).pause(); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(buffering: true)); final currentModel = ref.read(playBackModel); @@ -543,7 +601,10 @@ class PlaybackModelHelper { return Response(response.base, (response.body?.items?.map((e) => EpisodeModel.fromBaseDto(e, ref)).toList() ?? [])); } - Future shouldReload(PlaybackModel playbackModel) async { + Future shouldReload( + PlaybackModel playbackModel, { + bool isLocalTrackSwitch = false, + }) async { if (playbackModel is OfflinePlaybackModel) { return; } @@ -553,7 +614,29 @@ class PlaybackModelHelper { final userId = ref.read(userProvider)?.id; if (userId?.isEmpty == true) return; - final currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); + 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, + ); + + // The stale SyncPlayState.positionTicks would reload at an old position and SkipToSync forward visibly. + final positionTicks = ref.read(syncPlayProvider.notifier).estimateCurrentGroupPositionTicks(); + currentPosition = Duration(milliseconds: ticksToMilliseconds(positionTicks)); + + if (shouldReportGroupBuffering) { + // Report buffering before stop/reload only when this reload should affect group flow. + await ref.read(syncPlayProvider.notifier).reportBuffering(); + } + } else { + currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); + } final audioIndex = selectAudioStream( ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), @@ -645,9 +728,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/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 996491e8c..b1156efa3 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -111,6 +111,11 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(true) bool enableCrossfade, @Default(400) int crossfadeDurationMs, @Default(false) bool ambientBlur, + + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + @Default(true) bool enableSyncPlayCorrection, }) = _VideoPlayerSettingsModel; double get volume { diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 61568659c..392c2bf51 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -45,12 +45,18 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { int get crossfadeDurationMs; bool get ambientBlur; + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + bool get enableSyncPlayCorrection; + /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $VideoPlayerSettingsModelCopyWith get copyWith => - _$VideoPlayerSettingsModelCopyWithImpl(this as VideoPlayerSettingsModel, _$identity); + _$VideoPlayerSettingsModelCopyWithImpl( + this as VideoPlayerSettingsModel, _$identity); /// Serializes this VideoPlayerSettingsModel to a JSON map. Map toJson(); @@ -79,28 +85,32 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('enableSpeedBoost', enableSpeedBoost)) ..add(DiagnosticsProperty('speedBoostRate', speedBoostRate)) ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) - ..add(DiagnosticsProperty('enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty( + 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) - ..add(DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) + ..add( + DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) ..add(DiagnosticsProperty('enableReplayGain', enableReplayGain)) ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) - ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); + ..add(DiagnosticsProperty('ambientBlur', ambientBlur)) + ..add(DiagnosticsProperty( + 'enableSyncPlayCorrection', enableSyncPlayCorrection)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur, enableSyncPlayCorrection: $enableSyncPlayCorrection)'; } } /// @nodoc abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { - factory $VideoPlayerSettingsModelCopyWith( - VideoPlayerSettingsModel value, $Res Function(VideoPlayerSettingsModel) _then) = + factory $VideoPlayerSettingsModelCopyWith(VideoPlayerSettingsModel value, + $Res Function(VideoPlayerSettingsModel) _then) = _$VideoPlayerSettingsModelCopyWithImpl; @useResult $Res call( @@ -133,11 +143,13 @@ abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur}); + bool ambientBlur, + bool enableSyncPlayCorrection}); } /// @nodoc -class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettingsModelCopyWith<$Res> { +class _$VideoPlayerSettingsModelCopyWithImpl<$Res> + implements $VideoPlayerSettingsModelCopyWith<$Res> { _$VideoPlayerSettingsModelCopyWithImpl(this._self, this._then); final VideoPlayerSettingsModel _self; @@ -178,6 +190,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin Object? enableCrossfade = null, Object? crossfadeDurationMs = null, Object? ambientBlur = null, + Object? enableSyncPlayCorrection = null, }) { return _then(_self.copyWith( screenBrightness: freezed == screenBrightness @@ -300,6 +313,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> implements $VideoPlayerSettin ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable as bool, + enableSyncPlayCorrection: null == enableSyncPlayCorrection + ? _self.enableSyncPlayCorrection + : enableSyncPlayCorrection // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -427,7 +444,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur)? + bool ambientBlur, + bool enableSyncPlayCorrection)? $default, { required TResult orElse(), }) { @@ -464,7 +482,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: return orElse(); } @@ -515,7 +534,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur) + bool ambientBlur, + bool enableSyncPlayCorrection) $default, ) { final _that = this; @@ -551,7 +571,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: throw StateError('Unexpected subclass'); } @@ -601,7 +622,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur)? + bool ambientBlur, + bool enableSyncPlayCorrection)? $default, ) { final _that = this; @@ -637,7 +659,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.enablePlayPauseFade, _that.enableCrossfade, _that.crossfadeDurationMs, - _that.ambientBlur); + _that.ambientBlur, + _that.enableSyncPlayCorrection); case _: return null; } @@ -646,7 +669,8 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { /// @nodoc @JsonSerializable() -class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with DiagnosticableTreeMixin { +class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel + with DiagnosticableTreeMixin { _VideoPlayerSettingsModel( {this.screenBrightness, this.videoFit = BoxFit.contain, @@ -662,7 +686,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic this.maxHomeBitrate = Bitrate.original, this.maxInternetBitrate = Bitrate.original, this.audioDevice, - final Map segmentSkipSettings = defaultSegmentSkipValues, + final Map segmentSkipSettings = + defaultSegmentSkipValues, final Map hotKeys = const {}, this.screensaver = Screensaver.logo, this.enableSpeedBoost = false, @@ -677,12 +702,14 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic this.enablePlayPauseFade = true, this.enableCrossfade = true, this.crossfadeDurationMs = 400, - this.ambientBlur = false}) + this.ambientBlur = false, + this.enableSyncPlayCorrection = true}) : _allowedOrientations = allowedOrientations, _segmentSkipSettings = segmentSkipSettings, _hotKeys = hotKeys, super._(); - factory _VideoPlayerSettingsModel.fromJson(Map json) => _$VideoPlayerSettingsModelFromJson(json); + factory _VideoPlayerSettingsModel.fromJson(Map json) => + _$VideoPlayerSettingsModelFromJson(json); @override final double? screenBrightness; @@ -714,7 +741,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic Set? get allowedOrientations { final value = _allowedOrientations; if (value == null) return null; - if (_allowedOrientations is EqualUnmodifiableSetView) return _allowedOrientations; + if (_allowedOrientations is EqualUnmodifiableSetView) + return _allowedOrientations; // ignore: implicit_dynamic_type return EqualUnmodifiableSetView(value); } @@ -734,7 +762,8 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic @override @JsonKey() Map get segmentSkipSettings { - if (_segmentSkipSettings is EqualUnmodifiableMapView) return _segmentSkipSettings; + if (_segmentSkipSettings is EqualUnmodifiableMapView) + return _segmentSkipSettings; // ignore: implicit_dynamic_type return EqualUnmodifiableMapView(_segmentSkipSettings); } @@ -791,13 +820,21 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic @JsonKey() final bool ambientBlur; + /// SyncPlay drift correction (SpeedToSync / SkipToSync). On by + /// default: libmpv and ExoPlayer drift enough over a long film that + /// a group without it visibly desynchronises. + @override + @JsonKey() + final bool enableSyncPlayCorrection; + /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') _$VideoPlayerSettingsModelCopyWith<_VideoPlayerSettingsModel> get copyWith => - __$VideoPlayerSettingsModelCopyWithImpl<_VideoPlayerSettingsModel>(this, _$identity); + __$VideoPlayerSettingsModelCopyWithImpl<_VideoPlayerSettingsModel>( + this, _$identity); @override Map toJson() { @@ -830,28 +867,33 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel with Diagnostic ..add(DiagnosticsProperty('enableSpeedBoost', enableSpeedBoost)) ..add(DiagnosticsProperty('speedBoostRate', speedBoostRate)) ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) - ..add(DiagnosticsProperty('enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty( + 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) - ..add(DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) + ..add( + DiagnosticsProperty('enablePictureInPicture', enablePictureInPicture)) ..add(DiagnosticsProperty('enableReplayGain', enableReplayGain)) ..add(DiagnosticsProperty('replayGainVolumeLevel', replayGainVolumeLevel)) ..add(DiagnosticsProperty('enablePlayPauseFade', enablePlayPauseFade)) ..add(DiagnosticsProperty('enableCrossfade', enableCrossfade)) ..add(DiagnosticsProperty('crossfadeDurationMs', crossfadeDurationMs)) - ..add(DiagnosticsProperty('ambientBlur', ambientBlur)); + ..add(DiagnosticsProperty('ambientBlur', ambientBlur)) + ..add(DiagnosticsProperty( + 'enableSyncPlayCorrection', enableSyncPlayCorrection)); } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs, ambientBlur: $ambientBlur, enableSyncPlayCorrection: $enableSyncPlayCorrection)'; } } /// @nodoc -abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> implements $VideoPlayerSettingsModelCopyWith<$Res> { - factory _$VideoPlayerSettingsModelCopyWith( - _VideoPlayerSettingsModel value, $Res Function(_VideoPlayerSettingsModel) _then) = +abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> + implements $VideoPlayerSettingsModelCopyWith<$Res> { + factory _$VideoPlayerSettingsModelCopyWith(_VideoPlayerSettingsModel value, + $Res Function(_VideoPlayerSettingsModel) _then) = __$VideoPlayerSettingsModelCopyWithImpl; @override @useResult @@ -885,11 +927,13 @@ abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> implements $VideoP bool enablePlayPauseFade, bool enableCrossfade, int crossfadeDurationMs, - bool ambientBlur}); + bool ambientBlur, + bool enableSyncPlayCorrection}); } /// @nodoc -class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSettingsModelCopyWith<$Res> { +class __$VideoPlayerSettingsModelCopyWithImpl<$Res> + implements _$VideoPlayerSettingsModelCopyWith<$Res> { __$VideoPlayerSettingsModelCopyWithImpl(this._self, this._then); final _VideoPlayerSettingsModel _self; @@ -930,6 +974,7 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett Object? enableCrossfade = null, Object? crossfadeDurationMs = null, Object? ambientBlur = null, + Object? enableSyncPlayCorrection = null, }) { return _then(_VideoPlayerSettingsModel( screenBrightness: freezed == screenBrightness @@ -1052,6 +1097,10 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> implements _$VideoPlayerSett ? _self.ambientBlur : ambientBlur // ignore: cast_nullable_to_non_nullable as bool, + enableSyncPlayCorrection: null == enableSyncPlayCorrection + ? _self.enableSyncPlayCorrection + : enableSyncPlayCorrection // ignore: cast_nullable_to_non_nullable + as bool, )); } } diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index ea3c322c1..a4a8ee4a1 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -6,50 +6,71 @@ part of 'video_player_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -_VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson(Map json) => _VideoPlayerSettingsModel( +_VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson( + Map json) => + _VideoPlayerSettingsModel( screenBrightness: (json['screenBrightness'] as num?)?.toDouble(), - videoFit: $enumDecodeNullable(_$BoxFitEnumMap, json['videoFit']) ?? BoxFit.contain, + videoFit: $enumDecodeNullable(_$BoxFitEnumMap, json['videoFit']) ?? + BoxFit.contain, fillScreen: json['fillScreen'] as bool? ?? false, hardwareAccel: json['hardwareAccel'] as bool? ?? true, useLibass: json['useLibass'] as bool? ?? true, enableTunneling: json['enableTunneling'] as bool? ?? false, bufferSize: (json['bufferSize'] as num?)?.toInt() ?? 32, - playerOptions: $enumDecodeNullable(_$PlayerOptionsEnumMap, json['playerOptions']), + playerOptions: + $enumDecodeNullable(_$PlayerOptionsEnumMap, json['playerOptions']), internalVolume: (json['internalVolume'] as num?)?.toDouble() ?? 100, allowedOrientations: (json['allowedOrientations'] as List?) ?.map((e) => $enumDecode(_$DeviceOrientationEnumMap, e)) .toSet(), - nextVideoType: $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? AutoNextType.smart, - maxHomeBitrate: $enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ?? Bitrate.original, - maxInternetBitrate: $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? Bitrate.original, + nextVideoType: + $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? + AutoNextType.smart, + maxHomeBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ?? + Bitrate.original, + maxInternetBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? + Bitrate.original, audioDevice: json['audioDevice'] as String?, - segmentSkipSettings: (json['segmentSkipSettings'] as Map?)?.map( - (k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k), $enumDecode(_$SegmentSkipEnumMap, e)), - ) ?? - defaultSegmentSkipValues, + segmentSkipSettings: + (json['segmentSkipSettings'] as Map?)?.map( + (k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k), + $enumDecode(_$SegmentSkipEnumMap, e)), + ) ?? + defaultSegmentSkipValues, hotKeys: (json['hotKeys'] as Map?)?.map( - (k, e) => - MapEntry($enumDecode(_$VideoHotKeysEnumMap, k), KeyCombination.fromJson(e as Map)), + (k, e) => MapEntry($enumDecode(_$VideoHotKeysEnumMap, k), + KeyCombination.fromJson(e as Map)), ) ?? const {}, - screensaver: $enumDecodeNullable(_$ScreensaverEnumMap, json['screensaver']) ?? Screensaver.logo, + screensaver: + $enumDecodeNullable(_$ScreensaverEnumMap, json['screensaver']) ?? + Screensaver.logo, enableSpeedBoost: json['enableSpeedBoost'] as bool? ?? false, speedBoostRate: (json['speedBoostRate'] as num?)?.toDouble() ?? 2.0, enableDoubleTapSeek: json['enableDoubleTapSeek'] as bool? ?? true, - enableAdvancedVideoOptions: json['enableAdvancedVideoOptions'] as bool? ?? false, + enableAdvancedVideoOptions: + json['enableAdvancedVideoOptions'] as bool? ?? false, enableEdgeGestures: json['enableEdgeGestures'] as bool? ?? true, reverseEdgeGestures: json['reverseEdgeGestures'] as bool? ?? false, enablePictureInPicture: json['enablePictureInPicture'] as bool? ?? true, enableReplayGain: json['enableReplayGain'] as bool? ?? true, - replayGainVolumeLevel: $enumDecodeNullable(_$ReplayGainVolumeLevelEnumMap, json['replayGainVolumeLevel']) ?? + replayGainVolumeLevel: $enumDecodeNullable( + _$ReplayGainVolumeLevelEnumMap, json['replayGainVolumeLevel']) ?? ReplayGainVolumeLevel.quiet, enablePlayPauseFade: json['enablePlayPauseFade'] as bool? ?? true, enableCrossfade: json['enableCrossfade'] as bool? ?? true, - crossfadeDurationMs: (json['crossfadeDurationMs'] as num?)?.toInt() ?? 400, + crossfadeDurationMs: + (json['crossfadeDurationMs'] as num?)?.toInt() ?? 400, ambientBlur: json['ambientBlur'] as bool? ?? false, + enableSyncPlayCorrection: + json['enableSyncPlayCorrection'] as bool? ?? true, ); -Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel instance) => { +Map _$VideoPlayerSettingsModelToJson( + _VideoPlayerSettingsModel instance) => + { 'screenBrightness': instance.screenBrightness, 'videoFit': _$BoxFitEnumMap[instance.videoFit]!, 'fillScreen': instance.fillScreen, @@ -59,14 +80,17 @@ Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel 'bufferSize': instance.bufferSize, 'playerOptions': _$PlayerOptionsEnumMap[instance.playerOptions], 'internalVolume': instance.internalVolume, - 'allowedOrientations': instance.allowedOrientations?.map((e) => _$DeviceOrientationEnumMap[e]!).toList(), + 'allowedOrientations': instance.allowedOrientations + ?.map((e) => _$DeviceOrientationEnumMap[e]!) + .toList(), 'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!, 'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!, 'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!, 'audioDevice': instance.audioDevice, - 'segmentSkipSettings': - instance.segmentSkipSettings.map((k, e) => MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), - 'hotKeys': instance.hotKeys.map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)), + 'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) => + MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), + 'hotKeys': instance.hotKeys + .map((k, e) => MapEntry(_$VideoHotKeysEnumMap[k]!, e)), 'screensaver': _$ScreensaverEnumMap[instance.screensaver]!, 'enableSpeedBoost': instance.enableSpeedBoost, 'speedBoostRate': instance.speedBoostRate, @@ -76,11 +100,13 @@ Map _$VideoPlayerSettingsModelToJson(_VideoPlayerSettingsModel 'reverseEdgeGestures': instance.reverseEdgeGestures, 'enablePictureInPicture': instance.enablePictureInPicture, 'enableReplayGain': instance.enableReplayGain, - 'replayGainVolumeLevel': _$ReplayGainVolumeLevelEnumMap[instance.replayGainVolumeLevel]!, + 'replayGainVolumeLevel': + _$ReplayGainVolumeLevelEnumMap[instance.replayGainVolumeLevel]!, 'enablePlayPauseFade': instance.enablePlayPauseFade, 'enableCrossfade': instance.enableCrossfade, 'crossfadeDurationMs': instance.crossfadeDurationMs, 'ambientBlur': instance.ambientBlur, + 'enableSyncPlayCorrection': instance.enableSyncPlayCorrection, }; const _$BoxFitEnumMap = { diff --git a/lib/models/syncplay/syncplay_models.dart b/lib/models/syncplay/syncplay_models.dart new file mode 100644 index 000000000..c0b993792 --- /dev/null +++ b/lib/models/syncplay/syncplay_models.dart @@ -0,0 +1,432 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'syncplay_models.freezed.dart'; + +@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; + + /// Positive means the server is ahead of the 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); +} + +enum SyncPlayGroupState { + idle, + waiting, + paused, + playing, +} + +enum SyncPlayCommand { + pause('Pause'), + unpause('Unpause'), + seek('Seek'), + stop('Stop'); + + const SyncPlayCommand(this.wire); + + final String wire; + + /// 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; + } +} + +enum SyncCorrectionStrategy { + none, + speedToSync, + skipToSync, +} + +/// Defaults match the 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, + ); + } +} + +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, + ); + } +} + +/// SpeedToSync first, then SkipToSync, mirroring the official precedence. +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; +} + +/// `playlistItemId` is the server-generated id used by NextItem/PreviousItem/SetPlaylistItem. +class SyncPlayQueueEntry { + const SyncPlayQueueEntry({ + required this.itemId, + required this.playlistItemId, + }); + + final String itemId; + final String playlistItemId; + + @override + bool operator ==(Object other) { + return other is SyncPlayQueueEntry && other.itemId == itemId && other.playlistItemId == playlistItemId; + } + + @override + int get hashCode => Object.hash(itemId, playlistItemId); +} + +/// How a local "play this item" request maps onto the group queue. +enum SyncPlayQueueNavigation { + /// Target is the entry right after the playing one: `NextItem`. + next, + + /// Target is the entry right before the playing one: `PreviousItem`. + previous, + + /// Target is elsewhere in the queue: `SetPlaylistItem`. + setCurrentItem, + + /// Target is not in the group queue: `SetNewQueue`. + newQueue, +} + +/// Using the server-side queue keeps every participant's context intact and lets the server de-duplicate. +SyncPlayQueueNavigation resolveQueueNavigation({ + required List playlist, + required int playingItemIndex, + required String targetItemId, +}) { + final targetIndex = playlist.indexWhere((entry) => entry.itemId == targetItemId); + if (targetIndex < 0) { + return SyncPlayQueueNavigation.newQueue; + } + if (targetIndex == playingItemIndex + 1) { + return SyncPlayQueueNavigation.next; + } + if (targetIndex == playingItemIndex - 1) { + return SyncPlayQueueNavigation.previous; + } + return SyncPlayQueueNavigation.setCurrentItem; +} + +/// Shared by the Flutter player and the native Android activity so every participant sees the same thing. +enum SyncPlayOverlay { + none, + + /// A queue switch is loading the next item. + switching, + + /// A command from the server is being applied on this device. + command, + + /// The server is holding the group for a participant to buffer. + waiting, +} + +SyncPlayOverlay resolveSyncPlayOverlay(SyncPlayState state) { + if (!state.isInGroup || !state.isFollowingGroupPlayback) { + return SyncPlayOverlay.none; + } + if (state.startPlaybackInProgress) { + return SyncPlayOverlay.switching; + } + if (state.isProcessingCommand && state.processingCommandType != null) { + return SyncPlayOverlay.command; + } + if (state.groupState == SyncPlayGroupState.waiting) { + return SyncPlayOverlay.waiting; + } + return SyncPlayOverlay.none; +} + +/// Lets a device that stopped following the group estimate the live position when it resumes. +@Freezed(copyWith: true) +abstract class SyncPlayQueueTiming with _$SyncPlayQueueTiming { + const SyncPlayQueueTiming._(); + + const factory SyncPlayQueueTiming({ + required int startPositionTicks, + DateTime? lastUpdate, + @Default(false) bool isPlaying, + }) = _SyncPlayQueueTiming; + + /// Frozen while paused, extrapolated from [lastUpdate] while playing. + int positionTicksAt(DateTime remoteNow) { + final since = lastUpdate; + if (!isPlaying || since == null) { + return startPositionTicks; + } + return startPositionTicks + millisecondsToTicks(remoteNow.difference(since).inMilliseconds); + } +} + +/// Prefers the last command for the current item (recorded even while not following), then the last +/// `PlayQueue` timing; [fallbackPositionTicks] can be tens of seconds stale. +int estimateGroupPositionTicks({ + required LastSyncPlayCommand? lastCommand, + required String? currentPlaylistItemId, + required SyncPlayQueueTiming? queueTiming, + required int fallbackPositionTicks, + required DateTime remoteNow, +}) { + if (lastCommand != null) { + final forCurrentItem = lastCommand.playlistItemId.isEmpty || + currentPlaylistItemId == null || + lastCommand.playlistItemId == currentPlaylistItemId; + final when = DateTime.tryParse(lastCommand.when); + if (forCurrentItem && when != null) { + // Only Unpause moves the playhead; Pause/Seek leave it at the command's position. + if (lastCommand.command != SyncPlayCommand.unpause) { + return lastCommand.positionTicks; + } + return lastCommand.positionTicks + millisecondsToTicks(remoteNow.difference(when).inMilliseconds); + } + } + if (queueTiming != null) { + return queueTiming.positionTicksAt(remoteNow); + } + return fallbackPositionTicks; +} + +@Freezed(copyWith: true) +abstract class SyncPlayState with _$SyncPlayState { + const SyncPlayState._(); + + 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, + @Default(false) bool isProcessingCommand, + SyncPlayCommand? processingCommandType, + @Default(SyncCorrectionConfig()) SyncCorrectionConfig correctionConfig, + @Default(SyncCorrectionState()) SyncCorrectionState correctionState, + + /// True while a `_startPlayback` call is in flight (loader UX). + @Default(false) bool startPlaybackInProgress, + + /// Dedup key for concurrent PlayQueue updates racing each other. + String? startingPlaylistItemId, + + /// While > 0, `reportBuffering`/`reportReady` are suppressed so local reloads don't pause the group. + @Default(0) int localOnlyOperationCount, + + /// False after the user halted group playback on this device (`SetIgnoreWait(true)`): commands are + /// ignored until the user resumes or a new playlist is set. + @Default(true) bool isFollowingGroupPlayback, + + /// Local copy of the group play queue from the last `PlayQueue` frame. + @Default([]) List playlist, + + /// Index of the playing entry in [playlist], -1 when nothing plays. + @Default(-1) int playingItemIndex, + + /// Timing of the last `PlayQueue` frame; null until one arrived. + SyncPlayQueueTiming? queueTiming, + }) = _SyncPlayState; + + bool get isActive => isConnected && isInGroup; + + /// Entry after the playing one, if any. + SyncPlayQueueEntry? get nextQueueEntry { + final index = playingItemIndex + 1; + return index > 0 && index < playlist.length ? playlist[index] : null; + } + + /// Entry before the playing one, if any. + SyncPlayQueueEntry? get previousQueueEntry { + final index = playingItemIndex - 1; + return index >= 0 && index < playlist.length ? playlist[index] : null; + } + + /// True when local-only mode is active (audio/subtitle switch, etc.). + bool get isInLocalOnlyMode => localOnlyOperationCount > 0; + + /// The group has an item the local user could re-attach to ("Resume playback" outside the player route). + bool get hasActivePlayback => isInGroup && playingItemId != null && groupState != SyncPlayGroupState.idle; +} + +/// 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; +} + +const int ticksPerMillisecond = 10000; +const int ticksPerSecond = 10000000; + +int secondsToTicks(double seconds) => (seconds * ticksPerSecond).round(); + +double ticksToSeconds(int ticks) => ticks / ticksPerSecond; + +int millisecondsToTicks(int ms) => ms * ticksPerMillisecond; + +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..bd26cac33 --- /dev/null +++ b/lib/models/syncplay/syncplay_models.freezed.dart @@ -0,0 +1,1760 @@ +// 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 _$SyncPlayQueueTiming { + int get startPositionTicks; + DateTime? get lastUpdate; + bool get isPlaying; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith get copyWith => + _$SyncPlayQueueTimingCopyWithImpl(this as SyncPlayQueueTiming, _$identity); + + @override + String toString() { + return 'SyncPlayQueueTiming(startPositionTicks: $startPositionTicks, lastUpdate: $lastUpdate, isPlaying: $isPlaying)'; + } +} + +/// @nodoc +abstract mixin class $SyncPlayQueueTimingCopyWith<$Res> { + factory $SyncPlayQueueTimingCopyWith(SyncPlayQueueTiming value, $Res Function(SyncPlayQueueTiming) _then) = + _$SyncPlayQueueTimingCopyWithImpl; + @useResult + $Res call({int startPositionTicks, DateTime? lastUpdate, bool isPlaying}); +} + +/// @nodoc +class _$SyncPlayQueueTimingCopyWithImpl<$Res> implements $SyncPlayQueueTimingCopyWith<$Res> { + _$SyncPlayQueueTimingCopyWithImpl(this._self, this._then); + + final SyncPlayQueueTiming _self; + final $Res Function(SyncPlayQueueTiming) _then; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? startPositionTicks = null, + Object? lastUpdate = freezed, + Object? isPlaying = null, + }) { + return _then(_self.copyWith( + startPositionTicks: null == startPositionTicks + ? _self.startPositionTicks + : startPositionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastUpdate: freezed == lastUpdate + ? _self.lastUpdate + : lastUpdate // ignore: cast_nullable_to_non_nullable + as DateTime?, + isPlaying: null == isPlaying + ? _self.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// Adds pattern-matching-related methods to [SyncPlayQueueTiming]. +extension SyncPlayQueueTimingPatterns on SyncPlayQueueTiming { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SyncPlayQueueTiming value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_SyncPlayQueueTiming value) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SyncPlayQueueTiming value)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying) $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming(): + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(int startPositionTicks, DateTime? lastUpdate, bool isPlaying)? $default, + ) { + final _that = this; + switch (_that) { + case _SyncPlayQueueTiming() when $default != null: + return $default(_that.startPositionTicks, _that.lastUpdate, _that.isPlaying); + case _: + return null; + } + } +} + +/// @nodoc + +class _SyncPlayQueueTiming extends SyncPlayQueueTiming { + const _SyncPlayQueueTiming({required this.startPositionTicks, this.lastUpdate, this.isPlaying = false}) : super._(); + + @override + final int startPositionTicks; + @override + final DateTime? lastUpdate; + @override + @JsonKey() + final bool isPlaying; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$SyncPlayQueueTimingCopyWith<_SyncPlayQueueTiming> get copyWith => + __$SyncPlayQueueTimingCopyWithImpl<_SyncPlayQueueTiming>(this, _$identity); + + @override + String toString() { + return 'SyncPlayQueueTiming(startPositionTicks: $startPositionTicks, lastUpdate: $lastUpdate, isPlaying: $isPlaying)'; + } +} + +/// @nodoc +abstract mixin class _$SyncPlayQueueTimingCopyWith<$Res> implements $SyncPlayQueueTimingCopyWith<$Res> { + factory _$SyncPlayQueueTimingCopyWith(_SyncPlayQueueTiming value, $Res Function(_SyncPlayQueueTiming) _then) = + __$SyncPlayQueueTimingCopyWithImpl; + @override + @useResult + $Res call({int startPositionTicks, DateTime? lastUpdate, bool isPlaying}); +} + +/// @nodoc +class __$SyncPlayQueueTimingCopyWithImpl<$Res> implements _$SyncPlayQueueTimingCopyWith<$Res> { + __$SyncPlayQueueTimingCopyWithImpl(this._self, this._then); + + final _SyncPlayQueueTiming _self; + final $Res Function(_SyncPlayQueueTiming) _then; + + /// Create a copy of SyncPlayQueueTiming + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? startPositionTicks = null, + Object? lastUpdate = freezed, + Object? isPlaying = null, + }) { + return _then(_SyncPlayQueueTiming( + startPositionTicks: null == startPositionTicks + ? _self.startPositionTicks + : startPositionTicks // ignore: cast_nullable_to_non_nullable + as int, + lastUpdate: freezed == lastUpdate + ? _self.lastUpdate + : lastUpdate // ignore: cast_nullable_to_non_nullable + as DateTime?, + isPlaying: null == isPlaying + ? _self.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +mixin _$SyncPlayState { + bool get isConnected; + 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; + + /// False after the user halted group playback on this device + /// (`SetIgnoreWait(true)`): the server no longer waits for us and we + /// ignore playback commands until the user resumes or a new + /// playlist is set (jellyfin-web `followingGroupPlayback`). + bool get isFollowingGroupPlayback; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + List get playlist; + + /// Index of the playing entry in [playlist], -1 when nothing plays. + int get playingItemIndex; + + /// Timing of the last `PlayQueue` frame; null until one arrived. + SyncPlayQueueTiming? get queueTiming; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @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, isFollowingGroupPlayback: $isFollowingGroupPlayback, playlist: $playlist, playingItemIndex: $playingItemIndex, queueTiming: $queueTiming)'; + } +} + +/// @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, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming}); + + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming; +} + +/// @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, + Object? isFollowingGroupPlayback = null, + Object? playlist = null, + Object? playingItemIndex = null, + Object? queueTiming = freezed, + }) { + 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, + isFollowingGroupPlayback: null == isFollowingGroupPlayback + ? _self.isFollowingGroupPlayback + : isFollowingGroupPlayback // ignore: cast_nullable_to_non_nullable + as bool, + playlist: null == playlist + ? _self.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as List, + playingItemIndex: null == playingItemIndex + ? _self.playingItemIndex + : playingItemIndex // ignore: cast_nullable_to_non_nullable + as int, + queueTiming: freezed == queueTiming + ? _self.queueTiming + : queueTiming // ignore: cast_nullable_to_non_nullable + as SyncPlayQueueTiming?, + )); + } + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming { + if (_self.queueTiming == null) { + return null; + } + + return $SyncPlayQueueTimingCopyWith<$Res>(_self.queueTiming!, (value) { + return _then(_self.copyWith(queueTiming: value)); + }); + } +} + +/// Adds pattern-matching-related methods to [SyncPlayState]. +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, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming)? + $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, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); + 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, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming) + $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, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); + 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, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming)? + $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, + _that.isFollowingGroupPlayback, + _that.playlist, + _that.playingItemIndex, + _that.queueTiming); + 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, + this.isFollowingGroupPlayback = true, + final List playlist = const [], + this.playingItemIndex = -1, + this.queueTiming}) + : _participants = participants, + _playlist = playlist, + 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; + + /// False after the user halted group playback on this device + /// (`SetIgnoreWait(true)`): the server no longer waits for us and we + /// ignore playback commands until the user resumes or a new + /// playlist is set (jellyfin-web `followingGroupPlayback`). + @override + @JsonKey() + final bool isFollowingGroupPlayback; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + final List _playlist; + + /// Local copy of the group play queue from the last `PlayQueue` frame. + @override + @JsonKey() + List get playlist { + if (_playlist is EqualUnmodifiableListView) return _playlist; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_playlist); + } + + /// Index of the playing entry in [playlist], -1 when nothing plays. + @override + @JsonKey() + final int playingItemIndex; + + /// Timing of the last `PlayQueue` frame; null until one arrived. + @override + final SyncPlayQueueTiming? queueTiming; + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @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, isFollowingGroupPlayback: $isFollowingGroupPlayback, playlist: $playlist, playingItemIndex: $playingItemIndex, queueTiming: $queueTiming)'; + } +} + +/// @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, + bool isFollowingGroupPlayback, + List playlist, + int playingItemIndex, + SyncPlayQueueTiming? queueTiming}); + + @override + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming; +} + +/// @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, + Object? isFollowingGroupPlayback = null, + Object? playlist = null, + Object? playingItemIndex = null, + Object? queueTiming = freezed, + }) { + 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, + isFollowingGroupPlayback: null == isFollowingGroupPlayback + ? _self.isFollowingGroupPlayback + : isFollowingGroupPlayback // ignore: cast_nullable_to_non_nullable + as bool, + playlist: null == playlist + ? _self._playlist + : playlist // ignore: cast_nullable_to_non_nullable + as List, + playingItemIndex: null == playingItemIndex + ? _self.playingItemIndex + : playingItemIndex // ignore: cast_nullable_to_non_nullable + as int, + queueTiming: freezed == queueTiming + ? _self.queueTiming + : queueTiming // ignore: cast_nullable_to_non_nullable + as SyncPlayQueueTiming?, + )); + } + + /// Create a copy of SyncPlayState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SyncPlayQueueTimingCopyWith<$Res>? get queueTiming { + if (_self.queueTiming == null) { + return null; + } + + return $SyncPlayQueueTimingCopyWith<$Res>(_self.queueTiming!, (value) { + return _then(_self.copyWith(queueTiming: value)); + }); + } +} + +/// @nodoc +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/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index 4a7e50b90..b188e62e5 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -151,6 +151,8 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier state = state.copyWith(enableDoubleTapSeek: value); + void setEnableSyncPlayCorrection(bool value) => state = state.copyWith(enableSyncPlayCorrection: value); + void setEnableAdvancedVideoOptions(bool value) => state = state.copyWith(enableAdvancedVideoOptions: value); void setEnableEdgeGestures(bool value) => state = state.copyWith(enableEdgeGestures: value); diff --git a/lib/providers/syncplay/buffering_report_debouncer.dart b/lib/providers/syncplay/buffering_report_debouncer.dart new file mode 100644 index 000000000..463c91bfd --- /dev/null +++ b/lib/providers/syncplay/buffering_report_debouncer.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +/// A Buffering report pauses every participant, but libmpv/ExoPlayer flip their flag on every seek and +/// cache dip: stalls shorter than [threshold] never reach the server; a reported one gets exactly one [onReady]. +class BufferingReportDebouncer { + BufferingReportDebouncer({ + required this.onBuffering, + required this.onReady, + this.threshold = const Duration(seconds: 3), + }); + + final void Function() onBuffering; + final void Function() onReady; + final Duration threshold; + + Timer? _timer; + bool _reported = false; + + /// True between a reported Buffering and its closing Ready. + bool get isReported => _reported; + + /// True while a stall is being timed but has not been reported yet. + bool get isPending => _timer?.isActive == true; + + void update(bool buffering) { + if (buffering) { + if (_reported || isPending) { + return; + } + _timer = Timer(threshold, () { + _timer = null; + _reported = true; + onBuffering(); + }); + return; + } + + _timer?.cancel(); + _timer = null; + if (_reported) { + _reported = false; + onReady(); + } + } + + /// Forgets any pending or reported stall without emitting anything (media reload, player teardown). + void reset() { + _timer?.cancel(); + _timer = null; + _reported = false; + } + + void dispose() => reset(); +} diff --git a/lib/providers/syncplay/handlers/syncplay_command_handler.dart b/lib/providers/syncplay/handlers/syncplay_command_handler.dart new file mode 100644 index 000000000..ce843dc5b --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_command_handler.dart @@ -0,0 +1,492 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:math' show Random; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; + +typedef SyncPlayPlayerCallback = Future Function(); +typedef SyncPlaySeekCallback = Future Function(int positionTicks); +typedef SyncPlayPositionCallback = int Function(); +typedef SyncPlayReportReadyCallback = Future Function({required bool isPlaying, required int positionTicks}); +typedef SyncPlaySetSpeedCallback = Future Function(double speed); + +class _ParsedCommand { + const _ParsedCommand({ + required this.command, + required this.when, + required this.whenStr, + required this.positionTicks, + required this.playlistItemId, + }); + + final SyncPlayCommand command; + final DateTime when; + final String whenStr; + final int positionTicks; + final String playlistItemId; +} + +class _PendingCommand { + const _PendingCommand(this.command, this.when, this.positionTicks); + + final SyncPlayCommand command; + final DateTime when; + final int positionTicks; +} + +class SyncPlayCommandHandler { + SyncPlayCommandHandler({ + required this.timeSync, + required this.onStateUpdate, + Random? random, + this.clockFallback = const Duration(seconds: 2), + }) : _random = random ?? Random(); + + /// How long a command may wait for the first clock measurement before it is applied with the unsynced + /// clock; a broken `/GetUtcTime` must not freeze the group forever. + final Duration clockFallback; + Timer? _clockFallbackTimer; + + /// Commands later than this are dropped (typically the server replaying its backlog after a long + /// disconnect); the server's `StateUpdate` resyncs us. + static const _staleCommandThreshold = Duration(seconds: 30); + + /// Matches the server's `MaxPlaybackOffset` (500 ms) so we never sit outside the window it accepts. + static const int positionToleranceTicks = 500 * ticksPerMillisecond; + + /// Jitter applied when re-seeking on a duplicate Seek so the server sees a fresh position. + static const int _duplicateSeekJitterMs = 100; + + final SyncPlayClock? Function() timeSync; + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + final Random _random; + + LastSyncPlayCommand? _lastCommand; + + Timer? _commandTimer; + + // Command received before the clock was ready. + _PendingCommand? _queuedCommand; + + SyncPlayPlayerCallback? onPlay; + SyncPlayPlayerCallback? onPause; + SyncPlaySeekCallback? onSeek; + SyncPlayPlayerCallback? onStop; + SyncPlayPositionCallback? getPositionTicks; + bool Function()? isPlaying; + bool Function()? isBuffering; + + SyncPlaySeekCallback? onSeekRequested; + + SyncPlayReportReadyCallback? onReportReady; + + SyncPlaySetSpeedCallback? onSetSpeed; + bool Function()? hasPlaybackRate; + + LastSyncPlayCommand? get lastCommand => _lastCommand; + + /// True while a command is armed or executing; callers must not drive the player themselves meanwhile. + bool get hasPendingCommand => _commandTimer?.isActive == true || _isExecuting; + + bool _isExecuting = false; + + /// True while a command is parked waiting for the first clock measurement. + bool get hasQueuedCommand => _queuedCommand != null; + + /// Records without executing, used while not following group playback: [lastCommand] must keep + /// describing the group's playhead or a later resume starts from a stale position. + void recordCommand(Map data) { + final parsed = _parseCommand(data); + if (parsed == null) { + return; + } + _lastCommand = LastSyncPlayCommand( + when: parsed.whenStr, + positionTicks: parsed.positionTicks, + command: parsed.command, + playlistItemId: parsed.playlistItemId, + ); + onStateUpdate((state) => state.copyWith(positionTicks: parsed.positionTicks)); + log('SyncPlay: Recorded ${parsed.command.wire} at ${parsed.positionTicks} ticks (not following)'); + } + + _ParsedCommand? _parseCommand(Map data) { + final commandWire = data['Command'] as String?; + final whenStr = data['When'] as String?; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final playlistItemId = data['PlaylistItemId'] as String? ?? ''; + + final command = SyncPlayCommand.fromWire(commandWire); + if (command == null || whenStr == null) { + log('SyncPlay: Ignoring unknown command "$commandWire"'); + return null; + } + + final when = DateTime.tryParse(whenStr); + if (when == null) { + log('SyncPlay: Ignoring ${command.wire} with unparsable When "$whenStr"'); + return null; + } + return _ParsedCommand( + command: command, + when: when, + whenStr: whenStr, + positionTicks: positionTicks, + playlistItemId: playlistItemId, + ); + } + + void handleCommand(Map data, SyncPlayState currentState) { + final parsed = _parseCommand(data); + if (parsed == null) { + return; + } + final command = parsed.command; + final when = parsed.when; + final whenStr = parsed.whenStr; + final positionTicks = parsed.positionTicks; + final playlistItemId = parsed.playlistItemId; + + // A late command for the previous item must not be applied to the new one around an item switch. + // Stop is exempt: the server sends it with an empty playlist item. + final currentPlaylistItemId = currentState.playlistItemId; + final isForOtherItem = command != SyncPlayCommand.stop && + playlistItemId.isNotEmpty && + currentPlaylistItemId != null && + currentPlaylistItemId != playlistItemId; + if (isForOtherItem) { + log('SyncPlay: Ignoring ${command.wire} for playlist item $playlistItemId ' + '(current item is $currentPlaylistItemId)'); + return; + } + + // A resend of the command parked behind the clock gate has not been applied yet; re-queue it. + if (!hasQueuedCommand && _isDuplicateCommand(whenStr, positionTicks, command, playlistItemId)) { + _handleDuplicateCommand(command, when, positionTicks); + return; + } + + _lastCommand = LastSyncPlayCommand( + when: whenStr, + positionTicks: positionTicks, + command: command, + playlistItemId: playlistItemId, + ); + + onStateUpdate((state) => state.copyWith(positionTicks: positionTicks)); + + // Let the player report buffering right away on Seek. + if (command == SyncPlayCommand.seek) { + onSeekRequested?.call(positionTicks); + } + + _scheduleCommand(command, when, positionTicks); + } + + bool _isDuplicateCommand( + String when, + int positionTicks, + SyncPlayCommand command, + String playlistItemId, + ) { + final last = _lastCommand; + if (last == null) { + return false; + } + return last.when == when && + last.positionTicks == positionTicks && + last.command == command && + last.playlistItemId == playlistItemId; + } + + /// The server re-sends a byte-identical command as "you got lost, here is the current state": + /// a duplicate whose time is still in the future is already armed; a past duplicate is a correction check. + void _handleDuplicateCommand(SyncPlayCommand command, DateTime when, int positionTicks) { + final clock = timeSync(); + final localWhen = clock?.remoteDateToLocal(when) ?? when; + if (localWhen.isAfter(DateTime.now().toUtc())) { + log('SyncPlay: Duplicate ${command.wire} is already scheduled'); + return; + } + + final playing = isPlaying?.call() ?? false; + final currentTicks = getPositionTicks?.call() ?? 0; + final offPosition = (currentTicks - positionTicks).abs() > positionToleranceTicks; + + switch (command) { + case SyncPlayCommand.unpause: + if (!playing) { + log('SyncPlay: Duplicate Unpause while paused - resuming'); + _runNow(command, _estimateCurrentTicks(positionTicks, when)); + } + break; + case SyncPlayCommand.pause: + if (playing || offPosition) { + log('SyncPlay: Duplicate Pause while playing/off-position - correcting'); + _runNow(command, positionTicks); + } + break; + case SyncPlayCommand.seek: + if (playing || offPosition) { + final jitterMs = ((_random.nextDouble() - 0.5) * _duplicateSeekJitterMs).round(); + log('SyncPlay: Duplicate Seek while playing/off-position - re-seeking (${jitterMs}ms jitter)'); + _runNow(command, positionTicks + millisecondsToTicks(jitterMs)); + } else { + log('SyncPlay: Duplicate Seek already satisfied - reporting ready'); + unawaited(onReportReady?.call(isPlaying: false, positionTicks: currentTicks)); + } + break; + case SyncPlayCommand.stop: + if (playing) { + _runNow(command, positionTicks); + } + break; + } + } + + 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; + } + + /// The group's word is final once nothing of our own is in the way: no command armed/queued/executing, + /// not buffering, no Pause/Stop as last command, and not before a scheduled Unpause's `When`. + bool shouldRecoverPlayback(DateTime remoteNow) { + if (hasPendingCommand || hasQueuedCommand) { + return false; + } + if (isBuffering?.call() == true) { + return false; + } + final last = _lastCommand; + if (last != null) { + if (last.command == SyncPlayCommand.pause || last.command == SyncPlayCommand.stop) { + return false; + } + final when = DateTime.tryParse(last.when); + if (last.command == SyncPlayCommand.unpause && when != null && remoteNow.isBefore(when)) { + return false; + } + } + return isPlaying?.call() == false; + } + + /// Schedules the command parked while the clock was not ready; [force] applies it with the unsynced + /// clock (fallback timer, or the group already reports Playing). + void flushQueuedCommand({bool force = false}) { + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + final queued = _queuedCommand; + _queuedCommand = null; + if (queued == null) { + return; + } + log('SyncPlay: ${force ? 'Clock wait over' : 'Clock ready'} - applying queued ${queued.command.wire}'); + _scheduleCommand(queued.command, queued.when, queued.positionTicks, requireClockReady: !force); + } + + void _scheduleCommand( + SyncPlayCommand command, + DateTime serverTime, + int positionTicks, { + bool requireClockReady = true, + }) { + // A newer command always replaces whatever is armed, queued or not. + _commandTimer?.cancel(); + + final clock = timeSync(); + if (clock == null) { + log('SyncPlay: Cannot schedule command without time sync'); + _runNow(command, positionTicks); + return; + } + + if (requireClockReady && !clock.isReady) { + // Without a measurement the scheduled time would be off by the whole clock skew; park the latest + // command until the first measurement lands, but never longer than [clockFallback]. + log('SyncPlay: Clock not ready - queueing ${command.wire}'); + _queuedCommand = _PendingCommand(command, serverTime, positionTicks); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = Timer(clockFallback, () { + log('SyncPlay: Clock still not ready after ${clockFallback.inMilliseconds}ms - applying unsynced'); + flushQueuedCommand(force: true); + }); + return; + } + + final localTime = clock.remoteDateToLocal(serverTime); + final now = DateTime.now().toUtc(); + final delay = localTime.difference(now); + + // Executing stale commands extrapolates far past EOF and starts a buffer oscillation. + if (delay.isNegative && -delay > _staleCommandThreshold) { + log('SyncPlay: Discarding stale ${command.wire} command ' + '(${(-delay).inSeconds}s late > ' + '${_staleCommandThreshold.inSeconds}s threshold). ' + 'Server StateUpdate will resync.'); + return; + } + + if (delay.isNegative) { + // Only Unpause extrapolates by the elapsed delay (the group kept playing); Pause/Seek/Stop are + // static targets, and extrapolating them would seek past EOF and trigger a real buffer cycle. + final ticksToUse = + command == SyncPlayCommand.unpause ? _estimateCurrentTicks(positionTicks, serverTime) : positionTicks; + log('SyncPlay: Executing late command: ${command.wire} ' + '(${delay.inMilliseconds}ms late)'); + _runNow(command, ticksToUse); + return; + } + + if (delay.inMilliseconds > 5000) { + log('SyncPlay: Warning - large delay: ${delay.inMilliseconds}ms'); + } else { + log('SyncPlay: Scheduling command: ${command.wire} ' + 'in ${delay.inMilliseconds}ms'); + } + + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } + + void _runNow(SyncPlayCommand command, int positionTicks) { + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + unawaited(_executeCommand(command, positionTicks)); + } + + int _estimateCurrentTicks(int ticks, DateTime when) { + final clock = timeSync(); + if (clock == null) { + return ticks; + } + final remoteNow = clock.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'); + + _isExecuting = true; + try { + switch (command) { + case SyncPlayCommand.pause: + await onPause?.call(); + // Correct the position outside the server's tolerance so all participants freeze on the same frame. + final currentTicks = getPositionTicks?.call() ?? 0; + final needsCorrectionSeek = (positionTicks - currentTicks).abs() > positionToleranceTicks; + if (needsCorrectionSeek) { + await onSeek?.call(positionTicks); + // Seek can put native ExoPlayer through STATE_BUFFERING; hold isProcessingCommand until it clears. + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(); + } + } + break; + + case SyncPlayCommand.unpause: + // Seek first, then play; drift correction handles anything inside the tolerance. + final currentTicks = getPositionTicks?.call() ?? 0; + if ((positionTicks - currentTicks).abs() > positionToleranceTicks) { + await onSeek?.call(positionTicks); + } + await onPlay?.call(); + // Native ExoPlayer buffers for a few hundred ms after resume; holding isProcessingCommand prevents + // the player-state listener from leaking a stale Buffering report (a feedback loop with any TV). + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(); + } + break; + + case SyncPlayCommand.seek: + await onPause?.call(); + await onSeek?.call(positionTicks); + // We own the Ready signal here (the buffering listener is suppressed while processing). Cap at 2 s: + // libmpv keeps `paused-for-cache` true while paused until the cache is fully topped up. + if (isBuffering?.call() == true) { + await _waitUntilNotBuffering(timeout: const Duration(seconds: 2)); + } + // Report the requested position, not the live one: libmpv can land a hair off on keyframe-bound + // media and a value outside the server's 500 ms window would trigger another corrective Seek. + await onReportReady?.call(isPlaying: false, positionTicks: positionTicks); + break; + + case SyncPlayCommand.stop: + // The group went idle, playback ends. + final stop = onStop; + if (stop != null) { + await stop(); + } else { + await onPause?.call(); + await onSeek?.call(0); + } + break; + } + } finally { + _isExecuting = false; + onStateUpdate((state) => state.copyWith( + isProcessingCommand: false, + processingCommandType: null, + )); + } + } + + 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); + } + } + + void cancelPendingCommands() { + _commandTimer?.cancel(); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + _queuedCommand = null; + } + + void clearLastCommand() { + _lastCommand = null; + } + + void dispose() { + _commandTimer?.cancel(); + _clockFallbackTimer?.cancel(); + _clockFallbackTimer = null; + _queuedCommand = null; + } + + /// Runs as an Unpause so the overlay shows it and [hasPendingCommand] owns the player meanwhile. + void recoverPlayback() { + _runNow(SyncPlayCommand.unpause, getPositionTicks?.call() ?? 0); + } +} diff --git a/lib/providers/syncplay/handlers/syncplay_message_handler.dart b/lib/providers/syncplay/handlers/syncplay_message_handler.dart new file mode 100644 index 000000000..9facae4e7 --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_message_handler.dart @@ -0,0 +1,315 @@ +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'; + +/// [reason] is the server's `PlayQueueUpdateReason` wire value (`NewPlaylist`, `NextItem`, ...). +typedef StartPlaybackCallback = Future Function(String itemId, int startPositionTicks, String? reason); + +/// Never answers a `StateUpdate` with a request: the server only needs `Ready` from sessions it flagged +/// as buffering, and replying from here produced an N-client cascade of Ready/Pause exchanges. +class SyncPlayMessageHandler { + SyncPlayMessageHandler({ + required this.onStateUpdate, + required this.startPlayback, + required this.getContext, + required this.onGroupJoined, + required this.onGroupJoinFailed, + this.onGroupLeftOrKicked, + this.onStateUpdateToPlaying, + this.onGroupGone, + }); + + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + final StartPlaybackCallback startPlayback; + final BuildContext? Function() getContext; + final void Function() onGroupJoined; + final void Function() onGroupJoinFailed; + + final void Function()? onGroupLeftOrKicked; + + /// Lets the controller run its missed-Unpause recovery check. + final void Function()? onStateUpdateToPlaying; + + /// The server no longer considers us part of the group (kicked, group disposed). + final void Function({required bool wasKicked})? onGroupGone; + + bool _wasInGroupAtLastUpdate = false; + + /// `LastUpdate` of the most recent applied `PlayQueue` frame; older frames are ignored. + DateTime? _lastQueueUpdate; + + 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 'LibraryAccessDenied': + _handleLibraryAccessDenied(); + break; + case 'StateUpdate': + _handleStateUpdate(updateData as Map); + break; + case 'PlayQueue': + _handlePlayQueue(updateData as Map, currentState); + break; + default: + log('SyncPlay: Unhandled group update type: $updateType'); + } + } + + /// The playing item arrives in the `PlayQueue` frame that follows. + void _handleGroupJoined(Map data) { + final groupId = data['GroupId'] as String?; + final groupName = data['GroupName'] as String?; + final stateStr = data['State'] as String?; + final participants = (data['Participants'] as List?)?.cast() ?? []; + + // A (re)join's fresh PlayQueue frame may carry the LastUpdate we already applied; accept it. + _lastQueueUpdate = null; + + // `isFollowingGroupPlayback` is left alone: the controller sets it for user-initiated joins, and a + // silent rejoin must not un-halt a device the user halted. + onStateUpdate((state) => state.copyWith( + isInGroup: true, + groupId: groupId, + groupName: groupName, + groupState: _parseGroupState(stateStr), + participants: participants, + )); + + log('SyncPlay: Joined group "$groupName" ($groupId)'); + + onGroupJoined(); + } + + /// `UserJoined` / `UserLeft` carry the participant's display name in `Data`, not a userId. + void _handleUserJoined(String? userName, SyncPlayState currentState) { + if (userName == null) { + return; + } + // The server re-broadcasts `UserJoined` on every `Join` POST (reconnects, silent rejoins, retries). + 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'); + } + + /// Never pass the navigator-key context: it is not under an `Overlay`, so `Overlay.of` throws. + /// `FladderSnack` resolves the root overlay from its own stored context. + void _showSnackbar(String Function(AppLocalizations l) builder) { + final context = getContext(); + if (context != null) { + FladderSnack.show(builder(context.localized)); + return; + } + try { + final loc = lookupAppLocalizations(const Locale('en')); + FladderSnack.show(builder(loc)); + } catch (_) { + // No fallback available - silently swallow. + } + } + + SyncPlayState _clearGroup(SyncPlayState state) { + return state.copyWith( + isInGroup: false, + isFollowingGroupPlayback: true, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + playlist: [], + playingItemIndex: -1, + queueTiming: null, + ); + } + + void _handleGroupLeft() { + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Left group'); + } + + void _handleGroupDoesNotExist() { + final wasInGroup = _wasInGroupAtLastUpdate; + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); + log('SyncPlay: Group does not exist'); + + // A failed join must not stop what the user is watching; only a real member has playback to tear down. + if (wasInGroup) { + onGroupLeftOrKicked?.call(); + onGroupGone?.call(wasKicked: false); + } + + onGroupJoinFailed(); + } + + void _handleNotInGroup() { + final wasInGroup = _wasInGroupAtLastUpdate; + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); + log('SyncPlay: Not in group - server rejected operation'); + + if (wasInGroup) { + onGroupLeftOrKicked?.call(); + onGroupGone?.call(wasKicked: true); + } + + onGroupJoinFailed(); + } + + /// Sent instead of `GroupJoined` when the user lacks access to an item in the queue. + void _handleLibraryAccessDenied() { + _lastQueueUpdate = null; + onStateUpdate(_clearGroup); + log('SyncPlay: Join refused - library access denied'); + _showSnackbar((l) => l.syncPlayLibraryAccessDenied); + onGroupJoinFailed(); + } + + void _handleStateUpdate(Map data) { + final stateStr = data['State'] as String?; + final reasonStr = data['Reason'] as String?; + final newGroupState = _parseGroupState(stateStr); + + onStateUpdate((state) => state.copyWith( + groupState: newGroupState, + stateReason: reasonStr, + )); + + log('SyncPlay: State update: $stateStr (reason: $reasonStr)'); + + // The controller decides whether a missed Unpause needs recovering (see shouldRecoverPlayback). + if (newGroupState == SyncPlayGroupState.playing) { + onStateUpdateToPlaying?.call(); + } + } + + void _handlePlayQueue(Map data, SyncPlayState currentState) { + final playlist = data['Playlist'] as List? ?? []; + final playingItemIndex = data['PlayingItemIndex'] as int? ?? -1; + final startPositionTicks = data['StartPositionTicks'] as int? ?? 0; + final isPlayingNow = data['IsPlaying'] as bool? ?? false; + final reason = data['Reason'] as String?; + final lastUpdate = DateTime.tryParse(data['LastUpdate'] as String? ?? ''); + + final previousUpdate = _lastQueueUpdate; + if (lastUpdate != null && previousUpdate != null && !lastUpdate.isAfter(previousUpdate)) { + log('SyncPlay: Ignoring old PlayQueue update ($reason, $lastUpdate <= $previousUpdate)'); + return; + } + if (lastUpdate != null) { + _lastQueueUpdate = lastUpdate; + } + + final entries = []; + for (final raw in playlist) { + if (raw is! Map) { + continue; + } + final itemId = raw['ItemId'] as String?; + final playlistItemId = raw['PlaylistItemId'] as String?; + if (itemId == null || playlistItemId == null) { + continue; + } + entries.add(SyncPlayQueueEntry(itemId: itemId, playlistItemId: playlistItemId)); + } + + final hasPlayingEntry = playingItemIndex >= 0 && playingItemIndex < entries.length; + final playingEntry = hasPlayingEntry ? entries[playingItemIndex] : null; + final playingItemId = playingEntry?.itemId; + final playlistItemId = playingEntry?.playlistItemId; + + final previousItemId = currentState.playingItemId; + + onStateUpdate((state) => state.copyWith( + playingItemId: playingItemId, + playlistItemId: playlistItemId, + positionTicks: startPositionTicks, + playlist: entries, + playingItemIndex: hasPlayingEntry ? playingItemIndex : -1, + queueTiming: SyncPlayQueueTiming( + startPositionTicks: startPositionTicks, + lastUpdate: lastUpdate, + isPlaying: isPlayingNow, + ), + )); + + log('SyncPlay: PlayQueue update - playing: $playingItemId ' + '(reason: $reason, isPlaying: $isPlayingNow, previousItemId: $previousItemId, ' + 'queue: ${entries.length} items)'); + + // Trigger regardless of whether the item changed: the user who set the queue also receives the update. + final shouldTrigger = playingItemId != null && + (reason == 'NewPlaylist' || + reason == 'SetCurrentItem' || + 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, reason); + } + } + + 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..62d0b0ece --- /dev/null +++ b/lib/providers/syncplay/syncplay_controller.dart @@ -0,0 +1,1361 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/router_provider.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; +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:fladder/util/localization_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:fladder/l10n/generated/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class SyncPlayController { + static const bool _verboseSyncPlayLogs = false; + + /// Position ticks arrive far more often than corrections should be attempted. + static const Duration _driftCheckInterval = Duration(milliseconds: 1500); + + /// Position window inside which an already-loaded item is attached without a seek. + static const int _attachSeekToleranceTicks = SyncPlayCommandHandler.positionToleranceTicks; + + SyncPlayController(this._ref) { + _commandHandler = SyncPlayCommandHandler( + timeSync: () => _timeSync, + onStateUpdate: _updateStateWith, + ); + _messageHandler = SyncPlayMessageHandler( + onStateUpdate: _updateStateWith, + startPlayback: _onQueueStartPlayback, + getContext: () => getNavigatorKey(_ref)?.currentContext, + onGroupJoined: _onGroupJoined, + onGroupJoinFailed: _onGroupJoinFailed, + onGroupLeftOrKicked: _onGroupLeftOrKicked, + onStateUpdateToPlaying: _onStateUpdateToPlaying, + onGroupGone: ({required wasKicked}) => notifyGroupGone(wasKicked: wasKicked), + ); + } + + final Ref _ref; + + TimeSyncService? _timeSync; + StreamSubscription? _wsMessageSubscription; + StreamSubscription? _wsStateSubscription; + Timer? _syncCorrectionTimer; + DateTime? _lastDriftCheckAt; + + late final SyncPlayCommandHandler _commandHandler; + late final SyncPlayMessageHandler _messageHandler; + + SyncPlayState _state = SyncPlayState(); + final _stateController = StreamController.broadcast(); + + Stream get stateStream => _stateController.stream; + + SyncPlayState get state => _state; + + String? _lastGroupId; + + /// Set while a transparent rejoin after a socket drop is in flight; the `PlayQueue` frame that follows + /// its `GroupJoined` must not be mistaken for a user-initiated join on a halted device. + bool _silentRejoinInFlight = false; + DateTime? _silentJoinedAt; + bool _queueFrameFromSilentRejoin = false; + + /// The server replays the queue right after `GroupJoined`; a later frame is a participant's action. + static const Duration _silentRejoinQueueWindow = Duration(seconds: 5); + + // Previous socket state, used to detect reconnects for the silent rejoin. + WebSocketConnectionState? _previousWsState; + + Completer? _joinGroupCompleter; + + // Resolves when the next `_startPlayback` finishes; drives the loader UX. + Completer? _startPlaybackCompleter; + + // Dedup key against concurrent PlayQueue updates from simultaneous initiators. + String? _currentlyStartingPlaylistItemId; + Completer? _inFlightStartCompleter; + + // Debounces `setNewQueue` so a double tap does not send two requests. + DateTime? _lastSetNewQueueAt; + + 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); + } + } + + /// Stamps the last command time; the player-side cooldown uses it to avoid feedback loops. + void markCommandExecuted([DateTime? at]) { + _updateStateWith((state) => state.copyWith( + lastCommandTime: at ?? DateTime.now().toUtc(), + )); + } + + 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, + ), + )); + } + + /// [clearLastCommand] is false around halt/resume so the last command keeps describing the group's + /// playhead while this device is not following. + void resetCorrectionState({ + String reason = 'reset', + bool syncEnabled = true, + bool clearLastCommand = true, + }) { + _syncCorrectionTimer?.cancel(); + _syncCorrectionTimer = null; + _lastDriftCheckAt = null; + + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed != null) { + unawaited( + setSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed during correction reset: $error'); + }), + ); + } + if (clearLastCommand) { + _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, + ), + )); + } + + /// Drift = estimated server position - local position (positive: local is behind). + /// Throttled to [_driftCheckInterval] unless [force] (used right after a local-only reload). + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + bool force = false, + }) { + if (!_state.isFollowingGroupPlayback) { + return; + } + // Throttle before any other work: this runs on every position tick. + final now = (at ?? DateTime.now().toUtc()); + final lastCheck = _lastDriftCheckAt; + if (!force && lastCheck != null && now.difference(lastCheck) < _driftCheckInterval) { + return; + } + if (!_commandHandler.canAttemptSyncCorrection(_state)) { + return; + } + + final lastCommand = _commandHandler.lastCommand; + if (lastCommand == null) { + return; + } + + final when = DateTime.tryParse(lastCommand.when); + if (when == null) { + return; + } + _lastDriftCheckAt = now; + + 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 correctionEnabled = _ref.read(videoPlayerSettingsProvider).enableSyncPlayCorrection; + final correctionConfig = _state.correctionConfig.copyWith(enableSyncCorrection: correctionEnabled); + 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, + ), + )); + } + + /// Estimates the live group playhead from the last command, then the last `PlayQueue` frame timing; + /// `state.positionTicks` is only a fallback because it can be tens of seconds stale. + int estimateCurrentGroupPositionTicks() { + final now = DateTime.now().toUtc(); + return estimateGroupPositionTicks( + lastCommand: _commandHandler.lastCommand, + currentPlaylistItemId: _state.playlistItemId, + queueTiming: _state.queueTiming, + fallbackPositionTicks: _state.positionTicks, + remoteNow: _timeSync?.localDateToRemote(now) ?? now, + ); + } + + void _applySpeedToSync({ + 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; + + /// Attaches SyncPlay to the app-owned WebSocket (connected off `userProvider`) and starts time-sync. + Future connect() async { + final user = _ref.read(userProvider); + if (user == null) { + log('SyncPlay: Cannot connect without user'); + return; + } + + final ws = _ref.read(jellyfinWebSocketControllerProvider.notifier); + + // Idempotent: the sheet calls this every time it re-opens. + if (_wsStateSubscription != null) { + log('SyncPlay: connect() called but already subscribed; reusing shared socket'); + return; + } + + final timeSync = TimeSyncService(_api); + timeSync.onMeasurement = (_, __) { + // The server sizes the group's unpause delay from our ping; also release a clock-gated command. + unawaited(reportPing()); + _commandHandler.flushQueuedCommand(); + }; + _timeSync = timeSync; + timeSync.start(); + + _wsStateSubscription = ws.connectionState.listen(_handleConnectionState); + _wsMessageSubscription = ws.messages.listen(_handleMessage); + + // The state stream does not replay; seed from the current state or `joinGroup` would stay blocked. + _handleConnectionState(ws.currentState); + } + + /// Detaches SyncPlay from the shared WebSocket without closing it (the socket is app-owned). + Future disconnect() async { + resetCorrectionState( + reason: 'disconnect', + syncEnabled: false, + ); + await leaveGroup(); + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + await _wsMessageSubscription?.cancel(); + await _wsStateSubscription?.cancel(); + _wsMessageSubscription = null; + _wsStateSubscription = null; + _timeSync?.onMeasurement = null; + _timeSync?.dispose(); + _timeSync = null; + _updateState(SyncPlayState()); + } + + Future> listGroups() async { + try { + final response = await _api.syncPlayListGet(); + return response.body ?? []; + } catch (e) { + log('SyncPlay: Failed to list groups: $e'); + return []; + } + } + + /// Creates a group and waits for `GroupJoined`. Local video playback seeds the group through + /// `SetNewQueue`, since the server cannot seed from a `NowPlayingQueue` this client never reports. + Future createGroup(String groupName) async { + if (_state.isInGroup) { + log('SyncPlay: Already in a group, leaving before creating a new one'); + await leaveGroup(); + } + + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); + final completer = _joinGroupCompleter = Completer(); + try { + final response = await _api.syncPlayNewPost( + body: NewGroupRequestDto(groupName: groupName), + ); + final info = response.body; + if (info == null) { + _completeJoinRequest(false); + return null; + } + + final joined = await completer.future.timeout( + const Duration(seconds: 12), + onTimeout: () => _state.isInGroup && _state.groupId == info.groupId, + ); + if (identical(_joinGroupCompleter, completer)) { + _joinGroupCompleter = null; + } + if (!joined) { + log('SyncPlay: Group ${info.groupId} created but GroupJoined never arrived'); + return null; + } + await _seedQueueFromLocalPlayback(); + return info; + } catch (e) { + log('SyncPlay: Failed to create group: $e'); + if (identical(_joinGroupCompleter, completer)) { + _completeJoinRequest(false); + } + return null; + } + } + + /// Only an item showing in the video player qualifies: seeding from the audio queue would tear the + /// audio player down and reload the track through the video path. + Future _seedQueueFromLocalPlayback() async { + final model = _ref.read(playBackModel); + if (model == null) { + return; + } + final videoRouteOpen = _ref.read(isVideoPlayerRouteOpenProvider); + if (!videoRouteOpen || model.item.type == FladderItemType.audio) { + log('SyncPlay: Not seeding new group (no video playing locally)'); + return; + } + final queueIds = model.queue.map((item) => item.id).toList(); + final itemIds = queueIds.isNotEmpty ? queueIds : [model.item.id]; + final index = itemIds.indexOf(model.item.id).clamp(0, itemIds.length - 1); + final position = _ref.read(videoPlayerProvider).lastState?.position ?? Duration.zero; + final positionTicks = secondsToTicks(position.inMilliseconds / 1000); + log('SyncPlay: Seeding new group with local playback ' + '(item=${model.item.id}, index=$index, ticks=$positionTicks)'); + await setNewQueue( + itemIds: itemIds, + playingItemPosition: index, + startPositionTicks: positionTicks, + ); + } + + /// Returns true only once the server's `GroupJoined` frame arrived. + Future joinGroup(String groupId) async { + if (_state.isInGroup) { + log('SyncPlay: Already in a group, leaving first...'); + await leaveGroup(); + } + + if (!_state.isConnected) { + log('SyncPlay: WebSocket not connected, cannot join group'); + return false; + } + + log('SyncPlay: Joining group: $groupId'); + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); + final confirmed = await _sendJoinRequest(groupId); + // `_lastGroupId` is stamped in `_onGroupJoined` from the server frame, not from this result. + log(confirmed ? 'SyncPlay: Group join confirmed' : 'SyncPlay: Group join not confirmed'); + return confirmed; + } + + /// Sends Join and waits for the matching `GroupJoined`; shared by [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: () { + // Join is idempotent and real rejections arrive promptly, so a missing GroupJoined is almost + // always a slow socket: reconcile against the authoritative state instead of reporting failure. + final joined = _state.isInGroup && _state.groupId == groupId; + log('SyncPlay: GroupJoined not received within timeout; ' + 'reconciled isInGroup=$joined for $groupId'); + 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; + } + } + + /// Completes and clears the pending join completer exactly once, whichever path reaches it first. + void _completeJoinRequest(bool joined) { + final completer = _joinGroupCompleter; + _joinGroupCompleter = null; + if (completer != null && !completer.isCompleted) { + completer.complete(joined); + } + } + + /// The `PlayQueue` frame that follows `GroupJoined` drives playback, so nothing is started here. + void _onGroupJoined() { + resetCorrectionState( + reason: 'group_joined', + syncEnabled: true, + ); + // Stamp from the server frame, not the awaited `joinGroup` bool: a slow socket can deliver + // `GroupJoined` after `joinGroup` already timed out, and the silent rejoin depends on this. + _lastGroupId = _state.groupId ?? _lastGroupId; + final silentRejoin = _silentRejoinInFlight; + _silentJoinedAt = silentRejoin ? DateTime.now().toUtc() : null; + _silentRejoinInFlight = false; + _completeJoinRequest(true); + unawaited(_timeSync?.forceUpdate()); + // The server-side IgnoreWait flag died with the evicted session; a halted device must re-set it. + if (!_state.isFollowingGroupPlayback) { + unawaited(setIgnoreWait(true)); + } + // A transparent rejoin after a socket drop is not news to the user. + final showSnackbar = _state.groupName != null && !silentRejoin; + if (showSnackbar) { + _showGroupSnackbar( + (l) => l.syncPlayJoinedGroup(_state.groupName ?? ''), + ); + } + } + + void _onGroupJoinFailed() { + _completeJoinRequest(false); + } + + /// Also stops local playback: otherwise the old media stays loaded in the background and a later + /// `Unpause` from a different group would resume it. + void _onGroupLeftOrKicked() { + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'group_left_or_kicked', + syncEnabled: false, + ); + _updateStateWith((s) => s.copyWith( + isProcessingCommand: false, + processingCommandType: null, + playingItemId: null, + playlistItemId: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + isFollowingGroupPlayback: true, + )); + _stopLocalPlayback(); + } + + /// Deferred to a microtask: this can run inside a WebSocket handler still on the stack of a + /// `syncPlayProvider` listener, and reading `videoPlayerProvider` there throws `CircularDependencyError`. + void _stopLocalPlayback() { + Future.microtask(() { + try { + unawaited(_ref.read(videoPlayerProvider.notifier).stopAndClosePlayer()); + } catch (e) { + log('SyncPlay: Failed to stop local playback after leave: $e'); + } + }); + } + + /// Callers must check this between every `await` so they don't resume media for a group we left. + bool _shouldAbortStartPlayback() => !_state.isInGroup || !_state.isFollowingGroupPlayback; + + /// Clears in-group bookkeeping so a subsequent rejoin starts from a clean slate. + void _resetGroupLifecycleState() { + _lastSetNewQueueAt = null; + _silentRejoinInFlight = false; + _silentJoinedAt = null; + _currentlyStartingPlaylistItemId = null; + _inFlightStartCompleter = null; + if (_startPlaybackCompleter != null && !_startPlaybackCompleter!.isCompleted) { + _startPlaybackCompleter!.complete(false); + } + _startPlaybackCompleter = null; + _completeJoinRequest(false); + } + + /// Must never pre-empt a scheduled Unpause (the command owns the start time); releases a clock-gated + /// Unpause, otherwise recovers a paused player when nothing of our own is in flight. + void _onStateUpdateToPlaying() { + if (!_state.isFollowingGroupPlayback || _state.startPlaybackInProgress) { + return; + } + if (_commandHandler.hasQueuedCommand) { + log('SyncPlay: Group is Playing - applying the command queued behind the clock gate'); + _commandHandler.flushQueuedCommand(force: true); + return; + } + final now = DateTime.now().toUtc(); + final remoteNow = _timeSync?.localDateToRemote(now) ?? now; + if (_commandHandler.shouldRecoverPlayback(remoteNow)) { + log('SyncPlay: Group is Playing but player is paused with nothing pending - recovering'); + _commandHandler.recoverPlayback(); + } + } + + SyncPlayState _clearGroupState(SyncPlayState state) { + return state.copyWith( + isInGroup: false, + isFollowingGroupPlayback: true, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + positionTicks: 0, + playlistItemId: null, + playingItemId: null, + playlist: [], + playingItemIndex: -1, + queueTiming: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + ); + } + + Future leaveGroup() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayLeavePost(); + _lastGroupId = null; + log('SyncPlay: Left group, state reset'); + } catch (e) { + log('SyncPlay: Failed to leave group: $e'); + } finally { + _resetGroupLifecycleState(); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'leave_group', + syncEnabled: false, + ); + _updateState(_clearGroupState(_state)); + _stopLocalPlayback(); + } + } + + Future requestPause() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayPausePost(); + } catch (e) { + log('SyncPlay: Failed to request pause: $e'); + } + } + + /// The server moves to Waiting until all clients report Ready, then broadcasts 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'); + } + } + + 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'); + } + } + + /// Passes the current `playlistItemId` so the server rejects a stale request; that is what + /// de-duplicates simultaneous requests from every participant. + Future requestNextItem() async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot request NextItem - not in group'); + 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'); + } + } + + 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'); + } + } + + Future requestSetPlaylistItem(String playlistItemId) async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot request SetPlaylistItem - not in group'); + return; + } + try { + await _api.syncPlaySetPlaylistItemPost( + body: SetPlaylistItemRequestDto(playlistItemId: playlistItemId), + ); + } catch (e) { + log('SyncPlay: Failed to request SetPlaylistItem: $e'); + } + } + + /// `true` while the user halted playback on this device, so group waits skip this session. + Future setIgnoreWait(bool ignoreWait) async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlaySetIgnoreWaitPost( + body: IgnoreWaitRequestDto(ignoreWait: ignoreWait), + ); + } catch (e) { + log('SyncPlay: Failed to set IgnoreWait=$ignoreWait: $e'); + } + } + + /// Stops following group playback on this device while staying in the group; commands are only + /// recorded until [rejoinPlayback]. Local state changes first so `userStop` never waits on the network. + Future haltPlayback({bool stopLocalPlayer = true}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { + return; + } + log('SyncPlay: Halting group playback on this device'); + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'halt_playback', + syncEnabled: false, + clearLastCommand: false, + ); + _updateStateWith((s) => s.copyWith( + isFollowingGroupPlayback: false, + isProcessingCommand: false, + processingCommandType: null, + startPlaybackInProgress: false, + startingPlaylistItemId: null, + )); + if (stopLocalPlayer) { + _stopLocalPlayback(); + } + await setIgnoreWait(true); + } + + Future _followGroupPlayback() async { + if (_state.isFollowingGroupPlayback) { + return; + } + log('SyncPlay: Following group playback again'); + _updateStateWith((s) => s.copyWith(isFollowingGroupPlayback: true)); + resetCorrectionState( + reason: 'follow_playback', + syncEnabled: true, + clearLastCommand: false, + ); + await setIgnoreWait(false); + } + + /// Pass [positionTicks] during a rejoin/initial load: reporting the local 0 would become the group + /// position and reset every other client. No-op in local-only mode or while halted. + Future reportBuffering({int? positionTicks}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { + return; + } + if (_state.isInLocalOnlyMode) { + log('SyncPlay: Skipping reportBuffering (local-only mode)'); + return; + } + 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'); + } + } + + /// [isPlaying] must be the real player state: the server adds elapsed time only for a playing client. + /// [positionTicks] overrides the reported position (see [reportBuffering]). + Future reportReady({required bool isPlaying, int? positionTicks}) async { + if (!_state.isInGroup || !_state.isFollowingGroupPlayback) { + return; + } + if (_state.isInLocalOnlyMode) { + 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'); + } + } + + /// Suppresses Buffering/Ready reports while [body] runs (track switch), then forces a drift check. + /// Resumes explicitly if the group is Playing: media-kit on web does not reliably auto-play after a reload. + Future runLocalOnly(Future Function() body) async { + _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 = _state.groupState == SyncPlayGroupState.playing && _state.isFollowingGroupPlayback; + if (shouldResume && _state.localOnlyOperationCount == 0 && _commandHandler.isPlaying?.call() == false) { + log('SyncPlay: Resuming local playback after local-only switch'); + try { + 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, force: true); + } + } + + 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'); + } + } + + /// Debounced to 1s per device. Returns `true` only when the request was sent, so callers awaiting + /// the next `_startPlayback` do not wait for a `PlayQueue` broadcast that will never arrive. + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + 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; + } + } + + /// Completes with the outcome of the next `_startPlayback` (`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; + }); + } + + /// "Resume playback" from outside the player: follows the group again, then restarts at the live position. + Future rejoinPlayback() async { + final itemId = _state.playingItemId; + if (!_state.isInGroup || itemId == null) { + log('SyncPlay: rejoinPlayback called but no active item in group'); + return false; + } + // Loading at the stale `_state.positionTicks` costs a corrective Seek and a transcode at the wrong offset. + final positionTicks = estimateCurrentGroupPositionTicks(); + await _followGroupPlayback(); + final pending = awaitNextStartPlayback(); + log('SyncPlay: Rejoining playback for item=$itemId, ' + 'positionTicks=$positionTicks (estimated live)'); + 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'); + + // The initial connect lands here too; the `_lastGroupId` guard makes it a no-op until a group was joined. + final wasConnected = _previousWsState == WebSocketConnectionState.connected; + final isReconnect = isConnected && !wasConnected; + _previousWsState = wsState; + + if (isReconnect) { + // A fresh socket may carry a stale clock offset. + if (_timeSync != null) { + _timeSync!.start(); + unawaited(_timeSync!.forceUpdate()); + } + + if (_lastGroupId != null) { + // The server drops group membership with the socket; rejoin transparently and let it answer. + log('SyncPlay: WS reconnected, attempting silent rejoin of $_lastGroupId'); + unawaited(_attemptSilentRejoin()); + } + } + } + + /// Rejoins the last group after a socket reconnect without `joinGroup`'s leave-first path: the local + /// `isInGroup` may still be true although the server already evicted us. + Future _attemptSilentRejoin() async { + final groupId = _lastGroupId; + if (groupId == null) { + return; + } + if (!_state.isConnected) { + log('SyncPlay: WS not connected, skipping silent rejoin'); + return; + } + _silentRejoinInFlight = true; + final confirmed = await _sendJoinRequest(groupId); + if (confirmed) { + log('SyncPlay: Silent rejoin confirmed'); + } else { + log('SyncPlay: Silent rejoin not confirmed; clearing _lastGroupId'); + _silentRejoinInFlight = false; + _lastGroupId = null; + } + } + + 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'); + if (!_state.isInGroup) { + log('SyncPlay: Ignoring $cmd - not in a group'); + break; + } + if (!_state.isFollowingGroupPlayback) { + // Not applied, but recorded: a later resume needs the group's live position. + _commandHandler.recordCommand(data); + break; + } + _commandHandler.handleCommand(data, _state); + break; + case 'SyncPlayGroupUpdate': + log('SyncPlay: GroupUpdate data: $data'); + final update = data as Map; + // The first queue frame after a transparent rejoin is a replay; see [_onQueueStartPlayback]. + if (update['Type'] == 'PlayQueue') { + final joinedAt = _silentJoinedAt; + _silentJoinedAt = null; + _queueFrameFromSilentRejoin = + joinedAt != null && DateTime.now().toUtc().difference(joinedAt) < _silentRejoinQueueWindow; + } + _messageHandler.handleGroupUpdate(update, _state); + _queueFrameFromSilentRejoin = false; + break; + default: + if (messageType?.startsWith('SyncPlay') == true) { + log('SyncPlay: Unhandled SyncPlay message type: $messageType'); + } + } + } + + /// Resolves the loader completer as soon as the frame arrives: media-kit on web can leave `loadVideo()` + /// hanging although playback runs. A halted device only follows again on a brand-new playlist. + Future _onQueueStartPlayback(String itemId, int startPositionTicks, String? reason) async { + final fromSilentRejoin = _queueFrameFromSilentRejoin; + if (!_state.isFollowingGroupPlayback) { + // The replayed queue after a transparent rejoin is not a participant starting a new playlist. + if (reason != 'NewPlaylist' || fromSilentRejoin) { + log('SyncPlay: Ignoring $reason queue update while not following group playback'); + return; + } + await _followGroupPlayback(); + } + final completer = _startPlaybackCompleter; + if (completer != null && !completer.isCompleted) { + log('SyncPlay: PlayQueue accepted - resolving loader completer eagerly for item=$itemId'); + completer.complete(true); + } + await _startPlayback(itemId, startPositionTicks); + } + + /// True when the group's item is already loaded in an open player, so a seek can replace a full reload. + bool _isItemAttachable(String itemId) { + final routeOpen = _ref.read(isVideoPlayerRouteOpenProvider); + if (!routeOpen) { + return false; + } + final player = _ref.read(videoPlayerProvider); + if (!player.hasPlayer) { + return false; + } + final currentModel = _ref.read(playBackModel); + return currentModel != null && currentModel.item.id == itemId; + } + + /// The server flagged us as buffering when it sent the queue frame, so a Ready is owed either way. + Future _attachLoadedItem(int startPositionTicks) async { + final currentTicks = _commandHandler.getPositionTicks?.call() ?? 0; + await _commandHandler.onPause?.call(); + if ((currentTicks - startPositionTicks).abs() > _attachSeekToleranceTicks) { + log('SyncPlay: Attaching loaded item - seeking from $currentTicks to $startPositionTicks'); + await _commandHandler.onSeek?.call(startPositionTicks); + } else { + log('SyncPlay: Attaching loaded item in place (within tolerance)'); + } + setPlayerBufferingState(false); + await reportReady(isPlaying: false, positionTicks: startPositionTicks); + } + + /// Re-entrancy guard: a duplicate start for the same playlist item is ignored (two participants pressing + /// play produce two back-to-back PlayQueue frames); a different item waits for the in-flight start. + Future _startPlayback(String itemId, int startPositionTicks) async { + final dedupKey = _state.playlistItemId ?? itemId; + if (_state.startPlaybackInProgress) { + 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; + var loadAttempted = false; + try { + if (_isItemAttachable(itemId)) { + log('SyncPlay: Item $itemId already loaded - attaching without reload'); + await _attachLoadedItem(startPositionTicks); + success = true; + return; + } + + final playerRouteAlreadyOpen = _ref.read(isVideoPlayerRouteOpenProvider); + log('SyncPlay: Player route already open: $playerRouteAlreadyOpen'); + + // Clear the playback model before re-initializing so the fire-and-forget stop() inside init() is a + // no-op instead of racing the new loadPlaybackItem through the delayed playbackStopped flow. + if (!playerRouteAlreadyOpen) { + _ref.read(playBackModel.notifier).update((state) => null); + await _ref.read(videoPlayerProvider.notifier).init(); + } + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after init (left group)'); + return; + } + + 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}'); + + 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'); + + // From here on the player reports its own Ready on failure, so the finally block must not send one. + loadAttempted = true; + log('SyncPlay: Loading playback item...'); + final loadedCorrectly = await _ref.read(videoPlayerProvider.notifier).loadPlaybackItem( + playbackModel, + startPosition, + ); + if (_shouldAbortStartPlayback()) { + log('SyncPlay: _startPlayback aborted after loadPlaybackItem (left group)'); + // Tear down media loaded for a group we no longer belong to. + _stopLocalPlayback(); + return; + } + + if (!loadedCorrectly) { + log('SyncPlay: Failed to load playback item $itemId'); + return; + } + success = true; + log('SyncPlay: Playback item loaded successfully'); + + _ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.fullScreen), + ); + log('SyncPlay: Set state to fullScreen'); + + // When the route is already open, loadPlaybackItem swapped the content in place; pushing would stack routes. + if (!playerRouteAlreadyOpen) { + final navigatorKey = getNavigatorKey(_ref); + final context = navigatorKey?.currentContext; + log('SyncPlay: Navigator context: ${context != null ? "exists" : "null"}'); + + if (context != null && !_shouldAbortStartPlayback()) { + // openPlayer's Future only completes when the route is popped; awaiting it would keep + // startPlaybackInProgress (and the "Switching item…" overlay) up while the player is visible. + unawaited(_ref.read(videoPlayerProvider.notifier).openPlayer(context)); + log('SyncPlay: Pushed player route for $itemId'); + } else { + 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) { + // Clear the buffering flag so the rest of the group is not stranded waiting on us. + setPlayerBufferingState(false); + if (_state.isInGroup && !loadAttempted) { + unawaited(reportReady(isPlaying: false, positionTicks: startPositionTicks)); + } + } + _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); + } + + /// Never pass the navigator-key context to `FladderSnack`: it is not under an `Overlay`. The + /// notification manager resolves the root overlay from its own stored context. + void _showGroupSnackbar(String Function(AppLocalizations l) message) { + try { + final context = _ref.read(localizationContextProvider); + final loc = context != null ? context.localized : lookupAppLocalizations(const Locale('en')); + FladderSnack.show(message(loc)); + } catch (_) { + // Best effort - ignore if localizations are unavailable. + } + } + + /// Shown when the server evicted us from a group we still believed we belonged to. + void notifyGroupGone({bool wasKicked = false}) { + _showGroupSnackbar( + (l) => wasKicked ? l.syncPlayKickedFromGroup : l.syncPlayGroupNoLongerExists, + ); + } + + 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..896b9dd82 --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.dart @@ -0,0 +1,229 @@ +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'; + +@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!; + } + + /// Subscribes before connecting: `connect()` seeds `isConnected` synchronously on a broadcast + /// stream, and an event emitted before anyone listens is lost. + Future connect() async { + _stateSubscription ??= controller.stateStream.listen((newState) { + state = newState; + }); + await controller.connect(); + } + + Future disconnect() async { + await controller.disconnect(); + state = SyncPlayState(); + } + + Future> listGroups() => controller.listGroups(); + + Future createGroup(String groupName) => controller.createGroup(groupName); + + Future joinGroup(String groupId) => controller.joinGroup(groupId); + + Future leaveGroup() => controller.leaveGroup(); + + Future requestPause() => controller.requestPause(); + + Future requestUnpause() async => await controller.requestUnpause(); + + Future requestSeek(int positionTicks) => controller.requestSeek(positionTicks); + + Future requestNextItem() => controller.requestNextItem(); + + Future requestPreviousItem() => controller.requestPreviousItem(); + + Future requestSetPlaylistItem(String playlistItemId) => controller.requestSetPlaylistItem(playlistItemId); + + Future haltPlayback({bool stopLocalPlayer = true}) => controller.haltPlayback(stopLocalPlayer: stopLocalPlayer); + + Future reportBuffering({int? positionTicks}) => controller.reportBuffering(positionTicks: positionTicks); + + Future reportReady({required bool isPlaying, int? positionTicks}) => + controller.reportReady(isPlaying: isPlaying, positionTicks: positionTicks); + + void markCommandExecuted([DateTime? at]) => controller.markCommandExecuted(at); + + void setPlayerBufferingState(bool isBuffering) => controller.setPlayerBufferingState(isBuffering); + + void resetCorrectionState({ + String reason = 'manual', + bool syncEnabled = true, + }) => + controller.resetCorrectionState( + reason: reason, + syncEnabled: syncEnabled, + ); + + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + bool force = false, + }) => + controller.updatePlaybackDrift( + currentPositionTicks: currentPositionTicks, + at: at, + force: force, + ); + + int estimateCurrentGroupPositionTicks() => controller.estimateCurrentGroupPositionTicks(); + + Future awaitNextStartPlayback({ + Duration timeout = const Duration(seconds: 20), + }) => + controller.awaitNextStartPlayback(timeout: timeout); + + Future rejoinPlayback() => controller.rejoinPlayback(); + + Future runLocalOnly(Future Function() body) => controller.runLocalOnly(body); + + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + int startPositionTicks = 0, + }) => + controller.setNewQueue( + itemIds: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + + 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; + controller.onReportReady = ({required bool isPlaying, required int positionTicks}) => + controller.reportReady(isPlaying: isPlaying, positionTicks: positionTicks); + } + + 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; + } +} + +@riverpod +bool isSyncPlayActive(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.isActive)); +} + +@riverpod +String? syncPlayGroupName(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupName)); +} + +@riverpod +SyncPlayGroupState syncPlayGroupState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupState)); +} + +@riverpod +SyncCorrectionState syncCorrectionState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState)); +} + +@riverpod +SyncCorrectionStrategy syncCorrectionStrategy(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState.activeStrategy)); +} + +/// True while a SyncPlay-driven `_startPlayback` is in flight (initial play, episode switch, rejoin). +@riverpod +bool syncPlayStartPlaybackInProgress(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.startPlaybackInProgress)); +} + +/// True when the group has an active item the local user could resume from outside the player route. +@riverpod +bool syncPlayHasActivePlayback(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.hasActivePlayback)); +} + +@Freezed(copyWith: true) +abstract class SyncPlayGroupsState with _$SyncPlayGroupsState { + const factory SyncPlayGroupsState({ + List? groups, + @Default(false) bool isLoading, + String? error, + }) = _SyncPlayGroupsState; +} + +@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..926d35dbc --- /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'ffaacb6512f6389f68d693c76ab1e656a5606928'; + +/// 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..73a0861a1 --- /dev/null +++ b/lib/providers/syncplay/time_sync_service.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; + +/// Minimal clock contract so handler tests can substitute a fake clock without a Chopper client. +abstract class SyncPlayClock { + /// True once at least one measurement has been taken; commands must not be scheduled before this. + bool get isReady; + + /// One-way ping estimate from the best measurement. + Duration get ping; + + DateTime remoteDateToLocal(DateTime serverTime); + + DateTime localDateToRemote(DateTime localTime); +} + +/// NTP-style clock sync against the server's `/GetUtcTime`. +class TimeSyncService implements SyncPlayClock { + TimeSyncService(this._api); + + final JellyfinOpenApi _api; + + /// Invoked after every successful measurement with the current best offset and ping. + void Function(Duration offset, Duration ping)? onMeasurement; + + @override + bool get isReady => _measurements.isNotEmpty; + + final List _measurements = []; + static const int _maxMeasurements = 8; + + Timer? _pollingTimer; + int _pingCount = 0; + bool _isActive = false; + + static const Duration _greedyInterval = Duration(seconds: 1); + static const Duration _lowProfileInterval = Duration(seconds: 60); + static const int _greedyPingCount = 3; + + static const Duration _staleThreshold = Duration(seconds: 30); + DateTime? _lastMeasurementTime; + + 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; + } + + @override + Duration get ping { + if (_measurements.isEmpty) { + return Duration.zero; + } + final best = _measurements.reduce( + (a, b) => a.delay < b.delay ? a : b, + ); + return best.ping; + } + + bool get isStale { + if (_lastMeasurementTime == null) { + return true; + } + return DateTime.now().difference(_lastMeasurementTime!) > _staleThreshold; + } + + @override + DateTime remoteDateToLocal(DateTime serverTime) { + return serverTime.subtract(offset); + } + + @override + DateTime localDateToRemote(DateTime localTime) { + return localTime.add(offset); + } + + void start() { + if (_isActive) { + return; + } + _isActive = true; + _pingCount = 0; + _poll(); + } + + void stop() { + _isActive = false; + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + Future forceUpdate() async { + await _requestPing(); + } + + 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(); + + 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'); + onMeasurement?.call(offset, ping); + } catch (e) { + log('Time sync failed: $e'); + } + } + + void _addMeasurement(TimeSyncMeasurement measurement) { + _measurements.add(measurement); + while (_measurements.length > _maxMeasurements) { + _measurements.removeAt(0); + } + } + + void clear() { + _measurements.clear(); + _lastMeasurementTime = null; + _pingCount = 0; + } + + void dispose() { + stop(); + clear(); + } +} diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 84ec09baf..c36d9f6d0 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,24 +1,29 @@ 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/settings/video_player_settings.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/buffering_report_debouncer.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource, SyncPlayCommandType; import 'package:fladder/wrappers/media_control_wrapper.dart'; +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(); @@ -36,14 +41,72 @@ class VideoPlayerNotifier extends StateNotifier { MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider); + bool _syncPlayAction = false; + + /// True while [loadPlaybackItem] loads media for a SyncPlay flow; the buffering listener must not + /// auto-report then, because the load itself owes the server exactly one Ready. + bool _isLoadingForSyncPlay = false; + + /// Cooldown period after SyncPlay command during which we don't auto-report ready + static const _syncPlayCooldown = Duration(milliseconds: 500); + + /// Debounces spontaneous buffering before it is reported to the group; recreated on every [init]. + BufferingReportDebouncer? _bufferingDebouncer; + + /// Kept so a re-[init] does not stack native-overlay listeners. A stream subscription, not `ref.listen`: + /// listening to `syncPlayProvider` would make this provider its dependent, and the SyncPlay controller + /// reads `videoPlayerProvider` back, which trips Riverpod's circular-dependency assertion in debug builds. + StreamSubscription? _syncPlayStateSubscription; + SyncPlayCommandType _lastNativeOverlayType = SyncPlayCommandType.none; + + /// Bumped by everything that pauses or stops the player so a running [_playUntilPlaying] loop gives up. + int _playGeneration = 0; + + bool get _isSyncPlayActive => ref.read(isSyncPlayActiveProvider); + + bool get _isReloading => ref.read(syncPlayProvider.select((s) => s.correctionState.playerIsBuffering)); + + bool get _inSyncPlayCooldown { + final lastCommandTime = ref.read(syncPlayProvider.select((s) => s.lastCommandTime)); + if (lastCommandTime == null) { + return false; + } + return DateTime.now().toUtc().difference(lastCommandTime) < _syncPlayCooldown; + } + + /// The wrapper's last frame, never the throttled provider copy (1 s steps, frozen while paused): + /// SyncPlay reports must sit inside the server's 500 ms tolerance. + Duration get _livePosition => state.lastState?.position ?? playbackState.position; + + /// The backend's real playing state: on media-kit the wrapper's `playing` flag is only the requested one. + bool get _livePlaying => state.isPlayerPlaying; + ProviderSubscription? settingsChanged; @override void dispose() { settingsChanged?.close(); + _syncPlayStateSubscription?.cancel(); + _bufferingDebouncer?.dispose(); super.dispose(); } + /// media-kit sometimes drops the first `play()` after a paused `open()` or a reload, and in a group the + /// server's Unpause is the only thing that starts this device, so re-issue until the backend really plays. + Future _playUntilPlaying() async { + final generation = ++_playGeneration; + await state.play(); + for (var attempt = 0; attempt < 12; attempt++) { + await Future.delayed(const Duration(milliseconds: 250)); + if (generation != _playGeneration || !state.hasPlayer || _livePlaying) { + return; + } + await state.play(); + } + developer.log('SyncPlay: player still not playing after repeated play() calls'); + } + Future init() async { + _playGeneration++; await state.stop(); await state.dispose(); await state.init(); @@ -52,6 +115,12 @@ class VideoPlayerNotifier extends StateNotifier { s.cancel(); } + _bufferingDebouncer?.dispose(); + _bufferingDebouncer = BufferingReportDebouncer( + onBuffering: () => ref.read(syncPlayProvider.notifier).reportBuffering(), + onReady: () => ref.read(syncPlayProvider.notifier).reportReady(isPlaying: _livePlaying), + ); + settingsChanged = ref.listen( videoPlayerSettingsProvider, (previous, next) { @@ -66,18 +135,171 @@ class VideoPlayerNotifier extends StateNotifier { ); final subscription = state.stateStream.listen((value) { + // The native tag says which action the frame carries, so a seek that drops ExoPlayer into buffering + // (playing -> false on the same frame) is never mistaken for a pause. + switch (value.changeSource) { + case PlaybackChangeSource.userPlayPause: + if (value.playing != playbackState.playing) { + if (value.playing) { + userPlay(); + } else { + userPause(); + } + } + break; + case PlaybackChangeSource.userSeek: + userSeek(value.position); + break; + case PlaybackChangeSource.syncplay: + case PlaybackChangeSource.none: + case null: + break; + } + // Playing before buffering: the Ready sent when a stall ends must carry the current playing flag. + updatePlaying(value.playing); updateBuffering(value.buffering); updateBuffer(value.buffer); - updatePlaying(value.playing); updatePosition(value.position); updateDuration(value.duration); }); subscriptions.add(subscription); + + _registerSyncPlayCallbacks(); + + _setupSyncPlayStateListener(); + } + + void _setupSyncPlayStateListener() { + _syncPlayStateSubscription?.cancel(); + _lastNativeOverlayType = SyncPlayCommandType.none; + _syncPlayStateSubscription = ref.read(syncPlayProvider.notifier).controller.stateStream.listen( + _forwardNativeOverlay, + ); + } + + /// Pushed right before the native activity opens so it does not wait for the next state change. + void refreshNativeOverlay() { + _lastNativeOverlayType = SyncPlayCommandType.none; + _forwardNativeOverlay(ref.read(syncPlayProvider), force: true); + } + + /// Same resolution as the Flutter overlay, so the native activity shows the group's Waiting state too. + void _forwardNativeOverlay(SyncPlayState syncState, {bool force = false}) { + if (!state.isNativePlayerActive) { + _lastNativeOverlayType = SyncPlayCommandType.none; + return; + } + final type = switch (resolveSyncPlayOverlay(syncState)) { + SyncPlayOverlay.command => _toSyncPlayCommandType(syncState.processingCommandType), + SyncPlayOverlay.waiting => SyncPlayCommandType.waiting, + SyncPlayOverlay.switching || SyncPlayOverlay.none => SyncPlayCommandType.none, + }; + if (!force && type == _lastNativeOverlayType) { + return; + } + _lastNativeOverlayType = type; + state.updateSyncPlayCommandState(type != SyncPlayCommandType.none, type); + } + + SyncPlayCommandType _toSyncPlayCommandType(SyncPlayCommand? commandType) { + return switch (commandType) { + SyncPlayCommand.pause => SyncPlayCommandType.pause, + SyncPlayCommand.unpause => SyncPlayCommandType.unpause, + SyncPlayCommand.seek => SyncPlayCommandType.seek, + SyncPlayCommand.stop => SyncPlayCommandType.stop, + null => SyncPlayCommandType.none, + }; } - Future updateBuffering(bool event) async => - mediaState.update((state) => state.buffering == event ? state : state.copyWith(buffering: event)); + void setReloading( + bool value, { + bool reportToSyncPlay = true, + }) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(value); + if (value && _isSyncPlayActive && reportToSyncPlay) { + ref.read(syncPlayProvider.notifier).reportBuffering(); + } + } + + void _registerSyncPlayCallbacks() { + ref.read(syncPlayProvider.notifier).registerPlayer( + onPlay: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + try { + await _playUntilPlaying(); + } finally { + _syncPlayAction = false; + } + }, + onPause: () async { + _syncPlayAction = true; + _playGeneration++; + 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 { + // Report buffering without forcing local buffering state, or the command handler can get stuck waiting. + ref.read(syncPlayProvider.notifier).reportBuffering(); + }, + onStop: () async { + // Group went idle: end playback and leave the player screen. + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await stopAndClosePlayer(); + ref.read(syncPlayProvider.notifier).resetCorrectionState( + reason: 'stop_command', + ); + _syncPlayAction = false; + }, + onSetSpeed: (speed) async { + await state.setSpeed(speed); + }, + getPositionTicks: () => secondsToTicks(_livePosition.inMilliseconds / 1000), + isPlaying: () => _livePlaying, + isBuffering: () => _isReloading || playbackState.buffering, + // ExoPlayer supports setPlaybackSpeed; SpeedToSync avoids SkipToSync, which triggers STATE_BUFFERING + // and amplifies into a post-Unpause buffer cycle on Android-TV. + hasPlaybackRate: () => true, + ); + } + + /// The command handler owns the Buffering/Ready exchange while a command is in flight. + bool get _isSyncPlayCommandInFlight => ref.read(syncPlayProvider.select((s) => s.isProcessingCommand)); + + Future updateBuffering(bool event) async { + final oldState = playbackState; + if (oldState.buffering == event) { + return; + } + + mediaState.update((state) => state.copyWith(buffering: event)); + + // A stall is only fed to the debouncer when nothing else owns the Buffering/Ready exchange; the end + // of a stall is always fed. Read the reload flag before `setPlayerBufferingState` mutates the state. + if (!_isSyncPlayActive) { + _bufferingDebouncer?.reset(); + return; + } + final ownedElsewhere = _syncPlayAction || _inSyncPlayCooldown || _isReloading || _isSyncPlayCommandInFlight; + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(event); + if (event) { + if (!ownedElsewhere && !_isLoadingForSyncPlay) { + _bufferingDebouncer?.update(true); + } + } else { + _bufferingDebouncer?.update(false); + } + } Future updateBuffer(Duration buffer) async { mediaState.update( @@ -110,13 +332,28 @@ 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; + + // Every tick feeds drift estimation; the controller throttles the actual corrections. + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).updatePlaybackDrift( + currentPositionTicks: secondsToTicks(event.inMilliseconds / 1000), + at: DateTime.now().toUtc(), + ); + } + final currentPosition = currentState.position; - if ((currentPosition - event).inSeconds.abs() < 1) return; + if ((currentPosition - event).inSeconds.abs() < 1) { + return; + } final position = event; @@ -136,33 +373,74 @@ class VideoPlayerNotifier extends StateNotifier { } } - 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 { + // A play loop started for the previous item must not start the new one ahead of the group's Unpause. + _playGeneration++; + final oldPlaybackModel = ref.read(playBackModel); + + if (_isSyncPlayActive) { + // Null the model before state.stop() so its delayed Playing/Stopped report is suppressed; media-kit's + // open() replaces the media in place, so an in-route reload needs no explicit stop. + ref.read(playBackModel.notifier).update((_) => null); + } + oldPlaybackModel?.dispose(); + + _bufferingDebouncer?.reset(); + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(true); + + final reportingForSyncPlay = _isSyncPlayActive && waitForSyncPlayCommand; + // The local player is at 0 here, so pass the load position to the SyncPlay reports explicitly or the + // server finds us outside its 500 ms tolerance and answers with a corrective Seek. + final loadPositionTicks = startPosition.inMicroseconds * 10; + if (reportingForSyncPlay) { + _isLoadingForSyncPlay = true; + // Awaited: the server handles requests in arrival order, and a fast load (an item starting at 00:00) + // let the Ready below overtake this Buffering, leaving the session marked buffering with no Ready to + // follow and the whole group stuck in Waiting. + await ref.read(syncPlayProvider.notifier).reportBuffering(positionTicks: loadPositionTicks); + } final useMinimizedPlayer = 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(syncPlayProvider.notifier).setPlayerBufferingState(false); + mediaState.update((state) => state.copyWith(errorPlaying: true)); + if (reportingForSyncPlay) { + unawaited(ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: false, + positionTicks: loadPositionTicks, + )); + } + return false; + } - if (media != null) { - ref.read(playBackModel.notifier).update((state) => newPlaybackModel); - await state.loadVideo(model, effectiveStartPosition, true); + // No auto-play during a SyncPlay load: the server's Unpause drives playback for the group. + await state.loadVideo(model, effectiveStartPosition, !reportingForSyncPlay); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); 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, @@ -171,12 +449,32 @@ class VideoPlayerNotifier extends StateNotifier { skippedSegments: {}, )); - await state.play(); + if (!reportingForSyncPlay) { + await state.play(); + } else { + // Report the requested position with isPlaying=false: the server only corrects a paused client whose + // position is off by more than 500 ms, which keeps keyframe-bound media inside the window. + await ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: false, + 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 so the server doesn't keep everyone paused waiting on us. + if (reportingForSyncPlay) { + unawaited(ref.read(syncPlayProvider.notifier).reportReady( + isPlaying: false, + positionTicks: loadPositionTicks, + )); + } + developer.log('loadPlaybackItem failed: $e\n$stackTrace'); + return false; + } finally { + _isLoadingForSyncPlay = false; } - - mediaState.update((state) => state.copyWith(errorPlaying: true)); - return false; } Future loadAudioPlaybackItem( @@ -196,6 +494,7 @@ class VideoPlayerNotifier extends StateNotifier { repeatMode: playbackSettings.repeatMode, ); final queuedModel = model.updatePlaybackQueue(initializedQueueState); + _playGeneration++; final effectiveStartPosition = await queuedModel.resolvedStartPosition(startPosition); ref.read(playBackModel.notifier).update((state) => queuedModel); @@ -256,6 +555,19 @@ class VideoPlayerNotifier extends StateNotifier { Future openPlayer(BuildContext context) async => state.openPlayer(context); + /// Used by SyncPlay when the group ends, stops or removes us, so no dead player screen is left behind. + Future stopAndClosePlayer({bool closeRoute = true}) async { + _playGeneration++; + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + _bufferingDebouncer?.reset(); + // stop() reads the model synchronously before its first await; its delayed report must not block us. + unawaited(state.stop()); + ref.read(playBackModel.notifier).update((_) => null); + if (closeRoute) { + state.closePlayerRoute(); + } + } + Future takeScreenshot() async { final syncPath = ref.read(clientSettingsProvider).syncPath; // Early return here if we don't have a set/valid path. Skips actually taking the screenshot @@ -310,4 +622,73 @@ class VideoPlayerNotifier extends StateNotifier { return false; } + + // User-initiated actions (go through SyncPlay if active) + + Future userPlay() async { + if (_isSyncPlayActive) { + // Request only: the server answers with a scheduled Unpause for the whole group. + await ref.read(syncPlayProvider.notifier).requestUnpause(); + } else { + await state.play(); + } + } + + /// Pauses locally at once for responsiveness; the Pause command that follows aligns everyone. + Future userPause() async { + if (_isSyncPlayActive) { + _playGeneration++; + _syncPlayAction = true; + try { + await state.pause(); + } finally { + _syncPlayAction = false; + } + await ref.read(syncPlayProvider.notifier).requestPause(); + } else { + await state.pause(); + } + } + + /// In a group the seek is applied locally so the slider does not snap back, but the player stays paused: + /// the server's Seek and the following Unpause resume playback (resuming here gave play-pause-play). + Future userSeek(Duration position) async { + final wasPlaying = playbackState.playing; + if (_isSyncPlayActive) { + _playGeneration++; + _syncPlayAction = true; + try { + await state.pause(); + await state.seek(position); + } 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(); + } + } + } + + /// In a group this halts group playback on this device (`SetIgnoreWait`); the caller pops the route. + /// Local flags flip and the player stops before the request, so a slow server never keeps audio playing. + Future userStop() async { + Future? halt; + if (_isSyncPlayActive) { + halt = ref.read(syncPlayProvider.notifier).haltPlayback(stopLocalPlayer: false); + } + await stopAndClosePlayer(closeRoute: false); + await halt; + } + + Future userPlayOrPause() async { + if (playbackState.playing) { + await userPause(); + } 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..ac549b554 --- /dev/null +++ b/lib/providers/websocket/jellyfin_websocket.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:math' show Random, min, pow; + +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:web_socket_channel/web_socket_channel.dart'; + +enum WebSocketConnectionState { + disconnected, + connecting, + connected, + reconnecting, +} + +/// Only a phone (Android/iOS handheld, not leanback) gets the resume check; desktop, web and TV stay +/// always-alive. Free of Flutter bindings so it is unit-testable. +bool isPhonePlatform({ + required bool isWeb, + required TargetPlatform platform, + required bool leanBackMode, +}) { + if (isWeb) { + return false; + } + final isAndroidOrIos = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + return isAndroidOrIos && !leanBackMode; +} + +/// First reconnect delay; doubles on every failed attempt. +const Duration reconnectBaseDelay = Duration(seconds: 2); + +/// Backoff ceiling; a SyncPlay session dying while the socket is down needs a short one. +const Duration reconnectMaxDelay = Duration(seconds: 30); + +/// Random spread so many clients behind the same router do not reconnect in lockstep. +const double reconnectJitterFraction = 0.2; + +/// Exponential with a hard cap and jitter; never gives up. +Duration reconnectDelay(int attempt, {Random? random}) { + final exponent = attempt.clamp(0, 30); + final rawMs = reconnectBaseDelay.inMilliseconds * pow(2, exponent); + final cappedMs = min(rawMs, reconnectMaxDelay.inMilliseconds.toDouble()); + final jitter = ((random ?? Random()).nextDouble() * 2 - 1) * reconnectJitterFraction; + return Duration(milliseconds: (cappedMs * (1 + jitter)).round()); +} + +/// The `/socket` URI, carrying the token as the `ApiKey` query parameter (the legacy `api_key` name is +/// only honoured while `EnableLegacyAuthorization` is on, which Jellyfin 12 turns off by default). +Uri buildWebSocketUri({ + required String serverUrl, + required String token, + required String deviceId, +}) { + final baseUri = Uri.parse(serverUrl); + final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; + final basePath = baseUri.path.replaceAll(RegExp(r'/+$'), ''); + return Uri( + scheme: scheme, + host: baseUri.host, + port: baseUri.port, + path: '$basePath/socket', + queryParameters: { + 'ApiKey': token, + 'deviceId': deviceId, + }, + ); +} + +/// [uri] with every token-bearing query parameter masked, for logging. +String redactSocketUri(Uri uri) => + uri.toString().replaceAllMapped(RegExp(r'(ApiKey)=[^&]+'), (match) => '${match[1]}=***'); + +/// App-level shared connection; reconnects forever with capped backoff until [disconnect]. +class JellyfinWebSocket { + JellyfinWebSocket({ + required this.serverUrl, + required this.token, + required this.deviceId, + }); + + final String serverUrl; + final String token; + final String deviceId; + + WebSocketChannel? _channel; + StreamSubscription? _subscription; + Timer? _keepAliveTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + + /// Set by [disconnect] until the next [connect]; suppresses automatic reconnects. + bool _manuallyClosed = false; + + 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; + + Uri get _webSocketUri => buildWebSocketUri(serverUrl: serverUrl, token: token, deviceId: deviceId); + + /// The socket URI with every token-bearing parameter masked, for logging. + String get _redactedUri => redactSocketUri(_webSocketUri); + + Future connect() async { + if (_currentState == WebSocketConnectionState.connected || _currentState == WebSocketConnectionState.connecting) { + return; + } + + _manuallyClosed = false; + _updateState(WebSocketConnectionState.connecting); + + WebSocketChannel? opened; + try { + log('WebSocket: Connecting to $_redactedUri'); + final channel = WebSocketChannel.connect(_webSocketUri); + opened = channel; + _channel = channel; + await channel.ready; + + if (!identical(_channel, channel)) { + // `disconnect()` ran while the handshake was in flight. + await channel.sink.close(); + return; + } + + _updateState(WebSocketConnectionState.connected); + _reconnectAttempts = 0; + + _subscription = channel.stream.listen( + _handleMessage, + onError: (Object error) => _handleError(channel, error), + onDone: () => _handleDone(channel), + ); + } catch (e) { + log('WebSocket connection failed: $e'); + if (opened != null) { + if (identical(_channel, opened)) { + _onSocketClosed(opened); + } + } else if (!_manuallyClosed) { + // Channel never created (sync constructor failure); staying `connecting` would make later connect() a no-op. + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + } + } + + /// Disconnect from WebSocket and stop reconnecting. + Future disconnect() async { + _manuallyClosed = true; + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _keepAliveTimer?.cancel(); + _keepAliveTimer = null; + + // Detach first so the old channel's onDone/onError can never reach _onSocketClosed after a rebuild. + final channel = _channel; + _channel = null; + await _subscription?.cancel(); + _subscription = null; + await channel?.sink.close(); + _updateState(WebSocketConnectionState.disconnected); + } + + /// Resets the attempt counter and reconnects immediately (e.g. after app resume with a dead socket). + Future forceReconnect() async { + await disconnect(); + _reconnectAttempts = 0; + await connect(); + } + + 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'); + } + } + + 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?; + + if (messageType != 'KeepAlive') { + log('WebSocket: Received message: $message'); + } + + if (messageType == 'ForceKeepAlive') { + final timeoutSeconds = message['Data'] as int? ?? 60; + _setupKeepAlive(timeoutSeconds); + } + + _messageController.add(message); + } catch (e) { + log('Failed to parse WebSocket message: $e\nRaw data: $data'); + } + } + + void _handleError(WebSocketChannel channel, Object error) { + log('WebSocket error: $error'); + // onDone follows an error on the channel stream; scheduling is idempotent so handling both is harmless. + _onSocketClosed(channel); + } + + void _handleDone(WebSocketChannel channel) { + log('WebSocket connection closed'); + _onSocketClosed(channel); + } + + /// Common exit path for errors, remote closes and failed connects. Events from a channel that is no + /// longer current are ignored, or they would null out the live channel and arm a third socket. + void _onSocketClosed(WebSocketChannel channel) { + if (!identical(_channel, channel)) { + return; + } + _keepAliveTimer?.cancel(); + _keepAliveTimer = null; + _channel = null; + _subscription = null; + if (_manuallyClosed) { + _updateState(WebSocketConnectionState.disconnected); + return; + } + _scheduleReconnect(); + } + + void _setupKeepAlive(int timeoutSeconds) { + _keepAliveTimer?.cancel(); + final interval = Duration(seconds: (timeoutSeconds * 0.5).round()); + _keepAliveTimer = Timer.periodic(interval, (_) => _sendKeepAlive()); + } + + /// Arms a reconnect timer unless one is already pending; never gives up. + void _scheduleReconnect() { + if (_manuallyClosed) { + return; + } + if (_reconnectTimer?.isActive == true) { + return; + } + + if (_currentState != WebSocketConnectionState.reconnecting) { + _updateState(WebSocketConnectionState.reconnecting); + } + + final delay = reconnectDelay(_reconnectAttempts); + _reconnectAttempts++; + + log('Scheduling reconnect in ${delay.inMilliseconds}ms (attempt $_reconnectAttempts)'); + _reconnectTimer = Timer(delay, () { + _reconnectTimer = null; + unawaited(connect()); + }); + } + + void _updateState(WebSocketConnectionState state) { + _currentState = state; + if (!_connectionStateController.isClosed) { + _connectionStateController.add(state); + } + } + + 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..36a3db203 --- /dev/null +++ b/lib/providers/websocket/jellyfin_websocket_provider.dart @@ -0,0 +1,202 @@ +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/connectivity_provider.dart' as connectivity; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; + +part 'jellyfin_websocket_provider.g.dart'; + +/// Phone-only: on foreground it reconnects only if the socket died in the background. `inactive` is +/// ignored because Android raises it for the notification shade, dialogs, PiP and call banners. +class _WebSocketLifecycleObserver with WidgetsBindingObserver { + _WebSocketLifecycleObserver(this._controller); + + final JellyfinWebSocketController _controller; + + void register() => WidgetsBinding.instance.addObserver(this); + void unregister() => WidgetsBinding.instance.removeObserver(this); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + unawaited(_controller.ensureConnected(reason: 'app resumed')); + break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + break; + } + } +} + +/// Owns a single [JellyfinWebSocket], connects when a user is authenticated, and re-broadcasts its +/// streams through long-lived controllers so consumers survive account switches / socket rebuilds. +@Riverpod(keepAlive: true) +class JellyfinWebSocketController extends _$JellyfinWebSocketController { + JellyfinWebSocket? _socket; + StreamSubscription? _socketStateSub; + StreamSubscription>? _socketMessageSub; + _WebSocketLifecycleObserver? _observer; + + // Serializes socket teardown/rebuild so two quick auth changes cannot leak or duplicate a socket. + Future _socketOps = Future.value(); + + // Long-lived re-broadcast controllers; consumers never subscribe to a JellyfinWebSocket directly. + final _stateController = StreamController.broadcast(); + final _messageController = StreamController>.broadcast(); + + Stream get connectionState => _stateController.stream; + + /// Consumers filter by `MessageType` themselves. + Stream> get messages => _messageController.stream; + + WebSocketConnectionState get currentState => _socket?.currentState ?? WebSocketConnectionState.disconnected; + + void send(Map message) => _socket?.send(message); + + Future forceReconnectSocket() async => _socket?.forceReconnect(); + + /// A healthy socket only gets a KeepAlive so the server refreshes its liveness timer; a dead one is + /// rebuilt immediately instead of waiting for the backoff timer. + Future ensureConnected({required String reason}) async { + final socket = _socket; + if (socket == null) { + return; + } + if (socket.currentState == WebSocketConnectionState.connected) { + log('JellyfinWebSocket: $reason, socket healthy, sending KeepAlive'); + socket.send({'MessageType': 'KeepAlive'}); + return; + } + log('JellyfinWebSocket: $reason, socket ${socket.currentState.name}, reconnecting now'); + await socket.forceReconnect(); + } + + bool get _isPhone => isPhonePlatform( + isWeb: kIsWeb, + platform: defaultTargetPlatform, + leanBackMode: ref.read(argumentsStateProvider).leanBackMode, + ); + + @override + WebSocketConnectionState build() { + // fireImmediately covers a user already logged in when this provider is first activated. + ref.listen( + userProvider, + (previous, next) => _handleUserChange(previous, next), + fireImmediately: true, + ); + + if (_isPhone) { + _observer = _WebSocketLifecycleObserver(this)..register(); + } + + // Coming back online is worth an immediate attempt; do not wait for the backoff timer. + ref.listen( + connectivity.connectivityStatusProvider, + (previous, next) { + final wasOffline = previous == connectivity.ConnectionState.offline; + final isOnline = next != connectivity.ConnectionState.offline; + if (wasOffline && isOnline) { + unawaited(ensureConnected(reason: 'connectivity restored')); + } + }, + ); + + ref.onDispose(_disposeAll); + return WebSocketConnectionState.disconnected; + } + + /// Failures are logged, not propagated, so one bad op doesn't wedge the serial queue. + void _enqueueSocketOp(Future Function() op) { + _socketOps = _socketOps.then((_) => op()).catchError((Object e, StackTrace s) { + log('JellyfinWebSocket: socket op failed: $e'); + }); + } + + 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: 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: 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 882c2be5e..f334c4c05 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -21,6 +21,7 @@ import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; import 'package:fladder/widgets/navigation_scaffold/navigation_scaffold.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; +import 'package:fladder/widgets/syncplay/dashboard_fabs.dart'; enum HomeTabs { dashboard, @@ -132,13 +133,7 @@ class HomeScreen extends ConsumerWidget { action: () => e.navigate(context), onLongPress: () => _showDashboardSwitcher(context, ref), onSecondaryTapDown: (_) => _showDashboardSwitcher(context, ref), - floatingActionButton: AdaptiveFab( - context: context, - title: context.localized.search, - key: Key(e.name.capitalize()), - onPressed: () => context.router.navigate(LibrarySearchRoute()), - child: const Icon(IconsaxPlusLinear.search_normal_1), - ), + customFab: const DashboardFabs(), ); case HomeTabs.favorites: return DestinationModel( diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 4b4abc93f..a5a611e1c 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -272,6 +272,15 @@ class _PlayerSettingsPageState extends ConsumerState { ), ), ], + SettingsListTile( + label: Text(context.localized.syncPlayEnableCorrection), + subLabel: Text(context.localized.syncPlayEnableCorrectionDesc), + onTap: () => provider.setEnableSyncPlayCorrection(!videoSettings.enableSyncPlayCorrection), + trailing: Switch( + value: videoSettings.enableSyncPlayCorrection, + onChanged: (value) => provider.setEnableSyncPlayCorrection(value), + ), + ), SettingsListTile( label: Text(context.localized.enableSpeedBoostTitle), subLabel: Text(context.localized.enableSpeedBoostDesc), diff --git a/lib/screens/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..7db89c2d8 --- /dev/null +++ b/lib/screens/video_player/components/syncplay_command_indicator.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'; + +/// Centre overlay driven by [resolveSyncPlayOverlay], shared with the native Android overlay. Drift +/// corrections are deliberately not shown here: they can fire every couple of seconds ([SyncPlayBadge] only). +class SyncPlayCommandIndicator extends ConsumerWidget { + const SyncPlayCommandIndicator({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final overlay = ref.watch(syncPlayProvider.select(resolveSyncPlayOverlay)); + final commandType = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); + + final visible = isActive && overlay != SyncPlayOverlay.none; + + 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: [ + _OverlayIcon(overlay: overlay, commandType: commandType), + const SizedBox(height: 12), + Text( + _label(context, overlay, commandType), + 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, SyncPlayOverlay overlay, SyncPlayCommand? commandType) { + return switch (overlay) { + SyncPlayOverlay.switching => context.localized.syncPlaySwitchingItem, + SyncPlayOverlay.command => commandType.syncPlayCommandOverlayLabel(context), + SyncPlayOverlay.waiting => context.localized.syncPlayStateWaiting, + SyncPlayOverlay.none => context.localized.syncPlayCommandSyncing, + }; + } +} + +class _OverlayIcon extends StatelessWidget { + final SyncPlayOverlay overlay; + final SyncPlayCommand? commandType; + + const _OverlayIcon({ + required this.overlay, + required this.commandType, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (IconData icon, Color color) = switch (overlay) { + SyncPlayOverlay.switching => (IconsaxPlusBold.refresh, scheme.primary), + SyncPlayOverlay.command => commandType.syncPlayCommandIconAndColor(context), + SyncPlayOverlay.waiting => SyncPlayGroupState.waiting.iconAndColor(context), + SyncPlayOverlay.none => (IconsaxPlusBold.refresh, scheme.primary), + }; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 48, + color: color, + ), + ); + } +} diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 529a467e4..4b2e808a3 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,7 +122,9 @@ class _VideoPlayerNextWrapperState extends ConsumerState Future closePlayer() async { clearOverlaySettings(); - ref.read(videoPlayerProvider).stop(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + // Halts group playback on this device (SyncPlay) before stopping. + ref.read(videoPlayerProvider.notifier).userStop(); Navigator.of(context).pop(); } @@ -346,6 +347,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState class _NextUpInformation extends StatelessWidget { final ItemBaseModel item; + const _NextUpInformation({ required this.item, }); @@ -436,19 +438,19 @@ class _NextUpInformation extends StatelessWidget { class _SimpleControls extends ConsumerWidget { final Function()? skip; + const _SimpleControls({ this.skip, }); @override Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(videoPlayerProvider); final isPlaying = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton.filledTonal( - onPressed: () => player.playOrPause(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlayOrPause(), icon: Icon(isPlaying ? IconsaxPlusBold.pause : IconsaxPlusBold.play), ), if (skip != null) diff --git a/lib/screens/video_player/components/video_player_options_sheet.dart b/lib/screens/video_player/components/video_player_options_sheet.dart index 12d9658d2..1d76ede0a 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'; @@ -29,6 +30,7 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/map_bool_helper.dart'; import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/string_extensions.dart'; +import 'package:fladder/wrappers/media_control_wrapper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; @@ -194,6 +196,9 @@ class _VideoOptionsMobileState extends ConsumerState { onTap: () => showOrientationOptions(context, ref), ), ListTile( + // The rate belongs to SyncPlay's drift correction while in a group. + enabled: !ref.watch(isSyncPlayActiveProvider), + subtitle: ref.watch(isSyncPlayActiveProvider) ? Text(context.localized.syncPlaySpeedLocked) : null, onTap: () { Navigator.of(context).pop(); showPlaybackSpeed(context); @@ -397,48 +402,35 @@ Future showSubSelection(BuildContext context) { return showDialog( context: context, builder: (context) { - return Consumer( - builder: (context, ref, child) { - final playbackModel = ref.watch(playBackModel); - final player = ref.watch(videoPlayerProvider); - return SimpleDialog( - contentPadding: const EdgeInsets.only(top: 8, bottom: 24), - title: Row( - children: [ - Text(context.localized.subtitle), - const Spacer(), - if (player.backend == PlayerOptions.libMPV || player.backend == PlayerOptions.libMDK) - IconButton.outlined( - onPressed: () { - Navigator.pop(context); - showSubtitleControls( - context: context, - label: context.localized.subtitleConfiguration, - ); - }, - icon: const Icon(Icons.display_settings_rounded)) - ], - ), - children: playbackModel?.subStreams?.mapIndexed( - (index, subModel) { - final selected = playbackModel.mediaStreams?.defaultSubStreamIndex == subModel.index; - return ListTile( - title: Text(subModel.label(context)), - tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, - subtitle: subModel.language.isNotEmpty - ? Opacity(opacity: 0.6, child: Text(subModel.language.capitalize())) - : null, - onTap: () async { - final newModel = await playbackModel.setSubtitle(subModel, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); - } + return _TrackSelectionDialog( + title: (context, player) => Row( + children: [ + Text(context.localized.subtitle), + const Spacer(), + if (player.backend == PlayerOptions.libMPV || player.backend == PlayerOptions.libMDK) + IconButton.outlined( + onPressed: () { + Navigator.pop(context); + showSubtitleControls( + context: context, + label: context.localized.subtitleConfiguration, + ); }, - ); - }, - ).toList(), - ); + icon: const Icon(Icons.display_settings_rounded)) + ], + ), + tracks: (model) => + model.subStreams + ?.map((sub) => _TrackOption(index: sub.index, label: sub.label(context), language: sub.language)) + .toList() ?? + const [], + currentIndex: (model) => model.mediaStreams?.defaultSubStreamIndex, + apply: (model, player, index) async { + final subModel = model.subStreams?.firstWhereOrNull((sub) => sub.index == index); + if (subModel == null) { + return null; + } + return model.setSubtitle(subModel, player); }, ); }, @@ -449,42 +441,129 @@ Future showAudioSelection(BuildContext context) { return showDialog( context: context, builder: (context) { - return Consumer( - builder: (context, ref, child) { - final playbackModel = ref.watch(playBackModel); - final player = ref.watch(videoPlayerProvider); - return SimpleDialog( - contentPadding: const EdgeInsets.only(top: 8, bottom: 24), - title: Row( - children: [ - Text(context.localized.audio(1)), - ], - ), - children: playbackModel?.audioStreams?.mapIndexed( - (index, audioStream) { - final selected = playbackModel.mediaStreams?.defaultAudioStreamIndex == audioStream.index; - return ListTile( - title: Text(audioStream.label(context)), - tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, - subtitle: audioStream.language.isNotEmpty - ? Opacity(opacity: 0.6, child: Text(audioStream.language.capitalize())) - : null, - onTap: () async { - final newModel = await playbackModel.setAudio(audioStream, player); - ref.read(playBackModel.notifier).update((state) => newModel); - if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); - } - }); - }, - ).toList(), - ); + return _TrackSelectionDialog( + title: (context, player) => Row( + children: [ + Text(context.localized.audio(1)), + ], + ), + tracks: (model) => + model.audioStreams + ?.map( + (audio) => _TrackOption(index: audio.index, label: audio.label(context), language: audio.language)) + .toList() ?? + const [], + currentIndex: (model) => model.mediaStreams?.defaultAudioStreamIndex, + apply: (model, player, index) async { + final audioStream = model.audioStreams?.firstWhereOrNull((audio) => audio.index == index); + if (audioStream == null) { + return null; + } + return model.setAudio(audioStream, player); }, ); }, ); } +class _TrackOption { + const _TrackOption({required this.index, required this.label, required this.language}); + + final int index; + final String label; + final String language; +} + +/// The tapped entry is highlighted and marked busy at once while the switch and a possible transcode +/// reload run behind it. In a SyncPlay group the switch is a local-only operation. +class _TrackSelectionDialog extends ConsumerStatefulWidget { + const _TrackSelectionDialog({ + required this.title, + required this.tracks, + required this.currentIndex, + required this.apply, + }); + + final Widget Function(BuildContext context, MediaControlsWrapper player) title; + final List<_TrackOption> Function(PlaybackModel model) tracks; + final int? Function(PlaybackModel model) currentIndex; + final Future? Function(PlaybackModel model, MediaControlsWrapper player, int index) apply; + + @override + ConsumerState<_TrackSelectionDialog> createState() => _TrackSelectionDialogState(); +} + +class _TrackSelectionDialogState extends ConsumerState<_TrackSelectionDialog> { + int? _pendingIndex; + + Future _select(PlaybackModel model, MediaControlsWrapper player, int index) async { + if (_pendingIndex != null) { + return; + } + setState(() => _pendingIndex = index); + // Read everything before the first await: the dialog may be dismissed while the reload runs. + final modelNotifier = ref.read(playBackModel.notifier); + final helper = ref.read(playbackModelHelper); + final syncPlay = ref.read(isSyncPlayActiveProvider) ? ref.read(syncPlayProvider.notifier) : null; + Future doSwitch() async { + final newModel = await widget.apply(model, player, index); + modelNotifier.update((state) => newModel ?? state); + if (newModel != null) { + await helper.shouldReload( + newModel, + isLocalTrackSwitch: true, + ); + } + } + + try { + if (syncPlay != null) { + await syncPlay.runLocalOnly(doSwitch); + } else { + await doSwitch(); + } + } finally { + if (mounted) { + setState(() => _pendingIndex = null); + } + } + } + + @override + Widget build(BuildContext context) { + final playbackModel = ref.watch(playBackModel); + final player = ref.watch(videoPlayerProvider); + final currentIndex = playbackModel == null ? null : widget.currentIndex(playbackModel); + final highlighted = _pendingIndex ?? currentIndex; + final tracks = playbackModel == null ? const <_TrackOption>[] : widget.tracks(playbackModel); + + return SimpleDialog( + contentPadding: const EdgeInsets.only(top: 8, bottom: 24), + title: widget.title(context, player), + children: tracks.map( + (track) { + final selected = highlighted == track.index; + final busy = _pendingIndex == track.index && currentIndex != track.index; + return ListTile( + title: Text(track.label), + tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, + subtitle: + track.language.isNotEmpty ? Opacity(opacity: 0.6, child: Text(track.language.capitalize())) : null, + trailing: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: playbackModel == null ? null : () => _select(playbackModel, player, track.index), + ); + }, + ).toList(), + ); + } +} + Future showPlaybackSpeed(BuildContext context) { return showDialog( context: context, diff --git a/lib/screens/video_player/components/video_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..8da735069 100644 --- a/lib/screens/video_player/components/video_progress_bar.dart +++ b/lib/screens/video_player/components/video_progress_bar.dart @@ -1,11 +1,8 @@ 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/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/list_padding.dart'; @@ -13,6 +10,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 +106,14 @@ class _ChapterProgressSliderState extends ConsumerState { ), onChangeEnd: (e) async { currentDuration = Duration(milliseconds: e.toInt()); - widget.onPositionChanged.call(Duration(milliseconds: e.toInt())); - await Future.delayed(const Duration(milliseconds: 250)); - if (widget.wasPlaying) { - player.play(); + final inSyncPlay = ref.read(isSyncPlayActiveProvider); + // One seek per release; in a group the server's Seek and the following Unpause resume everyone. + widget.onPositionChanged(Duration(milliseconds: e.toInt())); + if (!inSyncPlay) { + await Future.delayed(const Duration(milliseconds: 250)); + if (widget.wasPlaying) { + ref.read(videoPlayerProvider.notifier).userPlay(); + } } widget.timerReset.call(); setState(() { @@ -122,7 +125,10 @@ class _ChapterProgressSliderState extends ConsumerState { onHoverStart = true; }); widget.wasPlayingChanged.call(player.lastState?.playing ?? false); - player.pause(); + // In a group the player keeps running while dragging: pausing here would pause everyone. + if (!ref.read(isSyncPlayActiveProvider)) { + ref.read(videoPlayerProvider.notifier).userPause(); + } }, onChanged: (e) { currentDuration = Duration(milliseconds: e.toInt()); diff --git a/lib/screens/video_player/tv_player_controls.dart b/lib/screens/video_player/tv_player_controls.dart index abf380490..06bf2efe7 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,7 +644,9 @@ class _TvPlayerControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); - ref.read(videoPlayerProvider).stop(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + // Halts group playback on this device (SyncPlay) first; not awaited so the route pops immediately. + ref.read(videoPlayerProvider.notifier).userStop(); Navigator.of(context).pop(); } @@ -683,7 +692,7 @@ class _TvPlayerControlsState extends ConsumerState { switch (value) { case VideoHotKeys.playPause: - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index b6091af35..8fdf564b7 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -44,6 +44,32 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb } } + @override + void deactivate() { + // Capture the notifier synchronously while the consumer element is + // still alive, then defer the mutation to the next microtask to + // avoid "Tried to modify a provider while the widget tree was + // building" when deactivate runs inside a parent rebuild. + try { + final notifier = ref.read(mediaPlaybackProvider.notifier); + final currentPlaybackState = ref.read(mediaPlaybackProvider).state; + if (currentPlaybackState == VideoPlayerState.fullScreen) { + Future.microtask(() { + try { + notifier.update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); + } catch (_) { + // ProviderContainer may already be torn down. + } + }); + } + } catch (_) { + // ProviderContainer may already be torn down (app shutdown). + } + super.deactivate(); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -56,6 +82,7 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb super.initState(); WidgetsBinding.instance.addObserver(this); Future.microtask(() { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = true; ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen)); final orientations = ref.read(videoPlayerSettingsProvider.select((value) => value.allowedOrientations)); SystemChrome.setPreferredOrientations( @@ -72,11 +99,17 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb final playerController = ref.watch(videoPlayerProvider.select((value) => value)); - //Watch playbackModel type changes to switch between normal players + // Watch playbackModel type changes to switch between normal + // players. Guard with `mounted`: this listener can fire from an + // async callback (e.g. media-kit's loadVideo Future) that resolves + // after the player route has been popped/disposed - calling + // setState then triggers a "_lifecycleState != defunct" assertion. ref.listen( playBackModel, (previous, next) { - if (next == null) return; + if (!mounted || next == null) { + return; + } if (previous.runtimeType != next.runtimeType) { setState(() { currentPlaybackModel = next; @@ -89,9 +122,12 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb ref.listen( videoPlayerSettingsProvider.select((value) => value.allowedOrientations), (previous, next) { - if (previous != next) { - SystemChrome.setPreferredOrientations(next?.isNotEmpty == true ? next!.toList() : DeviceOrientation.values); + if (!mounted || previous == next) { + return; } + SystemChrome.setPreferredOrientations( + next?.isNotEmpty == true ? next!.toList() : DeviceOrientation.values, + ); }, ); diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 6a556751f..4730054ac 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -1,10 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:async/async.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; @@ -20,10 +15,12 @@ import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/pip_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/shared/default_title_bar.dart'; import 'package:fladder/screens/shared/media/components/item_logo.dart'; +import 'package:fladder/screens/video_player/components/syncplay_command_indicator.dart'; import 'package:fladder/screens/video_player/components/video_playback_information.dart'; import 'package:fladder/screens/video_player/components/video_player_brightness_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_controls_extras.dart'; @@ -42,7 +39,12 @@ import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_badge.dart'; import 'package:fladder/wrappers/pip_manager.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class DesktopControls extends ConsumerStatefulWidget { const DesktopControls({super.key}); @@ -145,7 +147,9 @@ class _DesktopControlsState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: initInputDevice == InputDevice.pointer ? null : () => toggleOverlay(), + onTap: initInputDevice == InputDevice.pointer + ? () => ref.read(videoPlayerProvider.notifier).userPlayOrPause() + : () => toggleOverlay(), onDoubleTapDown: initInputDevice == InputDevice.touch ? _handleDoubleTapDown : null, onDoubleTap: initInputDevice == InputDevice.pointer ? () => fullScreenHelper.toggleFullScreen(ref) @@ -186,6 +190,7 @@ class _DesktopControlsState extends ConsumerState { const VideoPlayerBrightnessIndicator(), const VideoPlayerSpeedIndicator(), const VideoPlayerScreenshotIndicator(), + const SyncPlayCommandIndicator(), Consumer( builder: (context, ref, child) { final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); @@ -245,7 +250,7 @@ class _DesktopControlsState extends ConsumerState { : 1, duration: const Duration(milliseconds: 250), child: IconButton.outlined( - onPressed: () => ref.read(videoPlayerProvider).play(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlay(), isSelected: true, iconSize: 65, tooltip: "Resume video", @@ -311,6 +316,7 @@ class _DesktopControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -433,7 +439,7 @@ class _DesktopControlsState extends ConsumerState { IconButton.filledTonal( iconSize: 38, onPressed: () { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); }, icon: Icon( playing ? IconsaxPlusBold.pause : IconsaxPlusBold.play, @@ -513,9 +519,10 @@ class _DesktopControlsState extends ConsumerState { ), const Spacer(), if (playbackModel != null) - InkWell( - onTap: () => showVideoPlaybackInformation(context), - child: Card( + Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => showVideoPlaybackInformation(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text( @@ -547,7 +554,7 @@ class _DesktopControlsState extends ConsumerState { buffer: mediaPlayback.buffer, buffering: mediaPlayback.buffering, timerReset: () => timer.reset(), - onPositionChanged: (position) => ref.read(videoPlayerProvider).seek(position), + onPositionChanged: (position) => ref.read(videoPlayerProvider.notifier).userSeek(position), ), ), const SizedBox(height: 4), @@ -690,7 +697,7 @@ class _DesktopControlsState extends ConsumerState { final end = mediaSegment?.end; if (end != null) { resetTimer(); - ref.read(videoPlayerProvider).seek(end); + ref.read(videoPlayerProvider.notifier).userSeek(end); if (segmentId != null) { Future(() { @@ -717,7 +724,7 @@ class _DesktopControlsState extends ConsumerState { final mediaPlayback = ref.read(mediaPlaybackProvider); resetTimer(); final newPosition = (mediaPlayback.position.inSeconds + seconds).clamp(0, mediaPlayback.duration.inSeconds); - ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition)); + ref.read(videoPlayerProvider.notifier).userSeek(Duration(seconds: newPosition)); } void stepBack(WidgetRef ref) { @@ -769,7 +776,10 @@ class _DesktopControlsState extends ConsumerState { void minimizePlayer(BuildContext context) { clearOverlaySettings(); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.minimized)); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); Navigator.of(context).pop(); } @@ -777,7 +787,11 @@ class _DesktopControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); - ref.read(videoPlayerProvider).stop(); + // Mark the route closed now so a SyncPlay _startPlayback arriving during the pop knows to push a new one. + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + // Fire-and-forget: userStop() halts group playback first (SetIgnoreWait) and then stops the wrapper, + // whose ~1-2 s session report would reach `ref` after the route pop disposed the widget. + unawaited(ref.read(videoPlayerProvider.notifier).userStop()); Navigator.of(context).pop(); } @@ -815,6 +829,10 @@ class _DesktopControlsState extends ConsumerState { void _activateSpeedBoost() { if (_speedBoostActive) return; + // The rate belongs to SyncPlay's drift correction in a group; a local boost would desynchronise us. + if (ref.read(isSyncPlayActiveProvider)) { + return; + } final settings = ref.read(videoPlayerSettingsProvider); if (!settings.enableSpeedBoost) return; @@ -851,7 +869,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { _deactivateSpeedBoost(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } return KeyEventResult.handled; } @@ -880,7 +898,7 @@ class _DesktopControlsState extends ConsumerState { } else if (tapX > zoneThird * 2) { seekForwardWithIndicator(); } else { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); } _doubleTapPosition = null; } @@ -1014,7 +1032,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { return false; } - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); @@ -1079,10 +1097,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..c4b16ea7a 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,30 @@ 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(); + + String syncPlayStateWaiting(); + + 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 +106,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 +114,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 +126,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 +134,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 +146,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 +154,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 +172,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 +180,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 +192,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 +200,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 +212,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 +220,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 +238,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 +246,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 +264,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 +272,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 +290,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 +298,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 +316,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 +324,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 +336,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 +344,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 +365,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 +373,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 +385,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 +393,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 +405,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 +413,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 +425,147 @@ 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())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayStateWaiting$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayStateWaiting(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index b717b5ffe..de5f598ba 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,17 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, + + /// The group is waiting for a participant to buffer; shown so every device reports the same state. + waiting, +} + enum MediaSegmentType { commercial, preview, @@ -54,6 +63,22 @@ enum MediaSegmentType { outro, } +/// Who caused the last playback state change; Flutter infers SyncPlay requests from it. +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + + /// User pressed play or pause on native; Flutter turns it into the SyncPlay request. + userPlayPause, + + /// User seeked on native; the frame carries the new position even when the seek drops the player into + /// buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + userSeek, + + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, +} + class SimpleItemModel { SimpleItemModel({ required this.id, @@ -88,7 +113,8 @@ class SimpleItemModel { } Object encode() { - return _toList(); } + return _toList(); + } static SimpleItemModel decode(Object result) { result as List; @@ -116,8 +142,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 +163,8 @@ class MediaInfo { } Object encode() { - return _toList(); } + return _toList(); + } static MediaInfo decode(Object result) { result as List; @@ -162,8 +188,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 +257,8 @@ class PlayableData { } Object encode() { - return _toList(); } + return _toList(); + } static PlayableData decode(Object result) { result as List; @@ -268,8 +294,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 +323,8 @@ class MediaSegment { } Object encode() { - return _toList(); } + return _toList(); + } static MediaSegment decode(Object result) { result as List; @@ -324,8 +350,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 +387,8 @@ class AudioTrack { } Object encode() { - return _toList(); } + return _toList(); + } static AudioTrack decode(Object result) { result as List; @@ -390,8 +416,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 +453,8 @@ class SubtitleTrack { } Object encode() { - return _toList(); } + return _toList(); + } static SubtitleTrack decode(Object result) { result as List; @@ -456,8 +482,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 +507,8 @@ class Chapter { } Object encode() { - return _toList(); } + return _toList(); + } static Chapter decode(Object result) { result as List; @@ -507,8 +533,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 +574,8 @@ class TrickPlayModel { } Object encode() { - return _toList(); } + return _toList(); + } static TrickPlayModel decode(Object result) { result as List; @@ -578,8 +604,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 +621,8 @@ class StartResult { } Object encode() { - return _toList(); } + return _toList(); + } static StartResult decode(Object result) { result as List; @@ -619,8 +645,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 +657,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); int position; @@ -648,6 +674,9 @@ class PlaybackState { bool failed; + /// When set, indicates who caused this state update (for SyncPlay inference). + PlaybackChangeSource? changeSource; + List _toList() { return [ position, @@ -657,11 +686,13 @@ class PlaybackState { buffering, completed, failed, + changeSource, ]; } Object encode() { - return _toList(); } + return _toList(); + } static PlaybackState decode(Object result) { result as List; @@ -673,6 +704,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 +722,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 +767,8 @@ class SubtitleSettings { } Object encode() { - return _toList(); } + return _toList(); + } static SubtitleSettings decode(Object result) { result as List; @@ -766,8 +798,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 +827,8 @@ class TVGuideModel { } Object encode() { - return _toList(); } + return _toList(); + } static TVGuideModel decode(Object result) { result as List; @@ -822,8 +854,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 +887,8 @@ class GuideChannel { } Object encode() { - return _toList(); } + return _toList(); + } static GuideChannel decode(Object result) { result as List; @@ -883,8 +915,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 +960,8 @@ class GuideProgram { } Object encode() { - return _toList(); } + return _toList(); + } static GuideProgram decode(Object result) { result as List; @@ -959,11 +991,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 +1001,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 +1063,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 +1123,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 +1151,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 +1174,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 +1216,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 +1244,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 +1272,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 +1300,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 +1324,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 +1348,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 +1371,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 +1395,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 +1419,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 +1442,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 +1465,39 @@ 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; + } + } + + /// Drives the native player's SyncPlay overlay. + Future setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$pigeonVar_messageChannelSuffix'; + 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 +1517,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 +1543,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 +1569,16 @@ abstract class VideoPlayerControlsCallback { Future> fetchProgramsForChannel(String channelId); - static void setUp(VideoPlayerControlsCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + 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 +1589,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 +1597,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 +1609,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 +1617,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 +1629,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 +1637,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 +1655,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 +1663,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 +1681,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 +1689,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 +1707,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 +1715,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 +1733,7 @@ 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())); } }); diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index c3435370f..41e6bb835 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -1,16 +1,9 @@ import 'dart:developer'; import 'dart:math' show Random, min; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:async/async.dart'; import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:square_progress_indicator/square_progress_indicator.dart'; - import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/book_model.dart'; import 'package:fladder/models/item_base_model.dart'; @@ -18,14 +11,18 @@ import 'package:fladder/models/items/album_model.dart'; import 'package:fladder/models/items/artist_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/channel_model.dart'; +import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/models/items/playlist_model.dart'; +import 'package:fladder/models/items/season_model.dart'; +import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/tv_playback_model.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/book_viewer_provider.dart'; import 'package:fladder/providers/items/book_details_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/book_viewer/book_viewer_screen.dart'; @@ -38,6 +35,13 @@ import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:square_progress_indicator/square_progress_indicator.dart'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; part 'play_playlist_helpers.dart'; @@ -98,6 +102,8 @@ extension PhotoAlbumExtension on PhotoAlbumModel? { final getChildItems = await op.valueOrCancellation(null); if (op.isCanceled || getChildItems == null) { if (!op.isCanceled) { + log('unableToPlayMedia [PhotoAlbumModel.play]: ' + 'getChildItems was null for album=${albumModel.id}'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -155,6 +161,8 @@ extension ChannelModelExtension on ChannelModel? { if (op.isCanceled || model == null) { if (!op.isCanceled) { + log('unableToPlayMedia [ChannelModel.play]: ' + 'createPlaybackModel returned null for channel=${this!.id}'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -631,6 +639,17 @@ extension ItemBaseModelExtensions on ItemBaseModel? { }) async { if (itemModel == null) return; + // When SyncPlay is active, delegate to SyncPlay queue management. + // _startPlayback (triggered by the server's PlayQueue response) + // handles player init and route opening. + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + if (isSyncPlayActive) { + await _playSyncPlay(context, itemModel, ref, startPosition: startPosition); + return; + } + + await ref.read(videoPlayerProvider.notifier).init(); + final op = CancelableOperation.fromFuture(ref.read(playbackModelHelper).createPlaybackModel( context, itemModel, @@ -649,6 +668,8 @@ extension ItemBaseModelExtensions on ItemBaseModel? { log('Error closing loading dialog: $e'); } if (!showPlaybackOption) { + log('unableToPlayMedia [ItemBaseModel._default]: ' + 'createPlaybackModel returned null for item=${itemModel.id}'); FladderSnack.show(context.localized.unableToPlayMedia, context: context); } } @@ -661,6 +682,98 @@ extension ItemBaseModelExtensions on ItemBaseModel? { } } +/// Play item through SyncPlay - sets the queue and lets SyncPlay +/// handle synchronized playback. Mirrors the local playback path by +/// showing the same loader dialog so the user knows the request was +/// accepted while the server distributes the new queue and our own +/// `_startPlayback` runs. +Future _playSyncPlay( + BuildContext context, + ItemBaseModel itemModel, + WidgetRef ref, { + Duration? startPosition, +}) async { + // Build the full play queue so episodes are sent to the group WITH their + // series context. The local playback path builds this via + // createPlaybackModel -> collectQueue; the SyncPlay path must match it. + // A lone single-episode queue is not started by the official Jellyfin + // clients (e.g. the webOS TV app) even though movies — naturally + // single-item — work fine. Movies return an empty seriesQueue and fall + // back to a single item. + final helper = ref.read(playbackModelHelper); + final List seriesQueue = await helper.collectQueue(itemModel); + + // Series/Season tiles resolve to their next-up episode (mirrors + // createPlaybackModel's firstItemToPlay switch). + ItemBaseModel target = itemModel; + if (itemModel is SeriesModel || itemModel is SeasonModel) { + final resolved = seriesQueue.whereType().toList().nextUp; + if (resolved != null) target = resolved; + } + + final List itemIds = seriesQueue.isNotEmpty ? seriesQueue.map((e) => e.id).toList() : [target.id]; + final int playingItemPosition = + seriesQueue.isNotEmpty ? seriesQueue.indexWhere((e) => e.id == target.id).clamp(0, itemIds.length - 1) : 0; + + // Fall back to the resolved item's saved resume position (mirrors the local + // path's `model.startDuration()`) so "Continue Watching" resumes mid-item + // for the whole group instead of restarting from 0. Previously, every play + // that didn't pass an explicit startPosition sent startPositionTicks: 0. + final effectiveStart = startPosition ?? target.userData.playBackPosition; + final startPositionTicks = secondsToTicks(effectiveStart.inMilliseconds / 1000); + + final notifier = ref.read(syncPlayProvider.notifier); + final pending = notifier.awaitNextStartPlayback( + timeout: const Duration(seconds: 20), + ); + final op = CancelableOperation.fromFuture(pending); + + _showLoadingIndicator(context, itemModel, op, autoCloseOnComplete: true); + + final queueAccepted = await notifier.setNewQueue( + itemIds: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + + // setNewQueue is debounced server-side to avoid two participants + // racing the same request. When suppressed there is no PlayQueue + // broadcast and no `_startPlayback`, so awaiting it would always time + // out 20s later with a misleading "unable to play" snack. Cancel the + // pending wait and treat it as a successful no-op (the playback is + // already in flight from another participant or our own previous + // click). + if (!queueAccepted) { + log('SyncPlay: _playSyncPlay short-circuited - setNewQueue debounced ' + 'or rejected for item=${itemModel.id}'); + if (!op.isCanceled) { + await op.cancel(); + } + // Op is cancelled so the auto-close listener on the dialog won't + // fire (CancelableOperation.value never completes after cancel). + // No player route has been pushed yet (no PlayQueue → no + // _startPlayback), so popping the root navigator here safely closes + // just the loader dialog. + if (context.mounted) { + try { + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) {} + } + return; + } + + final ok = await op.valueOrCancellation(false) ?? false; + // Loading dialog auto-closes via _LoadIndicatorCancelable when [op] + // completes; do not pop the root navigator manually here, otherwise we + // may pop the player route that `_startPlayback` pushed on top of the + // dialog. + if (!op.isCanceled && !ok && context.mounted) { + log('unableToPlayMedia [_playSyncPlay]: ' + 'awaitNextStartPlayback returned false for item=${itemModel.id}'); + FladderSnack.show(context.localized.unableToPlayMedia, context: context); + } +} + extension ItemBaseModelsBooleans on List { Future playLibraryItems(BuildContext context, WidgetRef ref, {bool shuffle = false}) async { if (isEmpty) return; @@ -684,6 +797,18 @@ extension ItemBaseModelsBooleans on List { expandedList.shuffle(); } + // If in SyncPlay group, set the queue via SyncPlay + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + if (isSyncPlayActive) { + Navigator.of(context, rootNavigator: true).pop(); // Pop loading indicator + await ref.read(syncPlayProvider.notifier).setNewQueue( + itemIds: expandedList.map((e) => e.id).toList(), + playingItemPosition: 0, + startPositionTicks: 0, + ); + return (null, expandedList); + } + PlaybackModel? model = await ref.read(playbackModelHelper).createPlaybackModel( context, expandedList.firstOrNull, @@ -698,6 +823,8 @@ extension ItemBaseModelsBooleans on List { final result = await op.valueOrCancellation(null); if (op.isCanceled || result == null) { if (!op.isCanceled) { + log('unableToPlayMedia [playLibraryItems]: ' + 'aggregated playback result was null (items=$length)'); try { Navigator.of(context, rootNavigator: true).pop(); } catch (e) { @@ -711,6 +838,14 @@ extension ItemBaseModelsBooleans on List { final PlaybackModel? model = result.$1; final List expandedList = result.$2; + // SyncPlay path: queue was set via setNewQueue, no local PlaybackModel + if (model == null && expandedList.isNotEmpty) { + if (context.mounted) { + RefreshState.maybeOf(context)?.refresh(); + } + return; + } + if (context.mounted) { await _playVideo(context, ref: ref, queue: expandedList, current: model, cancelOperation: op); if (context.mounted) { @@ -803,19 +938,60 @@ extension ItemBaseModelsBooleans on List { } } -Future _showLoadingIndicator(BuildContext context, ItemBaseModel? item, CancelableOperation op) async { +Future _showLoadingIndicator( + BuildContext context, + ItemBaseModel? item, + CancelableOperation op, { + bool autoCloseOnComplete = false, +}) async { return showDialog( barrierDismissible: false, useRootNavigator: true, context: context, - builder: (context) => _LoadIndicatorCancelable(op: op, item: item), + builder: (context) => _LoadIndicatorCancelable( + op: op, + item: item, + autoCloseOnComplete: autoCloseOnComplete, + ), ); } -class _LoadIndicatorCancelable extends StatelessWidget { +class _LoadIndicatorCancelable extends StatefulWidget { final ItemBaseModel? item; final CancelableOperation op; - const _LoadIndicatorCancelable({required this.op, this.item}); + final bool autoCloseOnComplete; + const _LoadIndicatorCancelable({ + required this.op, + this.item, + this.autoCloseOnComplete = false, + }); + + @override + State<_LoadIndicatorCancelable> createState() => _LoadIndicatorCancelableState(); +} + +class _LoadIndicatorCancelableState extends State<_LoadIndicatorCancelable> { + @override + void initState() { + super.initState(); + if (!widget.autoCloseOnComplete) { + return; + } + // Auto-close as soon as the underlying operation finishes. Used by + // the SyncPlay flow where `_startPlayback` pushes the player route + // on top of this dialog after the server's PlayQueue update; if the + // caller popped the root navigator manually after awaiting the op, + // it would pop the player route instead of this dialog, which would + // minimize the player and surface a spurious "unable to play" snack. + widget.op.value.whenComplete(() { + if (!mounted) { + return; + } + try { + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) {} + }); + } @override Widget build(BuildContext context) { @@ -837,7 +1013,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { child: Row( spacing: 16, children: [ - if (item != null) + if (widget.item != null) Flexible( child: Container( decoration: FladderTheme.defaultPosterDecoration, @@ -862,7 +1038,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { ), clipBehavior: Clip.hardEdge, child: FladderImage( - image: item!.getPosters?.primary, + image: widget.item!.getPosters?.primary, fit: BoxFit.cover, ), ), @@ -886,9 +1062,9 @@ class _LoadIndicatorCancelable extends StatelessWidget { context.localized.loading, style: Theme.of(context).textTheme.titleLarge, ), - if (item != null) ...[ + if (widget.item != null) ...[ Text( - item!.title, + widget.item!.title, style: Theme.of(context).textTheme.bodyMedium, ), ], @@ -903,7 +1079,7 @@ class _LoadIndicatorCancelable extends StatelessWidget { tooltip: context.localized.close, onPressed: () { try { - op.cancel(); + widget.op.cancel(); } catch (_) {} Navigator.of(context, rootNavigator: true).pop(); }, @@ -932,6 +1108,8 @@ Future _playVideo( } catch (e) { log('Error closing loading dialog: $e'); } + log('unableToPlayMedia [_playVideo]: ' + 'current PlaybackModel was null (queue=${queue?.length ?? 0})'); FladderSnack.show(context.localized.unableToPlayMedia, context: context); } return; diff --git a/lib/util/localization_helper.dart b/lib/util/localization_helper.dart index c34d74408..43fc73624 100644 --- a/lib/util/localization_helper.dart +++ b/lib/util/localization_helper.dart @@ -129,4 +129,26 @@ 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; + + @override + String syncPlayStateWaiting() => context.localized.syncPlayStateWaiting; } diff --git a/lib/widgets/navigation_scaffold/components/destination_model.dart b/lib/widgets/navigation_scaffold/components/destination_model.dart index f59a22340..ae76365fe 100644 --- a/lib/widgets/navigation_scaffold/components/destination_model.dart +++ b/lib/widgets/navigation_scaffold/components/destination_model.dart @@ -17,6 +17,9 @@ class DestinationModel { final Widget? badge; final AdaptiveFab? floatingActionButton; + /// Custom FAB widget - takes precedence over floatingActionButton if provided + final Widget? customFab; + DestinationModel({ required this.label, this.icon, @@ -28,8 +31,12 @@ class DestinationModel { this.tooltip, this.badge, this.floatingActionButton, + this.customFab, }); + /// Returns the FAB widget to use - prefers customFab over floatingActionButton.normal + Widget? get fabWidget => customFab ?? floatingActionButton?.normal; + /// Converts this [DestinationModel] to a [NavigationRailDestination] used in a [NavigationRail]. NavigationRailDestination toNavigationRailDestination({EdgeInsets? padding}) { return NavigationRailDestination( diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index bc310a2ce..4e6f9d659 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -181,7 +181,7 @@ class SideNavigationRail extends ConsumerWidget { const EdgeInsets.symmetric(horizontal: 4).copyWith(bottom: expandedSideBar ? 10 : 0), child: AnimatedFadeSize( duration: const Duration(milliseconds: 250), - child: shouldExpand ? actionButton(context).extended : actionButton(context).normal, + child: actionButtonWidget(context, shouldExpand), ), ), ], @@ -236,6 +236,23 @@ class SideNavigationRail extends ConsumerWidget { child: const Icon(IconsaxPlusLinear.search_normal_1), ); } + + Widget actionButtonWidget(BuildContext context, bool expanded) { + final destination = (currentIndex >= 0 && currentIndex < destinations.length) ? destinations[currentIndex] : null; + + // If there's a custom FAB widget, use it (DashboardFabs already + // includes SyncPlay for the dashboard route). + if (destination?.customFab != null) { + return destination!.customFab!; + } + + // For non-dashboard rails: show only the route's primary action FAB. + // SyncPlay access comes from the dashboard FAB and the SyncPlayBadge + // (a non-FAB indicator that opens the same sheet) — stacking two FABs + // here violates AGENTS.md rule 4. + final fab = actionButton(context); + return expanded ? fab.extended : fab.normal; + } } class _RailTraversalPolicy extends ReadingOrderTraversalPolicy { diff --git a/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart b/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart index d502464b9..146b093e6 100644 --- a/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart +++ b/lib/widgets/navigation_scaffold/components/video_player_bar_content.dart @@ -90,7 +90,7 @@ class VideoFloatingPlayerBarContent extends ConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: IconButton.filledTonal( - onPressed: () => ref.read(videoPlayerProvider).playOrPause(), + onPressed: () => ref.read(videoPlayerProvider.notifier).userPlayOrPause(), icon: playbackState.playing ? const Icon(Icons.pause_rounded) : const Icon(Icons.play_arrow_rounded), diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index aa1bca04e..b18f84260 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -116,7 +116,7 @@ class _NavigationScaffoldState extends ConsumerState { extendBody: true, floatingActionButton: !showAudioFullScreen && AdaptiveLayout.layoutModeOf(scaffoldContext) == LayoutMode.single && isHomeScreen - ? widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal + ? widget.destinations.elementAtOrNull(currentIndex)?.fabWidget : null, drawer: !showAudioFullScreen && homeRoutes.any((element) => element.name.contains(currentLocation)) ? NestedNavigationDrawer( diff --git a/lib/widgets/syncplay/dashboard_fabs.dart b/lib/widgets/syncplay/dashboard_fabs.dart new file mode 100644 index 000000000..48c114afa --- /dev/null +++ b/lib/widgets/syncplay/dashboard_fabs.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/routes/auto_router.gr.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; +import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; + +/// Combined FAB for dashboard with search and SyncPlay actions +class DashboardFabs extends ConsumerWidget { + const DashboardFabs({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final isDualLayout = AdaptiveLayout.of(context).layoutMode == LayoutMode.dual; + + final children = [ + // SyncPlay FAB + _SyncPlayFabButton(isActive: isActive), + // Search FAB + AdaptiveFab( + context: context, + title: context.localized.search, + key: const Key('dashboard_search'), + onPressed: () => context.router.navigate(LibrarySearchRoute()), + child: const Icon(IconsaxPlusLinear.search_normal_1), + ).normal, + ]; + + return isDualLayout + ? Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ) + : Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ); + } +} + +class _SyncPlayFabButton extends StatelessWidget { + final bool isActive; + + const _SyncPlayFabButton({required this.isActive}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: 'syncplay_fab', + child: IconButton.filledTonal( + iconSize: 26, + tooltip: context.localized.syncPlay, + onPressed: () => showSyncPlaySheet(context), + style: IconButton.styleFrom( + backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, + ), + icon: Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + Icon( + isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_badge.dart b/lib/widgets/syncplay/syncplay_badge.dart new file mode 100644 index 000000000..936083942 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_badge.dart @@ -0,0 +1,129 @@ +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'; + +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, + ), + ], + ], + ), + ); + } +} + +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..bee3bbafe --- /dev/null +++ b/lib/widgets/syncplay/syncplay_extensions.dart @@ -0,0 +1,91 @@ +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 SyncPlayGroupStateExtension on SyncPlayGroupState { + (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, + ), + }; + } + + 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 SyncPlayCommandLabelExtension on SyncPlayCommand? { + 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, + }; + } + + 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, + }; + } + + (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 SyncCorrectionStrategyExtension on SyncCorrectionStrategy { + String label(BuildContext context) { + return switch (this) { + SyncCorrectionStrategy.none => context.localized.syncPlaySyncing, + SyncCorrectionStrategy.speedToSync => 'SpeedToSync', + SyncCorrectionStrategy.skipToSync => 'SkipToSync', + }; + } + + (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_group_sheet.dart b/lib/widgets/syncplay/syncplay_group_sheet.dart new file mode 100644 index 000000000..d0fa37ee6 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_group_sheet.dart @@ -0,0 +1,529 @@ +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/user_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; + +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) { + // The controller toasts the server's GroupJoined confirmation. + 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) { + // The controller toasts the server's GroupJoined confirmation. + 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); + // Mirrors the server policy; an unknown policy (older servers) does not block anything. + final syncPlayAccess = ref.watch(userProvider.select((user) => user?.policy?.syncPlayAccess)); + final canCreate = syncPlayAccess == null || syncPlayAccess == SyncPlayUserAccessType.createandjoingroups; + final canJoin = syncPlayAccess == null || syncPlayAccess != SyncPlayUserAccessType.none; + + return ConstrainedBox( + constraints: BoxConstraints( + 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: [ + 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), + 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 if (canCreate) + IconButton( + onPressed: _createGroup, + icon: const Icon(IconsaxPlusLinear.add), + tooltip: context.localized.create, + ), + ], + ), + ), + const Divider(), + Flexible( + child: _SyncPlaySheetContent( + syncPlayState: syncPlayState, + groupsState: groupsState, + canCreate: canCreate, + canJoin: canJoin, + onCreateGroup: _createGroup, + onJoinGroup: _joinGroup, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SyncPlaySheetContent extends ConsumerWidget { + const _SyncPlaySheetContent({ + required this.syncPlayState, + required this.groupsState, + required this.canCreate, + required this.canJoin, + required this.onCreateGroup, + required this.onJoinGroup, + }); + + final SyncPlayState syncPlayState; + final SyncPlayGroupsState groupsState; + final bool canCreate; + final bool canJoin; + final VoidCallback onCreateGroup; + final void Function(GroupInfoDto) onJoinGroup; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (syncPlayState.isInGroup) { + return _ActiveGroupView(state: syncPlayState); + } + if (!canJoin) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.lock, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayAccessDisabled, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ); + } + if (groupsState.isLoading) { + return const Center( + child: Padding( + 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, + ), + ), + if (canCreate) ...[ + 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(); + } + + Future _onHaltPlayback(BuildContext context, WidgetRef ref) async { + await ref.read(syncPlayProvider.notifier).haltPlayback(); + if (context.mounted) { + Navigator.of(context).maybePop(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasActivePlayback = state.hasActivePlayback; + final playerRouteOpen = ref.watch(isVideoPlayerRouteOpenProvider); + final following = state.isFollowingGroupPlayback; + // "Resume playback" when the group has content this device is not showing, "Stop" while following. + final showResume = hasActivePlayback && (!playerRouteOpen || !following); + final showHalt = hasActivePlayback && playerRouteOpen && following; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ), + ], + if (showHalt) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _onHaltPlayback(context, ref), + icon: const Icon(IconsaxPlusBold.pause_circle), + label: Text(context.localized.syncPlayHaltPlayback), + ), + ], + const SizedBox(height: 16), + Text( + context.localized.syncPlayInstructions, + 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) { + _ when !state.isFollowingGroupPlayback => ( + IconsaxPlusLinear.pause_circle, + context.localized.syncPlayNotFollowing, + Theme.of(context).colorScheme.onSurfaceVariant, + ), + 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 90ebdbc19..505861dd1 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -2,15 +2,8 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/channel_model.dart'; @@ -38,6 +31,11 @@ import 'package:fladder/wrappers/players/lib_mdk.dart' import 'package:fladder/wrappers/players/lib_mpv.dart'; import 'package:fladder/wrappers/players/native_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; part 'audio_queue_handler.dart'; @@ -60,8 +58,12 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Stream get stateStream => _stateController.stream; PlayerState? get lastState => _player?.lastState; + /// The backend's real playing state, see [BasePlayer.isPlaying]. + bool get isPlayerPlaying => _player?.isPlaying ?? false; + Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) => _player?.subtitles(showOverlay, controlsKey: controlsKey); + Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit); final Ref ref; @@ -126,6 +128,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } finally { _player?.dispose(); } + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + } } Future setup(BasePlayer newPlayer) async { @@ -197,7 +202,35 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro _previousPlayer = null; } - Future openPlayer(BuildContext context) async => _player?.open(context); + bool get isNativePlayerActive => _player is NativePlayer; + + Future updateSyncPlayCommandState( + bool processing, + SyncPlayCommandType commandType, + ) async { + if (_player is NativePlayer) { + await (_player as NativePlayer).player.setSyncPlayCommandState(processing, commandType); + } + } + + /// The native activity has no Flutter route, so its route-open flag is maintained here. + Future openPlayer(BuildContext context) async { + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = true; + ref.read(videoPlayerProvider.notifier).refreshNativeOverlay(); + } + return _player?.open(context); + } + + /// Removes the player route wherever it sits in the stack; no-op for the native player (closed by [stop]). + void closePlayerRoute() { + final player = _player; + final route = player?.playerRoute; + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + player?.playerRoute = null; + } Future _updatePositionWithRetry(PlaybackModel model, Duration position, bool isPlaying) async { try { @@ -527,6 +560,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro unawaited(_applyWakelock(false)); _player?.stop(); + if (_player is NativePlayer) { + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + } ref.read(windowTitleProvider.notifier).setPlayTitle(null); final position = _player?.lastState.position; @@ -644,8 +680,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo); } + /// The native activity was closed by the user: halt group playback on this device before teardown. @override - void onStop() => stop(); + void onStop() => ref.read(videoPlayerProvider.notifier).userStop(); @override void swapAudioTrack(int value) async { @@ -654,7 +691,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, + ); } } @@ -665,7 +705,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, + ); } } diff --git a/lib/wrappers/players/base_player.dart b/lib/wrappers/players/base_player.dart index 4adf59a10..2372efbfa 100644 --- a/lib/wrappers/players/base_player.dart +++ b/lib/wrappers/players/base_player.dart @@ -11,10 +11,20 @@ import 'package:fladder/wrappers/players/player_states.dart'; const libassFallbackFont = "assets/mp-font.ttf"; +/// Lets the full-screen player route be found and removed from the navigator (SyncPlay leave/kick/stop). +const String videoPlayerRouteName = 'fladder/video_player'; + abstract class BasePlayer { Stream get stateStream; PlayerState lastState = PlayerState(); + /// Whether the backend is really playing: on mpv [lastState.playing] only means "not paused by the user", + /// so anything that must know whether a `play()` took reads this instead. media-kit overrides it. + bool get isPlaying => lastState.playing; + + /// Route pushed by [open] for Flutter-rendered players; null for the native activity and once popped. + Route? playerRoute; + Future init(VideoPlayerSettingsModel settings); Widget? videoWidget(Key key, BoxFit fit); Widget? subtitles(bool showOverlay, {GlobalKey? controlsKey}); diff --git a/lib/wrappers/players/lib_mdk.dart b/lib/wrappers/players/lib_mdk.dart index bed8097e7..8d621eea4 100644 --- a/lib/wrappers/players/lib_mdk.dart +++ b/lib/wrappers/players/lib_mdk.dart @@ -127,11 +127,17 @@ class LibMDK extends BasePlayer { } @override - Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const video_screen.VideoPlayer(), - ), - ); + Future open(BuildContext context) async { + final route = MaterialPageRoute( + settings: const RouteSettings(name: videoPlayerRouteName), + builder: (context) => const video_screen.VideoPlayer(), + ); + playerRoute = route; + await Navigator.of(context, rootNavigator: true).push(route); + if (identical(playerRoute, route)) { + playerRoute = null; + } + } @override Future pause() async { diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index ed55a697a..e091c3c7a 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -16,6 +16,7 @@ import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/settings/subtitle_settings_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; @@ -384,11 +385,17 @@ class LibMPV extends BasePlayer { } @override - Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const video_screen.VideoPlayer(), - ), - ); + Future open(BuildContext context) async { + final route = MaterialPageRoute( + settings: const RouteSettings(name: videoPlayerRouteName), + builder: (context) => const video_screen.VideoPlayer(), + ); + playerRoute = route; + await Navigator.of(context, rootNavigator: true).push(route); + if (identical(playerRoute, route)) { + playerRoute = null; + } + } List get subTracks => _player?.state.tracks.subtitle ?? []; mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no(); @@ -466,6 +473,10 @@ class LibMPV extends BasePlayer { @override Future seek(Duration position) async => _player?.seek(position); + /// media-kit's real state; [lastState] only mirrors [_musicPaused]. + @override + bool get isPlaying => _player?.state.playing ?? lastState.playing; + // mpv parses tracks asynchronously after open(); at playback start the list is still empty, so a // positional lookup would miss and leave mpv on its own default pick. Wait (capped) for [index]. Future _awaitTrack(int index, int Function(mpv.Tracks) count) async { @@ -482,6 +493,9 @@ class LibMPV extends BasePlayer { if (wantedAudioStream == null) return -1; if (wantedAudioStream.index == AudioStreamModel.no().index) { await _player?.setAudioTrack(mpv.AudioTrack.no()); + } else if (playbackModel is TranscodePlaybackModel) { + // The server picks the audio track for a transcode; the reload applies it. + return wantedAudioStream.index; } else { final index = (playbackModel.audioStreams?.indexOf(wantedAudioStream) ?? -1) - 1; await _awaitTrack(index, (tracks) => tracks.audio.length); @@ -506,6 +520,11 @@ class LibMPV extends BasePlayer { return -1; } _currentSubtitleCodec = wantedSubtitle.codec; + // A non-external subtitle on a transcode is burned in by the server: there is no selectable track, and + // waiting for one only stalls the caller for the whole `_awaitTrack` timeout before the reload. + if (playbackModel is TranscodePlaybackModel && !wantedSubtitle.isExternal) { + return wantedSubtitle.index; + } final index = playbackModel.subStreams?.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id) ?? -1; if (!wantedSubtitle.isExternal) await _awaitTrack(index, (tracks) => tracks.subtitle.length); final internalTrack = subTracks.getRange(2, subTracks.length).toList(); diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index ffa249336..d1a7039f3 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -44,17 +44,26 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { return activity.launchActivity(); } + // Synthetic frames come from Flutter, never a remote gesture, so they must not carry a stale user tag. @override Future pause() async { await player.pause(); - lastState = lastState.update(playing: false); + lastState = lastState.update( + playing: false, + changeSource: PlaybackChangeSource.none, + updateChangeSource: true, + ); _stateController.add(lastState); } @override Future play() async { await player.play(); - lastState = lastState.update(playing: true); + lastState = lastState.update( + playing: true, + changeSource: PlaybackChangeSource.none, + updateChangeSource: true, + ); _stateController.add(lastState); } @@ -78,7 +87,9 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { } @override - Future setSpeed(double speed) async {} + Future setSpeed(double speed) async { + await player.setPlaybackSpeed(speed); + } @override Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async { @@ -114,6 +125,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..0aea93026 100644 --- a/pigeons/translations_pigeon.dart +++ b/pigeons/translations_pigeon.dart @@ -34,4 +34,13 @@ abstract class TranslationsPigeon { String watch(); String now(); String decline(); + + // SyncPlay overlay strings + String syncPlaySyncingWithGroup(); + String syncPlayCommandPausing(); + String syncPlayCommandPlaying(); + String syncPlayCommandSeeking(); + String syncPlayCommandStopping(); + String syncPlayCommandSyncing(); + String syncPlayStateWaiting(); } diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 693228e86..740e7560d 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -34,6 +34,17 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, + + /// The group is waiting for a participant to buffer; shown so every device reports the same state. + waiting, +} + class MediaInfo { final PlaybackType playbackType; final String videoInformation; @@ -139,6 +150,7 @@ class SubtitleTrack { class Chapter { final String name; final String url; + // Duration in milliseconds final int time; @@ -155,6 +167,7 @@ class TrickPlayModel { final int tileWidth; final int tileHeight; final int thumbnailCount; + //Duration in milliseconds final int interval; final List images; @@ -178,6 +191,7 @@ class StartResult { abstract class NativeVideoActivity { @async StartResult launchActivity(); + void disposeActivity(); bool isLeanBackEnabled(); @@ -213,13 +227,34 @@ abstract class VideoPlayerApi { void stop(); void setSubtitleSettings(SubtitleSettings settings); + + /// Drives the native player's SyncPlay overlay. + void setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType); +} + +/// Who caused the last playback state change; Flutter infers SyncPlay requests from it. +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + + /// User pressed play or pause on native; Flutter turns it into the SyncPlay request. + userPlayPause, + + /// User seeked on native; the frame carries the new position even when the seek drops the player into + /// buffering. Kept apart from [userPlayPause] so a paused-while-buffering frame is never ambiguous. + userSeek, + + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, } class PlaybackState { //Milliseconds final int position; + //Milliseconds final int buffered; + //Milliseconds final int duration; final bool playing; @@ -227,6 +262,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 +273,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); } @@ -320,11 +359,17 @@ 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); } diff --git a/pubspec.lock b/pubspec.lock index 24e869c4c..380c36650 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2452,7 +2452,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 diff --git a/pubspec.yaml b/pubspec.yaml index 48d24e94e..afe5a9a72 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/buffering_report_debouncer_test.dart b/test/providers/syncplay/buffering_report_debouncer_test.dart new file mode 100644 index 000000000..d8944f934 --- /dev/null +++ b/test/providers/syncplay/buffering_report_debouncer_test.dart @@ -0,0 +1,91 @@ +import 'package:fladder/providers/syncplay/buffering_report_debouncer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Real timers with a short threshold: fake_async is not a direct dependency of the app. +const _threshold = Duration(milliseconds: 200); + +Future _wait(int milliseconds) => Future.delayed(Duration(milliseconds: milliseconds)); + +BufferingReportDebouncer _debouncer(List calls, {Duration threshold = _threshold}) { + return BufferingReportDebouncer( + threshold: threshold, + onBuffering: () => calls.add('buffering'), + onReady: () => calls.add('ready'), + ); +} + +void main() { + group('BufferingReportDebouncer', () { + test('a stall shorter than the threshold is never reported', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + await _wait(20); + debouncer.update(false); + await _wait(320); + + expect(calls, isEmpty); + expect(debouncer.isPending, isFalse); + debouncer.dispose(); + }); + + test('a stall held past the threshold is reported once and closed once', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + debouncer.update(true); // duplicate frames must not restart the timer + await _wait(320); + expect(calls, ['buffering']); + expect(debouncer.isReported, isTrue); + + debouncer.update(false); + debouncer.update(false); + expect(calls, ['buffering', 'ready']); + expect(debouncer.isReported, isFalse); + debouncer.dispose(); + }); + + test('duplicate true frames do not extend the threshold', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + await _wait(120); + debouncer.update(true); + await _wait(120); + + expect(calls, ['buffering'], reason: 'the first frame started the timer; the second must not restart it'); + debouncer.dispose(); + }); + + test('reset drops a pending stall and a reported one silently', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(true); + debouncer.reset(); + await _wait(320); + expect(calls, isEmpty); + + debouncer.update(true); + await _wait(320); + debouncer.reset(); + debouncer.update(false); + expect(calls, ['buffering']); + debouncer.dispose(); + }); + + test('a ready frame with nothing reported is silent', () async { + final calls = []; + final debouncer = _debouncer(calls); + + debouncer.update(false); + debouncer.update(false); + + expect(calls, isEmpty); + debouncer.dispose(); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_command_handler_test.dart b/test/providers/syncplay/syncplay_command_handler_test.dart new file mode 100644 index 000000000..79efc840b --- /dev/null +++ b/test/providers/syncplay/syncplay_command_handler_test.dart @@ -0,0 +1,543 @@ +import 'dart:async'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Zero-offset clock whose readiness the test controls. +class _FakeClock implements SyncPlayClock { + _FakeClock({required this.ready}); + + bool ready; + + @override + bool get isReady => ready; + + @override + Duration get ping => Duration.zero; + + @override + DateTime remoteDateToLocal(DateTime serverTime) => serverTime; + + @override + DateTime localDateToRemote(DateTime localTime) => localTime; +} + +Map _command( + String command, { + required DateTime when, + int positionTicks = 0, + String playlistItemId = 'plist-1', +}) { + return { + 'Command': command, + 'When': when.toIso8601String(), + 'PositionTicks': positionTicks, + 'PlaylistItemId': playlistItemId, + }; +} + +Future _settle() => Future.delayed(const Duration(milliseconds: 10)); + +void main() { + group('recording while not following', () { + test('recordCommand keeps the position bookkeeping without touching the player', () async { + var pauseCalls = 0; + int? writtenTicks; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) => writtenTicks = updater(SyncPlayState()).positionTicks, + )..onPause = () async { + pauseCalls++; + }; + final when = DateTime.now().toUtc(); + + handler.recordCommand(_command('Pause', when: when, positionTicks: 42000)); + await _settle(); + + expect(pauseCalls, 0); + expect(writtenTicks, 42000); + expect(handler.lastCommand?.command, SyncPlayCommand.pause); + expect(handler.lastCommand?.positionTicks, 42000); + expect(handler.lastCommand?.when, when.toIso8601String()); + expect(handler.hasPendingCommand, isFalse); + }); + }); + + group('execution ownership', () { + test('hasPendingCommand stays true while the player callback runs', () async { + final playStarted = Completer(); + final release = Completer(); + final handler = SyncPlayCommandHandler( + timeSync: () => _FakeClock(ready: true), + onStateUpdate: (_) {}, + ) + ..onPlay = () async { + playStarted.complete(); + await release.future; + } + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(milliseconds: 100))), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + await playStarted.future; + + expect(handler.hasPendingCommand, isTrue, reason: 'a running command owns the player'); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + + release.complete(); + await _settle(); + expect(handler.hasPendingCommand, isFalse); + }); + }); + + group('playlist item guard', () { + test('command for another playlist item is ignored', () async { + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + } + ..getPositionTicks = (() => 0); + + handler.handleCommand( + _command('Pause', when: DateTime.now().toUtc(), playlistItemId: 'plist-1'), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-2'), + ); + await _settle(); + + expect(pauseCalls, 0); + }); + + test('Stop is applied even when the playlist item differs', () async { + var stopCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onStop = () async { + stopCalls++; + }; + + handler.handleCommand( + _command('Stop', when: DateTime.now().toUtc(), playlistItemId: ''), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-2'), + ); + await _settle(); + + expect(stopCalls, 1); + }); + + test('command does not overwrite the current playlist item in state', () async { + String? writtenPlaylistItemId = 'unchanged'; + final handler = SyncPlayCommandHandler( + timeSync: () => null, + onStateUpdate: (updater) { + writtenPlaylistItemId = updater(SyncPlayState(playlistItemId: 'unchanged')).playlistItemId; + }, + ) + ..onPause = () async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(writtenPlaylistItemId, 'unchanged'); + }); + }); + + group('duplicate command correction', () { + test('duplicate Pause re-applies when the player is playing again', () async { + var pauseCalls = 0; + var playing = true; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + playing = false; + } + ..onSeek = (_) async {} + ..isPlaying = (() => playing) + ..getPositionTicks = (() => 0); + final cmd = _command('Pause', when: DateTime.now().toUtc().subtract(const Duration(seconds: 1))); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + playing = true; // user resumed locally + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(pauseCalls, 2); + }); + + test('duplicate Pause is ignored when already paused at the right position', () async { + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async { + pauseCalls++; + } + ..onSeek = (_) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => ticksPerSecond * 10); + final cmd = _command( + 'Pause', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: ticksPerSecond * 10, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(pauseCalls, 1); + }); + + test('duplicate Seek re-seeks with jitter when off position', () async { + final seeks = []; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (ticks) async { + seeks.add(ticks); + } + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => 0); + final target = ticksPerSecond * 30; + final cmd = _command( + 'Seek', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: target, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(seeks.length, 2); + expect(seeks.first, target); + expect((seeks.last - target).abs(), lessThanOrEqualTo(millisecondsToTicks(50))); + }); + + test('duplicate Seek at the right position only reports ready', () async { + final seeks = []; + var readyCalls = 0; + final target = ticksPerSecond * 30; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (ticks) async { + seeks.add(ticks); + } + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async { + readyCalls++; + } + ..isPlaying = (() => false) + ..getPositionTicks = (() => target); + final cmd = _command( + 'Seek', + when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + positionTicks: target, + ); + + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + + expect(seeks.length, 1); + expect(readyCalls, 2, reason: 'first from the Seek itself, second from the duplicate check'); + }); + + test('duplicate whose time is still in the future is left to the timer', () async { + var playCalls = 0; + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..isPlaying = (() => false) + ..getPositionTicks = (() => 0); + final cmd = _command('Unpause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 500))); + + handler.handleCommand(cmd, SyncPlayState()); + handler.handleCommand(cmd, SyncPlayState()); + await _settle(); + expect(playCalls, 0); + expect(handler.hasPendingCommand, isTrue); + + handler.cancelPendingCommands(); + }); + }); + + group('recovery gate', () { + test('shouldRecoverPlayback is false while an Unpause is still scheduled', () { + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 800))), + SyncPlayState(), + ); + + expect(handler.hasPendingCommand, isTrue); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + handler.cancelPendingCommands(); + }); + + test('shouldRecoverPlayback is true once the Unpause time has passed and the player is paused', () async { + final clock = _FakeClock(ready: true); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(seconds: 2))), + SyncPlayState(), + ); + await _settle(); + + expect(handler.hasPendingCommand, isFalse); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is false after a Pause', () async { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + }); + + test('shouldRecoverPlayback is true after a Seek once nothing is pending', () async { + // The Unpause that follows a Seek can be lost or dropped by the + // player; the group saying Playing is then the only signal left. + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand(_command('Seek', when: DateTime.now().toUtc(), positionTicks: 5000), SyncPlayState()); + await _settle(); + + expect(handler.hasPendingCommand, isFalse); + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is true with no command context and a paused player', () { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isTrue); + }); + + test('shouldRecoverPlayback is false while the player is buffering', () { + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..isPlaying = (() => false) + ..isBuffering = (() => true); + + expect(handler.shouldRecoverPlayback(DateTime.now().toUtc()), isFalse); + }); + }); + + group('clock gate', () { + test('a queued command is applied with the unsynced clock once the fallback expires', () async { + var seekCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler( + timeSync: () => clock, + onStateUpdate: (_) {}, + clockFallback: const Duration(milliseconds: 100), + ) + ..onSeek = (_) async { + seekCalls++; + } + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand( + _command('Seek', when: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), positionTicks: 9000), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + await _settle(); + expect(seekCalls, 0); + expect(handler.hasQueuedCommand, isTrue); + + await Future.delayed(const Duration(milliseconds: 250)); + expect(seekCalls, 1, reason: 'a broken time sync must not freeze the group'); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('flushQueuedCommand(force) applies the queued command before the clock is ready', () async { + var playCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + + handler.handleCommand( + _command('Unpause', when: DateTime.now().toUtc().subtract(const Duration(milliseconds: 100))), + SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'), + ); + expect(handler.hasQueuedCommand, isTrue); + + handler.flushQueuedCommand(force: true); + await _settle(); + expect(playCalls, 1); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('a resend of the queued command is re-queued, not treated as a correction', () async { + var seekCalls = 0; + final clock = _FakeClock(ready: false); + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onSeek = (_) async { + seekCalls++; + } + ..onPause = () async {} + ..getPositionTicks = (() => 0) + ..isPlaying = (() => false) + ..isBuffering = (() => false); + final when = DateTime.now().toUtc().subtract(const Duration(seconds: 1)); + final state = SyncPlayState(isInGroup: true, playlistItemId: 'plist-1'); + + handler.handleCommand(_command('Seek', when: when, positionTicks: 9000), state); + handler.handleCommand(_command('Seek', when: when, positionTicks: 9000), state); + await _settle(); + expect(seekCalls, 0, reason: 'nothing may run before the first clock measurement'); + expect(handler.hasQueuedCommand, isTrue); + + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + expect(seekCalls, 1); + }); + + test('commands queue until the clock is ready and run on flush', () async { + final clock = _FakeClock(ready: false); + var playCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + playCalls++; + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Unpause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + expect(playCalls, 0); + expect(handler.hasQueuedCommand, isTrue); + + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + expect(playCalls, 1); + expect(handler.hasQueuedCommand, isFalse); + }); + + test('only the latest command is kept while waiting for the clock', () async { + final clock = _FakeClock(ready: false); + final calls = []; + final handler = SyncPlayCommandHandler(timeSync: () => clock, onStateUpdate: (_) {}) + ..onPlay = () async { + calls.add('play'); + } + ..onPause = () async { + calls.add('pause'); + } + ..onSeek = (_) async {} + ..getPositionTicks = (() => 0); + + handler.handleCommand(_command('Unpause', when: DateTime.now().toUtc()), SyncPlayState()); + handler.handleCommand( + _command('Pause', when: DateTime.now().toUtc().add(const Duration(milliseconds: 1))), + SyncPlayState(), + ); + clock.ready = true; + handler.flushQueuedCommand(); + await _settle(); + + expect(calls, ['pause']); + }); + }); + + group('execution', () { + test('Seek reports ready as paused at the requested position', () async { + bool? reportedPlaying; + int? reportedTicks; + final target = ticksPerSecond * 42; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async {} + ..onReportReady = ({required bool isPlaying, required int positionTicks}) async { + reportedPlaying = isPlaying; + reportedTicks = positionTicks; + } + ..isBuffering = (() => false) + ..getPositionTicks = (() => target - millisecondsToTicks(300)); + + handler.handleCommand( + _command('Seek', when: DateTime.now().toUtc(), positionTicks: target), + SyncPlayState(), + ); + await _settle(); + + expect(reportedPlaying, isFalse); + expect(reportedTicks, target); + }); + + test('Pause does not seek inside the 500 ms tolerance', () async { + var seekCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onPause = () async {} + ..onSeek = (_) async { + seekCalls++; + } + ..getPositionTicks = (() => millisecondsToTicks(400)); + + handler.handleCommand(_command('Pause', when: DateTime.now().toUtc()), SyncPlayState()); + await _settle(); + + expect(seekCalls, 0); + }); + + test('Stop runs the stop callback', () async { + var stopCalls = 0; + var pauseCalls = 0; + final handler = SyncPlayCommandHandler(timeSync: () => null, onStateUpdate: (_) {}) + ..onStop = () async { + stopCalls++; + } + ..onPause = () async { + pauseCalls++; + }; + + handler.handleCommand(_command('Stop', when: DateTime.now().toUtc(), playlistItemId: ''), SyncPlayState()); + await _settle(); + + expect(stopCalls, 1); + expect(pauseCalls, 0); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_correction_test.dart b/test/providers/syncplay/syncplay_correction_test.dart new file mode 100644 index 000000000..8651c8a4c --- /dev/null +++ b/test/providers/syncplay/syncplay_correction_test.dart @@ -0,0 +1,297 @@ +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('duplicate Pause while already paused at position is a no-op correction check', () 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 = ({required bool isPlaying, required int positionTicks}) 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) { + // Detect the finally block's isProcessingCommand=false transition by applying the updater to a sentinel. + final after = updater(SyncPlayState(isProcessingCommand: true)); + if (after.isProcessingCommand == false) { + stateClearFired = true; + } + }, + ) + ..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..c2ebb5239 --- /dev/null +++ b/test/providers/syncplay/syncplay_lifecycle_test.dart @@ -0,0 +1,378 @@ +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'; + +// The full SyncPlayController needs Ref + Chopper + WebSocket; these cover the invariants it relies on. + +SyncPlayMessageHandler _handler({ + required List calls, + void Function(SyncPlayState Function(SyncPlayState))? onStateUpdate, +}) { + return SyncPlayMessageHandler( + onStateUpdate: onStateUpdate ?? (_) {}, + startPlayback: (id, ticks, reason) async { + calls.add('start:$id:$ticks:$reason'); + }, + getContext: () => null, + onGroupJoined: () => calls.add('joined'), + onGroupJoinFailed: () => calls.add('joinFailed'), + onGroupLeftOrKicked: () => calls.add('leftOrKicked'), + onStateUpdateToPlaying: () => calls.add('toPlaying'), + onGroupGone: ({required bool wasKicked}) => calls.add('gone:$wasKicked'), + ); +} + +Map _playQueue({ + required String reason, + required List<(String, String)> items, + int playingItemIndex = 0, + int startPositionTicks = 0, + bool isPlaying = false, + DateTime? lastUpdate, +}) { + return { + 'Type': 'PlayQueue', + 'Data': { + 'Reason': reason, + 'LastUpdate': (lastUpdate ?? DateTime.now().toUtc()).toIso8601String(), + 'Playlist': [ + for (final (itemId, playlistItemId) in items) + {'ItemId': itemId, 'PlaylistItemId': playlistItemId}, + ], + 'PlayingItemIndex': playingItemIndex, + 'StartPositionTicks': startPositionTicks, + 'IsPlaying': isPlaying, + 'ShuffleMode': 'Sorted', + 'RepeatMode': 'RepeatNone', + }, + }; +} + +void main() { + group('SyncPlayState lifecycle reset', () { + 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, + isFollowingGroupPlayback: false, + ); + + 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, + isFollowingGroupPlayback: true, + ); + + expect(cleared.isInGroup, isFalse); + expect(cleared.groupId, isNull); + expect(cleared.startPlaybackInProgress, isFalse); + expect(cleared.startingPlaylistItemId, isNull); + expect(cleared.processingCommandType, isNull); + expect(cleared.playingItemId, isNull); + expect(cleared.isFollowingGroupPlayback, isTrue); + }); + }); + + group('resolveSyncPlayOverlay', () { + test('nothing outside a group or while halted', () { + expect(resolveSyncPlayOverlay(SyncPlayState(groupState: SyncPlayGroupState.waiting)), SyncPlayOverlay.none); + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + isFollowingGroupPlayback: false, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.none, + ); + }); + + test('a queue switch wins over a command, a command over the group waiting', () { + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + startPlaybackInProgress: true, + isProcessingCommand: true, + processingCommandType: SyncPlayCommand.seek, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.switching, + ); + expect( + resolveSyncPlayOverlay(SyncPlayState( + isInGroup: true, + isProcessingCommand: true, + processingCommandType: SyncPlayCommand.seek, + groupState: SyncPlayGroupState.waiting, + )), + SyncPlayOverlay.command, + ); + }); + + test('the group waiting shows on every device, even one with nothing to do', () { + expect( + resolveSyncPlayOverlay(SyncPlayState(isInGroup: true, groupState: SyncPlayGroupState.waiting)), + SyncPlayOverlay.waiting, + ); + expect( + resolveSyncPlayOverlay(SyncPlayState(isInGroup: true, groupState: SyncPlayGroupState.playing)), + SyncPlayOverlay.none, + ); + }); + }); + + group('SyncPlayMessageHandler state updates', () { + test('Waiting/Buffer only updates state, never replies', () async { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), + ); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': {'State': 'Waiting', 'Reason': 'Buffer'}, + }, SyncPlayState(isInGroup: true)); + await Future.delayed(const Duration(milliseconds: 5)); + + expect(calls, isEmpty); + expect(written?.groupState, SyncPlayGroupState.waiting); + }); + + test('Waiting/Unpause only updates state, never replies', () async { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': {'State': 'Waiting', 'Reason': 'Unpause'}, + }, SyncPlayState(isInGroup: true)); + await Future.delayed(const Duration(milliseconds: 5)); + + expect(calls, isEmpty); + }); + + test('Playing hands recovery to the controller', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'StateUpdate', + 'Data': {'State': 'Playing', 'Reason': 'Unpause'}, + }, SyncPlayState(isInGroup: true)); + + expect(calls, ['toPlaying']); + }); + }); + + group('SyncPlayMessageHandler membership', () { + test('LibraryAccessDenied fails the join and clears the group', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true, groupId: 'g')), + ); + + handler.handleGroupUpdate({ + 'Type': 'LibraryAccessDenied', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState()); + + expect(calls, ['joinFailed']); + expect(written?.isInGroup, isFalse); + expect(written?.groupId, isNull); + }); + + test('GroupJoined leaves the following flag to the controller', () { + // A silent rejoin after a socket drop must not un-halt a device the + // user halted; user-initiated joins set the flag themselves. + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isFollowingGroupPlayback: false)), + ); + + handler.handleGroupUpdate({ + 'Type': 'GroupJoined', + 'Data': { + 'GroupId': 'g', + 'GroupName': 'Movie night', + 'State': 'Paused', + 'Participants': ['alice'], + 'LastUpdatedAt': DateTime.now().toUtc().toIso8601String(), + }, + }, SyncPlayState()); + + expect(calls, ['joined']); + expect(written?.isInGroup, isTrue); + expect(written?.isFollowingGroupPlayback, isFalse); + expect(written?.groupState, SyncPlayGroupState.paused); + }); + + test('NotInGroup for a failed join does not stop local playback', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'NotInGroup', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: false)); + + expect(calls, ['joinFailed']); + }); + + test('NotInGroup while in a group tears playback down and reports the kick', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'NotInGroup', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: true, groupId: 'g')); + + expect(calls, ['leftOrKicked', 'gone:true', 'joinFailed']); + }); + + test('GroupDoesNotExist for a stale id does not stop local playback', () { + final calls = []; + final handler = _handler(calls: calls); + + handler.handleGroupUpdate({ + 'Type': 'GroupDoesNotExist', + 'Data': {'GroupId': 'g', 'Data': ''}, + }, SyncPlayState(isInGroup: false)); + + expect(calls, ['joinFailed']); + }); + }); + + group('SyncPlayMessageHandler play queue', () { + test('stores the playlist, playing index and starts playback with the reason', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), + ); + + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: const [('a', 'pa'), ('b', 'pb'), ('c', 'pc')], + playingItemIndex: 1, + startPositionTicks: 5000, + ), + SyncPlayState(isInGroup: true), + ); + + expect(calls, ['start:b:5000:NewPlaylist']); + expect(written?.playlist.length, 3); + expect(written?.playingItemIndex, 1); + expect(written?.playlistItemId, 'pb'); + expect(written?.playingItemId, 'b'); + }); + + test('stores the frame timing for position extrapolation', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true)), + ); + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: [('item-1', 'plist-1')], + startPositionTicks: 5000, + isPlaying: true, + lastUpdate: at, + ), + SyncPlayState(isInGroup: true), + ); + + final timing = written?.queueTiming; + expect(timing, isNotNull); + expect(timing?.startPositionTicks, 5000); + expect(timing?.isPlaying, isTrue); + expect(timing?.lastUpdate, at); + }); + + test('an older PlayQueue frame is ignored', () { + final calls = []; + final handler = _handler(calls: calls); + final now = DateTime.now().toUtc(); + + handler.handleGroupUpdate( + _playQueue(reason: 'NewPlaylist', items: const [('a', 'pa')], lastUpdate: now), + SyncPlayState(isInGroup: true), + ); + handler.handleGroupUpdate( + _playQueue( + reason: 'NewPlaylist', + items: const [('z', 'pz')], + lastUpdate: now.subtract(const Duration(seconds: 1)), + ), + SyncPlayState(isInGroup: true, playingItemId: 'a'), + ); + + expect(calls, ['start:a:0:NewPlaylist']); + }); + + test('a rejoin accepts a PlayQueue frame with an unchanged LastUpdate', () { + final calls = []; + final handler = _handler(calls: calls); + final now = DateTime.now().toUtc(); + final frame = _playQueue(reason: 'NewPlaylist', items: const [('a', 'pa')], lastUpdate: now); + + handler.handleGroupUpdate(frame, SyncPlayState(isInGroup: true)); + handler.handleGroupUpdate({ + 'Type': 'GroupJoined', + 'Data': {'GroupId': 'g', 'GroupName': 'n', 'State': 'Waiting', 'Participants': []}, + }, SyncPlayState(isInGroup: true)); + handler.handleGroupUpdate(frame, SyncPlayState(isInGroup: true, playingItemId: 'a')); + + expect(calls, ['start:a:0:NewPlaylist', 'joined', 'start:a:0:NewPlaylist']); + }); + + test('an empty playlist clears the playing item without starting playback', () { + final calls = []; + SyncPlayState? written; + final handler = _handler( + calls: calls, + onStateUpdate: (updater) => written = updater(SyncPlayState(isInGroup: true, playingItemId: 'a')), + ); + + handler.handleGroupUpdate( + _playQueue(reason: 'NewPlaylist', items: const [], playingItemIndex: -1), + SyncPlayState(isInGroup: true, playingItemId: 'a'), + ); + + expect(calls, isEmpty); + expect(written?.playingItemId, isNull); + expect(written?.playingItemIndex, -1); + }); + }); +} diff --git a/test/providers/syncplay/syncplay_queue_test.dart b/test/providers/syncplay/syncplay_queue_test.dart new file mode 100644 index 000000000..fcae7dd0a --- /dev/null +++ b/test/providers/syncplay/syncplay_queue_test.dart @@ -0,0 +1,163 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const playlist = [ + SyncPlayQueueEntry(itemId: 'a', playlistItemId: 'pa'), + SyncPlayQueueEntry(itemId: 'b', playlistItemId: 'pb'), + SyncPlayQueueEntry(itemId: 'c', playlistItemId: 'pc'), + SyncPlayQueueEntry(itemId: 'd', playlistItemId: 'pd'), + ]; + + group('SyncPlayQueueTiming', () { + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + + test('a paused group stays at the frame position', () { + const timing = SyncPlayQueueTiming(startPositionTicks: 5000); + expect(timing.positionTicksAt(at.add(const Duration(minutes: 5))), 5000); + }); + + test('a playing group is extrapolated from LastUpdate', () { + final timing = SyncPlayQueueTiming(startPositionTicks: 5000, lastUpdate: at, isPlaying: true); + expect( + timing.positionTicksAt(at.add(const Duration(seconds: 3))), + 5000 + millisecondsToTicks(3000), + ); + }); + + test('a playing group without LastUpdate cannot be extrapolated', () { + const timing = SyncPlayQueueTiming(startPositionTicks: 5000, isPlaying: true); + expect(timing.positionTicksAt(at.add(const Duration(seconds: 3))), 5000); + }); + }); + + group('estimateGroupPositionTicks', () { + final at = DateTime.utc(2026, 9, 2, 12, 0, 0); + final remoteNow = at.add(const Duration(seconds: 10)); + final timing = SyncPlayQueueTiming(startPositionTicks: 1000, lastUpdate: at, isPlaying: true); + + test('an Unpause for the current item is extrapolated', () { + final unpause = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: SyncPlayCommand.unpause, + playlistItemId: 'pa', + ); + expect( + estimateGroupPositionTicks( + lastCommand: unpause, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 5000 + millisecondsToTicks(10000), + ); + }); + + test('a Pause or Seek freezes the playhead at the command position', () { + for (final command in [SyncPlayCommand.pause, SyncPlayCommand.seek]) { + final last = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: command, + playlistItemId: 'pa', + ); + expect( + estimateGroupPositionTicks( + lastCommand: last, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 5000, + reason: '$command must not extrapolate', + ); + } + }); + + test('a command for another item yields to the queue frame timing', () { + final stale = LastSyncPlayCommand( + when: at.toIso8601String(), + positionTicks: 5000, + command: SyncPlayCommand.unpause, + playlistItemId: 'pb', + ); + expect( + estimateGroupPositionTicks( + lastCommand: stale, + currentPlaylistItemId: 'pa', + queueTiming: timing, + fallbackPositionTicks: 0, + remoteNow: remoteNow, + ), + 1000 + millisecondsToTicks(10000), + ); + }); + + test('without any context the last state update position is used', () { + expect( + estimateGroupPositionTicks( + lastCommand: null, + currentPlaylistItemId: 'pa', + queueTiming: null, + fallbackPositionTicks: 777, + remoteNow: remoteNow, + ), + 777, + ); + }); + }); + + group('resolveQueueNavigation', () { + test('adjacent following entry is NextItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 1, targetItemId: 'c'), + SyncPlayQueueNavigation.next, + ); + }); + + test('adjacent preceding entry is PreviousItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 1, targetItemId: 'a'), + SyncPlayQueueNavigation.previous, + ); + }); + + test('non-adjacent entry is SetPlaylistItem', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 0, targetItemId: 'd'), + SyncPlayQueueNavigation.setCurrentItem, + ); + }); + + test('unknown item needs a new queue', () { + expect( + resolveQueueNavigation(playlist: playlist, playingItemIndex: 0, targetItemId: 'z'), + SyncPlayQueueNavigation.newQueue, + ); + }); + + test('empty queue needs a new queue', () { + expect( + resolveQueueNavigation(playlist: const [], playingItemIndex: -1, targetItemId: 'a'), + SyncPlayQueueNavigation.newQueue, + ); + }); + }); + + group('SyncPlayState queue helpers', () { + test('next and previous entries follow the playing index', () { + final state = SyncPlayState(playlist: playlist, playingItemIndex: 1); + expect(state.nextQueueEntry?.itemId, 'c'); + expect(state.previousQueueEntry?.itemId, 'a'); + }); + + test('edges return null', () { + expect(SyncPlayState(playlist: playlist, playingItemIndex: 0).previousQueueEntry, isNull); + expect(SyncPlayState(playlist: playlist, playingItemIndex: 3).nextQueueEntry, isNull); + expect(SyncPlayState().nextQueueEntry, isNull); + }); + }); +} 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..3cd499ce1 --- /dev/null +++ b/test/providers/websocket/jellyfin_websocket_platform_test.dart @@ -0,0 +1,81 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('reconnectDelay', () { + test('first attempt is the base delay within jitter', () { + final delay = reconnectDelay(0, random: Random(1)); + expect(delay.inMilliseconds, inInclusiveRange(1600, 2400)); + }); + + test('doubles per attempt until the cap', () { + final second = reconnectDelay(1, random: _NoJitter()); + final third = reconnectDelay(2, random: _NoJitter()); + expect(second, const Duration(seconds: 4)); + expect(third, const Duration(seconds: 8)); + }); + + test('is capped and never gives up', () { + for (final attempt in [4, 10, 100, 1 << 20]) { + final delay = reconnectDelay(attempt, random: Random(attempt)); + expect(delay.inMilliseconds, lessThanOrEqualTo(36000), reason: 'attempt $attempt'); + expect(delay.inMilliseconds, greaterThanOrEqualTo(24000), reason: 'attempt $attempt'); + } + }); + }); + + group('isPhonePlatform', () { + test('Android handheld (not leanback) is a phone', () { + expect( + 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', + ); + } + }); + }); +} + +/// Random that always yields 0.5 so the jitter term is exactly zero. +class _NoJitter implements Random { + @override + bool nextBool() => false; + + @override + double nextDouble() => 0.5; + + @override + int nextInt(int max) => 0; +} diff --git a/test/providers/websocket/jellyfin_websocket_uri_test.dart b/test/providers/websocket/jellyfin_websocket_uri_test.dart new file mode 100644 index 000000000..02d3be955 --- /dev/null +++ b/test/providers/websocket/jellyfin_websocket_uri_test.dart @@ -0,0 +1,55 @@ +import 'package:fladder/providers/websocket/jellyfin_websocket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buildWebSocketUri', () { + test('carries the token as ApiKey', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com', + token: 'tok-123', + deviceId: 'dev-abc', + ); + + // Jellyfin 12 ignores the legacy `api_key` name by default, so the socket must send `ApiKey`. + expect(uri.queryParameters['ApiKey'], 'tok-123'); + expect(uri.queryParameters.containsKey('api_key'), isFalse); + expect(uri.queryParameters['deviceId'], 'dev-abc'); + }); + + test('upgrades the scheme and keeps host, port and base path', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com:8920/jellyfin/', + token: 't', + deviceId: 'd', + ); + + expect(uri.scheme, 'wss'); + expect(uri.host, 'jelly.example.com'); + expect(uri.port, 8920); + expect(uri.path, '/jellyfin/socket'); + }); + + test('uses ws for a plain-http server', () { + final uri = buildWebSocketUri(serverUrl: 'http://192.168.1.10:8096', token: 't', deviceId: 'd'); + + expect(uri.scheme, 'ws'); + expect(uri.path, '/socket'); + }); + }); + + group('redactSocketUri', () { + test('masks the token but keeps the rest readable', () { + final uri = buildWebSocketUri( + serverUrl: 'https://jelly.example.com', + token: 'super-secret', + deviceId: 'dev-abc', + ); + + final redacted = redactSocketUri(uri); + + expect(redacted, isNot(contains('super-secret'))); + expect(redacted, contains('ApiKey=***')); + expect(redacted, contains('deviceId=dev-abc')); + }); + }); +}