diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/BatteryOptimizationPigeon.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/BatteryOptimizationPigeon.g.kt index bb0aec8d6..4d2009591 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/BatteryOptimizationPigeon.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/BatteryOptimizationPigeon.g.kt @@ -12,102 +12,82 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer - private object BatteryOptimizationPigeonPigeonUtils { - fun wrapResult(result: Any?): List { - return listOf(result) - } + fun wrapResult(result: Any?): List { + return listOf(result) + } - fun wrapError(exception: Throwable): List { - return if (exception is FlutterError) { - listOf( - exception.code, - exception.message, - exception.details - ) - } else { - listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) - } + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) } + } } - private open class BatteryOptimizationPigeonPigeonCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return super.readValueOfType(type, buffer) - } - - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - super.writeValue(stream, value) - } + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface BatteryOptimizationPigeon { - /** Returns whether the app is currently *ignored* from battery optimizations. */ - fun isIgnoringBatteryOptimizations(): Boolean + fun isIgnoringBatteryOptimizations(): Boolean + fun openBatteryOptimizationSettings() - /** Opens the battery-optimization/settings screen for this app (Android). */ - fun openBatteryOptimizationSettings() - - companion object { - /** The codec used by BatteryOptimizationPigeon. */ - val codec: MessageCodec by lazy { - BatteryOptimizationPigeonPigeonCodec() - } - - /** Sets up an instance of `BatteryOptimizationPigeon` to handle messages through the `binaryMessenger`. */ - @JvmOverloads - fun setUp( - binaryMessenger: BinaryMessenger, - api: BatteryOptimizationPigeon?, - messageChannelSuffix: String = "" - ) { - val separatedMessageChannelSuffix = - if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - run { - val channel = BasicMessageChannel( - binaryMessenger, - "dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", - codec - ) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - listOf(api.isIgnoringBatteryOptimizations()) - } catch (exception: Throwable) { - BatteryOptimizationPigeonPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } + companion object { + /** The codec used by BatteryOptimizationPigeon. */ + val codec: MessageCodec by lazy { + BatteryOptimizationPigeonPigeonCodec() + } + /** Sets up an instance of `BatteryOptimizationPigeon` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BatteryOptimizationPigeon?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isIgnoringBatteryOptimizations()) + } catch (exception: Throwable) { + BatteryOptimizationPigeonPigeonUtils.wrapError(exception) } - run { - val channel = BasicMessageChannel( - binaryMessenger, - "dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.openBatteryOptimizationSettings$separatedMessageChannelSuffix", - codec - ) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.openBatteryOptimizationSettings() - listOf(null) - } catch (exception: Throwable) { - BatteryOptimizationPigeonPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.openBatteryOptimizationSettings$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.openBatteryOptimizationSettings() + listOf(null) + } catch (exception: Throwable) { + BatteryOptimizationPigeonPigeonUtils.wrapError(exception) } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) } + } } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt index c2fd9925f..83d7d356a 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/TranslationsPigeon.g.kt @@ -334,4 +334,124 @@ class TranslationsPigeon(private val binaryMessenger: BinaryMessenger, private v } } } + fun syncPlaySyncingWithGroup(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlaySyncingWithGroup$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandPausing(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPausing$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandPlaying(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPlaying$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandSeeking(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSeeking$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandStopping(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandStopping$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } + fun syncPlayCommandSyncing(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSyncing$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback(Result.failure(FlutterError("null-error", "Flutter api returned null value for non-null return value.", ""))) + } else { + val output = it[0] as String + callback(Result.success(output)) + } + } else { + callback(Result.failure(TranslationsPigeonPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt index 440f8edf4..367e0a680 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -93,6 +93,20 @@ enum class PlaybackType(val raw: Int) { } } +enum class SyncPlayCommandType(val raw: Int) { + NONE(0), + PAUSE(1), + UNPAUSE(2), + SEEK(3), + STOP(4); + + companion object { + fun ofRaw(raw: Int): SyncPlayCommandType? { + return values().firstOrNull { it.raw == raw } + } + } +} + enum class MediaSegmentType(val raw: Int) { COMMERCIAL(0), PREVIEW(1), @@ -107,6 +121,22 @@ enum class MediaSegmentType(val raw: Int) { } } +/** Source of the last playback state change (for SyncPlay: infer user actions from stream). */ +enum class PlaybackChangeSource(val raw: Int) { + /** No specific source (e.g. periodic update, buffering). */ + NONE(0), + /** User tapped play/pause/seek on native; Flutter should send SyncPlay if active. */ + USER(1), + /** Change was caused by applying a SyncPlay command; do not send again. */ + SYNCPLAY(2); + + companion object { + fun ofRaw(raw: Int): PlaybackChangeSource? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class SimpleItemModel ( val id: String, @@ -487,7 +517,9 @@ data class PlaybackState ( val playing: Boolean, val buffering: Boolean, val completed: Boolean, - val failed: Boolean + val failed: Boolean, + /** When set, indicates who caused this state update (for SyncPlay inference). */ + val changeSource: PlaybackChangeSource? = null ) { companion object { @@ -499,7 +531,8 @@ data class PlaybackState ( val buffering = pigeonVar_list[4] as Boolean val completed = pigeonVar_list[5] as Boolean val failed = pigeonVar_list[6] as Boolean - return PlaybackState(position, buffered, duration, playing, buffering, completed, failed) + val changeSource = pigeonVar_list[7] as PlaybackChangeSource? + return PlaybackState(position, buffered, duration, playing, buffering, completed, failed, changeSource) } } fun toList(): List { @@ -511,6 +544,7 @@ data class PlaybackState ( buffering, completed, failed, + changeSource, ) } override fun equals(other: Any?): Boolean { @@ -709,75 +743,85 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() { } 130.toByte() -> { return (readValue(buffer) as Long?)?.let { - MediaSegmentType.ofRaw(it.toInt()) + SyncPlayCommandType.ofRaw(it.toInt()) } } 131.toByte() -> { + return (readValue(buffer) as Long?)?.let { + MediaSegmentType.ofRaw(it.toInt()) + } + } + 132.toByte() -> { + return (readValue(buffer) as Long?)?.let { + PlaybackChangeSource.ofRaw(it.toInt()) + } + } + 133.toByte() -> { return (readValue(buffer) as? List)?.let { SimpleItemModel.fromList(it) } } - 132.toByte() -> { + 134.toByte() -> { return (readValue(buffer) as? List)?.let { MediaInfo.fromList(it) } } - 133.toByte() -> { + 135.toByte() -> { return (readValue(buffer) as? List)?.let { PlayableData.fromList(it) } } - 134.toByte() -> { + 136.toByte() -> { return (readValue(buffer) as? List)?.let { MediaSegment.fromList(it) } } - 135.toByte() -> { + 137.toByte() -> { return (readValue(buffer) as? List)?.let { AudioTrack.fromList(it) } } - 136.toByte() -> { + 138.toByte() -> { return (readValue(buffer) as? List)?.let { SubtitleTrack.fromList(it) } } - 137.toByte() -> { + 139.toByte() -> { return (readValue(buffer) as? List)?.let { Chapter.fromList(it) } } - 138.toByte() -> { + 140.toByte() -> { return (readValue(buffer) as? List)?.let { TrickPlayModel.fromList(it) } } - 139.toByte() -> { + 141.toByte() -> { return (readValue(buffer) as? List)?.let { StartResult.fromList(it) } } - 140.toByte() -> { + 142.toByte() -> { return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) } } - 141.toByte() -> { + 143.toByte() -> { return (readValue(buffer) as? List)?.let { SubtitleSettings.fromList(it) } } - 142.toByte() -> { + 144.toByte() -> { return (readValue(buffer) as? List)?.let { TVGuideModel.fromList(it) } } - 143.toByte() -> { + 145.toByte() -> { return (readValue(buffer) as? List)?.let { GuideChannel.fromList(it) } } - 144.toByte() -> { + 146.toByte() -> { return (readValue(buffer) as? List)?.let { GuideProgram.fromList(it) } @@ -791,64 +835,72 @@ private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() { stream.write(129) writeValue(stream, value.raw.toLong()) } - is MediaSegmentType -> { + is SyncPlayCommandType -> { stream.write(130) writeValue(stream, value.raw.toLong()) } - is SimpleItemModel -> { + is MediaSegmentType -> { stream.write(131) + writeValue(stream, value.raw.toLong()) + } + is PlaybackChangeSource -> { + stream.write(132) + writeValue(stream, value.raw.toLong()) + } + is SimpleItemModel -> { + stream.write(133) writeValue(stream, value.toList()) } is MediaInfo -> { - stream.write(132) + stream.write(134) writeValue(stream, value.toList()) } is PlayableData -> { - stream.write(133) + stream.write(135) writeValue(stream, value.toList()) } is MediaSegment -> { - stream.write(134) + stream.write(136) writeValue(stream, value.toList()) } is AudioTrack -> { - stream.write(135) + stream.write(137) writeValue(stream, value.toList()) } is SubtitleTrack -> { - stream.write(136) + stream.write(138) writeValue(stream, value.toList()) } is Chapter -> { - stream.write(137) + stream.write(139) writeValue(stream, value.toList()) } is TrickPlayModel -> { - stream.write(138) + stream.write(140) writeValue(stream, value.toList()) } is StartResult -> { - stream.write(139) + stream.write(141) writeValue(stream, value.toList()) } is PlaybackState -> { - stream.write(140) + stream.write(142) writeValue(stream, value.toList()) } is SubtitleSettings -> { - stream.write(141) + stream.write(143) writeValue(stream, value.toList()) } is TVGuideModel -> { - stream.write(142) + stream.write(144) writeValue(stream, value.toList()) } is GuideChannel -> { - stream.write(143) + stream.write(145) writeValue(stream, value.toList()) } is GuideProgram -> { - stream.write(144) + stream.write(146) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) @@ -941,6 +993,12 @@ interface VideoPlayerApi { fun seekTo(position: Long) fun stop() fun setSubtitleSettings(settings: SubtitleSettings) + /** + * Sets the SyncPlay command state for the native player overlay. + * [processing] indicates if a SyncPlay command is being processed. + * [commandType] is the type of command. + */ + fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) companion object { /** The codec used by VideoPlayerApi. */ @@ -1150,6 +1208,25 @@ interface VideoPlayerApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val processingArg = args[0] as Boolean + val commandTypeArg = args[1] as SyncPlayCommandType + val wrapped: List = try { + api.setSyncPlayCommandState(processingArg, commandTypeArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -1309,4 +1386,61 @@ class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, } } } + /** User-initiated play action from native player (for SyncPlay integration) */ + fun onUserPlay(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + /** User-initiated pause action from native player (for SyncPlay integration) */ + fun onUserPause(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + /** + * User-initiated seek action from native player (for SyncPlay integration) + * Position is in milliseconds + */ + fun onUserSeek(positionMsArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(positionMsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt index 88e43de95..3299ea1b6 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.delay +import PlaybackChangeSource import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.Translate import nl.jknaapen.fladder.objects.VideoPlayerObject @@ -450,6 +451,7 @@ internal fun RowScope.SimpleProgressBar( val clickRelativeOffset = offset.x / width.toFloat() val newPosition = effectiveDuration.milliseconds * clickRelativeOffset.toDouble() + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS)) } } @@ -473,6 +475,7 @@ internal fun RowScope.SimpleProgressBar( }, onDragEnd = { onScrubbingChanged(false) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(internalTempPosition) }, onDragCancel = { @@ -655,6 +658,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.pause() } val newPos = max( @@ -676,6 +680,7 @@ internal fun RowScope.SimpleProgressBar( if (!scrubbingTimeLine) { onTempPosChanged(effectivePosition) onScrubbingChanged(true) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.pause() } val newPos = min(player.duration.takeIf { it > 0 } ?: 1L, @@ -687,6 +692,7 @@ internal fun RowScope.SimpleProgressBar( Enter, Spacebar, ButtonSelect, DirectionCenter -> { if (scrubbingTimeLine) { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.seekTo(tempPosition) player.play() onScrubbingChanged(false) @@ -697,6 +703,7 @@ internal fun RowScope.SimpleProgressBar( Escape, Back -> { if (scrubbingTimeLine) { onScrubbingChanged(false) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player.play() true } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt index 0a11c08f9..2f46e5670 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -5,6 +5,8 @@ import MediaSegmentType import SegmentSkip import SegmentType import android.os.Build +import PlaybackChangeSource +import nl.jknaapen.fladder.objects.VideoPlayerObject import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background @@ -38,7 +40,6 @@ import androidx.compose.ui.unit.dp import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.Translate -import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.defaultSelected import nl.jknaapen.fladder.utility.leanBackEnabled import kotlin.time.Duration.Companion.milliseconds @@ -69,7 +70,8 @@ internal fun BoxScope.SegmentSkipOverlay( val currentSegmentId = activeSegment?.let { "${it.type}-${it.start}-${it.end}" } fun skipSegment(segment: MediaSegment, segmentId: String) { - player.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.implementation.player?.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) skippedSegments.add(segmentId) } @@ -108,7 +110,8 @@ internal fun BoxScope.SegmentSkipOverlay( enableScaledFocus = true, onClick = { activeSegment?.let { - player.seekTo(it.end) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + VideoPlayerObject.implementation.player?.seekTo(it.end.toLong()) } } ) { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index f425790e4..d491afc13 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -70,7 +70,9 @@ import nl.jknaapen.fladder.composables.dialogs.AudioPicker import nl.jknaapen.fladder.composables.dialogs.ChapterSelectionSheet import nl.jknaapen.fladder.composables.dialogs.PlaybackSpeedPicker import nl.jknaapen.fladder.composables.dialogs.SubtitlePicker +import nl.jknaapen.fladder.composables.overlays.SyncPlayCommandOverlay import nl.jknaapen.fladder.composables.shared.CurrentTime +import PlaybackChangeSource import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.ImmersiveSystemBars @@ -147,6 +149,8 @@ fun CustomVideoControls( LaunchedEffect(lastSeekInteraction.longValue) { delay(1.seconds) if (currentSkipTime == 0L) return@LaunchedEffect + // SyncPlay: user action is applied locally; Flutter infers from playback state stream and sends to server. + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player?.seekTo(position + currentSkipTime) currentSkipTime = 0L } @@ -172,12 +176,14 @@ fun CustomVideoControls( } Key.MediaPlay -> { - player?.play() + // Route through Flutter for SyncPlay support + VideoPlayerObject.videoPlayerControls?.onUserPlay {} return@keyEvent true } Key.MediaPlayPause -> { player?.let { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) if (it.isPlaying) { it.pause() updateLastInteraction() @@ -186,10 +192,10 @@ fun CustomVideoControls( } } return@keyEvent true - } Key.MediaPause, Key.P -> { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) player?.pause() updateLastInteraction() return@keyEvent true @@ -351,6 +357,7 @@ fun CustomVideoControls( } SegmentSkipOverlay() SeekOverlay(value = currentSkipTime) + SyncPlayCommandOverlay() if (buffering && !playing) { CircularProgressIndicator( modifier = Modifier @@ -383,7 +390,8 @@ fun CustomVideoControls( if (showChapterDialog) { ChapterSelectionSheet( onSelected = { - exoPlayer.seekTo(it.time) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + exoPlayer.seekTo(it.time.toLong()) showChapterDialog = false }, onDismiss = { @@ -444,9 +452,8 @@ fun PlaybackButtons( } CustomButton( onClick = { - player.seekTo( - player.currentPosition - backwardSpeed.inWholeMilliseconds - ) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + player.seekTo((player.currentPosition - backwardSpeed.inWholeMilliseconds).coerceAtLeast(0L)) }, ) { Box( @@ -472,6 +479,7 @@ fun PlaybackButtons( .defaultSelected(true), enableScaledFocus = true, onClick = { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) if (player.isPlaying) { player.pause() onPause() @@ -489,9 +497,8 @@ fun PlaybackButtons( if (!isTVMode) { CustomButton( onClick = { - player.seekTo( - player.currentPosition + forwardSpeed.inWholeMilliseconds - ) + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.USER) + player.seekTo(player.currentPosition + forwardSpeed.inWholeMilliseconds) }, ) { Box( diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt new file mode 100644 index 000000000..01e0d5a15 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/overlays/SyncPlayCommandOverlay.kt @@ -0,0 +1,172 @@ +package nl.jknaapen.fladder.composables.overlays + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.rabehx.iconsax.Iconsax +import io.github.rabehx.iconsax.filled.Forward +import io.github.rabehx.iconsax.filled.Pause +import io.github.rabehx.iconsax.filled.Play +import io.github.rabehx.iconsax.filled.Refresh +import io.github.rabehx.iconsax.filled.Stop +import SyncPlayCommandType +import nl.jknaapen.fladder.objects.Localized +import nl.jknaapen.fladder.objects.Translate +import nl.jknaapen.fladder.objects.VideoPlayerObject + +/** + * Centered overlay showing SyncPlay command being processed. + * Mirrors the Flutter SyncPlayCommandIndicator design. + */ +@Composable +fun BoxScope.SyncPlayCommandOverlay( + modifier: Modifier = Modifier +) { + val syncPlayState by VideoPlayerObject.syncPlayCommandState.collectAsState() + val visible by remember(syncPlayState) { + derivedStateOf { + syncPlayState.processing && syncPlayState.commandType != SyncPlayCommandType.NONE + } + } + + AnimatedVisibility( + visible = visible, + modifier = modifier.align(Alignment.Center), + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f) + ) { + Box( + modifier = Modifier + .shadow( + elevation = 20.dp, + shape = RoundedCornerShape(20.dp), + ambientColor = Color.Black.copy(alpha = 0.3f), + spotColor = Color.Black.copy(alpha = 0.3f) + ) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(20.dp) + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + shape = RoundedCornerShape(20.dp) + ) + .padding(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CommandIcon(commandType = syncPlayState.commandType) + + Spacer(modifier = Modifier.height(12.dp)) + + Translate( + callback = { cb -> + when (syncPlayState.commandType) { + SyncPlayCommandType.PAUSE -> Localized.syncPlayCommandPausing(cb) + SyncPlayCommandType.UNPAUSE -> Localized.syncPlayCommandPlaying(cb) + SyncPlayCommandType.SEEK -> Localized.syncPlayCommandSeeking(cb) + SyncPlayCommandType.STOP -> Localized.syncPlayCommandStopping(cb) + else -> Localized.syncPlayCommandSyncing(cb) + } + }, + key = syncPlayState.commandType + ) { label -> + Text( + text = label, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Translate({ Localized.syncPlaySyncingWithGroup(it) }) { syncingText -> + Text( + text = syncingText, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + } +} + +@Composable +private fun CommandIcon(commandType: SyncPlayCommandType) { + val (icon, color) = when (commandType) { + SyncPlayCommandType.PAUSE -> Pair(Iconsax.Filled.Pause, MaterialTheme.colorScheme.secondary) + SyncPlayCommandType.UNPAUSE -> Pair(Iconsax.Filled.Play, MaterialTheme.colorScheme.primary) + SyncPlayCommandType.SEEK -> Pair(Iconsax.Filled.Forward, MaterialTheme.colorScheme.tertiary) + SyncPlayCommandType.STOP -> Pair(Iconsax.Filled.Stop, MaterialTheme.colorScheme.error) + else -> Pair(Iconsax.Filled.Refresh, MaterialTheme.colorScheme.primary) + } + + Box( + modifier = Modifier + .background( + color = color.copy(alpha = 0.15f), + shape = CircleShape + ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = commandType.name, + modifier = Modifier.size(48.dp), + tint = color + ) + } +} + diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index 88f0ed170..67f60121e 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -1,7 +1,10 @@ package nl.jknaapen.fladder.messengers +import PlaybackChangeSource +import PlaybackType import PlayableData import SubtitleSettings +import SyncPlayCommandType import TVGuideModel import VideoPlayerApi import android.os.Handler @@ -140,14 +143,17 @@ class VideoPlayerImplementation( } override fun play() { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.play() } override fun pause() { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.pause() } override fun seekTo(position: Long) { + VideoPlayerObject.setPendingPlaybackChangeSource(PlaybackChangeSource.SYNCPLAY) player?.seekTo(position) } @@ -155,6 +161,13 @@ class VideoPlayerImplementation( player?.stop() } + override fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) { + VideoPlayerObject.setSyncPlayCommandState( + processing = processing, + commandType = commandType + ) + } + fun init(exoPlayer: ExoPlayer?) { player = exoPlayer subsInitialized = false diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt index 1f335ae51..6472203be 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -1,6 +1,8 @@ package nl.jknaapen.fladder.objects +import PlaybackChangeSource import PlaybackState +import SyncPlayCommandType import TVGuideModel import VideoPlayerControlsCallback import VideoPlayerListenerCallback @@ -15,6 +17,11 @@ import nl.jknaapen.fladder.messengers.VideoPlayerImplementation import nl.jknaapen.fladder.utility.InternalTrack object VideoPlayerObject { + data class SyncPlayCommandUiState( + val processing: Boolean, + val commandType: SyncPlayCommandType + ) + val implementation: VideoPlayerImplementation = VideoPlayerImplementation() private var _currentState = MutableStateFlow(null) @@ -104,5 +111,32 @@ object VideoPlayerObject { guideVisible.value = !guideVisible.value } + // SyncPlay command state for overlay (Pigeon-generated type) + val syncPlayCommandState = MutableStateFlow( + SyncPlayCommandUiState(false, SyncPlayCommandType.NONE) + ) + + fun setSyncPlayCommandState(processing: Boolean, commandType: SyncPlayCommandType) { + syncPlayCommandState.value = SyncPlayCommandUiState( + processing = processing, + commandType = commandType + ) + } + + /** Set before updating player so the next PlaybackState sent to Flutter is tagged (for SyncPlay inference). */ + @Volatile + private var pendingPlaybackChangeSource: PlaybackChangeSource? = null + + fun setPendingPlaybackChangeSource(source: PlaybackChangeSource) { + pendingPlaybackChangeSource = source + } + + /** Consumed when building PlaybackState in ExoPlayer; clears after read. */ + fun getAndClearPendingPlaybackChangeSource(): PlaybackChangeSource? { + val r = pendingPlaybackChangeSource + pendingPlaybackChangeSource = null + return r + } + var currentActivity: VideoPlayerActivity? = null } \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 44bb2a8d5..542ab6ffb 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -131,7 +131,8 @@ internal fun ExoPlayer( playing = exoPlayer.isPlaying, buffering = exoPlayer.playbackState == Player.STATE_BUFFERING, completed = exoPlayer.playbackState == Player.STATE_ENDED, - failed = exoPlayer.playbackState == Player.STATE_IDLE + failed = exoPlayer.playbackState == Player.STATE_IDLE, + changeSource = videoHost.getAndClearPendingPlaybackChangeSource() ) ) } @@ -167,7 +168,8 @@ internal fun ExoPlayer( playing = exoPlayer.isPlaying, buffering = playbackState == Player.STATE_BUFFERING, completed = playbackState == Player.STATE_ENDED, - failed = playbackState == Player.STATE_IDLE + failed = playbackState == Player.STATE_IDLE, + changeSource = videoHost.getAndClearPendingPlaybackChangeSource() ) ) } diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md new file mode 100644 index 000000000..b7789a095 --- /dev/null +++ b/docs/syncplay-implementation.md @@ -0,0 +1,1396 @@ +# 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 | +| **Time Sync** | Calculates clock offset between client and server | +| **Command Handler** | Schedules commands for future execution, handles duplicates | +| **Player Interface** | Abstraction layer between SyncPlay and actual video player | +| **SyncPlay Controller** | Orchestrates all components, manages group state | + +--- + +## Communication Protocols + +SyncPlay uses two complementary communication channels: + +### REST API (Client → Server) + +Used for **requesting** state changes. The server processes these and broadcasts commands to all clients. + +| Endpoint | Purpose | +|----------|---------| +| `GET /SyncPlay/List` | List available groups | +| `POST /SyncPlay/New` | Create a new group | +| `POST /SyncPlay/Join` | Join an existing group | +| `POST /SyncPlay/Leave` | Leave current group | +| `POST /SyncPlay/Pause` | Request pause | +| `POST /SyncPlay/Unpause` | Request unpause/play | +| `POST /SyncPlay/Seek` | Request seek to position | +| `POST /SyncPlay/Stop` | Request stop | +| `POST /SyncPlay/Buffering` | Report buffering state | +| `POST /SyncPlay/Ready` | Report ready state | +| `POST /SyncPlay/SetNewQueue` | Set a new playlist | +| `POST /SyncPlay/Queue` | Add items to queue | +| `POST /SyncPlay/Ping` | Report ping measurement | +| `GET /GetUtcTime` | Get server timestamps for time sync (T2, T3) | + +### WebSocket (Server → Client) + +Used for **receiving** commands and state updates. Connect to: + +``` +wss://{server}/socket?api_key={token}&deviceId={deviceId} +``` + +Message types received: +- `SyncPlayCommand` - Playback control commands (pause, unpause, seek, stop) +- `SyncPlayGroupUpdate` - Group state changes (join, leave, playlist, state) +- `ForceKeepAlive` - Keep-alive configuration +- `KeepAlive` - Keep-alive acknowledgment + +--- + +## Time Synchronization + +**Critical for accurate synchronization.** Clients must maintain an accurate estimate of the offset between their local clock and the server's clock. + +### Algorithm (NTP-like) + +``` +┌────────┐ ┌────────┐ +│ CLIENT │ │ SERVER │ +└────┬───┘ └────┬───┘ + │ │ + │ requestSent (T1) │ + │──────────────────────────────────────►│ + │ │ requestReceived (T2) + │ │ + │ │ responseSent (T3) + │◄──────────────────────────────────────│ + │ responseReceived (T4) │ + │ │ +``` + +**Offset Calculation:** + +``` +offset = ((T2 - T1) + (T3 - T4)) / 2 +``` + +**Round-trip Delay:** + +``` +delay = (T4 - T1) - (T3 - T2) +ping = delay / 2 +``` + +### Server Time API + +Jellyfin provides a dedicated endpoint for time synchronization that returns server-side timestamps: + +**Endpoint:** +```http +GET /GetUtcTime +``` + +**Response:** +```json +{ + "RequestReceptionTime": "2024-01-15T12:00:00.0000000Z", + "ResponseTransmissionTime": "2024-01-15T12:00:00.0010000Z" +} +``` + +| Field | Description | +|-------|-------------| +| `RequestReceptionTime` | When the server received the request (T2) | +| `ResponseTransmissionTime` | When the server sent the response (T3) | + +The client records T1 (before sending) and T4 (after receiving) locally. + +**Client-side Implementation:** + +```typescript +async function requestPing(): Promise<{ + requestSent: Date; + requestReceived: Date; + responseSent: Date; + responseReceived: Date; +}> { + // T1: Record local time before request + const requestSent = new Date(); + + // Make request to Jellyfin TimeSync API + const response = await fetch(`${serverUrl}/GetUtcTime`, { + headers: { 'Authorization': `MediaBrowser Token="${accessToken}"` } + }); + const data = await response.json(); + + // T4: Record local time after response + const responseReceived = new Date(); + + // T2 and T3 come from server response + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); + + return { + requestSent, // T1 - local + requestReceived, // T2 - from server + responseSent, // T3 - from server + responseReceived // T4 - local + }; +} +``` + +**Flutter/Dart equivalent:** + +```dart +Future requestPing() async { + // T1: Record local time before request + final requestSent = DateTime.now().toUtc(); + + // Make request to Jellyfin TimeSync API + final response = await http.get( + Uri.parse('$serverUrl/GetUtcTime'), + headers: {'Authorization': 'MediaBrowser Token="$accessToken"'}, + ); + + // T4: Record local time after response + final responseReceived = DateTime.now().toUtc(); + + final data = jsonDecode(response.body); + + // T2 and T3 from server + final requestReceived = DateTime.parse(data['RequestReceptionTime']); + final responseSent = DateTime.parse(data['ResponseTransmissionTime']); + + return TimeSyncMeasurement( + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived, + ); +} +``` + +### Implementation Details + +```typescript +class Measurement { + requestSent: number; // T1 - when client sent request + requestReceived: number; // T2 - when server received request + responseSent: number; // T3 - when server sent response + responseReceived: number; // T4 - when client received response + + getOffset(): number { + return ((this.requestReceived - this.requestSent) + + (this.responseSent - this.responseReceived)) / 2; + } + + getDelay(): number { + return (this.responseReceived - this.requestSent) - + (this.responseSent - this.requestReceived); + } + + getPing(): number { + return this.getDelay() / 2; + } +} +``` + +### Measurement Strategy + +| Phase | Interval | Purpose | +|-------|----------|---------| +| **Greedy** (first 3 pings) | 1 second | Quick initial synchronization | +| **Low Profile** (subsequent) | 60 seconds | Maintain sync without network overhead | + +**Best Measurement Selection:** +- Keep last 8 measurements +- Use measurement with **minimum delay** (least network jitter) + +### Time Conversion Functions + +```typescript +// Convert server time to local time +function remoteDateToLocal(serverTime: Date): Date { + return new Date(serverTime.getTime() - offset); +} + +// Convert local time to server time +function localDateToRemote(localTime: Date): Date { + return new Date(localTime.getTime() + offset); +} +``` + +### Staleness Detection + +Time sync becomes unreliable over time. Mark as stale after 30 seconds and force a refresh before critical operations. + +```typescript +function isStale(): boolean { + return (Date.now() - lastMeasurement.timestamp) > 30000; +} +``` + +--- + +## Group Management + +### Group Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> NoGroup: Initial State + NoGroup --> Creating: createGroup() + NoGroup --> Joining: joinGroup(id) + Creating --> InGroup: GroupJoined message + Joining --> InGroup: GroupJoined message + InGroup --> NoGroup: leaveGroup() + InGroup --> NoGroup: GroupDoesNotExist message +``` + +### Create Group + +**Request:** +```http +POST /SyncPlay/New +Content-Type: application/json + +{ + "GroupName": "Movie Night" +} +``` + +**Response:** Group is created and you automatically join. Wait for `GroupJoined` WebSocket message. + +### Join Group + +**Request:** +```http +POST /SyncPlay/Join +Content-Type: application/json + +{ + "GroupId": "abc-123-def" +} +``` + +### Leave Group + +**Request:** +```http +POST /SyncPlay/Leave +``` + +### Group State Structure + +```typescript +interface SyncPlayGroup { + GroupId: string; + GroupName: string; + State: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; + StateReason?: string; + Participants: string[]; + PlayingItemId?: string; + PositionTicks: number; + IsPaused: boolean; // derived: State === 'Paused' || State === 'Waiting' +} +``` + +--- + +## Playback Control + +### Position Units + +Jellyfin uses **ticks** for time positions: +- **1 tick = 100 nanoseconds** +- **10,000,000 ticks = 1 second** + +```typescript +const TICKS_PER_SECOND = 10_000_000; + +function secondsToTicks(seconds: number): number { + return Math.floor(seconds * TICKS_PER_SECOND); +} + +function ticksToSeconds(ticks: number): number { + return ticks / TICKS_PER_SECOND; +} +``` + +### Pause Request + +```http +POST /SyncPlay/Pause +``` + +No body required. Server broadcasts `Pause` command to all clients. + +### Unpause Request + +```http +POST /SyncPlay/Unpause +``` + +No body required. Server transitions group to `Waiting` state, waits for all clients to report ready, then broadcasts `Unpause` command. + +### Seek Request + +```http +POST /SyncPlay/Seek +Content-Type: application/json + +{ + "PositionTicks": 300000000 // 30 seconds +} +``` + +### Ready State + +Report when video is ready to play (buffering complete): + +```http +POST /SyncPlay/Ready +Content-Type: application/json + +{ + "When": "2024-01-15T12:00:00.000Z", + "PositionTicks": 300000000, + "IsPlaying": true, + "PlaylistItemId": "playlist-item-uuid" +} +``` + +### Buffering State + +Report when video starts buffering: + +```http +POST /SyncPlay/Buffering +Content-Type: application/json + +{ + "When": "2024-01-15T12:00:00.000Z", + "PositionTicks": 300000000, + "IsPlaying": false, + "PlaylistItemId": "playlist-item-uuid" +} +``` + +### Ping Reporting + +Report your measured ping to the server (helps with latency compensation): + +```http +POST /SyncPlay/Ping +Content-Type: application/json + +{ + "Ping": 45 // milliseconds, integer +} +``` + +--- + +## State Machine + +### Group States + +```mermaid +stateDiagram-v2 + [*] --> Idle: Group Created + + Idle --> Waiting: SetNewQueue / Play Request + + Waiting --> Playing: All Clients Ready + Waiting --> Paused: Pause Request + + Playing --> Waiting: Client Buffering + Playing --> Waiting: Seek Request + Playing --> Paused: Pause Request + + Paused --> Waiting: Unpause Request + Paused --> Waiting: Seek Request + + note right of Waiting + Group waits here until ALL clients + report Ready state + end note +``` + +### State Definitions + +| State | Description | +|-------|-------------| +| **Idle** | No media playing, group is empty or inactive | +| **Waiting** | Waiting for all clients to buffer and report ready | +| **Playing** | All clients are playing in sync | +| **Paused** | Playback paused for all clients | + +### State Reasons + +The `StateReason` field indicates why the group entered its current state: + +| Reason | Meaning | +|--------|---------| +| `NewPlaylist` | A new queue was set | +| `SetCurrentItem` | Current item changed | +| `Unpause` | User requested unpause | +| `Pause` | User requested pause | +| `Seek` | User requested seek | +| `Buffer` | A client started buffering | +| `Ready` | All clients reported ready | + +--- + +## Command Scheduling + +Commands include a `When` timestamp indicating when they should execute. This is critical for synchronization. + +### Command Flow + +```mermaid +sequenceDiagram + participant C1 as Client 1 + participant S as Server + participant C2 as Client 2 + + C1->>S: Unpause Request + Note over S: Calculate When = Now + buffer + S->>C1: SyncPlayCommand (Unpause, When=T) + S->>C2: SyncPlayCommand (Unpause, When=T) + + Note over C1: Wait until local time = T + Note over C2: Wait until local time = T + + C1->>C1: Execute Play at T + C2->>C2: Execute Play at T +``` + +### Scheduling Algorithm + +```typescript +async function scheduleCommand(command: SyncPlayCommand): Promise { + const serverTime = new Date(command.When); + const localTime = new Date(); + + // Convert server time to local time using time sync + const commandTime = timeSync.remoteDateToLocal(serverTime); + + // Calculate delay + let delay = commandTime.getTime() - localTime.getTime(); + + if (delay < 0) { + // Command is in the past - execute immediately + executeCommand(command); + return; + } + + if (delay > 5000) { + // Suspiciously large delay - might indicate time sync issue + console.warn(`Large delay detected: ${delay}ms`); + // Optionally force time sync update + } + + // Schedule for future execution + setTimeout(() => { + executeCommand(command); + }, delay); +} +``` + +### Command Execution Order + +Different commands require different execution sequences: + +#### Pause Command +1. Pause the player +2. Wait for pause to complete +3. Seek to position if provided (and significantly different from current) + +#### Unpause Command +1. Seek to position if significantly different from current +2. Wait for seek to complete (video can play) +3. Start playback + +#### Seek Command +1. Start playback (unpause) +2. Seek to target position +3. Wait for seek to complete +4. Pause the player +5. Send Ready state to server + +```typescript +async function executeCommand( + type: 'pause' | 'unpause' | 'seek' | 'stop', + positionTicks?: number +): Promise { + const timeInSeconds = positionTicks ? ticksToSeconds(positionTicks) : 0; + + switch (type) { + case 'pause': + player.pause(); + await waitForPause(); + if (positionTicks && Math.abs(timeInSeconds - player.currentTime) > 0.5) { + player.seek(timeInSeconds); + } + break; + + case 'unpause': + if (positionTicks && Math.abs(timeInSeconds - player.currentTime) > 0.5) { + player.seek(timeInSeconds); + await player.waitForCanPlay(); + } + player.play(); + break; + + case 'seek': + player.play(); + player.seek(timeInSeconds); + await player.waitForCanPlay(); + player.pause(); + sendReady(true, player.positionTicks); + break; + + case 'stop': + player.pause(); + player.seek(0); + break; + } +} +``` + +### Handling Late Commands (estimateCurrentTicks) + +When a command's `When` timestamp is in the past (command arrived late), you must estimate where playback *should* be now: + +```typescript +/** + * Estimates current position given a past state. + * @param ticks - Position at the time of the command + * @param when - Server time when position was valid + * @param currentTime - Current local time (optional) + */ +function estimateCurrentTicks( + ticks: number, + when: Date, + currentTime: Date = new Date() +): number { + const remoteTime = timeSync.localDateToRemote(currentTime); + const elapsedMs = remoteTime.getTime() - when.getTime(); + return ticks + (elapsedMs * TICKS_PER_MILLISECOND); +} +``` + +**Usage in scheduleUnpause:** + +```typescript +async function scheduleUnpause(playAtTime: Date, positionTicks: number): Promise { + const currentTime = new Date(); + const playAtTimeLocal = timeSync.remoteDateToLocal(playAtTime); + + if (playAtTimeLocal > currentTime) { + // Future command - schedule it + const delay = playAtTimeLocal.getTime() - currentTime.getTime(); + setTimeout(() => { + player.play(); + }, delay); + } else { + // Late command - estimate where playback should be NOW + const serverPositionTicks = estimateCurrentTicks(positionTicks, playAtTime); + player.seek(ticksToSeconds(serverPositionTicks)); + player.play(); + } +} +``` + +### Playback Sync Correction (SpeedToSync / SkipToSync) + +During playback, clients may drift out of sync. The official Jellyfin client implements two correction strategies: + +#### Strategy 1: SpeedToSync + +Adjusts playback rate temporarily to catch up without visible jumps: + +```typescript +// Constants +const MIN_DELAY_SPEED_TO_SYNC = 60; // ms - minimum delay to trigger +const MAX_DELAY_SPEED_TO_SYNC = 3000; // ms - maximum delay (use SkipToSync above this) +const SPEED_TO_SYNC_DURATION = 1000; // ms - how long to speed up + +function syncPlaybackTime(currentPosition: number, currentTime: Date): void { + if (!lastCommand || lastCommand.Command !== 'Unpause') return; + + const currentPositionTicks = currentPosition * TICKS_PER_MILLISECOND; + const serverPositionTicks = estimateCurrentTicks( + lastCommand.PositionTicks, + lastCommand.When, + currentTime + ); + + const diffMs = (serverPositionTicks - currentPositionTicks) / TICKS_PER_MILLISECOND; + const absDiffMs = Math.abs(diffMs); + + if (absDiffMs >= MIN_DELAY_SPEED_TO_SYNC && absDiffMs < MAX_DELAY_SPEED_TO_SYNC) { + // Calculate speed to catch up within SPEED_TO_SYNC_DURATION + const speed = 1 + (diffMs / SPEED_TO_SYNC_DURATION); + + player.setPlaybackRate(speed); + + setTimeout(() => { + player.setPlaybackRate(1.0); + }, SPEED_TO_SYNC_DURATION); + } +} +``` + +#### Strategy 2: SkipToSync + +For larger delays, seek directly to the correct position: + +```typescript +const MIN_DELAY_SKIP_TO_SYNC = 400; // ms + +if (absDiffMs >= MIN_DELAY_SKIP_TO_SYNC) { + player.seek(ticksToSeconds(serverPositionTicks)); +} +``` + +#### Sync Correction Flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Playback Diff Detection │ +├─────────────────────────────────────────────────────────────────────┤ +│ Calculate: diffMs = serverPosition - clientPosition │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ if (|diffMs| < 60ms) │ +│ → In sync, do nothing │ +│ │ +│ else if (60ms <= |diffMs| < 3000ms) │ +│ → SpeedToSync: adjust playback rate │ +│ → speed = 1 + (diffMs / 1000) │ +│ → Reset rate after 1 second │ +│ │ +│ else if (|diffMs| >= 400ms && SpeedToSync not applicable) │ +│ → SkipToSync: seek to correct position │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### When to Run Sync Correction + +- Only when `syncEnabled` is true (after initial unpause settles) +- Only during active playback (`Command === 'Unpause'`) +- Throttle to avoid overloading (e.g., check every 500ms-1s) +- Disable during buffering + +### Duplicate Command Detection + +The server may send the same command multiple times (network retries, state synchronization). Track recent commands to avoid re-execution: + +```typescript +interface LastCommand { + when: string; + positionTicks: number; + command: string; + playlistItemId: string; +} + +function isDuplicate(command: SyncPlayCommand, lastCommand: LastCommand): boolean { + return ( + lastCommand.when === command.When && + lastCommand.positionTicks === command.PositionTicks && + lastCommand.command === command.Command && + lastCommand.playlistItemId === command.PlaylistItemId + ); +} +``` + +--- + +## Player Interface + +Define a clean abstraction between SyncPlay and your video player. This enables SyncPlay to control any player implementation. + +### Interface Definition + +```typescript +interface SyncPlayPlayerInterface { + // State Queries + getCurrentTime(): number; // Current position in seconds + getPositionTicks(): number; // Current position in ticks + isPaused(): boolean; // Is player paused + isReady(): boolean; // Can video play (not buffering) + isPlaying(): boolean; // Is actively playing (not paused, not buffering) + + // Controls + play(): void; + pause(): void; + seek(timeInSeconds: number): void; + seekToTicks(positionTicks: number): void; + + // Playback Rate (for SpeedToSync) + hasPlaybackRate(): boolean; // Does player support playback rate? + getPlaybackRate(): number; // Current playback rate (1.0 = normal) + setPlaybackRate(rate: number): void; // Set playback rate + + // Event Handling + on(event: PlayerEvent, handler: Function): void; + off(event: PlayerEvent, handler: Function): void; + once(event: PlayerEvent): Promise; +} + +type PlayerEvent = + | 'userPlay' // User initiated play (not SyncPlay) + | 'userPause' // User initiated pause + | 'userSeek' // User initiated seek + | 'videoCanPlay' // Buffering complete, ready to play + | 'videoBuffering'// Started buffering + | 'videoSeeked' // Seek operation complete + | 'timeUpdate'; // Periodic position update (for sync correction) +``` + +**Dart/Flutter equivalent:** + +```dart +abstract class SyncPlayPlayerInterface { + // State Queries + double getCurrentTime(); // seconds + int getPositionTicks(); // ticks + bool isPaused(); + bool isReady(); + bool isPlaying(); + + // Controls + void play(); + void pause(); + void seek(double timeInSeconds); + void seekToTicks(int positionTicks); + + // Playback Rate (for SpeedToSync) + bool hasPlaybackRate(); + double getPlaybackRate(); + void setPlaybackRate(double rate); + + // Event Streams + Stream get onUserPlay; + Stream get onUserPause; + Stream get onUserSeek; + Stream get onVideoCanPlay; + Stream get onVideoBuffering; + Stream get onTimeUpdate; +} +``` + +### Event Flow: User Actions + +When a user interacts with the player directly (not through SyncPlay commands), the player should emit events: + +```mermaid +sequenceDiagram + participant User + participant Player + participant SyncPlay + participant Server + + User->>Player: Clicks Pause + Player->>SyncPlay: 'userPause' event + SyncPlay->>Server: POST /SyncPlay/Pause + Server->>SyncPlay: SyncPlayCommand (Pause) + SyncPlay->>Player: pause() +``` + +### Distinguishing User vs SyncPlay Actions + +You must differentiate between: +- **User actions**: Should trigger REST API requests to server +- **SyncPlay commands**: Should control player without triggering API requests + +Common approaches: +1. Set a flag before executing SyncPlay commands, check it in event handlers +2. Use separate code paths for user controls vs SyncPlay controls +3. Temporarily unsubscribe from events during SyncPlay operations + +--- + +## Message Types Reference + +### SyncPlayCommand + +```typescript +interface SyncPlayCommandMessage { + MessageId: string; + MessageType: 'SyncPlayCommand'; + Data: { + GroupId: string; + PlaylistItemId: string; + When: string; // ISO 8601 timestamp for execution + PositionTicks: number; + Command: 'Unpause' | 'Pause' | 'Seek' | 'Stop'; + EmittedAt: string; // When server sent this command + }; +} +``` + +### SyncPlayGroupUpdate + +```typescript +interface SyncPlayGroupUpdateMessage { + MessageId: string; + MessageType: 'SyncPlayGroupUpdate'; + Data: { + GroupId: string; + Type: 'GroupJoined' | 'UserJoined' | 'UserLeft' | + 'PlayQueue' | 'StateUpdate' | 'GroupDoesNotExist'; + Data: GroupJoinedData | string | PlayQueueData | StateUpdateData; + }; +} +``` + +### GroupJoined Data + +```typescript +interface GroupJoinedData { + GroupId: string; + GroupName: string; + State: string; + Participants: string[]; + LastUpdatedAt: string; + PlayingItemId?: string; + PositionTicks?: number; +} +``` + +### PlayQueue Data + +```typescript +interface PlayQueueData { + Reason: 'NewPlaylist' | 'SetCurrentItem' | 'Queue' | 'RemoveFromPlaylist'; + LastUpdate: string; + Playlist: Array<{ + ItemId: string; + PlaylistItemId: string; + }>; + PlayingItemIndex: number; + StartPositionTicks: number; + IsPlaying: boolean; + ShuffleMode: string; + RepeatMode: string; +} +``` + +### StateUpdate Data + +```typescript +interface StateUpdateData { + State: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; + Reason: string; + PositionTicks: number; +} +``` + +### ForceKeepAlive + +```typescript +interface ForceKeepAliveMessage { + MessageType: 'ForceKeepAlive'; + Data: number; // Timeout in seconds +} +``` + +Handle by sending `KeepAlive` messages at half the timeout interval: + +```typescript +function handleForceKeepAlive(timeout: number): void { + const intervalMs = timeout * 1000 * 0.5; + + setInterval(() => { + websocket.send(JSON.stringify({ MessageType: 'KeepAlive' })); + }, intervalMs); +} +``` + +--- + +## Edge Cases & Error Handling + +### Message Deduplication + +WebSocket messages may be received multiple times. Track recent message IDs: + +```typescript +const recentMessageIds: string[] = []; +const MAX_TRACKED_IDS = 10; + +function handleMessage(message: any): void { + const messageId = message.MessageId; + + if (messageId && recentMessageIds.includes(messageId)) { + // Duplicate - ignore + return; + } + + if (messageId) { + recentMessageIds.push(messageId); + if (recentMessageIds.length > MAX_TRACKED_IDS) { + recentMessageIds.shift(); + } + } + + // Process message... +} +``` + +### Network Disconnection + +When WebSocket disconnects: +1. Update connection status to `disconnected` +2. Clear keep-alive interval +3. Attempt reconnection with exponential backoff +4. On reconnect, the server will send current group state + +### Group No Longer Exists + +Handle `GroupDoesNotExist` message: + +```typescript +function handleGroupDoesNotExist(): void { + currentGroup = null; + isEnabled = false; + disconnect(); + showNotification('Group no longer exists'); +} +``` + +### Stale Time Sync + +Before executing critical commands, check if time sync is stale: + +```typescript +async function executeTimeSensitiveCommand(command: Command): Promise { + if (timeSync.isStale()) { + await timeSync.forceUpdateAndWait(); + } + + // Now safe to schedule command + await scheduleCommand(command); +} +``` + +### Player Not Ready + +When receiving commands before player is initialized: + +```typescript +async function handleCommand(command: SyncPlayCommand): Promise { + if (!player) { + console.warn('Command received but player not registered'); + return; + } + + // If command requires ready state, wait for it + if (!player.isReady()) { + await player.once('videoCanPlay'); + } + + await executeCommand(command); +} +``` + +### State Transition: Waiting → Playing + +When group transitions from `Waiting` to `Playing`, ensure your client is actually playing: + +```typescript +function handleStateUpdate(previousState: string, newState: string): void { + if (newState === 'Playing' && previousState === 'Waiting') { + // Double-check player is playing + if (player.isPaused()) { + player.play(); + } + } +} +``` + +### Handling Waiting State with Different Reasons + +```typescript +async function handleWaitingState(reason: string, positionTicks: number): Promise { + switch (reason) { + case 'Ready': + // All clients ready - unpause will follow + await requestUnpause(); + break; + + case 'Buffer': + // Another client is buffering + // Wait for our player to be ready, then report + if (!player.isReady()) { + await player.once('videoCanPlay'); + } + await sendReady(true, positionTicks); + break; + + case 'Unpause': + // Unpause requested, waiting for all clients + await sendReady(true, positionTicks); + break; + + case 'Seek': + // Seek was processed, now waiting + // Ready state should be sent after seek command completes + break; + } +} +``` + +### Large Delay Detection + +If calculated command delay is suspiciously large (>5 seconds), time sync may be off: + +```typescript +async function scheduleWithValidation(command: SyncPlayCommand): Promise { + const delay = calculateDelay(command); + + if (delay > 5000) { + // Force time sync update + await timeSync.forceUpdateAndWait(); + + // Recalculate delay + const newDelay = calculateDelay(command); + + if (newDelay > 5000) { + // Still too large - log warning but proceed + console.warn(`Executing command with large delay: ${newDelay}ms`); + } + } + + // Continue with scheduling... +} +``` + +--- + +## Implementation Checklist + +Use this checklist when implementing SyncPlay in a new client: + +### Core Infrastructure + +- [ ] WebSocket connection manager with auto-reconnect +- [ ] Keep-alive message handling +- [ ] REST API client for all SyncPlay endpoints +- [ ] Message routing by type + +### Time Synchronization + +- [ ] Implement `GET /GetUtcTime` API call +- [ ] Record T1 (local) before request, T4 (local) after response +- [ ] Parse T2 (`RequestReceptionTime`) and T3 (`ResponseTransmissionTime`) from server +- [ ] Offset calculation using NTP-like algorithm +- [ ] Storage of last N measurements (recommend 8) +- [ ] Best measurement selection (minimum delay) +- [ ] Greedy → low-profile polling transition +- [ ] Staleness detection (>30s) +- [ ] Force update capability +- [ ] Local ↔ remote time conversion + +### Group Management + +- [ ] Create group +- [ ] List available groups +- [ ] Join group +- [ ] Leave group +- [ ] Handle GroupJoined message +- [ ] Handle UserJoined/UserLeft messages +- [ ] Handle GroupDoesNotExist message +- [ ] Track current group state + +### Playback Control + +- [ ] Send pause request +- [ ] Send unpause request +- [ ] Send seek request +- [ ] Send stop request +- [ ] Send buffering state +- [ ] Send ready state +- [ ] Send ping measurements +- [ ] Set new queue +- [ ] Queue additional items + +### Command Processing + +- [ ] Parse SyncPlayCommand messages +- [ ] Convert server time to local time +- [ ] Calculate execution delay +- [ ] Schedule commands for future execution +- [ ] Execute immediately if delay < 0 with `estimateCurrentTicks()` +- [ ] Handle large delay warnings +- [ ] Duplicate command detection +- [ ] Command-specific execution sequences (pause, unpause, seek, stop) + +### Sync Correction (Optional but Recommended) + +- [ ] Implement `estimateCurrentTicks()` for late command handling +- [ ] Track playback diff during playback +- [ ] Implement SpeedToSync (playback rate adjustment) +- [ ] Implement SkipToSync (seek to correct position) +- [ ] Throttle sync checks (every 500ms-1s) +- [ ] Disable sync during buffering + +### Player Integration + +- [ ] Define player interface abstraction +- [ ] Register player with SyncPlay controller +- [ ] Subscribe to player events +- [ ] Distinguish user actions from SyncPlay commands +- [ ] Handle videoCanPlay event → send ready +- [ ] Handle videoBuffering event → send buffering +- [ ] Implement once() for async event waiting +- [ ] Support playback rate control (for SpeedToSync) +- [ ] Implement timeUpdate event (for sync correction) + +### State Management + +- [ ] Track group state (Idle, Waiting, Paused, Playing) +- [ ] Track state reason +- [ ] Track current playlist +- [ ] Track current playing item ID +- [ ] Track position in ticks +- [ ] Handle StateUpdate messages +- [ ] Handle PlayQueue messages + +### Error Handling + +- [ ] Message deduplication +- [ ] WebSocket disconnection recovery +- [ ] Stale time sync detection +- [ ] Player not ready handling +- [ ] API error handling with user feedback + +--- + +## Appendix: Sequence Diagrams + +### Full Unpause Flow + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Initiator) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Paused + + C1->>S: POST /SyncPlay/Unpause + S->>S: State → Waiting (Unpause) + S->>C1: SyncPlayGroupUpdate (StateUpdate: Waiting) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Waiting) + + C1->>S: POST /SyncPlay/Ready (IsPlaying: true) + C2->>S: POST /SyncPlay/Ready (IsPlaying: true) + + S->>S: All clients ready + S->>S: Calculate When = Now + latency buffer + + S->>C1: SyncPlayCommand (Unpause, When=T, Position=P) + S->>C2: SyncPlayCommand (Unpause, When=T, Position=P) + + Note over C1: Wait until local time = T + Note over C2: Wait until local time = T + + C1->>C1: Seek to P if needed, then Play + C2->>C2: Seek to P if needed, then Play + + S->>S: State → Playing + S->>C1: SyncPlayGroupUpdate (StateUpdate: Playing) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Playing) +``` + +### Client Buffering During Playback + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Buffering) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Playing + + C1->>C1: Network slow, starts buffering + C1->>S: POST /SyncPlay/Buffering (IsPlaying: false) + + S->>S: State → Waiting (Buffer) + S->>C1: SyncPlayGroupUpdate (StateUpdate: Waiting, Buffer) + S->>C2: SyncPlayGroupUpdate (StateUpdate: Waiting, Buffer) + + C2->>C2: Pause playback locally + C2->>S: POST /SyncPlay/Ready (IsPlaying: true) + + Note over C1: Buffering completes + C1->>S: POST /SyncPlay/Ready (IsPlaying: true) + + S->>S: All clients ready, resume + S->>C1: SyncPlayCommand (Unpause, When=T) + S->>C2: SyncPlayCommand (Unpause, When=T) + + S->>S: State → Playing +``` + +### Seek Operation + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Seeker) + participant S as Server + participant C2 as Client 2 + + Note over C1,C2: Group State: Playing + + C1->>S: POST /SyncPlay/Seek (Position: 5min) + + S->>C1: SyncPlayCommand (Seek, When=T, Position=5min) + S->>C2: SyncPlayCommand (Seek, When=T, Position=5min) + + C1->>C1: Unpause → Seek → Wait → Pause + C2->>C2: Unpause → Seek → Wait → Pause + + C1->>S: POST /SyncPlay/Ready + C2->>S: POST /SyncPlay/Ready + + S->>S: All ready after seek + S->>C1: SyncPlayCommand (Unpause) + S->>C2: SyncPlayCommand (Unpause) +``` + +--- + +## Appendix: Validation Against Official Jellyfin Web Client + +This documentation was validated against the [official Jellyfin web client](https://github.com/jellyfin/jellyfin-web/tree/master/src/plugins/syncPlay) (as of January 2025). + +### ✅ Correctly Documented + +| Component | Source File | Status | +|-----------|-------------|--------| +| Time Sync Algorithm | `TimeSync.js` | ✅ Same constants and algorithm | +| Server Time API | `TimeSyncServer.js` | ✅ Uses `getServerTime()` → same endpoint | +| Offset Calculation | `TimeSync.js` | ✅ `((T2-T1) + (T3-T4)) / 2` | +| Measurement Selection | `TimeSync.js` | ✅ Picks minimum delay | +| Polling Strategy | `TimeSync.js` | ✅ 1s greedy (3x), then 60s | +| Command Scheduling | `PlaybackCore.js` | ✅ setTimeout with time conversion | +| Pause Sequence | `PlaybackCore.js` | ✅ Pause → wait → seek | +| Unpause Sequence | `PlaybackCore.js` | ✅ Seek → wait → play (or estimate if late) | +| Seek Sequence | `PlaybackCore.js` | ✅ Play → seek → wait ready → pause → send ready | +| Duplicate Detection | `PlaybackCore.js` | ✅ Same 4-field comparison | +| Buffering/Ready | `PlaybackCore.js` | ✅ Same payload structure | +| Queue Management | `QueueCore.js` | ✅ Same reason handling | +| Player Interface | `GenericPlayer.js` | ✅ Same abstraction pattern | +| WebSocket Keep-Alive | External | ✅ Half-timeout interval | + +### ⚠️ Advanced Features (Optional) + +These features are in the official client but may be omitted for simpler implementations: + +| Feature | Source File | Notes | +|---------|-------------|-------| +| SpeedToSync | `PlaybackCore.js` | Adjusts playback rate to catch up | +| SkipToSync | `PlaybackCore.js` | Seeks to correct position for large drifts | +| estimateCurrentTicks | `PlaybackCore.js` | Estimates position for late commands | +| extraTimeOffset | `TimeSyncCore.js` | Manual time offset adjustment setting | +| Repeat/Shuffle Mode | `QueueCore.js` | Queue mode synchronization | +| Player Factory | `PlayerFactory.js` | Multiple player type support | + +### Key Differences from Official Client + +1. **Sync Correction**: Official client continuously monitors playback position and corrects drift using SpeedToSync (60-3000ms drift) or SkipToSync (>400ms drift). + +2. **Late Command Handling**: Official client uses `estimateCurrentTicks()` to calculate where playback *should* be when a command arrives after its scheduled time. + +3. **Event Waiting**: Official uses `waitForEventOnce()` with timeout and reject events for robust async handling. + +4. **Settings**: Official has configurable thresholds (`minDelaySpeedToSync`, `maxDelaySpeedToSync`, `speedToSyncDuration`, etc.). + +--- + +## References + +- [Jellyfin SyncPlay API Documentation](https://api.jellyfin.org/#tag/SyncPlay) +- [Jellyfin Web Client SyncPlay Implementation](https://github.com/jellyfin/jellyfin-web/tree/master/src/plugins/syncPlay) +- [Jellyfin Web Client Source - PlaybackCore.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/PlaybackCore.js) +- [Jellyfin Web Client Source - TimeSync.js](https://raw.githubusercontent.com/jellyfin/jellyfin-web/master/src/plugins/syncPlay/core/timeSync/TimeSync.js) +- [NTP Clock Synchronization Algorithm](https://en.wikipedia.org/wiki/Network_Time_Protocol#Clock_synchronization_algorithm) diff --git a/lib/bootstrap/platform/base_app_wrapper.dart b/lib/bootstrap/platform/base_app_wrapper.dart index 29242469e..10577b281 100644 --- a/lib/bootstrap/platform/base_app_wrapper.dart +++ b/lib/bootstrap/platform/base_app_wrapper.dart @@ -17,6 +17,7 @@ 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/routes/auto_router.dart'; +import 'package:fladder/providers/router_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/login/lock_screen.dart'; import 'package:fladder/services/notification_service.dart'; @@ -46,6 +47,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(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2b3b7344b..def6fac97 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1005,6 +1005,106 @@ "@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": {}, + "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" + } + } + }, "syncDeleteItemDesc": "Delete all synced data for {item}?", "@syncDeleteItemDesc": { "description": "Sync delete item pop-up window", @@ -1414,6 +1514,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 815f11d97..10d941419 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -567,6 +567,107 @@ "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": {}, + "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" + } + } + }, "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/home_preferences_model.freezed.dart b/lib/models/home_preferences_model.freezed.dart index 2a0fccaae..587e66c11 100644 --- a/lib/models/home_preferences_model.freezed.dart +++ b/lib/models/home_preferences_model.freezed.dart @@ -26,7 +26,8 @@ mixin _$HomePreferencesModel { @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $HomePreferencesModelCopyWith get copyWith => - _$HomePreferencesModelCopyWithImpl(this as HomePreferencesModel, _$identity); + _$HomePreferencesModelCopyWithImpl( + this as HomePreferencesModel, _$identity); @override String toString() { @@ -36,7 +37,8 @@ mixin _$HomePreferencesModel { /// @nodoc abstract mixin class $HomePreferencesModelCopyWith<$Res> { - factory $HomePreferencesModelCopyWith(HomePreferencesModel value, $Res Function(HomePreferencesModel) _then) = + factory $HomePreferencesModelCopyWith(HomePreferencesModel value, + $Res Function(HomePreferencesModel) _then) = _$HomePreferencesModelCopyWithImpl; @useResult $Res call( @@ -49,7 +51,8 @@ abstract mixin class $HomePreferencesModelCopyWith<$Res> { } /// @nodoc -class _$HomePreferencesModelCopyWithImpl<$Res> implements $HomePreferencesModelCopyWith<$Res> { +class _$HomePreferencesModelCopyWithImpl<$Res> + implements $HomePreferencesModelCopyWith<$Res> { _$HomePreferencesModelCopyWithImpl(this._self, this._then); final HomePreferencesModel _self; @@ -189,16 +192,26 @@ extension HomePreferencesModelPatterns on HomePreferencesModel { @optionalTypeArgs TResult maybeWhen( - TResult Function(List orderedLibraryIds, List latestItemsExcludes, bool hidePlayedInLatest, - List groupedFolders, List availableFolders, bool loading)? + TResult Function( + List orderedLibraryIds, + List latestItemsExcludes, + bool hidePlayedInLatest, + List groupedFolders, + List availableFolders, + bool loading)? $default, { required TResult orElse(), }) { final _that = this; switch (_that) { case _HomePreferencesModel() when $default != null: - return $default(_that.orderedLibraryIds, _that.latestItemsExcludes, _that.hidePlayedInLatest, - _that.groupedFolders, _that.availableFolders, _that.loading); + return $default( + _that.orderedLibraryIds, + _that.latestItemsExcludes, + _that.hidePlayedInLatest, + _that.groupedFolders, + _that.availableFolders, + _that.loading); case _: return orElse(); } @@ -219,15 +232,25 @@ extension HomePreferencesModelPatterns on HomePreferencesModel { @optionalTypeArgs TResult when( - TResult Function(List orderedLibraryIds, List latestItemsExcludes, bool hidePlayedInLatest, - List groupedFolders, List availableFolders, bool loading) + TResult Function( + List orderedLibraryIds, + List latestItemsExcludes, + bool hidePlayedInLatest, + List groupedFolders, + List availableFolders, + bool loading) $default, ) { final _that = this; switch (_that) { case _HomePreferencesModel(): - return $default(_that.orderedLibraryIds, _that.latestItemsExcludes, _that.hidePlayedInLatest, - _that.groupedFolders, _that.availableFolders, _that.loading); + return $default( + _that.orderedLibraryIds, + _that.latestItemsExcludes, + _that.hidePlayedInLatest, + _that.groupedFolders, + _that.availableFolders, + _that.loading); case _: throw StateError('Unexpected subclass'); } @@ -247,15 +270,25 @@ extension HomePreferencesModelPatterns on HomePreferencesModel { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(List orderedLibraryIds, List latestItemsExcludes, bool hidePlayedInLatest, - List groupedFolders, List availableFolders, bool loading)? + TResult? Function( + List orderedLibraryIds, + List latestItemsExcludes, + bool hidePlayedInLatest, + List groupedFolders, + List availableFolders, + bool loading)? $default, ) { final _that = this; switch (_that) { case _HomePreferencesModel() when $default != null: - return $default(_that.orderedLibraryIds, _that.latestItemsExcludes, _that.hidePlayedInLatest, - _that.groupedFolders, _that.availableFolders, _that.loading); + return $default( + _that.orderedLibraryIds, + _that.latestItemsExcludes, + _that.hidePlayedInLatest, + _that.groupedFolders, + _that.availableFolders, + _that.loading); case _: return null; } @@ -282,7 +315,8 @@ class _HomePreferencesModel extends HomePreferencesModel { @override @JsonKey() List get orderedLibraryIds { - if (_orderedLibraryIds is EqualUnmodifiableListView) return _orderedLibraryIds; + if (_orderedLibraryIds is EqualUnmodifiableListView) + return _orderedLibraryIds; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_orderedLibraryIds); } @@ -291,7 +325,8 @@ class _HomePreferencesModel extends HomePreferencesModel { @override @JsonKey() List get latestItemsExcludes { - if (_latestItemsExcludes is EqualUnmodifiableListView) return _latestItemsExcludes; + if (_latestItemsExcludes is EqualUnmodifiableListView) + return _latestItemsExcludes; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_latestItemsExcludes); } @@ -312,7 +347,8 @@ class _HomePreferencesModel extends HomePreferencesModel { @override @JsonKey() List get availableFolders { - if (_availableFolders is EqualUnmodifiableListView) return _availableFolders; + if (_availableFolders is EqualUnmodifiableListView) + return _availableFolders; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_availableFolders); } @@ -327,7 +363,8 @@ class _HomePreferencesModel extends HomePreferencesModel { @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') _$HomePreferencesModelCopyWith<_HomePreferencesModel> get copyWith => - __$HomePreferencesModelCopyWithImpl<_HomePreferencesModel>(this, _$identity); + __$HomePreferencesModelCopyWithImpl<_HomePreferencesModel>( + this, _$identity); @override String toString() { @@ -336,8 +373,10 @@ class _HomePreferencesModel extends HomePreferencesModel { } /// @nodoc -abstract mixin class _$HomePreferencesModelCopyWith<$Res> implements $HomePreferencesModelCopyWith<$Res> { - factory _$HomePreferencesModelCopyWith(_HomePreferencesModel value, $Res Function(_HomePreferencesModel) _then) = +abstract mixin class _$HomePreferencesModelCopyWith<$Res> + implements $HomePreferencesModelCopyWith<$Res> { + factory _$HomePreferencesModelCopyWith(_HomePreferencesModel value, + $Res Function(_HomePreferencesModel) _then) = __$HomePreferencesModelCopyWithImpl; @override @useResult @@ -351,7 +390,8 @@ abstract mixin class _$HomePreferencesModelCopyWith<$Res> implements $HomePrefer } /// @nodoc -class __$HomePreferencesModelCopyWithImpl<$Res> implements _$HomePreferencesModelCopyWith<$Res> { +class __$HomePreferencesModelCopyWithImpl<$Res> + implements _$HomePreferencesModelCopyWith<$Res> { __$HomePreferencesModelCopyWithImpl(this._self, this._then); final _HomePreferencesModel _self; diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 738a64dfa..8fe1a8ea0 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -1,12 +1,8 @@ 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/channel_model.dart'; @@ -25,6 +21,7 @@ import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/playback/tv_playback_model.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'; @@ -32,6 +29,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'; @@ -40,6 +38,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; @@ -79,12 +79,16 @@ 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(); final MediaStreamsModel? mediaStreams; + List? get subStreams => throw UnimplementedError(); + List? get audioStreams => throw UnimplementedError(); Future? startDuration() async => item.userData.playBackPosition; @@ -92,7 +96,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(); ItemBaseModel? get nextVideo { @@ -133,6 +139,20 @@ class PlaybackModelHelper { JellyService get api => ref.read(jellyApiProvider); + Future _ensureLocalTrackSwitchAutoplay() async { + for (var attempt = 0; attempt < 8; attempt++) { + final playbackState = ref.read(mediaPlaybackProvider); + if (!playbackState.buffering && !playbackState.playing) { + await ref.read(videoPlayerProvider).play(); + return; + } + if (playbackState.playing) { + return; + } + await Future.delayed(const Duration(milliseconds: 250)); + } + } + Future loadNewVideo(ItemBaseModel newItem) async { ref.read(videoPlayerProvider).pause(); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(buffering: true)); @@ -481,7 +501,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; } @@ -491,7 +514,33 @@ class PlaybackModelHelper { final userId = ref.read(userProvider)?.id; if (userId?.isEmpty == true) return; - final currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); + // Check if syncplay is active and get position from syncplay if so + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + final Duration currentPosition; + + final shouldReportGroupBuffering = (isSyncPlayActive && !isLocalTrackSwitch); + + if (isSyncPlayActive) { + // Set reloading state in the player notifier to prevent premature ready reporting + ref.read(videoPlayerProvider.notifier).setReloading( + true, + reportToSyncPlay: shouldReportGroupBuffering, + ); + + // Get syncplay position FIRST before any state changes + final syncPlayState = ref.read(syncPlayProvider); + final positionTicks = syncPlayState.positionTicks; + // Convert ticks to Duration: 1 tick = 100 nanoseconds, 10000 ticks = 1 millisecond + 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)), @@ -581,9 +630,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/arguments_model.freezed.dart b/lib/models/settings/arguments_model.freezed.dart index 618293fba..23b11053e 100644 --- a/lib/models/settings/arguments_model.freezed.dart +++ b/lib/models/settings/arguments_model.freezed.dart @@ -118,13 +118,16 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult maybeWhen( - TResult Function(bool htpcMode, bool leanBackMode, bool newWindow, bool skipNotifications)? $default, { + TResult Function(bool htpcMode, bool leanBackMode, bool newWindow, + bool skipNotifications)? + $default, { required TResult orElse(), }) { final _that = this; switch (_that) { case _ArgumentsModel() when $default != null: - return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, _that.skipNotifications); + return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, + _that.skipNotifications); case _: return orElse(); } @@ -145,12 +148,15 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult when( - TResult Function(bool htpcMode, bool leanBackMode, bool newWindow, bool skipNotifications) $default, + TResult Function(bool htpcMode, bool leanBackMode, bool newWindow, + bool skipNotifications) + $default, ) { final _that = this; switch (_that) { case _ArgumentsModel(): - return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, _that.skipNotifications); + return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, + _that.skipNotifications); case _: throw StateError('Unexpected subclass'); } @@ -170,12 +176,15 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(bool htpcMode, bool leanBackMode, bool newWindow, bool skipNotifications)? $default, + TResult? Function(bool htpcMode, bool leanBackMode, bool newWindow, + bool skipNotifications)? + $default, ) { final _that = this; switch (_that) { case _ArgumentsModel() when $default != null: - return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, _that.skipNotifications); + return $default(_that.htpcMode, _that.leanBackMode, _that.newWindow, + _that.skipNotifications); case _: return null; } @@ -186,7 +195,10 @@ extension ArgumentsModelPatterns on ArgumentsModel { class _ArgumentsModel extends ArgumentsModel { _ArgumentsModel( - {this.htpcMode = false, this.leanBackMode = false, this.newWindow = false, this.skipNotifications = false}) + {this.htpcMode = false, + this.leanBackMode = false, + this.newWindow = false, + this.skipNotifications = false}) : super._(); @override diff --git a/lib/models/syncplay/syncplay_models.dart b/lib/models/syncplay/syncplay_models.dart new file mode 100644 index 000000000..aeb2eeaa5 --- /dev/null +++ b/lib/models/syncplay/syncplay_models.dart @@ -0,0 +1,240 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'syncplay_models.freezed.dart'; + +/// Time sync measurement for NTP-like clock synchronization +@Freezed(copyWith: true) +abstract class TimeSyncMeasurement with _$TimeSyncMeasurement { + const TimeSyncMeasurement._(); + + factory TimeSyncMeasurement({ + required DateTime requestSent, + required DateTime requestReceived, + required DateTime responseSent, + required DateTime responseReceived, + }) = _TimeSyncMeasurement; + + /// Clock offset between client and server + /// Positive = server is ahead of client + Duration get offset { + final t1 = requestSent.millisecondsSinceEpoch; + final t2 = requestReceived.millisecondsSinceEpoch; + final t3 = responseSent.millisecondsSinceEpoch; + final t4 = responseReceived.millisecondsSinceEpoch; + final offsetMs = ((t2 - t1) + (t3 - t4)) / 2; + return Duration(milliseconds: offsetMs.round()); + } + + /// Round-trip delay + Duration get delay { + final t1 = requestSent.millisecondsSinceEpoch; + final t2 = requestReceived.millisecondsSinceEpoch; + final t3 = responseSent.millisecondsSinceEpoch; + final t4 = responseReceived.millisecondsSinceEpoch; + final delayMs = (t4 - t1) - (t3 - t2); + return Duration(milliseconds: delayMs); + } + + /// One-way ping (half of round-trip) + Duration get ping => Duration(milliseconds: delay.inMilliseconds ~/ 2); +} + +/// SyncPlay group state +enum SyncPlayGroupState { + idle, + waiting, + paused, + playing, +} + +/// Playback correction strategy used to resync local playback with group time. +enum SyncCorrectionStrategy { + none, + speedToSync, + skipToSync, +} + +/// Config values for playback drift correction. +/// +/// Defaults match official Jellyfin SyncPlay thresholds. +class SyncCorrectionConfig { + const SyncCorrectionConfig({ + this.minDelaySpeedToSyncMs = 60, + this.maxDelaySpeedToSyncMs = 3000, + this.speedToSyncDurationMs = 1000, + this.minDelaySkipToSyncMs = 400, + this.useSpeedToSync = true, + this.useSkipToSync = true, + this.enableSyncCorrection = true, + }); + + final double minDelaySpeedToSyncMs; + final double maxDelaySpeedToSyncMs; + final double speedToSyncDurationMs; + final double minDelaySkipToSyncMs; + final bool useSpeedToSync; + final bool useSkipToSync; + final bool enableSyncCorrection; + + SyncCorrectionConfig copyWith({ + double? minDelaySpeedToSyncMs, + double? maxDelaySpeedToSyncMs, + double? speedToSyncDurationMs, + double? minDelaySkipToSyncMs, + bool? useSpeedToSync, + bool? useSkipToSync, + bool? enableSyncCorrection, + }) { + return SyncCorrectionConfig( + minDelaySpeedToSyncMs: minDelaySpeedToSyncMs ?? this.minDelaySpeedToSyncMs, + maxDelaySpeedToSyncMs: maxDelaySpeedToSyncMs ?? this.maxDelaySpeedToSyncMs, + speedToSyncDurationMs: speedToSyncDurationMs ?? this.speedToSyncDurationMs, + minDelaySkipToSyncMs: minDelaySkipToSyncMs ?? this.minDelaySkipToSyncMs, + useSpeedToSync: useSpeedToSync ?? this.useSpeedToSync, + useSkipToSync: useSkipToSync ?? this.useSkipToSync, + enableSyncCorrection: enableSyncCorrection ?? this.enableSyncCorrection, + ); + } +} + +/// Runtime state of playback correction logic. +class SyncCorrectionState { + const SyncCorrectionState({ + this.syncEnabled = true, + this.playerIsBuffering = false, + this.playbackDiffMillis = 0, + this.syncAttempts = 0, + this.lastSyncAt, + this.activeStrategy = SyncCorrectionStrategy.none, + }); + + final bool syncEnabled; + final bool playerIsBuffering; + final double playbackDiffMillis; + final int syncAttempts; + final DateTime? lastSyncAt; + final SyncCorrectionStrategy activeStrategy; + + SyncCorrectionState copyWith({ + bool? syncEnabled, + bool? playerIsBuffering, + double? playbackDiffMillis, + int? syncAttempts, + DateTime? lastSyncAt, + SyncCorrectionStrategy? activeStrategy, + }) { + return SyncCorrectionState( + syncEnabled: syncEnabled ?? this.syncEnabled, + playerIsBuffering: playerIsBuffering ?? this.playerIsBuffering, + playbackDiffMillis: playbackDiffMillis ?? this.playbackDiffMillis, + syncAttempts: syncAttempts ?? this.syncAttempts, + lastSyncAt: lastSyncAt ?? this.lastSyncAt, + activeStrategy: activeStrategy ?? this.activeStrategy, + ); + } +} + +/// Select correction strategy based on current diff and runtime/config state. +/// +/// Precedence intentionally mirrors official behavior: +/// SpeedToSync first, then SkipToSync fallback. +SyncCorrectionStrategy selectSyncCorrectionStrategy({ + required SyncCorrectionConfig config, + required SyncCorrectionState state, + required double diffMillis, + required bool hasPlaybackRate, +}) { + if (!config.enableSyncCorrection || !state.syncEnabled) { + return SyncCorrectionStrategy.none; + } + + if (state.activeStrategy != SyncCorrectionStrategy.none) { + return SyncCorrectionStrategy.none; + } + + final absDiffMillis = diffMillis.abs(); + + final canUseSpeedToSync = (config.useSpeedToSync && + hasPlaybackRate && + absDiffMillis >= config.minDelaySpeedToSyncMs && + absDiffMillis < config.maxDelaySpeedToSyncMs); + if (canUseSpeedToSync) { + return SyncCorrectionStrategy.speedToSync; + } + + final canUseSkipToSync = (config.useSkipToSync && absDiffMillis >= config.minDelaySkipToSyncMs); + if (canUseSkipToSync) { + return SyncCorrectionStrategy.skipToSync; + } + + return SyncCorrectionStrategy.none; +} + +/// Current SyncPlay session state +@Freezed(copyWith: true) +abstract class SyncPlayState with _$SyncPlayState { + const SyncPlayState._(); + + factory SyncPlayState({ + @Default(false) bool isConnected, + @Default(false) bool isInGroup, + String? groupId, + String? groupName, + @Default(SyncPlayGroupState.idle) SyncPlayGroupState groupState, + String? stateReason, + @Default([]) List participants, + String? playingItemId, + String? playlistItemId, + @Default(0) int positionTicks, + DateTime? lastCommandTime, + + /// Whether a SyncPlay command is currently being processed + @Default(false) bool isProcessingCommand, + + /// The type of command being processed (for UI feedback) + String? processingCommandType, + + /// Internal correction configuration and thresholds. + @Default(SyncCorrectionConfig()) SyncCorrectionConfig correctionConfig, + + /// Runtime correction status for UI and command logic. + @Default(SyncCorrectionState()) SyncCorrectionState correctionState, + }) = _SyncPlayState; + + bool get isActive => isConnected && isInGroup; +} + +/// Last executed command for duplicate detection +@Freezed(copyWith: true) +abstract class LastSyncPlayCommand with _$LastSyncPlayCommand { + factory LastSyncPlayCommand({ + required String when, + required int positionTicks, + required String command, + required String playlistItemId, + }) = _LastSyncPlayCommand; +} + +/// WebSocket connection state +enum WebSocketConnectionState { + disconnected, + connecting, + connected, + reconnecting, +} + +/// Ticks conversion constants +const int ticksPerMillisecond = 10000; +const int ticksPerSecond = 10000000; + +/// Convert seconds to ticks +int secondsToTicks(double seconds) => (seconds * ticksPerSecond).round(); + +/// Convert ticks to seconds +double ticksToSeconds(int ticks) => ticks / ticksPerSecond; + +/// Convert milliseconds to ticks +int millisecondsToTicks(int ms) => ms * ticksPerMillisecond; + +/// Convert ticks to milliseconds +int ticksToMilliseconds(int ticks) => ticks ~/ ticksPerMillisecond; diff --git a/lib/models/syncplay/syncplay_models.freezed.dart b/lib/models/syncplay/syncplay_models.freezed.dart new file mode 100644 index 000000000..35a23e80e --- /dev/null +++ b/lib/models/syncplay/syncplay_models.freezed.dart @@ -0,0 +1,1279 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'syncplay_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TimeSyncMeasurement { + DateTime get requestSent; + DateTime get requestReceived; + DateTime get responseSent; + DateTime get responseReceived; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $TimeSyncMeasurementCopyWith get copyWith => + _$TimeSyncMeasurementCopyWithImpl( + this as TimeSyncMeasurement, _$identity); + + @override + String toString() { + return 'TimeSyncMeasurement(requestSent: $requestSent, requestReceived: $requestReceived, responseSent: $responseSent, responseReceived: $responseReceived)'; + } +} + +/// @nodoc +abstract mixin class $TimeSyncMeasurementCopyWith<$Res> { + factory $TimeSyncMeasurementCopyWith( + TimeSyncMeasurement value, $Res Function(TimeSyncMeasurement) _then) = + _$TimeSyncMeasurementCopyWithImpl; + @useResult + $Res call( + {DateTime requestSent, + DateTime requestReceived, + DateTime responseSent, + DateTime responseReceived}); +} + +/// @nodoc +class _$TimeSyncMeasurementCopyWithImpl<$Res> + implements $TimeSyncMeasurementCopyWith<$Res> { + _$TimeSyncMeasurementCopyWithImpl(this._self, this._then); + + final TimeSyncMeasurement _self; + final $Res Function(TimeSyncMeasurement) _then; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? requestSent = null, + Object? requestReceived = null, + Object? responseSent = null, + Object? responseReceived = null, + }) { + return _then(_self.copyWith( + requestSent: null == requestSent + ? _self.requestSent + : requestSent // ignore: cast_nullable_to_non_nullable + as DateTime, + requestReceived: null == requestReceived + ? _self.requestReceived + : requestReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + responseSent: null == responseSent + ? _self.responseSent + : responseSent // ignore: cast_nullable_to_non_nullable + as DateTime, + responseReceived: null == responseReceived + ? _self.responseReceived + : responseReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// Adds pattern-matching-related methods to [TimeSyncMeasurement]. +extension TimeSyncMeasurementPatterns on TimeSyncMeasurement { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_TimeSyncMeasurement value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_TimeSyncMeasurement value) $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_TimeSyncMeasurement value)? $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(DateTime requestSent, DateTime requestReceived, + DateTime responseSent, DateTime responseReceived)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that.requestSent, _that.requestReceived, + _that.responseSent, _that.responseReceived); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(DateTime requestSent, DateTime requestReceived, + DateTime responseSent, DateTime responseReceived) + $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement(): + return $default(_that.requestSent, _that.requestReceived, + _that.responseSent, _that.responseReceived); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(DateTime requestSent, DateTime requestReceived, + DateTime responseSent, DateTime responseReceived)? + $default, + ) { + final _that = this; + switch (_that) { + case _TimeSyncMeasurement() when $default != null: + return $default(_that.requestSent, _that.requestReceived, + _that.responseSent, _that.responseReceived); + case _: + return null; + } + } +} + +/// @nodoc + +class _TimeSyncMeasurement extends TimeSyncMeasurement { + _TimeSyncMeasurement( + {required this.requestSent, + required this.requestReceived, + required this.responseSent, + required this.responseReceived}) + : super._(); + + @override + final DateTime requestSent; + @override + final DateTime requestReceived; + @override + final DateTime responseSent; + @override + final DateTime responseReceived; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$TimeSyncMeasurementCopyWith<_TimeSyncMeasurement> get copyWith => + __$TimeSyncMeasurementCopyWithImpl<_TimeSyncMeasurement>( + this, _$identity); + + @override + String toString() { + return 'TimeSyncMeasurement(requestSent: $requestSent, requestReceived: $requestReceived, responseSent: $responseSent, responseReceived: $responseReceived)'; + } +} + +/// @nodoc +abstract mixin class _$TimeSyncMeasurementCopyWith<$Res> + implements $TimeSyncMeasurementCopyWith<$Res> { + factory _$TimeSyncMeasurementCopyWith(_TimeSyncMeasurement value, + $Res Function(_TimeSyncMeasurement) _then) = + __$TimeSyncMeasurementCopyWithImpl; + @override + @useResult + $Res call( + {DateTime requestSent, + DateTime requestReceived, + DateTime responseSent, + DateTime responseReceived}); +} + +/// @nodoc +class __$TimeSyncMeasurementCopyWithImpl<$Res> + implements _$TimeSyncMeasurementCopyWith<$Res> { + __$TimeSyncMeasurementCopyWithImpl(this._self, this._then); + + final _TimeSyncMeasurement _self; + final $Res Function(_TimeSyncMeasurement) _then; + + /// Create a copy of TimeSyncMeasurement + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? requestSent = null, + Object? requestReceived = null, + Object? responseSent = null, + Object? responseReceived = null, + }) { + return _then(_TimeSyncMeasurement( + requestSent: null == requestSent + ? _self.requestSent + : requestSent // ignore: cast_nullable_to_non_nullable + as DateTime, + requestReceived: null == requestReceived + ? _self.requestReceived + : requestReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + responseSent: null == responseSent + ? _self.responseSent + : responseSent // ignore: cast_nullable_to_non_nullable + as DateTime, + responseReceived: null == responseReceived + ? _self.responseReceived + : responseReceived // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +mixin _$SyncPlayState { + bool get isConnected; + bool get isInGroup; + String? get groupId; + String? get groupName; + SyncPlayGroupState get groupState; + String? get stateReason; + List get participants; + String? get playingItemId; + String? get playlistItemId; + int get positionTicks; + DateTime? get lastCommandTime; + + /// Whether a SyncPlay command is currently being processed + bool get isProcessingCommand; + + /// The type of command being processed (for UI feedback) + String? get processingCommandType; + + /// Internal correction configuration and thresholds. + SyncCorrectionConfig get correctionConfig; + + /// Runtime correction status for UI and command logic. + SyncCorrectionState get correctionState; + + /// 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)'; + } +} + +/// @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, + String? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState}); +} + +/// @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, + }) { + 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 String?, + 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, + )); + } +} + +/// 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, + String? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState)? + $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); + 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, + String? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState) + $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); + 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, + String? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState)? + $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); + 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()}) + : _participants = participants, + super._(); + + @override + @JsonKey() + final bool isConnected; + @override + @JsonKey() + final bool isInGroup; + @override + final String? groupId; + @override + final String? groupName; + @override + @JsonKey() + final SyncPlayGroupState groupState; + @override + final String? stateReason; + final List _participants; + @override + @JsonKey() + List get participants { + if (_participants is EqualUnmodifiableListView) return _participants; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_participants); + } + + @override + final String? playingItemId; + @override + final String? playlistItemId; + @override + @JsonKey() + final int positionTicks; + @override + final DateTime? lastCommandTime; + + /// Whether a SyncPlay command is currently being processed + @override + @JsonKey() + final bool isProcessingCommand; + + /// The type of command being processed (for UI feedback) + @override + final String? processingCommandType; + + /// Internal correction configuration and thresholds. + @override + @JsonKey() + final SyncCorrectionConfig correctionConfig; + + /// Runtime correction status for UI and command logic. + @override + @JsonKey() + final SyncCorrectionState correctionState; + + /// 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)'; + } +} + +/// @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, + String? processingCommandType, + SyncCorrectionConfig correctionConfig, + SyncCorrectionState correctionState}); +} + +/// @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, + }) { + 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 String?, + 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, + )); + } +} + +/// @nodoc +mixin _$LastSyncPlayCommand { + String get when; + int get positionTicks; + String 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, String 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 String, + 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, String 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, String 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, String 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 String 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, String 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 String, + playlistItemId: null == playlistItemId + ? _self.playlistItemId + : playlistItemId // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +// dart format on diff --git a/lib/providers/lock_screen_provider.dart b/lib/providers/lock_screen_provider.dart index e69de29bb..8b1378917 100644 --- a/lib/providers/lock_screen_provider.dart +++ b/lib/providers/lock_screen_provider.dart @@ -0,0 +1 @@ + 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/seerr_api_provider.g.dart b/lib/providers/seerr_api_provider.g.dart index 4f11f3a66..f84b6adbb 100644 --- a/lib/providers/seerr_api_provider.g.dart +++ b/lib/providers/seerr_api_provider.g.dart @@ -6,7 +6,7 @@ part of 'seerr_api_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$seerrApiHash() => r'a0579ab868cc9021e4c3e8830d6c1a6a614a055c'; +String _$seerrApiHash() => r'57b39e9af4926a0b255b94ff257c738ffbd91d32'; /// See also [SeerrApi]. @ProviderFor(SeerrApi) diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 4174f4f41..1dd34b5ff 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -1,13 +1,8 @@ import 'dart:convert'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:chopper/chopper.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; - import 'package:fladder/fake/fake_jellyfin_open_api.dart'; import 'package:fladder/jellyfin/enum_models.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart' as enums; @@ -26,6 +21,9 @@ import 'package:fladder/providers/image_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/jellyfin_extension.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; const _userSettings = "usersettings"; const _client = "fladder"; @@ -1238,7 +1236,9 @@ class JellyService { (e) { final parsed = Uri.tryParse(e); if (parsed == null) return ''; - if (parsed.hasScheme && parsed.host.isNotEmpty) return parsed.toString(); + if (parsed.hasScheme && parsed.host.isNotEmpty) { + return parsed.toString(); + } return buildServerUrl( ref, pathSegments: [ diff --git a/lib/providers/sync/background_download_provider.g.dart b/lib/providers/sync/background_download_provider.g.dart index 27ffbc405..2f569229c 100644 --- a/lib/providers/sync/background_download_provider.g.dart +++ b/lib/providers/sync/background_download_provider.g.dart @@ -7,7 +7,7 @@ part of 'background_download_provider.dart'; // ************************************************************************** String _$backgroundDownloaderHash() => - r'4dcf61b6439ce1251d42abc80b99e53fe97d7465'; + r'9b4032e6ee780c64ea44d4ab1f451e5278b6d8f6'; /// See also [BackgroundDownloader]. @ProviderFor(BackgroundDownloader) 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..90f809bf2 --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_command_handler.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; + +/// Callback types for player control commands from SyncPlay +typedef SyncPlayPlayerCallback = Future Function(); +typedef SyncPlaySeekCallback = Future Function(int positionTicks); +typedef SyncPlayPositionCallback = int Function(); +typedef SyncPlayReportReadyCallback = Future Function(); +typedef SyncPlaySetSpeedCallback = Future Function(double speed); + +/// Handles scheduling and execution of SyncPlay commands +class SyncPlayCommandHandler { + SyncPlayCommandHandler({ + required this.timeSync, + required this.onStateUpdate, + }); + + final TimeSyncService? Function() timeSync; + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + + // Last command for duplicate detection + LastSyncPlayCommand? _lastCommand; + + // Pending command timer + Timer? _commandTimer; + + // Player callbacks + SyncPlayPlayerCallback? onPlay; + SyncPlayPlayerCallback? onPause; + SyncPlaySeekCallback? onSeek; + SyncPlayPlayerCallback? onStop; + SyncPlayPositionCallback? getPositionTicks; + bool Function()? isPlaying; + bool Function()? isBuffering; + + // New callback to signal that a seek has been requested by someone else + SyncPlaySeekCallback? onSeekRequested; + + // Report ready callback (to tell server we're ready after seek) + SyncPlayReportReadyCallback? onReportReady; + + // Playback rate callbacks for SpeedToSync + SyncPlaySetSpeedCallback? onSetSpeed; + bool Function()? hasPlaybackRate; + + /// Last accepted command (non-duplicate), exposed for correction logic. + LastSyncPlayCommand? get lastCommand => _lastCommand; + + /// Handle incoming SyncPlay command from WebSocket + void handleCommand(Map data, SyncPlayState currentState) { + final command = data['Command'] as String?; + final whenStr = data['When'] as String?; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final playlistItemId = data['PlaylistItemId'] as String? ?? ''; + + if (command == null || whenStr == null) { + return; + } + + // Check for duplicate command + if (_isDuplicateCommand(whenStr, positionTicks, command, playlistItemId)) { + log('SyncPlay: Ignoring duplicate command: $command'); + return; + } + + _lastCommand = LastSyncPlayCommand( + when: whenStr, + positionTicks: positionTicks, + command: command, + playlistItemId: playlistItemId, + ); + + onStateUpdate((state) => state.copyWith( + positionTicks: positionTicks, + playlistItemId: playlistItemId, + )); + + // If it's a Seek command, notify the player immediately so it can report buffering + if (command == 'Seek') { + onSeekRequested?.call(positionTicks); + } + + final when = DateTime.parse(whenStr); + _scheduleCommand(command, when, positionTicks); + } + + bool _isDuplicateCommand(String when, int positionTicks, String command, String playlistItemId) { + if (_lastCommand == null) { + return false; + } + + // For Unpause commands, if we are not currently playing, we should NEVER treat it as a duplicate + // to ensure the player actually resumes. + if (command == 'Unpause' && isPlaying?.call() == false) { + return false; + } + + return _lastCommand!.when == when && + _lastCommand!.positionTicks == positionTicks && + _lastCommand!.command == command && + _lastCommand!.playlistItemId == playlistItemId; + } + + /// Guard rules before any playback correction attempt. + /// + /// Rules: + /// - only after `Unpause` command context + /// - skip while player is buffering/reloading + /// - skip when command playlist item does not match current item + bool canAttemptSyncCorrection(SyncPlayState currentState) { + final command = _lastCommand; + if (command == null) { + return false; + } + if (command.command != 'Unpause') { + return false; + } + if (isBuffering?.call() == true) { + return false; + } + + final commandItemId = command.playlistItemId; + final currentItemId = currentState.playlistItemId; + if (commandItemId.isNotEmpty && currentItemId != null && commandItemId != currentItemId) { + return false; + } + + return true; + } + + void _scheduleCommand(String command, DateTime serverTime, int positionTicks) { + final timeSyncService = timeSync(); + if (timeSyncService == null) { + log('SyncPlay: Cannot schedule command without time sync'); + _executeCommand(command, positionTicks); + return; + } + + final localTime = timeSyncService.remoteDateToLocal(serverTime); + final now = DateTime.now().toUtc(); + final delay = localTime.difference(now); + + _commandTimer?.cancel(); + + // Show processing indicator + onStateUpdate((state) => state.copyWith( + isProcessingCommand: true, + processingCommandType: command, + )); + + if (delay.isNegative) { + // Command is in the past - execute immediately + // Estimate where playback should be now + final estimatedTicks = _estimateCurrentTicks(positionTicks, serverTime); + log('SyncPlay: Executing late command: $command (${delay.inMilliseconds}ms late)'); + _executeCommand(command, estimatedTicks); + } else if (delay.inMilliseconds > 5000) { + // Suspiciously large delay - might indicate time sync issue + log('SyncPlay: Warning - large delay: ${delay.inMilliseconds}ms'); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } else { + log('SyncPlay: Scheduling command: $command in ${delay.inMilliseconds}ms'); + _commandTimer = Timer(delay, () => _executeCommand(command, positionTicks)); + } + } + + int _estimateCurrentTicks(int ticks, DateTime when) { + final timeSyncService = timeSync(); + if (timeSyncService == null) { + return ticks; + } + final remoteNow = timeSyncService.localDateToRemote(DateTime.now().toUtc()); + final elapsedMs = remoteNow.difference(when).inMilliseconds; + return ticks + millisecondsToTicks(elapsedMs); + } + + Future _executeCommand(String command, int positionTicks) async { + log('SyncPlay: Executing command: $command at $positionTicks ticks'); + + try { + switch (command) { + case 'Pause': + await onPause?.call(); + // Only seek if position is significantly different (>1 second) + final currentTicks = getPositionTicks?.call() ?? 0; + if ((positionTicks - currentTicks).abs() > ticksPerSecond) { + await onSeek?.call(positionTicks); + } + break; + + case 'Unpause': + // Only seek if position is significantly different (>1 second) + // Seek first, then play for smoother unpause alignment. + final currentTicks = getPositionTicks?.call() ?? 0; + if ((positionTicks - currentTicks).abs() > ticksPerSecond) { + await onSeek?.call(positionTicks); + } + await onPlay?.call(); + break; + + case 'Seek': + // Pause first to stop any ongoing playback + await onPause?.call(); + // Seek to the target position + await onSeek?.call(positionTicks); + // Report ready after seek so server knows to send unpause + // If we're buffering, the buffering state handler will report ready when done + // If we're not buffering, report ready immediately + if (isBuffering?.call() != true) { + await onReportReady?.call(); + } + break; + + case 'Stop': + await onPause?.call(); + await onSeek?.call(0); + break; + } + } finally { + // Clear processing state after command completes + onStateUpdate((state) => state.copyWith( + isProcessingCommand: false, + processingCommandType: null, + )); + } + } + + /// Cancel any pending commands + void cancelPendingCommands() { + _commandTimer?.cancel(); + } + + /// Clear last command context used for duplicate detection and correction. + void clearLastCommand() { + _lastCommand = null; + } + + /// Dispose resources + void dispose() { + _commandTimer?.cancel(); + } +} diff --git a/lib/providers/syncplay/handlers/syncplay_message_handler.dart b/lib/providers/syncplay/handlers/syncplay_message_handler.dart new file mode 100644 index 000000000..ec4cf3f85 --- /dev/null +++ b/lib/providers/syncplay/handlers/syncplay_message_handler.dart @@ -0,0 +1,261 @@ +import 'dart:developer'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:flutter/material.dart'; + +/// Callback for reporting ready state after seek +typedef ReportReadyCallback = Future Function({bool isPlaying}); + +/// Callback for starting playback of an item +typedef StartPlaybackCallback = Future Function(String itemId, int startPositionTicks); + +/// Handles SyncPlay group update messages from WebSocket +class SyncPlayMessageHandler { + SyncPlayMessageHandler({ + required this.onStateUpdate, + required this.reportReady, + required this.startPlayback, + required this.isBuffering, + required this.getContext, + required this.onGroupJoined, + required this.onGroupJoinFailed, + this.onGroupLeftOrKicked, + this.onStateUpdateToPlaying, + }); + + final void Function(SyncPlayState Function(SyncPlayState)) onStateUpdate; + final ReportReadyCallback reportReady; + final StartPlaybackCallback startPlayback; + final bool Function() isBuffering; + final BuildContext? Function() getContext; + final void Function() onGroupJoined; + final void Function() onGroupJoinFailed; + + /// Called when we leave or are kicked so controller can cancel pending commands and clear processing state. + final void Function()? onGroupLeftOrKicked; + + /// Called when group state becomes Playing so controller can ensure player is actually playing (per docs). + final void Function()? onStateUpdateToPlaying; + + /// Handle group update message + void handleGroupUpdate(Map data, SyncPlayState currentState) { + final updateType = data['Type'] as String?; + final updateData = data['Data']; + + switch (updateType) { + case 'GroupJoined': + _handleGroupJoined(updateData as Map); + break; + case 'UserJoined': + _handleUserJoined(updateData as String?, currentState); + break; + case 'UserLeft': + _handleUserLeft(updateData as String?, currentState); + break; + case 'GroupLeft': + _handleGroupLeft(); + break; + case 'GroupDoesNotExist': + _handleGroupDoesNotExist(); + break; + case 'NotInGroup': + _handleNotInGroup(); + break; + case 'StateUpdate': + _handleStateUpdate(updateData as Map); + break; + case 'PlayQueue': + _handlePlayQueue(updateData as Map, currentState); + break; + } + } + + void _handleGroupJoined(Map data) { + final groupId = data['GroupId'] as String?; + final groupName = data['GroupName'] as String?; + final stateStr = data['State'] as String?; + final participants = (data['Participants'] as List?)?.cast() ?? []; + + onStateUpdate((state) => state.copyWith( + isInGroup: true, + groupId: groupId, + groupName: groupName, + groupState: _parseGroupState(stateStr), + participants: participants, + )); + + log('SyncPlay: Joined group "$groupName" ($groupId)'); + + // Notify controller that group join was confirmed + onGroupJoined(); + } + + void _handleUserJoined(String? userId, SyncPlayState currentState) { + if (userId == null) { + return; + } + final participants = [...currentState.participants, userId]; + onStateUpdate((state) => state.copyWith(participants: participants)); + + final context = getContext(); + if (context != null) { + FladderSnack.show(context.localized.syncPlayUserJoined(userId), context: context); + } + log('SyncPlay: User joined: $userId'); + } + + void _handleUserLeft(String? userId, SyncPlayState currentState) { + if (userId == null) { + return; + } + final participants = currentState.participants.where((p) => p != userId).toList(); + onStateUpdate((state) => state.copyWith(participants: participants)); + + final context = getContext(); + if (context != null) { + FladderSnack.show(context.localized.syncPlayUserLeft(userId), context: context); + } + log('SyncPlay: User left: $userId'); + } + + void _handleGroupLeft() { + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Left group'); + } + + void _handleGroupDoesNotExist() { + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Group does not exist'); + + // Notify controller that group join failed + onGroupJoinFailed(); + } + + void _handleNotInGroup() { + onStateUpdate((state) => state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + onGroupLeftOrKicked?.call(); + log('SyncPlay: Not in group - server rejected operation'); + + // Notify controller that group join failed + onGroupJoinFailed(); + } + + void _handleStateUpdate(Map data) { + final stateStr = data['State'] as String?; + final reason = data['Reason'] as String?; + final positionTicks = data['PositionTicks'] as int? ?? 0; + final newGroupState = _parseGroupState(stateStr); + + onStateUpdate((state) => state.copyWith( + groupState: newGroupState, + stateReason: reason, + positionTicks: positionTicks, + )); + + log('SyncPlay: State update: $stateStr (reason: $reason)'); + + // Handle waiting state (per docs: report Ready for Unpause/Buffer so server can broadcast Unpause) + if (newGroupState == SyncPlayGroupState.waiting) { + _handleWaitingState(reason); + } + + // Per docs: when state becomes Playing, ensure player is actually playing (recover if Unpause was missed) + if (newGroupState == SyncPlayGroupState.playing) { + onStateUpdateToPlaying?.call(); + } + } + + void _handleWaitingState(String? reason) { + switch (reason) { + case 'Buffer': + case 'Unpause': + // Report ready if we're ready + if (!isBuffering()) { + reportReady(isPlaying: true); + } + break; + } + } + + void _handlePlayQueue(Map data, SyncPlayState currentState) { + final playlist = data['Playlist'] as List? ?? []; + final playingItemIndex = data['PlayingItemIndex'] as int? ?? 0; + final startPositionTicks = data['StartPositionTicks'] as int? ?? 0; + final isPlayingNow = data['IsPlaying'] as bool? ?? false; + final reason = data['Reason'] as String?; + + String? playingItemId; + String? playlistItemId; + + if (playlist.isNotEmpty && playingItemIndex < playlist.length) { + final item = playlist[playingItemIndex] as Map; + playingItemId = item['ItemId'] as String?; + playlistItemId = item['PlaylistItemId'] as String?; + } + + final previousItemId = currentState.playingItemId; + + onStateUpdate((state) => state.copyWith( + playingItemId: playingItemId, + playlistItemId: playlistItemId, + positionTicks: startPositionTicks, + )); + + log('SyncPlay: PlayQueue update - playing: $playingItemId (reason: $reason, isPlaying: $isPlayingNow, previousItemId: $previousItemId)'); + + // Trigger playback for NewPlaylist/SetCurrentItem regardless of whether item changed + // (the same user who set the queue also receives the update and needs to start playing) + final shouldTrigger = playingItemId != null && + (reason == 'NewPlaylist' || reason == 'SetCurrentItem' || (playingItemId != previousItemId && isPlayingNow)); + + log('SyncPlay: shouldTrigger=$shouldTrigger (reason: $reason)'); + + if (shouldTrigger) { + log('SyncPlay: Triggering playback for item: $playingItemId'); + startPlayback(playingItemId, startPositionTicks); + } + } + + SyncPlayGroupState _parseGroupState(String? state) { + switch (state?.toLowerCase()) { + case 'idle': + return SyncPlayGroupState.idle; + case 'waiting': + return SyncPlayGroupState.waiting; + case 'paused': + return SyncPlayGroupState.paused; + case 'playing': + return SyncPlayGroupState.playing; + default: + return SyncPlayGroupState.idle; + } + } +} diff --git a/lib/providers/syncplay/syncplay.dart b/lib/providers/syncplay/syncplay.dart new file mode 100644 index 000000000..1ab5c0f9e --- /dev/null +++ b/lib/providers/syncplay/syncplay.dart @@ -0,0 +1,27 @@ +/// 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 +/// - [WebSocketManager] - WebSocket connection management +/// +/// 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'; +export 'websocket_manager.dart'; diff --git a/lib/providers/syncplay/syncplay_controller.dart b/lib/providers/syncplay/syncplay_controller.dart new file mode 100644 index 000000000..f6f6814af --- /dev/null +++ b/lib/providers/syncplay/syncplay_controller.dart @@ -0,0 +1,872 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/router_provider.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_command_handler.dart'; +import 'package:fladder/providers/syncplay/handlers/syncplay_message_handler.dart'; +import 'package:fladder/providers/syncplay/time_sync_service.dart'; +import 'package:fladder/providers/syncplay/websocket_manager.dart'; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Controller for SyncPlay synchronized playback +class SyncPlayController { + static const bool _verboseSyncPlayLogs = false; + + SyncPlayController(this._ref) { + _commandHandler = SyncPlayCommandHandler( + timeSync: () => _timeSync, + onStateUpdate: _updateStateWith, + ); + _messageHandler = SyncPlayMessageHandler( + onStateUpdate: _updateStateWith, + reportReady: ({bool isPlaying = true}) => reportReady(isPlaying: isPlaying), + startPlayback: _startPlayback, + isBuffering: () => _commandHandler.isBuffering?.call() ?? false, + getContext: () => getNavigatorKey(_ref)?.currentContext, + onGroupJoined: _onGroupJoined, + onGroupJoinFailed: _onGroupJoinFailed, + onGroupLeftOrKicked: _onGroupLeftOrKicked, + onStateUpdateToPlaying: _onStateUpdateToPlaying, + ); + } + + final Ref _ref; + + WebSocketManager? _wsManager; + TimeSyncService? _timeSync; + StreamSubscription? _wsMessageSubscription; + StreamSubscription? _wsStateSubscription; + Timer? _syncCorrectionTimer; + + late final SyncPlayCommandHandler _commandHandler; + late final SyncPlayMessageHandler _messageHandler; + + SyncPlayState _state = SyncPlayState(); + final _stateController = StreamController.broadcast(); + + Stream get stateStream => _stateController.stream; + + SyncPlayState get state => _state; + + // Lifecycle state for reconnection + String? _lastGroupId; + bool _wasConnected = false; + + // Completer for waiting on group join confirmation + Completer? _joinGroupCompleter; + + // Player callbacks (delegated to command handler) + set onPlay(SyncPlayPlayerCallback? callback) => _commandHandler.onPlay = callback; + + set onPause(SyncPlayPlayerCallback? callback) => _commandHandler.onPause = callback; + + set onSeek(SyncPlaySeekCallback? callback) => _commandHandler.onSeek = callback; + + set onStop(SyncPlayPlayerCallback? callback) => _commandHandler.onStop = callback; + + set getPositionTicks(SyncPlayPositionCallback? callback) => _commandHandler.getPositionTicks = callback; + + set isPlaying(bool Function()? callback) => _commandHandler.isPlaying = callback; + + set isBuffering(bool Function()? callback) => _commandHandler.isBuffering = callback; + + set onSeekRequested(SyncPlaySeekCallback? callback) => _commandHandler.onSeekRequested = callback; + + set onReportReady(SyncPlayReportReadyCallback? callback) => _commandHandler.onReportReady = callback; + + set onSetSpeed(SyncPlaySetSpeedCallback? callback) => _commandHandler.onSetSpeed = callback; + + set hasPlaybackRate(bool Function()? callback) => _commandHandler.hasPlaybackRate = callback; + + void log(String message) { + final isImportant = message.contains('Failed') || message.contains('Error') || message.contains('Cannot'); + if (_verboseSyncPlayLogs || isImportant) { + developer.log(message); + } + } + + /// Mark that a SyncPlay command was executed locally. + /// Used by player-side cooldown logic to avoid feedback loops. + void markCommandExecuted([DateTime? at]) { + _updateStateWith((state) => state.copyWith( + lastCommandTime: at ?? DateTime.now().toUtc(), + )); + } + + /// Update buffering/reloading status used by SyncPlay integration. + void setPlayerBufferingState(bool isBuffering) { + if (isBuffering) { + _syncCorrectionTimer?.cancel(); + _syncCorrectionTimer = null; + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed != null) { + unawaited( + setSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed while buffering: $error'); + }), + ); + } + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playerIsBuffering: true, + syncEnabled: false, + activeStrategy: SyncCorrectionStrategy.none, + ), + )); + return; + } + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playerIsBuffering: false, + syncEnabled: true, + ), + )); + } + + /// Reset correction strategy/state when commands are cleared, on stop, + /// or around rejoin flows. + void resetCorrectionState({ + String reason = 'reset', + bool syncEnabled = true, + }) { + _syncCorrectionTimer?.cancel(); + _syncCorrectionTimer = null; + + final setSpeed = _commandHandler.onSetSpeed; + if (setSpeed != null) { + unawaited( + setSpeed(1.0).catchError((Object error, StackTrace stackTrace) { + log('SyncPlay: Failed to reset speed during correction reset: $error'); + }), + ); + } + _commandHandler.clearLastCommand(); + + log('SyncPlay: Reset correction state ($reason)'); + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + activeStrategy: SyncCorrectionStrategy.none, + syncEnabled: syncEnabled, + playbackDiffMillis: 0, + syncAttempts: 0, + ), + )); + } + + /// Update current playback drift against estimated SyncPlay server time. + /// + /// Drift is computed as: + /// `estimatedServerPositionTicks - currentLocalPositionTicks`. + /// Positive means local player is behind, negative means ahead. + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + }) { + if (!_commandHandler.canAttemptSyncCorrection(_state)) { + return; + } + + final lastCommand = _commandHandler.lastCommand; + if (lastCommand == null) { + return; + } + + final when = DateTime.tryParse(lastCommand.when); + if (when == null) { + return; + } + + final now = (at ?? DateTime.now().toUtc()); + final remoteNow = _timeSync?.localDateToRemote(now) ?? now; + final elapsedMs = remoteNow.difference(when).inMilliseconds; + + final estimatedServerTicks = lastCommand.positionTicks + millisecondsToTicks(elapsedMs); + final diffTicks = estimatedServerTicks - currentPositionTicks; + final diffMillis = ticksToMilliseconds(diffTicks).toDouble(); + final correctionConfig = _state.correctionConfig; + final correctionState = _state.correctionState; + final strategy = selectSyncCorrectionStrategy( + config: correctionConfig, + state: correctionState, + diffMillis: diffMillis, + hasPlaybackRate: _commandHandler.hasPlaybackRate?.call() == true, + ); + + if (strategy == SyncCorrectionStrategy.speedToSync) { + _applySpeedToSync( + diffMillis: diffMillis, + config: correctionConfig, + now: now, + ); + return; + } + + if (strategy == SyncCorrectionStrategy.skipToSync) { + _applySkipToSync( + diffMillis: diffMillis, + targetPositionTicks: estimatedServerTicks, + config: correctionConfig, + now: now, + ); + return; + } + + _updateStateWith((state) => state.copyWith( + correctionState: state.correctionState.copyWith( + playbackDiffMillis: diffMillis, + lastSyncAt: now, + ), + )); + } + + 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; + + /// Initialize and connect to SyncPlay + Future connect() async { + final user = _ref.read(userProvider); + if (user == null) { + log('SyncPlay: Cannot connect without user'); + return; + } + + final serverUrl = _ref.read(serverUrlProvider); + if (serverUrl == null || serverUrl.isEmpty) { + log('SyncPlay: Cannot connect without server URL'); + return; + } + + // Initialize time sync + _timeSync = TimeSyncService(_api); + _timeSync!.start(); + + // Initialize WebSocket + log('SyncPlay: Initializing WebSocket with deviceId: ${user.credentials.deviceId}'); + _wsManager = WebSocketManager( + serverUrl: serverUrl, + token: user.credentials.token, + deviceId: user.credentials.deviceId, + ); + + _wsStateSubscription = _wsManager!.connectionState.listen(_handleConnectionState); + _wsMessageSubscription = _wsManager!.messages.listen(_handleMessage); + + await _wsManager!.connect(); + } + + /// Disconnect from SyncPlay + Future disconnect() async { + resetCorrectionState( + reason: 'disconnect', + syncEnabled: false, + ); + await leaveGroup(); + _commandHandler.cancelPendingCommands(); + _wsMessageSubscription?.cancel(); + _wsStateSubscription?.cancel(); + _timeSync?.dispose(); + await _wsManager?.dispose(); + _wsManager = null; + _timeSync = null; + _updateState(SyncPlayState()); + } + + /// List available SyncPlay groups + Future> listGroups() async { + try { + final response = await _api.syncPlayListGet(); + return response.body ?? []; + } catch (e) { + log('SyncPlay: Failed to list groups: $e'); + return []; + } + } + + /// Create a new SyncPlay group + Future createGroup(String groupName) async { + try { + final response = await _api.syncPlayNewPost( + body: NewGroupRequestDto(groupName: groupName), + ); + return response.body; + } catch (e) { + log('SyncPlay: Failed to create group: $e'); + return null; + } + } + + /// Join an existing SyncPlay group + /// Returns true only after receiving GroupJoined confirmation from WebSocket + Future joinGroup(String groupId) async { + // Check if already in a group + if (_state.isInGroup) { + log('SyncPlay: Already in a group, leaving first...'); + await leaveGroup(); + } + + // Check if WebSocket is connected + if (!_state.isConnected) { + log('SyncPlay: WebSocket not connected, cannot join group'); + return false; + } + + try { + log('SyncPlay: Joining group: $groupId'); + + // Create completer to wait for GroupJoined confirmation + _joinGroupCompleter = Completer(); + + await _api.syncPlayJoinPost( + body: JoinGroupRequestDto(groupId: groupId), + ); + _lastGroupId = groupId; + log('SyncPlay: Join request sent, waiting for confirmation...'); + + // Wait for GroupJoined message with timeout + final confirmed = await _joinGroupCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + log('SyncPlay: Timeout waiting for GroupJoined confirmation'); + return false; + }, + ); + + _joinGroupCompleter = null; + + if (confirmed) { + log('SyncPlay: Group join confirmed'); + } else { + log('SyncPlay: Group join not confirmed'); + _lastGroupId = null; + } + + return confirmed; + } catch (e) { + log('SyncPlay: Failed to join group: $e'); + _joinGroupCompleter?.complete(false); + _joinGroupCompleter = null; + return false; + } + } + + /// Called by message handler when GroupJoined is received + void _onGroupJoined() { + resetCorrectionState( + reason: 'group_joined', + syncEnabled: true, + ); + _joinGroupCompleter?.complete(true); + } + + /// Called by message handler when NotInGroup/GroupDoesNotExist is received + void _onGroupJoinFailed() { + _joinGroupCompleter?.complete(false); + } + + /// Called when we leave or are kicked; cancel pending commands and clear processing so playback is not stuck. + void _onGroupLeftOrKicked() { + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'group_left_or_kicked', + syncEnabled: false, + ); + _updateStateWith((s) => s.copyWith( + isProcessingCommand: false, + processingCommandType: null, + )); + } + + /// When server reports Playing, ensure player is actually playing (per docs: recover if Unpause command was missed). + void _onStateUpdateToPlaying() { + if (_commandHandler.isPlaying?.call() != true) { + log('SyncPlay: State is Playing but player not playing, triggering play'); + _commandHandler.onPlay?.call(); + } + } + + /// Leave the current SyncPlay group. + /// Resets processing state and cancels pending commands so playback is not stuck (per docs). + Future leaveGroup() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayLeavePost(); + _lastGroupId = null; + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'leave_group', + syncEnabled: false, + ); + _updateState(_state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + positionTicks: 0, + playlistItemId: null, + )); + log('SyncPlay: Left group, state reset'); + } catch (e) { + log('SyncPlay: Failed to leave group: $e'); + // Still reset local state so we are not stuck + _commandHandler.cancelPendingCommands(); + resetCorrectionState( + reason: 'leave_group_failed_local_reset', + syncEnabled: false, + ); + _updateState(_state.copyWith( + isInGroup: false, + groupId: null, + groupName: null, + groupState: SyncPlayGroupState.idle, + participants: [], + isProcessingCommand: false, + processingCommandType: null, + )); + } + } + + /// Request pause + Future requestPause() async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlayPausePost(); + } catch (e) { + log('SyncPlay: Failed to request pause: $e'); + } + } + + /// Request unpause/play (server will move to Waiting until all clients report Ready, then broadcast Unpause). + Future requestUnpause() async { + if (!_state.isInGroup) { + return; + } + try { + log('SyncPlay: Sending Unpause request'); + await _api.syncPlayUnpausePost(); + } catch (e) { + log('SyncPlay: Failed to request unpause: $e'); + } + } + + /// Request seek + Future requestSeek(int positionTicks) async { + if (!_state.isInGroup) { + return; + } + try { + await _api.syncPlaySeekPost( + body: SeekRequestDto(positionTicks: positionTicks), + ); + } catch (e) { + log('SyncPlay: Failed to request seek: $e'); + } + } + + /// Report buffering state + Future reportBuffering() async { + if (!_state.isInGroup) { + return; + } + try { + final when = _timeSync?.localDateToRemote(DateTime.now().toUtc()); + await _api.syncPlayBufferingPost( + body: BufferRequestDto( + when: when, + positionTicks: _commandHandler.getPositionTicks?.call() ?? 0, + isPlaying: false, + playlistItemId: _state.playlistItemId, + ), + ); + } catch (e) { + log('SyncPlay: Failed to report buffering: $e'); + } + } + + /// Report ready state (required for server to broadcast Unpause when in Waiting). + Future reportReady({bool isPlaying = true}) async { + if (!_state.isInGroup) { + return; + } + try { + final when = _timeSync?.localDateToRemote(DateTime.now().toUtc()); + final ticks = _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'); + } + } + + /// Report ping to server + Future reportPing() async { + if (!_state.isInGroup || _timeSync == null) { + return; + } + try { + await _api.syncPlayPingPost( + body: PingRequestDto(ping: _timeSync!.ping.inMilliseconds), + ); + } catch (e) { + log('SyncPlay: Failed to report ping: $e'); + } + } + + /// Set a new queue/playlist + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + int startPositionTicks = 0, + }) async { + if (!_state.isInGroup) { + log('SyncPlay: Cannot set queue - not in group'); + return; + } + 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}'); + } catch (e) { + log('SyncPlay: Failed to set new queue: $e'); + } + } + + 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'); + } + + void _handleMessage(Map message) { + final messageType = message['MessageType'] as String?; + final data = message['Data']; + + log('SyncPlay: Received WebSocket message: $messageType'); + + switch (messageType) { + case 'SyncPlayCommand': + final cmd = (data as Map)['Command'] as String?; + log('SyncPlay: Received SyncPlayCommand: $cmd'); + _commandHandler.handleCommand(data, _state); + break; + case 'SyncPlayGroupUpdate': + log('SyncPlay: GroupUpdate data: $data'); + _messageHandler.handleGroupUpdate(data as Map, _state); + break; + default: + // Log unhandled message types for debugging + if (messageType?.startsWith('SyncPlay') == true) { + log('SyncPlay: Unhandled SyncPlay message type: $messageType'); + } + } + } + + /// Start playback of an item from SyncPlay + Future _startPlayback(String itemId, int startPositionTicks) async { + log('SyncPlay: _startPlayback called for item: $itemId, ticks: $startPositionTicks'); + + try { + final playerRouteAlreadyOpen = _ref.read(isVideoPlayerRouteOpenProvider); + log('SyncPlay: Player route already open: $playerRouteAlreadyOpen'); + + // Clear the old playback model BEFORE re-initializing. This prevents + // the fire-and-forget stop() inside _initPlayer() from entering a + // 1-second delayed playbackStopped flow that races against the new + // loadPlaybackItem call (which also calls stop()). With playBackModel + // null, every stop() becomes a no-op. + if (!playerRouteAlreadyOpen) { + _ref.read(playBackModel.notifier).update((state) => null); + await _ref.read(videoPlayerProvider.notifier).init(); + } + + // Fetch the item from Jellyfin + log('SyncPlay: Fetching item from API...'); + final api = _ref.read(jellyApiProvider); + final itemResponse = await api.usersUserIdItemsItemIdGet(itemId: itemId); + final itemModel = itemResponse.body; + + if (itemModel == null) { + log('SyncPlay: Failed to fetch item $itemId - response body was null'); + return; + } + log('SyncPlay: Fetched item: ${itemModel.name}'); + + // Create playback model (context is optional - null for SyncPlay auto-play) + log('SyncPlay: Creating playback model...'); + final playbackHelper = _ref.read(playbackModelHelper); + final startPosition = Duration(microseconds: startPositionTicks ~/ 10); + + final playbackModel = await playbackHelper.createPlaybackModel( + null, // No context needed for SyncPlay + itemModel, + startPosition: startPosition, + ); + + if (playbackModel == null) { + log('SyncPlay: Failed to create playback model for $itemId'); + return; + } + log('SyncPlay: Playback model created successfully'); + + // Load and play + log('SyncPlay: Loading playback item...'); + final loadedCorrectly = await _ref.read(videoPlayerProvider.notifier).loadPlaybackItem( + playbackModel, + startPosition, + ); + + if (!loadedCorrectly) { + log('SyncPlay: Failed to load playback item $itemId'); + return; + } + log('SyncPlay: Playback item loaded successfully'); + + // Set state to fullScreen + _ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.fullScreen), + ); + log('SyncPlay: Set state to fullScreen'); + + // Only push the player route when it isn't already on screen. + // When the route is already open (e.g. User B whose player stayed + // open), loadPlaybackItem already swapped the video content in the + // existing player — pushing again would stack duplicate routes. + if (!playerRouteAlreadyOpen) { + final navigatorKey = getNavigatorKey(_ref); + final context = navigatorKey?.currentContext; + log('SyncPlay: Navigator context: ${context != null ? "exists" : "null"}'); + + if (context != null) { + await _ref.read(videoPlayerProvider.notifier).openPlayer(context); + log('SyncPlay: Successfully opened player 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'); + } + } + + void _updateState(SyncPlayState newState) { + _state = newState; + _stateController.add(newState); + } + + void _updateStateWith(SyncPlayState Function(SyncPlayState) updater) { + _state = updater(_state); + _stateController.add(_state); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle Handling (for mobile background/resume) + // ───────────────────────────────────────────────────────────────────────── + + /// Handle app lifecycle state changes + /// Call this from a WidgetsBindingObserver when app state changes + Future handleAppLifecycleChange(AppLifecycleState lifecycleState) async { + // On web, we want to stay connected even in background and avoid forced reconnection on resume. + if (kIsWeb) { + return; + } + + switch (lifecycleState) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + // App going to background - remember state for reconnection + _wasConnected = _wsManager?.currentState == WebSocketConnectionState.connected; + log('SyncPlay: App paused, wasConnected=$_wasConnected, lastGroupId=$_lastGroupId'); + break; + + case AppLifecycleState.resumed: + // App returning to foreground - attempt reconnection if needed + log('SyncPlay: App resumed, wasConnected=$_wasConnected, isInGroup=${_state.isInGroup}'); + if (_wasConnected || _state.isInGroup) { + await _handleAppResume(); + } + break; + + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + // No action needed + break; + } + } + + /// Handle app resume - reconnect WebSocket and optionally rejoin group + Future _handleAppResume() async { + // Force reconnect WebSocket + if (_wsManager != null) { + log('SyncPlay: Force reconnecting WebSocket on resume'); + await _wsManager!.forceReconnect(); + + // Wait for connection to establish + await Future.delayed(const Duration(milliseconds: 500)); + + // Restart time sync if it was active + if (_timeSync != null) { + _timeSync!.start(); + await _timeSync!.forceUpdate(); + } + + // If we were in a group but got disconnected, try to rejoin + if (_lastGroupId != null && !_state.isInGroup) { + resetCorrectionState( + reason: 'pre_rejoin', + syncEnabled: false, + ); + log('SyncPlay: Attempting to rejoin group $_lastGroupId'); + final success = await joinGroup(_lastGroupId!); + if (!success) { + log('SyncPlay: Failed to rejoin group, clearing lastGroupId'); + _lastGroupId = null; + } + } + } + } + + /// Dispose resources + Future dispose() async { + _commandHandler.dispose(); + await disconnect(); + await _stateController.close(); + } +} diff --git a/lib/providers/syncplay/syncplay_provider.dart b/lib/providers/syncplay/syncplay_provider.dart new file mode 100644 index 000000000..6b6d85943 --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.dart @@ -0,0 +1,251 @@ +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/widgets.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'; + +/// Lifecycle observer for SyncPlay - handles app background/resume +class _SyncPlayLifecycleObserver with WidgetsBindingObserver { + _SyncPlayLifecycleObserver(this._controller); + + final SyncPlayController _controller; + + void register() { + WidgetsBinding.instance.addObserver(this); + } + + void unregister() { + WidgetsBinding.instance.removeObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _controller.handleAppLifecycleChange(state); + } +} + +/// Provider for SyncPlay controller instance +@Riverpod(keepAlive: true) +class SyncPlay extends _$SyncPlay { + SyncPlayController? _controller; + StreamSubscription? _stateSubscription; + _SyncPlayLifecycleObserver? _lifecycleObserver; + + @override + SyncPlayState build() { + ref.onDispose(() { + _lifecycleObserver?.unregister(); + _stateSubscription?.cancel(); + _controller?.dispose(); + }); + return SyncPlayState(); + } + + SyncPlayController get controller { + if (_controller == null) { + _controller = SyncPlayController(ref); + // Register lifecycle observer when controller is created (except on Web) + if (!kIsWeb) { + _lifecycleObserver = _SyncPlayLifecycleObserver(_controller!); + _lifecycleObserver!.register(); + } + } + return _controller!; + } + + /// Initialize and connect to SyncPlay WebSocket + Future connect() async { + await controller.connect(); + _stateSubscription?.cancel(); + _stateSubscription = controller.stateStream.listen((newState) { + state = newState; + }); + } + + /// Disconnect from SyncPlay + Future disconnect() async { + await controller.disconnect(); + state = SyncPlayState(); + } + + /// List available SyncPlay groups + Future> listGroups() => controller.listGroups(); + + /// Create a new SyncPlay group + Future createGroup(String groupName) => controller.createGroup(groupName); + + /// Join an existing group + Future joinGroup(String groupId) => controller.joinGroup(groupId); + + /// Leave current group + Future leaveGroup() => controller.leaveGroup(); + + /// Request pause + Future requestPause() => controller.requestPause(); + + /// Request unpause/play + Future requestUnpause() async => await controller.requestUnpause(); + + /// Request seek + Future requestSeek(int positionTicks) => controller.requestSeek(positionTicks); + + /// Report buffering state + Future reportBuffering() => controller.reportBuffering(); + + /// Report ready state + Future reportReady({bool isPlaying = true}) => controller.reportReady(isPlaying: isPlaying); + + /// Mark local execution of a SyncPlay command for cooldown handling. + void markCommandExecuted([DateTime? at]) => controller.markCommandExecuted(at); + + /// Update buffering/reloading status inside SyncPlay state. + void setPlayerBufferingState(bool isBuffering) => controller.setPlayerBufferingState(isBuffering); + + /// Reset correction state and timers. + void resetCorrectionState({ + String reason = 'manual', + bool syncEnabled = true, + }) => + controller.resetCorrectionState( + reason: reason, + syncEnabled: syncEnabled, + ); + + /// Update playback drift using current local position ticks. + void updatePlaybackDrift({ + required int currentPositionTicks, + DateTime? at, + }) => + controller.updatePlaybackDrift( + currentPositionTicks: currentPositionTicks, + at: at, + ); + + /// Set a new queue/playlist + Future setNewQueue({ + required List itemIds, + int playingItemPosition = 0, + int startPositionTicks = 0, + }) => + controller.setNewQueue( + itemIds: itemIds, + playingItemPosition: playingItemPosition, + startPositionTicks: startPositionTicks, + ); + + /// Register player callbacks + void registerPlayer({ + required Future Function() onPlay, + required Future Function() onPause, + required Future Function(int positionTicks) onSeek, + required Future Function() onStop, + required Future Function(double speed) onSetSpeed, + required int Function() getPositionTicks, + required bool Function() isPlaying, + required bool Function() isBuffering, + required bool Function() hasPlaybackRate, + Future Function(int positionTicks)? onSeekRequested, + }) { + controller.onPlay = onPlay; + controller.onPause = onPause; + controller.onSeek = onSeek; + controller.onStop = onStop; + controller.onSetSpeed = onSetSpeed; + controller.getPositionTicks = getPositionTicks; + controller.isPlaying = isPlaying; + controller.isBuffering = isBuffering; + controller.hasPlaybackRate = hasPlaybackRate; + controller.onSeekRequested = onSeekRequested; + // Wire up reportReady callback so command handler can report ready after seek + controller.onReportReady = () => controller.reportReady(); + } + + /// Unregister player callbacks + void unregisterPlayer() { + controller.onPlay = null; + controller.onPause = null; + controller.onSeek = null; + controller.onStop = null; + controller.onSetSpeed = null; + controller.getPositionTicks = null; + controller.isPlaying = null; + controller.isBuffering = null; + controller.hasPlaybackRate = null; + controller.onSeekRequested = null; + controller.onReportReady = null; + } +} + +/// Provider to check if currently in a SyncPlay session +@riverpod +bool isSyncPlayActive(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.isActive)); +} + +/// Provider for current SyncPlay group name +@riverpod +String? syncPlayGroupName(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupName)); +} + +/// Provider for SyncPlay group state +@riverpod +SyncPlayGroupState syncPlayGroupState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.groupState)); +} + +/// Provider for SyncPlay correction runtime state (UI + diagnostics). +@riverpod +SyncCorrectionState syncCorrectionState(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState)); +} + +/// Provider for active correction strategy. +@riverpod +SyncCorrectionStrategy syncCorrectionStrategy(Ref ref) { + return ref.watch(syncPlayProvider.select((s) => s.correctionState.activeStrategy)); +} + +/// Immutable state for the SyncPlay groups list (used by group sheet). +/// Lists are stored unmodifiable so the state cannot be mutated. +@Freezed(copyWith: true) +abstract class SyncPlayGroupsState with _$SyncPlayGroupsState { + const factory SyncPlayGroupsState({ + List? groups, + @Default(false) bool isLoading, + String? error, + }) = _SyncPlayGroupsState; +} + +/// Provider for the list of SyncPlay groups (load/refresh from sheet). +@Riverpod(keepAlive: false) +class SyncPlayGroups extends _$SyncPlayGroups { + @override + SyncPlayGroupsState build() => const SyncPlayGroupsState(isLoading: true); + + Future loadGroups() async { + state = state.copyWith(isLoading: true, error: null); + try { + await ref.read(syncPlayProvider.notifier).connect(); + final groups = await ref.read(syncPlayProvider.notifier).listGroups(); + state = state.copyWith( + groups: List.unmodifiable(groups), + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + void setLoading(bool isLoading) { + state = state.copyWith(isLoading: isLoading); + } +} diff --git a/lib/providers/syncplay/syncplay_provider.freezed.dart b/lib/providers/syncplay/syncplay_provider.freezed.dart new file mode 100644 index 000000000..67e812c5e --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.freezed.dart @@ -0,0 +1,342 @@ +// 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..76384e4a9 --- /dev/null +++ b/lib/providers/syncplay/syncplay_provider.g.dart @@ -0,0 +1,146 @@ +// 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 _$syncPlayHash() => r'adbc9eaf226b0e9e24982f9967c986f0ddb51e84'; + +/// Provider for SyncPlay controller instance +/// +/// Copied from [SyncPlay]. +@ProviderFor(SyncPlay) +final syncPlayProvider = NotifierProvider.internal( + SyncPlay.new, + name: r'syncPlayProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$syncPlayHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncPlay = Notifier; +String _$syncPlayGroupsHash() => r'7f17436df1b0afb4c77cd21128e03b1ed0875939'; + +/// Provider for the list of SyncPlay groups (load/refresh from sheet). +/// +/// Copied from [SyncPlayGroups]. +@ProviderFor(SyncPlayGroups) +final syncPlayGroupsProvider = + AutoDisposeNotifierProvider.internal( + SyncPlayGroups.new, + name: r'syncPlayGroupsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$syncPlayGroupsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SyncPlayGroups = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/providers/syncplay/time_sync_service.dart b/lib/providers/syncplay/time_sync_service.dart new file mode 100644 index 000000000..907aab095 --- /dev/null +++ b/lib/providers/syncplay/time_sync_service.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; + +/// Service for synchronizing client clock with Jellyfin server using NTP-like algorithm +class TimeSyncService { + TimeSyncService(this._api); + + final JellyfinOpenApi _api; + + final List _measurements = []; + static const int _maxMeasurements = 8; + + Timer? _pollingTimer; + int _pingCount = 0; + bool _isActive = false; + + // Polling intervals + static const Duration _greedyInterval = Duration(seconds: 1); + static const Duration _lowProfileInterval = Duration(seconds: 60); + static const int _greedyPingCount = 3; + + // Staleness threshold + static const Duration _staleThreshold = Duration(seconds: 30); + DateTime? _lastMeasurementTime; + + /// Current best offset estimate + Duration get offset { + if (_measurements.isEmpty) { + return Duration.zero; + } + // Use measurement with minimum delay (least network jitter) + final best = _measurements.reduce( + (a, b) => a.delay < b.delay ? a : b, + ); + return best.offset; + } + + /// Current ping estimate (from best measurement) + Duration get ping { + if (_measurements.isEmpty) { + return Duration.zero; + } + final best = _measurements.reduce( + (a, b) => a.delay < b.delay ? a : b, + ); + return best.ping; + } + + /// Whether time sync is stale and needs refresh + bool get isStale { + if (_lastMeasurementTime == null) { + return true; + } + return DateTime.now().difference(_lastMeasurementTime!) > _staleThreshold; + } + + /// Convert server time to local time + DateTime remoteDateToLocal(DateTime serverTime) { + return serverTime.subtract(offset); + } + + /// Convert local time to server time + DateTime localDateToRemote(DateTime localTime) { + return localTime.add(offset); + } + + /// Start time synchronization + void start() { + if (_isActive) { + return; + } + _isActive = true; + _pingCount = 0; + _poll(); + } + + /// Stop time synchronization + void stop() { + _isActive = false; + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + /// Force an immediate sync update + Future forceUpdate() async { + await _requestPing(); + } + + /// Force update and wait for completion + Future forceUpdateAndWait() async { + await _requestPing(); + } + + void _poll() { + if (!_isActive) { + return; + } + + _requestPing().then((_) { + if (!_isActive) { + return; + } + + _pingCount++; + final interval = _pingCount <= _greedyPingCount ? _greedyInterval : _lowProfileInterval; + + _pollingTimer?.cancel(); + _pollingTimer = Timer(interval, _poll); + }); + } + + Future _requestPing() async { + try { + // T1: Record local time before request + final requestSent = DateTime.now().toUtc(); + + // Make request to Jellyfin TimeSync API + final response = await _api.getUtcTimeGet(); + + // T4: Record local time after response + final responseReceived = DateTime.now().toUtc(); + + final data = response.body; + if (data == null) { + log('Time sync: No response body'); + return; + } + + // T2 and T3 from server + final requestReceived = data.requestReceptionTime; + final responseSent = data.responseTransmissionTime; + + if (requestReceived == null || responseSent == null) { + log('Time sync: Missing server timestamps'); + return; + } + + final measurement = TimeSyncMeasurement( + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived, + ); + + _addMeasurement(measurement); + _lastMeasurementTime = DateTime.now(); + + log('Time sync: offset=${offset.inMilliseconds}ms, ping=${ping.inMilliseconds}ms'); + } catch (e) { + log('Time sync failed: $e'); + } + } + + void _addMeasurement(TimeSyncMeasurement measurement) { + _measurements.add(measurement); + // Keep only the last N measurements + while (_measurements.length > _maxMeasurements) { + _measurements.removeAt(0); + } + } + + /// Clear all measurements + void clear() { + _measurements.clear(); + _lastMeasurementTime = null; + _pingCount = 0; + } + + /// Dispose resources + void dispose() { + stop(); + clear(); + } +} diff --git a/lib/providers/syncplay/websocket_manager.dart b/lib/providers/syncplay/websocket_manager.dart new file mode 100644 index 000000000..55604511e --- /dev/null +++ b/lib/providers/syncplay/websocket_manager.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Manages WebSocket connection to Jellyfin server for SyncPlay +class WebSocketManager { + WebSocketManager({ + required this.serverUrl, + required this.token, + required this.deviceId, + }); + + final String serverUrl; + final String token; + final String deviceId; + + WebSocketChannel? _channel; + Timer? _keepAliveTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static const Duration _baseReconnectDelay = Duration(seconds: 2); + + final _connectionStateController = StreamController.broadcast(); + final _messageController = StreamController>.broadcast(); + + Stream get connectionState => _connectionStateController.stream; + Stream> get messages => _messageController.stream; + + WebSocketConnectionState _currentState = WebSocketConnectionState.disconnected; + WebSocketConnectionState get currentState => _currentState; + + /// Build WebSocket URL for Jellyfin + Uri get _webSocketUri { + final baseUri = Uri.parse(serverUrl); + final scheme = baseUri.scheme == 'https' ? 'wss' : 'ws'; + return Uri( + scheme: scheme, + host: baseUri.host, + port: baseUri.port, + path: '${baseUri.path}/socket', + queryParameters: { + 'api_key': token, + 'deviceId': deviceId, + }, + ); + } + + /// Connect to WebSocket + Future connect() async { + if (_currentState == WebSocketConnectionState.connected || _currentState == WebSocketConnectionState.connecting) { + return; + } + + _updateState(WebSocketConnectionState.connecting); + + try { + log('WebSocket: Connecting to ${_webSocketUri.toString().replaceAll(RegExp(r'api_key=[^&]+'), 'api_key=***')}'); + _channel = WebSocketChannel.connect(_webSocketUri); + await _channel!.ready; + + _updateState(WebSocketConnectionState.connected); + _reconnectAttempts = 0; + + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDone, + ); + } catch (e) { + log('WebSocket connection failed: $e'); + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + } + + /// Disconnect from WebSocket + Future disconnect() async { + _reconnectTimer?.cancel(); + _keepAliveTimer?.cancel(); + _reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect + + await _channel?.sink.close(); + _channel = null; + _updateState(WebSocketConnectionState.disconnected); + } + + /// Force reconnect (e.g., after app resume) + /// Resets attempt counter and immediately reconnects + Future forceReconnect() async { + _reconnectTimer?.cancel(); + _keepAliveTimer?.cancel(); + await _channel?.sink.close(); + _channel = null; + _reconnectAttempts = 0; + _updateState(WebSocketConnectionState.disconnected); + await connect(); + } + + /// Send a message through WebSocket + void send(Map message) { + if (_currentState != WebSocketConnectionState.connected) { + log('Cannot send message: WebSocket not connected'); + return; + } + + try { + _channel?.sink.add(json.encode(message)); + } catch (e) { + log('Failed to send WebSocket message: $e'); + } + } + + /// Send keep-alive message + void _sendKeepAlive() { + send({'MessageType': 'KeepAlive'}); + } + + void _handleMessage(dynamic data) { + try { + final message = json.decode(data as String) as Map; + final messageType = message['MessageType'] as String?; + + // Log all received messages for debugging (except KeepAlive spam) + if (messageType != 'KeepAlive') { + log('WebSocket: Received message: $message'); + } + + // Handle ForceKeepAlive to set up keep-alive interval + if (messageType == 'ForceKeepAlive') { + final timeoutSeconds = message['Data'] as int? ?? 60; + _setupKeepAlive(timeoutSeconds); + } + + // Forward message to listeners + _messageController.add(message); + } catch (e) { + log('Failed to parse WebSocket message: $e\nRaw data: $data'); + } + } + + void _handleError(dynamic error) { + log('WebSocket error: $error'); + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + + void _handleDone() { + log('WebSocket connection closed'); + _keepAliveTimer?.cancel(); + + if (_currentState != WebSocketConnectionState.disconnected) { + _updateState(WebSocketConnectionState.disconnected); + _scheduleReconnect(); + } + } + + void _setupKeepAlive(int timeoutSeconds) { + _keepAliveTimer?.cancel(); + // Send keep-alive at half the timeout interval + final interval = Duration(seconds: (timeoutSeconds * 0.5).round()); + _keepAliveTimer = Timer.periodic(interval, (_) => _sendKeepAlive()); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + log('Max reconnect attempts reached'); + return; + } + + _reconnectTimer?.cancel(); + _updateState(WebSocketConnectionState.reconnecting); + + // Exponential backoff + final delay = _baseReconnectDelay * (1 << _reconnectAttempts); + _reconnectAttempts++; + + log('Scheduling reconnect in ${delay.inSeconds}s (attempt $_reconnectAttempts)'); + _reconnectTimer = Timer(delay, connect); + } + + void _updateState(WebSocketConnectionState state) { + _currentState = state; + _connectionStateController.add(state); + } + + /// Dispose resources + Future dispose() async { + await disconnect(); + await _connectionStateController.close(); + await _messageController.close(); + } +} diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 5c7037f61..3d80fe3ea 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,21 +1,24 @@ import 'dart:async'; 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/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/syncplay/syncplay_models.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource, SyncPlayCommandType; import 'package:fladder/wrappers/media_control_wrapper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; final mediaPlaybackProvider = StateProvider((ref) => MediaPlaybackModel()); final playBackModel = StateProvider((ref) => null); +final isVideoPlayerRouteOpenProvider = StateProvider((ref) => false); + final videoPlayerProvider = StateNotifierProvider((ref) { final videoPlayer = VideoPlayerNotifier(ref); videoPlayer.init(); @@ -33,6 +36,27 @@ class VideoPlayerNotifier extends StateNotifier { MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider); + /// Flag to indicate if the current action is initiated by SyncPlay + bool _syncPlayAction = false; + + /// Cooldown period after SyncPlay command during which we don't auto-report ready + static const _syncPlayCooldown = Duration(milliseconds: 500); + + /// Check if SyncPlay is active + bool get _isSyncPlayActive => ref.read(isSyncPlayActiveProvider); + + /// Whether player is reloading/buffering from SyncPlay perspective. + bool get _isReloading => ref.read(syncPlayProvider.select((s) => s.correctionState.playerIsBuffering)); + + /// Check if we're in the SyncPlay cooldown period + bool get _inSyncPlayCooldown { + final lastCommandTime = ref.read(syncPlayProvider.select((s) => s.lastCommandTime)); + if (lastCommandTime == null) { + return false; + } + return DateTime.now().toUtc().difference(lastCommandTime) < _syncPlayCooldown; + } + Future init() async { await state.dispose(); await state.init(); @@ -42,6 +66,19 @@ class VideoPlayerNotifier extends StateNotifier { } final subscription = state.stateStream?.listen((value) { + // Infer SyncPlay user actions from native player state stream (reviewer request). + if (value.changeSource == PlaybackChangeSource.user) { + final prev = playbackState; + if (value.playing != prev.playing) { + if (value.playing) { + userPlay(); + } else { + userPause(); + } + } else if ((value.position - prev.position).inSeconds.abs() > 2) { + userSeek(value.position); + } + } updateBuffering(value.buffering); updateBuffer(value.buffer); updatePlaying(value.playing); @@ -52,10 +89,129 @@ class VideoPlayerNotifier extends StateNotifier { if (subscription != null) { subscriptions.add(subscription); } + + // Register player callbacks with SyncPlay + _registerSyncPlayCallbacks(); + + // Listen to SyncPlay state changes for native player overlay + _setupSyncPlayStateListener(); + } + + /// Set up listener to forward SyncPlay command state to native player + void _setupSyncPlayStateListener() { + ref.listen( + syncPlayProvider, + (previous, next) { + // Only forward to native player if it's active + if (state.isNativePlayerActive) { + // Check if the relevant state changed + if (previous?.isProcessingCommand != next.isProcessingCommand || + previous?.processingCommandType != next.processingCommandType) { + state.updateSyncPlayCommandState( + next.isProcessingCommand, + _toSyncPlayCommandType(next.processingCommandType), + ); + } + } + }, + ); + } + + SyncPlayCommandType _toSyncPlayCommandType(String? commandType) { + return switch (commandType) { + 'Pause' => SyncPlayCommandType.pause, + 'Unpause' => SyncPlayCommandType.unpause, + 'Seek' => SyncPlayCommandType.seek, + 'Stop' => SyncPlayCommandType.stop, + _ => SyncPlayCommandType.none, + }; + } + + /// Manually set the reloading state (e.g. before fetching new PlaybackInfo) + void setReloading( + bool value, { + bool reportToSyncPlay = true, + }) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(value); + if (value && _isSyncPlayActive && reportToSyncPlay) { + ref.read(syncPlayProvider.notifier).reportBuffering(); + } } - Future updateBuffering(bool event) async => - mediaState.update((state) => state.buffering == event ? state : state.copyWith(buffering: event)); + /// Register player callbacks with SyncPlay controller + void _registerSyncPlayCallbacks() { + ref.read(syncPlayProvider.notifier).registerPlayer( + onPlay: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.play(); + _syncPlayAction = false; + }, + onPause: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.pause(); + _syncPlayAction = false; + }, + onSeek: (positionTicks) async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + final position = Duration(microseconds: positionTicks ~/ 10); + await state.seek(position); + _syncPlayAction = false; + }, + onSeekRequested: (positionTicks) async { + // Another user requested a seek. Report buffering to SyncPlay + // without forcing local buffering state, otherwise the command + // handler can get stuck waiting and suppress Ready/Unpause. + ref.read(syncPlayProvider.notifier).reportBuffering(); + }, + onStop: () async { + _syncPlayAction = true; + ref.read(syncPlayProvider.notifier).markCommandExecuted(); + await state.stop(); + ref.read(syncPlayProvider.notifier).resetCorrectionState( + reason: 'stop_command', + ); + _syncPlayAction = false; + }, + onSetSpeed: (speed) async { + await state.setSpeed(speed); + }, + getPositionTicks: () { + final position = playbackState.position; + return secondsToTicks(position.inMilliseconds / 1000); + }, + isPlaying: () => playbackState.playing, + isBuffering: () => _isReloading || playbackState.buffering, + hasPlaybackRate: () => !state.isNativePlayerActive, + ); + } + + Future updateBuffering(bool event) async { + final oldState = playbackState; + if (oldState.buffering == event) { + return; + } + + mediaState.update((state) => state.copyWith(buffering: event)); + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(event); + } + + // Report buffering state to SyncPlay if active + // Skip if we're in the cooldown period after a SyncPlay command to prevent feedback loops + // Also skip if we are currently reloading (we'll report manually when done) + if (_isSyncPlayActive && !_syncPlayAction && !_inSyncPlayCooldown && !_isReloading) { + if (event) { + // Started buffering + ref.read(syncPlayProvider.notifier).reportBuffering(); + } else { + // Finished buffering - ready + ref.read(syncPlayProvider.notifier).reportReady(isPlaying: playbackState.playing); + } + } + } Future updateBuffer(Duration buffer) async { mediaState.update( @@ -79,7 +235,9 @@ class VideoPlayerNotifier extends StateNotifier { Future updatePlaying(bool event) async { final currentState = playbackState; - if (!state.hasPlayer || currentState.playing == event) return; + if (!state.hasPlayer || currentState.playing == event) { + return; + } mediaState.update( (state) => state.copyWith(playing: event), ); @@ -87,12 +245,18 @@ 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; final currentPosition = currentState.position; - if ((currentPosition - event).inSeconds.abs() < 1) return; + if ((currentPosition - event).inSeconds.abs() < 1) { + return; + } final position = event; @@ -110,9 +274,31 @@ class VideoPlayerNotifier extends StateNotifier { position: event, )); } + + // Feed time updates into SyncPlay drift estimation. + if (_isSyncPlayActive) { + ref.read(syncPlayProvider.notifier).updatePlaybackDrift( + currentPositionTicks: secondsToTicks( + event.inMilliseconds / 1000, + ), + at: DateTime.now().toUtc(), + ); + } } - Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { + Future loadPlaybackItem( + PlaybackModel model, + Duration startPosition, { + bool waitForSyncPlayCommand = true, + }) async { + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(true); + + // Only report group buffering for flows that should wait + // for a SyncPlay unpause command. + if (_isSyncPlayActive && waitForSyncPlayCommand) { + ref.read(syncPlayProvider.notifier).reportBuffering(); + } + await state.stop(); ref.read(playbackRateProvider.notifier).state = 1.0; mediaState.update((state) => state.copyWith( @@ -125,6 +311,9 @@ class VideoPlayerNotifier extends StateNotifier { final media = model.media; PlaybackModel? newPlaybackModel = model; + // Capture syncplay state before async operations + final syncPlayActive = _isSyncPlayActive; + if (media != null) { await state.loadVideo(model, startPosition, false); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); @@ -138,7 +327,18 @@ class VideoPlayerNotifier extends StateNotifier { } await state.setAudioTrack(null, model); await state.setSubtitleTrack(null, model); - state.play(); + + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); + + // For local track-switch reloads in SyncPlay, resume local playback + // directly and avoid forcing group-wide wait/unpause. + if (!syncPlayActive || !waitForSyncPlayCommand) { + await state.play(); + } else { + // For SyncPlay, we report ready now that reload AND seek are done. + // We report NOT playing so the server sends an explicit Unpause command. + await ref.read(syncPlayProvider.notifier).reportReady(isPlaying: false); + } ref.read(playBackModel.notifier).update((state) => newPlaybackModel); }, ); @@ -148,6 +348,7 @@ class VideoPlayerNotifier extends StateNotifier { return true; } + ref.read(syncPlayProvider.notifier).setPlayerBufferingState(false); mediaState.update((state) => state.copyWith(errorPlaying: true)); return false; } @@ -208,4 +409,53 @@ class VideoPlayerNotifier extends StateNotifier { return false; } + + // ============================================ + // User-initiated actions (go through SyncPlay if active) + // ============================================ + + /// User-initiated play - routes through SyncPlay if active + Future userPlay() async { + if (_isSyncPlayActive) { + // Just request unpause. The server will put the group in Waiting state, + // and our buffering listener will report Ready(isPlaying: false) when appropriate. + await ref.read(syncPlayProvider.notifier).requestUnpause(); + } else { + await state.play(); + } + } + + /// User-initiated pause - routes through SyncPlay if active + Future userPause() async { + if (_isSyncPlayActive) { + await ref.read(syncPlayProvider.notifier).requestPause(); + } else { + await state.pause(); + } + } + + /// User-initiated seek - routes through SyncPlay if active + Future userSeek(Duration position) async { + if (_isSyncPlayActive) { + final positionTicks = secondsToTicks(position.inMilliseconds / 1000); + await ref.read(syncPlayProvider.notifier).requestSeek(positionTicks); + } else { + // Remember if we were playing before seek + final wasPlaying = playbackState.playing; + await state.seek(position); + // Resume playback if we were playing before (for native player consistency) + if (wasPlaying && !playbackState.playing) { + await state.play(); + } + } + } + + /// User-initiated play/pause toggle - routes through SyncPlay if active + Future userPlayOrPause() async { + if (playbackState.playing) { + await userPause(); + } else { + await userPlay(); + } + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index db643ed20..ae73670a6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -20,6 +20,7 @@ import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart'; 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/syncplay/dashboard_fabs.dart'; enum HomeTabs { dashboard, @@ -83,13 +84,7 @@ class HomeScreen extends ConsumerWidget { selectedIcon: Icon(e.selectedIcon), route: const DashboardRoute(), action: () => e.navigate(context), - 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/login/controllers/login_controller.dart b/lib/screens/login/controllers/login_controller.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/controllers/login_controller.dart +++ b/lib/screens/login/controllers/login_controller.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/login_code_dialog.dart b/lib/screens/login/login_code_dialog.dart index 61121ce01..4a2e2fa32 100644 --- a/lib/screens/login/login_code_dialog.dart +++ b/lib/screens/login/login_code_dialog.dart @@ -27,6 +27,7 @@ Future openLoginCodeDialog( class LoginCodeDialog extends ConsumerStatefulWidget { final QuickConnectResult quickConnectInfo; final Function(BuildContext context, String secret) onAuthenticated; + const LoginCodeDialog({ required this.quickConnectInfo, required this.onAuthenticated, diff --git a/lib/screens/login/screens/server_selection_screen.dart b/lib/screens/login/screens/server_selection_screen.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/screens/server_selection_screen.dart +++ b/lib/screens/login/screens/server_selection_screen.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/widgets/credentials_input_section.dart b/lib/screens/login/widgets/credentials_input_section.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/widgets/credentials_input_section.dart +++ b/lib/screens/login/widgets/credentials_input_section.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/widgets/login_credentials_input_extensions.dart b/lib/screens/login/widgets/login_credentials_input_extensions.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/widgets/login_credentials_input_extensions.dart +++ b/lib/screens/login/widgets/login_credentials_input_extensions.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/widgets/server_input_section.dart b/lib/screens/login/widgets/server_input_section.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/widgets/server_input_section.dart +++ b/lib/screens/login/widgets/server_input_section.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/widgets/server_url_input.dart b/lib/screens/login/widgets/server_url_input.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/widgets/server_url_input.dart +++ b/lib/screens/login/widgets/server_url_input.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/login/widgets/server_url_input_extensions.dart b/lib/screens/login/widgets/server_url_input_extensions.dart index e69de29bb..8b1378917 100644 --- a/lib/screens/login/widgets/server_url_input_extensions.dart +++ b/lib/screens/login/widgets/server_url_input_extensions.dart @@ -0,0 +1 @@ + diff --git a/lib/screens/settings/quick_connect_window.dart b/lib/screens/settings/quick_connect_window.dart index 6e01c737a..e89efed53 100644 --- a/lib/screens/settings/quick_connect_window.dart +++ b/lib/screens/settings/quick_connect_window.dart @@ -9,16 +9,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; Future openQuickConnectDialog( BuildContext context, ) async { - return showDialog( - context: context, builder: (context) => const QuickConnectDialog()); + return showDialog(context: context, builder: (context) => const QuickConnectDialog()); } class QuickConnectDialog extends ConsumerStatefulWidget { const QuickConnectDialog({super.key}); @override - ConsumerState createState() => - _QuickConnectDialogState(); + ConsumerState createState() => _QuickConnectDialogState(); } class _QuickConnectDialogState extends ConsumerState { @@ -105,8 +103,7 @@ class _QuickConnectDialogState extends ConsumerState { error = null; loading = true; }); - final response = - await ref.read(userProvider.notifier).quickConnect(controller.text); + final response = await ref.read(userProvider.notifier).quickConnect(controller.text); if (response.isSuccessful) { setState( () { diff --git a/lib/screens/shared/media/person_list_.dart b/lib/screens/shared/media/person_list_.dart index 30d9788d1..7650cd62a 100644 --- a/lib/screens/shared/media/person_list_.dart +++ b/lib/screens/shared/media/person_list_.dart @@ -20,12 +20,10 @@ class PersonList extends ConsumerWidget { label, style: Theme.of(context).textTheme.titleMedium, ), - ...people - .map((person) => TextButton( - onPressed: - onPersonTap != null ? () => onPersonTap?.call(person) : () => openPersonDetailPage(context, person), - child: Text(person.name))) - + ...people.map((person) => TextButton( + onPressed: + onPersonTap != null ? () => onPersonTap?.call(person) : () => openPersonDetailPage(context, person), + child: Text(person.name))) ], ); } diff --git a/lib/screens/syncing/sync_item_details.dart b/lib/screens/syncing/sync_item_details.dart index 0e55dfde8..2f4edf42c 100644 --- a/lib/screens/syncing/sync_item_details.dart +++ b/lib/screens/syncing/sync_item_details.dart @@ -1,9 +1,4 @@ -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'; - import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart'; @@ -25,6 +20,9 @@ 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'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.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..0acbd22c8 --- /dev/null +++ b/lib/screens/video_player/components/syncplay_command_indicator.dart @@ -0,0 +1,121 @@ +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'; + +/// Centered overlay showing SyncPlay command being processed +class SyncPlayCommandIndicator extends ConsumerWidget { + const SyncPlayCommandIndicator({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final commandType = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); + final strategy = ref.watch(syncCorrectionStrategyProvider); + + final hasCorrection = strategy != SyncCorrectionStrategy.none; + final showCommand = isProcessing && commandType != null; + final visible = isActive && (showCommand || hasCorrection); + + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: visible ? 1 : 0, + child: Center( + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: visible ? 1.0 : 0.8, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CommandIcon( + commandType: commandType, + strategy: strategy, + ), + const SizedBox(height: 12), + Text( + showCommand ? commandType.syncPlayCommandOverlayLabel(context) : strategy.label(context), + 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, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CommandIcon extends StatelessWidget { + final String? commandType; + final SyncCorrectionStrategy strategy; + + const _CommandIcon({ + required this.commandType, + required this.strategy, + }); + + @override + Widget build(BuildContext context) { + final (icon, color) = + commandType != null ? commandType.syncPlayCommandIconAndColor(context) : strategy.iconAndColor(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 48, + color: color, + ), + ); + } +} diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index c3f31eb27..b9ecc553d 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/floating_player_bar.dart'; import 'package:fladder/widgets/shared/progress_floating_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class VideoPlayerNextWrapper extends ConsumerStatefulWidget { final Widget video; final Widget controls; final List overlays; + const VideoPlayerNextWrapper({ required this.video, required this.controls, @@ -123,6 +122,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState Future closePlayer() async { clearOverlaySettings(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; ref.read(videoPlayerProvider).stop(); Navigator.of(context).pop(); } @@ -350,6 +350,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState class _NextUpInformation extends StatelessWidget { final ItemBaseModel item; + const _NextUpInformation({ required this.item, }); @@ -440,6 +441,7 @@ class _NextUpInformation extends StatelessWidget { class _SimpleControls extends ConsumerWidget { final Function()? skip; + const _SimpleControls({ this.skip, }); 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 d451f39ef..a9e0580b0 100644 --- a/lib/screens/video_player/components/video_player_options_sheet.dart +++ b/lib/screens/video_player/components/video_player_options_sheet.dart @@ -420,7 +420,10 @@ Future showSubSelection(BuildContext context) { final newModel = await playbackModel.setSubtitle(subModel, player); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { - await ref.read(playbackModelHelper).shouldReload(newModel); + await ref.read(playbackModelHelper).shouldReload( + newModel, + isLocalTrackSwitch: true, + ); } }, ); @@ -461,7 +464,10 @@ Future showAudioSelection(BuildContext context) { final newModel = await playbackModel.setAudio(audioStream, player); 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/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_player_speed_indicator.dart b/lib/screens/video_player/components/video_player_speed_indicator.dart index 40114f477..6367824d0 100644 --- a/lib/screens/video_player/components/video_player_speed_indicator.dart +++ b/lib/screens/video_player/components/video_player_speed_indicator.dart @@ -71,8 +71,8 @@ class _VideoPlayerSpeedIndicatorState extends ConsumerState { ), onChangeEnd: (e) async { currentDuration = Duration(milliseconds: e.toInt()); + // Route seek through SyncPlay if active + widget.onPositionChanged(Duration(milliseconds: e.toInt())); widget.onPositionChanged.call(Duration(milliseconds: e.toInt())); await Future.delayed(const Duration(milliseconds: 250)); if (widget.wasPlaying) { - player.play(); + // Route play through SyncPlay if active + ref.read(videoPlayerProvider.notifier).userPlay(); } widget.timerReset.call(); setState(() { @@ -122,7 +123,8 @@ class _ChapterProgressSliderState extends ConsumerState { onHoverStart = true; }); widget.wasPlayingChanged.call(player.lastState?.playing ?? false); - player.pause(); + // Route pause through SyncPlay if active + ref.read(videoPlayerProvider.notifier).userPause(); }, onChanged: (e) { currentDuration = Duration(milliseconds: e.toInt()); diff --git a/lib/screens/video_player/tv_player_controls.dart b/lib/screens/video_player/tv_player_controls.dart index 205b36629..bc2978411 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, @@ -126,6 +127,7 @@ class _TvPlayerControlsState extends ConsumerState { const VideoPlayerSeekIndicator(), const VideoPlayerVolumeIndicator(), const VideoPlayerScreenshotIndicator(), + const SyncPlayCommandIndicator(), ], ), ), @@ -205,6 +207,7 @@ class _TvPlayerControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -629,7 +632,10 @@ class _TvPlayerControlsState extends ConsumerState { void minimizePlayer(BuildContext context) { clearOverlaySettings(); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.minimized)); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); Navigator.of(context).pop(); } @@ -637,6 +643,7 @@ class _TvPlayerControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; ref.read(videoPlayerProvider).stop(); Navigator.of(context).pop(); } diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index 198126900..69ea21abb 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -55,6 +55,12 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb @override void dispose() { WidgetsBinding.instance.removeObserver(this); + final currentPlaybackState = ref.read(mediaPlaybackProvider).state; + if (currentPlaybackState == VideoPlayerState.fullScreen) { + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith(state: VideoPlayerState.minimized), + ); + } SystemChrome.setPreferredOrientations(DeviceOrientation.values); super.dispose(); } @@ -64,6 +70,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( diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 03c332693..e612089c5 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -1,15 +1,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:async/async.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:screen_brightness/screen_brightness.dart'; - import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/media_playback_model.dart'; @@ -17,10 +8,12 @@ import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.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_controls_extras.dart'; import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart'; @@ -38,6 +31,14 @@ 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:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class DesktopControls extends ConsumerStatefulWidget { const DesktopControls({super.key}); @@ -123,7 +124,9 @@ class _DesktopControlsState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: initInputDevice == InputDevice.pointer ? () => player.playOrPause() : () => toggleOverlay(), + onTap: initInputDevice == InputDevice.pointer + ? () => ref.read(videoPlayerProvider.notifier).userPlayOrPause() + : () => toggleOverlay(), onDoubleTapDown: initInputDevice == InputDevice.touch ? _handleDoubleTapDown : null, onDoubleTap: initInputDevice == InputDevice.pointer ? () => fullScreenHelper.toggleFullScreen(ref) @@ -157,6 +160,7 @@ class _DesktopControlsState extends ConsumerState { const VideoPlayerVolumeIndicator(), const VideoPlayerSpeedIndicator(), const VideoPlayerScreenshotIndicator(), + const SyncPlayCommandIndicator(), Consumer( builder: (context, ref, child) { final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); @@ -216,7 +220,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", @@ -282,6 +286,7 @@ class _DesktopControlsState extends ConsumerState { ], ), ), + const SyncPlayBadge(), if (initInputDevice == InputDevice.touch) Align( alignment: Alignment.centerRight, @@ -386,7 +391,7 @@ class _DesktopControlsState extends ConsumerState { IconButton.filledTonal( iconSize: 38, onPressed: () { - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); }, icon: Icon( mediaPlayback.playing ? IconsaxPlusBold.pause : IconsaxPlusBold.play, @@ -466,9 +471,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( @@ -500,7 +506,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), @@ -643,7 +649,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(() { @@ -662,14 +668,14 @@ 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 seekForward(WidgetRef ref, {int seconds = 15}) { 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 seekBackWithIndicator() { @@ -702,7 +708,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(); } @@ -710,7 +719,15 @@ class _DesktopControlsState extends ConsumerState { Future closePlayer() async { clearOverlaySettings(); - ref.read(videoPlayerProvider).stop(); + // Mark the route as closed immediately so that a SyncPlay + // _startPlayback call arriving during the pop animation knows + // it must push a new route. + ref.read(isVideoPlayerRouteOpenProvider.notifier).state = false; + if (ref.read(isSyncPlayActiveProvider)) { + await ref.read(videoPlayerProvider).pause(); + } else { + ref.read(videoPlayerProvider).stop(); + } Navigator.of(context).pop(); } @@ -843,7 +860,7 @@ class _DesktopControlsState extends ConsumerState { if (_speedBoostActive) { return false; } - ref.read(videoPlayerProvider).playOrPause(); + ref.read(videoPlayerProvider.notifier).userPlayOrPause(); return true; case VideoHotKeys.volumeUp: resetTimer(); diff --git a/lib/src/battery_optimization_pigeon.g.dart b/lib/src/battery_optimization_pigeon.g.dart index de26360b8..6de9ef8fb 100644 --- a/lib/src/battery_optimization_pigeon.g.dart +++ b/lib/src/battery_optimization_pigeon.g.dart @@ -50,7 +50,6 @@ class BatteryOptimizationPigeon { final String pigeonVar_messageChannelSuffix; - /// Returns whether the app is currently *ignored* from battery optimizations. Future isIgnoringBatteryOptimizations() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -79,7 +78,6 @@ class BatteryOptimizationPigeon { } } - /// Opens the battery-optimization/settings screen for this app (Android). Future openBatteryOptimizationSettings() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.BatteryOptimizationPigeon.openBatteryOptimizationSettings$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( diff --git a/lib/src/translations_pigeon.g.dart b/lib/src/translations_pigeon.g.dart index a68570968..7ffd04d29 100644 --- a/lib/src/translations_pigeon.g.dart +++ b/lib/src/translations_pigeon.g.dart @@ -73,6 +73,18 @@ abstract class TranslationsPigeon { String decline(); + String syncPlaySyncingWithGroup(); + + String syncPlayCommandPausing(); + + String syncPlayCommandPlaying(); + + String syncPlayCommandSeeking(); + + String syncPlayCommandStopping(); + + String syncPlayCommandSyncing(); + static void setUp(TranslationsPigeon? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { @@ -399,5 +411,119 @@ abstract class TranslationsPigeon { }); } } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlaySyncingWithGroup$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlaySyncingWithGroup(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPausing$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandPausing(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandPlaying$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandPlaying(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSeeking$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandSeeking(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandStopping$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandStopping(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.TranslationsPigeon.syncPlayCommandSyncing$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final String output = api.syncPlayCommandSyncing(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart index b717b5ffe..ba6ed00e7 100644 --- a/lib/src/video_player_helper.g.dart +++ b/lib/src/video_player_helper.g.dart @@ -46,6 +46,14 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, +} + enum MediaSegmentType { commercial, preview, @@ -54,6 +62,16 @@ enum MediaSegmentType { outro, } +/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. + user, + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, +} + class SimpleItemModel { SimpleItemModel({ required this.id, @@ -632,6 +650,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); int position; @@ -648,6 +667,9 @@ class PlaybackState { bool failed; + /// When set, indicates who caused this state update (for SyncPlay inference). + PlaybackChangeSource? changeSource; + List _toList() { return [ position, @@ -657,6 +679,7 @@ class PlaybackState { buffering, completed, failed, + changeSource, ]; } @@ -673,6 +696,7 @@ class PlaybackState { buffering: result[4]! as bool, completed: result[5]! as bool, failed: result[6]! as bool, + changeSource: result[7] as PlaybackChangeSource?, ); } @@ -974,50 +998,56 @@ class _PigeonCodec extends StandardMessageCodec { } 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.index); + } else if (value is PlaybackChangeSource) { + buffer.putUint8(132); + writeValue(buffer, value.index); + } else if (value is SimpleItemModel) { + buffer.putUint8(133); writeValue(buffer, value.encode()); } else if (value is MediaInfo) { - buffer.putUint8(132); + buffer.putUint8(134); writeValue(buffer, value.encode()); } else if (value is PlayableData) { - buffer.putUint8(133); + buffer.putUint8(135); writeValue(buffer, value.encode()); } else if (value is MediaSegment) { - buffer.putUint8(134); + buffer.putUint8(136); writeValue(buffer, value.encode()); } else if (value is AudioTrack) { - buffer.putUint8(135); + buffer.putUint8(137); writeValue(buffer, value.encode()); } else if (value is SubtitleTrack) { - buffer.putUint8(136); + buffer.putUint8(138); writeValue(buffer, value.encode()); } else if (value is Chapter) { - buffer.putUint8(137); + buffer.putUint8(139); writeValue(buffer, value.encode()); } else if (value is TrickPlayModel) { - buffer.putUint8(138); + buffer.putUint8(140); writeValue(buffer, value.encode()); } else if (value is StartResult) { - buffer.putUint8(139); + buffer.putUint8(141); writeValue(buffer, value.encode()); } else if (value is PlaybackState) { - buffer.putUint8(140); + buffer.putUint8(142); writeValue(buffer, value.encode()); } else if (value is SubtitleSettings) { - buffer.putUint8(141); + buffer.putUint8(143); writeValue(buffer, value.encode()); } else if (value is TVGuideModel) { - buffer.putUint8(142); + buffer.putUint8(144); writeValue(buffer, value.encode()); } else if (value is GuideChannel) { - buffer.putUint8(143); + buffer.putUint8(145); writeValue(buffer, value.encode()); } else if (value is GuideProgram) { - buffer.putUint8(144); + buffer.putUint8(146); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -1032,34 +1062,40 @@ class _PigeonCodec extends StandardMessageCodec { return value == null ? null : PlaybackType.values[value]; case 130: final int? value = readValue(buffer) as int?; - return value == null ? null : MediaSegmentType.values[value]; + return value == null ? null : SyncPlayCommandType.values[value]; case 131: - return SimpleItemModel.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : MediaSegmentType.values[value]; case 132: - return MediaInfo.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : PlaybackChangeSource.values[value]; case 133: - return PlayableData.decode(readValue(buffer)!); + return SimpleItemModel.decode(readValue(buffer)!); case 134: - return MediaSegment.decode(readValue(buffer)!); + return MediaInfo.decode(readValue(buffer)!); case 135: - return AudioTrack.decode(readValue(buffer)!); + return PlayableData.decode(readValue(buffer)!); case 136: - return SubtitleTrack.decode(readValue(buffer)!); + return MediaSegment.decode(readValue(buffer)!); case 137: - return Chapter.decode(readValue(buffer)!); + return AudioTrack.decode(readValue(buffer)!); case 138: - return TrickPlayModel.decode(readValue(buffer)!); + return SubtitleTrack.decode(readValue(buffer)!); case 139: - return StartResult.decode(readValue(buffer)!); + return Chapter.decode(readValue(buffer)!); case 140: - return PlaybackState.decode(readValue(buffer)!); + return TrickPlayModel.decode(readValue(buffer)!); case 141: - return SubtitleSettings.decode(readValue(buffer)!); + return StartResult.decode(readValue(buffer)!); case 142: - return TVGuideModel.decode(readValue(buffer)!); + return PlaybackState.decode(readValue(buffer)!); case 143: - return GuideChannel.decode(readValue(buffer)!); + return SubtitleSettings.decode(readValue(buffer)!); case 144: + return TVGuideModel.decode(readValue(buffer)!); + case 145: + return GuideChannel.decode(readValue(buffer)!); + case 146: return GuideProgram.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -1444,6 +1480,32 @@ class VideoPlayerApi { return; } } + + /// Sets the SyncPlay command state for the native player overlay. + /// [processing] indicates if a SyncPlay command is being processed. + /// [commandType] is the type of command. + Future setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setSyncPlayCommandState$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([processing, commandType]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } abstract class VideoPlayerListenerCallback { @@ -1498,6 +1560,16 @@ abstract class VideoPlayerControlsCallback { Future> fetchProgramsForChannel(String channelId); + /// User-initiated play action from native player (for SyncPlay integration) + void onUserPlay(); + + /// User-initiated pause action from native player (for SyncPlay integration) + void onUserPause(); + + /// User-initiated seek action from native player (for SyncPlay integration) + /// Position is in milliseconds + void onUserSeek(int positionMs); + static void setUp(VideoPlayerControlsCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { @@ -1657,5 +1729,68 @@ abstract class VideoPlayerControlsCallback { }); } } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPlay$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onUserPlay(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserPause$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onUserPause(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null.'); + final List args = (message as List?)!; + final int? arg_positionMs = (args[0] as int?); + assert(arg_positionMs != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onUserSeek was null, expected non-null int.'); + try { + api.onUserSeek(arg_positionMs!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index 63989110e..31f34c604 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -1,15 +1,8 @@ import 'dart:developer'; -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/models/book_model.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/channel_model.dart'; @@ -21,6 +14,7 @@ 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'; @@ -32,6 +26,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 '../../models/syncplay/syncplay_models.dart'; extension BookBaseModelExtension on BookModel? { Future play( @@ -198,6 +199,15 @@ extension ItemBaseModelExtensions on ItemBaseModel? { }) async { if (itemModel == null) return; + // When SyncPlay is active, delegate to SyncPlay queue management. + // _startPlayback (triggered by the server's PlayQueue response) + // handles player init and route opening. + final isSyncPlayActive = ref.read(isSyncPlayActiveProvider); + if (isSyncPlayActive) { + await _playSyncPlay(context, itemModel, ref, startPosition: startPosition); + return; + } + await ref.read(videoPlayerProvider.notifier).init(); final op = CancelableOperation.fromFuture(ref.read(playbackModelHelper).createPlaybackModel( @@ -226,6 +236,25 @@ extension ItemBaseModelExtensions on ItemBaseModel? { } } +/// Play item through SyncPlay - sets the queue and lets SyncPlay handle synchronized playback +Future _playSyncPlay( + BuildContext context, + ItemBaseModel itemModel, + WidgetRef ref, { + Duration? startPosition, +}) async { + final startPositionTicks = startPosition != null ? secondsToTicks(startPosition.inMilliseconds / 1000) : 0; + + // Set the new queue via SyncPlay - server will broadcast to all clients + await ref.read(syncPlayProvider.notifier).setNewQueue( + itemIds: [itemModel.id], + playingItemPosition: 0, + startPositionTicks: startPositionTicks, + ); + + // The PlayQueue update from server will trigger playback via _handlePlayQueue +} + extension ItemBaseModelsBooleans on List { Future playLibraryItems(BuildContext context, WidgetRef ref, {bool shuffle = false}) async { if (isEmpty) return; @@ -251,6 +280,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, @@ -278,6 +319,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) { diff --git a/lib/util/localization_helper.dart b/lib/util/localization_helper.dart index c34d74408..e5c2438a9 100644 --- a/lib/util/localization_helper.dart +++ b/lib/util/localization_helper.dart @@ -129,4 +129,23 @@ class _TranslationsMessgener extends messenger.TranslationsPigeon { @override String watch() => context.localized.watch; + + // SyncPlay overlay strings + @override + String syncPlaySyncingWithGroup() => context.localized.syncPlaySyncingWithGroup; + + @override + String syncPlayCommandPausing() => context.localized.syncPlayCommandPausing; + + @override + String syncPlayCommandPlaying() => context.localized.syncPlayCommandPlaying; + + @override + String syncPlayCommandSeeking() => context.localized.syncPlayCommandSeeking; + + @override + String syncPlayCommandStopping() => context.localized.syncPlayCommandStopping; + + @override + String syncPlayCommandSyncing() => context.localized.syncPlayCommandSyncing; } diff --git a/lib/widgets/navigation_scaffold/components/destination_model.dart b/lib/widgets/navigation_scaffold/components/destination_model.dart index f5e4255c8..fb27874f4 100644 --- a/lib/widgets/navigation_scaffold/components/destination_model.dart +++ b/lib/widgets/navigation_scaffold/components/destination_model.dart @@ -15,6 +15,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, @@ -24,8 +27,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 f8c5f631d..900726127 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -1,10 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; - import 'package:fladder/models/collection_types.dart'; import 'package:fladder/models/settings/client_settings_model.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; @@ -28,6 +23,10 @@ import 'package:fladder/widgets/shared/custom_tooltip.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/simple_overflow_widget.dart'; +import 'package:fladder/widgets/syncplay/syncplay_fab.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; final navBarNode = FocusNode(); @@ -170,7 +169,7 @@ class _SideNavigationRail extends ConsumerState { padding: 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), ), ), ], @@ -411,6 +410,28 @@ class _SideNavigationRail extends ConsumerState { child: const Icon(IconsaxPlusLinear.search_normal_1), ); } + + Widget actionButtonWidget(BuildContext context, bool expanded) { + final destination = (widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length) + ? widget.destinations[widget.currentIndex] + : null; + + // If there's a custom FAB widget, use it (already includes SyncPlay for dashboard) + if (destination?.customFab != null) { + return destination!.customFab!; + } + + // Otherwise show SyncPlay + action button (same pattern as DashboardFabs) + final fab = actionButton(context); + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const SyncPlayFab(), + expanded ? fab.extended : fab.normal, + ], + ); + } } class _RailTraversalPolicy extends ReadingOrderTraversalPolicy { diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index 658e010f6..f4e9f5e3a 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -115,7 +115,7 @@ class _NavigationScaffoldState extends ConsumerState { resizeToAvoidBottomInset: false, extendBody: true, floatingActionButton: AdaptiveLayout.layoutModeOf(context) == LayoutMode.single && isHomeScreen - ? widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal + ? widget.destinations.elementAtOrNull(currentIndex)?.fabWidget : null, drawer: homeRoutes.any((element) => element.name.contains(currentLocation)) ? NestedNavigationDrawer( diff --git a/lib/widgets/shared/back_intent_dpad.dart b/lib/widgets/shared/back_intent_dpad.dart index a6a1a1175..59f6a37b2 100644 --- a/lib/widgets/shared/back_intent_dpad.dart +++ b/lib/widgets/shared/back_intent_dpad.dart @@ -1,11 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/gestures.dart'; - import 'package:auto_route/auto_route.dart'; - import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/focus_helper.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class BackIntentDpad extends StatelessWidget { final Widget child; @@ -34,11 +32,11 @@ class BackIntentDpad extends StatelessWidget { if (event.logicalKey == LogicalKeyboardKey.backspace) { if (isEditableTextFocused()) { return KeyEventResult.ignored; - } else { + } else { context.maybePop(); return KeyEventResult.handled; + } } - } return KeyEventResult.ignored; }, diff --git a/lib/widgets/shared/background_item_image.dart b/lib/widgets/shared/background_item_image.dart index e69de29bb..8b1378917 100644 --- a/lib/widgets/shared/background_item_image.dart +++ b/lib/widgets/shared/background_item_image.dart @@ -0,0 +1 @@ + diff --git a/lib/widgets/syncplay/dashboard_fabs.dart b/lib/widgets/syncplay/dashboard_fabs.dart new file mode 100644 index 000000000..48c114afa --- /dev/null +++ b/lib/widgets/syncplay/dashboard_fabs.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/routes/auto_router.gr.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; +import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; + +/// Combined FAB for dashboard with search and SyncPlay actions +class DashboardFabs extends ConsumerWidget { + const DashboardFabs({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + final isDualLayout = AdaptiveLayout.of(context).layoutMode == LayoutMode.dual; + + final children = [ + // SyncPlay FAB + _SyncPlayFabButton(isActive: isActive), + // Search FAB + AdaptiveFab( + context: context, + title: context.localized.search, + key: const Key('dashboard_search'), + onPressed: () => context.router.navigate(LibrarySearchRoute()), + child: const Icon(IconsaxPlusLinear.search_normal_1), + ).normal, + ]; + + return isDualLayout + ? Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ) + : Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: children, + ); + } +} + +class _SyncPlayFabButton extends StatelessWidget { + final bool isActive; + + const _SyncPlayFabButton({required this.isActive}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: 'syncplay_fab', + child: IconButton.filledTonal( + iconSize: 26, + tooltip: context.localized.syncPlay, + onPressed: () => showSyncPlaySheet(context), + style: IconButton.styleFrom( + backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, + ), + icon: Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + Icon( + isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_badge.dart b/lib/widgets/syncplay/syncplay_badge.dart new file mode 100644 index 000000000..c668e8db4 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_badge.dart @@ -0,0 +1,131 @@ +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/syncplay/syncplay_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +/// Badge widget showing SyncPlay status in the video player +class SyncPlayBadge extends ConsumerWidget { + const SyncPlayBadge({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + if (!isActive) return const SizedBox.shrink(); + + final groupName = ref.watch(syncPlayGroupNameProvider); + final groupState = ref.watch(syncPlayGroupStateProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final processingCommand = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); + final correctionStrategy = ref.watch(syncCorrectionStrategyProvider); + final hasCorrection = correctionStrategy != SyncCorrectionStrategy.none; + + final (icon, color) = groupState.iconAndColor(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: (isProcessing || hasCorrection) + ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.95) + : Theme.of(context).colorScheme.surface.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: (isProcessing || hasCorrection) ? Theme.of(context).colorScheme.primary : color.withValues(alpha: 0.5), + width: (isProcessing || hasCorrection) ? 2 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isProcessing || hasCorrection) ...[ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 6), + Text( + isProcessing ? processingCommand.syncPlayProcessingLabel(context) : correctionStrategy.label(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ] else ...[ + Icon( + IconsaxPlusLinear.people, + size: 14, + color: color, + ), + const SizedBox(width: 6), + Text( + groupName ?? context.localized.syncPlay, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 6), + Icon( + icon, + size: 12, + color: color, + ), + ], + ], + ), + ); + } +} + +/// Compact SyncPlay indicator for tight spaces +class SyncPlayIndicator extends ConsumerWidget { + const SyncPlayIndicator({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + if (!isActive) return const SizedBox.shrink(); + + final groupState = ref.watch(syncPlayGroupStateProvider); + final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); + final correctionStrategy = ref.watch(syncCorrectionStrategyProvider); + final hasCorrection = correctionStrategy != SyncCorrectionStrategy.none; + + final color = groupState.color(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: (isProcessing || hasCorrection) + ? Theme.of(context).colorScheme.primaryContainer + : color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: + (isProcessing || hasCorrection) ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) : null, + ), + child: (isProcessing || hasCorrection) + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ) + : Icon( + IconsaxPlusBold.people, + size: 16, + color: color, + ), + ); + } +} diff --git a/lib/widgets/syncplay/syncplay_extensions.dart b/lib/widgets/syncplay/syncplay_extensions.dart new file mode 100644 index 000000000..296759720 --- /dev/null +++ b/lib/widgets/syncplay/syncplay_extensions.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/models/syncplay/syncplay_models.dart'; +import 'package:fladder/util/localization_helper.dart'; + +/// Extension on [SyncPlayGroupState] for badge/indicator icon and color. +extension SyncPlayGroupStateExtension on SyncPlayGroupState { + /// Returns (icon, color) for the current group state. + (IconData, Color) iconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncPlayGroupState.idle => ( + IconsaxPlusLinear.pause_circle, + scheme.onSurfaceVariant, + ), + SyncPlayGroupState.waiting => ( + IconsaxPlusLinear.timer_1, + scheme.tertiary, + ), + SyncPlayGroupState.paused => ( + IconsaxPlusLinear.pause, + scheme.secondary, + ), + SyncPlayGroupState.playing => ( + IconsaxPlusLinear.play, + scheme.primary, + ), + }; + } + + /// Returns the color only (for compact indicator). + Color color(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncPlayGroupState.idle => scheme.onSurfaceVariant, + SyncPlayGroupState.waiting => scheme.tertiary, + SyncPlayGroupState.paused => scheme.secondary, + SyncPlayGroupState.playing => scheme.primary, + }; + } +} + +/// Extension for localized SyncPlay command processing label (command type string). +extension SyncPlayCommandLabelExtension on String? { + /// Returns the localized "Syncing..." text for this command type. + String syncPlayProcessingLabel(BuildContext context) { + return switch (this) { + 'Pause' => context.localized.syncPlaySyncingPause, + 'Unpause' => context.localized.syncPlaySyncingPlay, + 'Seek' => context.localized.syncPlaySyncingSeek, + 'Stop' => context.localized.syncPlayStopping, + _ => context.localized.syncPlaySyncing, + }; + } + + /// Returns the short command label for overlay (e.g. "Pausing", "Seeking"). + String syncPlayCommandOverlayLabel(BuildContext context) { + return switch (this) { + 'Pause' => context.localized.syncPlayCommandPausing, + 'Unpause' => context.localized.syncPlayCommandPlaying, + 'Seek' => context.localized.syncPlayCommandSeeking, + 'Stop' => context.localized.syncPlayCommandStopping, + _ => context.localized.syncPlayCommandSyncing, + }; + } + + /// Returns (icon, color) for the command overlay. + (IconData, Color) syncPlayCommandIconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + 'Pause' => (IconsaxPlusBold.pause, scheme.secondary), + 'Unpause' => (IconsaxPlusBold.play, scheme.primary), + 'Seek' => (IconsaxPlusBold.forward, scheme.tertiary), + 'Stop' => (IconsaxPlusBold.stop, scheme.error), + _ => (IconsaxPlusBold.refresh, scheme.primary), + }; + } +} + +/// Extension for correction strategy UI mapping. +extension SyncCorrectionStrategyExtension on SyncCorrectionStrategy { + /// Returns short label for active correction strategy. + String label(BuildContext context) { + return switch (this) { + SyncCorrectionStrategy.none => context.localized.syncPlaySyncing, + SyncCorrectionStrategy.speedToSync => 'SpeedToSync', + SyncCorrectionStrategy.skipToSync => 'SkipToSync', + }; + } + + /// Returns icon and color for active correction strategy. + (IconData, Color) iconAndColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (this) { + SyncCorrectionStrategy.none => (IconsaxPlusBold.refresh, scheme.primary), + SyncCorrectionStrategy.speedToSync => (IconsaxPlusBold.flash_1, scheme.primary), + SyncCorrectionStrategy.skipToSync => (IconsaxPlusBold.forward, scheme.tertiary), + }; + } +} diff --git a/lib/widgets/syncplay/syncplay_fab.dart b/lib/widgets/syncplay/syncplay_fab.dart new file mode 100644 index 000000000..9df83c6cc --- /dev/null +++ b/lib/widgets/syncplay/syncplay_fab.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/syncplay/syncplay_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; +import 'package:fladder/widgets/syncplay/syncplay_utils.dart'; + +/// FAB for accessing SyncPlay from the home screen +class SyncPlayFab extends ConsumerWidget { + const SyncPlayFab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isActive = ref.watch(isSyncPlayActiveProvider); + + return AdaptiveFab( + context: context, + title: context.localized.syncPlay, + heroTag: 'syncplay_fab', + backgroundColor: isActive ? Theme.of(context).colorScheme.primaryContainer : null, + onPressed: () => showSyncPlaySheet(context), + child: Stack( + children: [ + Icon( + isActive ? IconsaxPlusBold.people : IconsaxPlusLinear.people, + ), + if (isActive) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ).normal; + } +} diff --git a/lib/widgets/syncplay/syncplay_group_sheet.dart b/lib/widgets/syncplay/syncplay_group_sheet.dart new file mode 100644 index 000000000..81a5fbdfc --- /dev/null +++ b/lib/widgets/syncplay/syncplay_group_sheet.dart @@ -0,0 +1,440 @@ +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/screens/shared/fladder_notification_overlay.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/localization_helper.dart'; + +/// Bottom sheet for managing SyncPlay groups +class SyncPlayGroupSheet extends ConsumerStatefulWidget { + const SyncPlayGroupSheet({super.key}); + + @override + ConsumerState createState() => _SyncPlayGroupSheetState(); +} + +class _SyncPlayGroupSheetState extends ConsumerState { + @override + void initState() { + super.initState(); + // Defer so we don't modify a provider during the widget lifecycle (Riverpod disallows this). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).loadGroups(); + } + }); + } + + Future _createGroup() async { + final name = await _showCreateGroupDialog(); + if (name == null || name.isEmpty) return; + + ref.read(syncPlayGroupsProvider.notifier).setLoading(true); + + final group = await ref.read(syncPlayProvider.notifier).createGroup(name); + if (group != null && mounted) { + FladderSnack.show(context.localized.syncPlayCreatedGroup(group.groupName ?? ''), context: context); + Navigator.of(context).pop(); + } else { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).setLoading(false); + FladderSnack.show(context.localized.syncPlayFailedToCreateGroup, context: context); + } + } + } + + Future _showCreateGroupDialog() async { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.localized.syncPlayCreateGroup), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: context.localized.syncPlayGroupName, + hintText: context.localized.syncPlayGroupNameHint, + ), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.localized.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: Text(context.localized.create), + ), + ], + ), + ); + } + + Future _joinGroup(GroupInfoDto group) async { + ref.read(syncPlayGroupsProvider.notifier).setLoading(true); + + final success = await ref.read(syncPlayProvider.notifier).joinGroup(group.groupId ?? ''); + if (success && mounted) { + FladderSnack.show(context.localized.syncPlayJoinedGroup(group.groupName ?? ''), context: context); + Navigator.of(context).pop(); + } else { + if (mounted) { + ref.read(syncPlayGroupsProvider.notifier).setLoading(false); + FladderSnack.show(context.localized.syncPlayFailedToJoinGroup, context: context); + } + } + } + + Future _leaveGroup() async { + await ref.read(syncPlayProvider.notifier).leaveGroup(); + if (mounted) { + FladderSnack.show(context.localized.syncPlayLeftGroup, context: context); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final syncPlayState = ref.watch(syncPlayProvider); + final groupsState = ref.watch(syncPlayGroupsProvider); + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8).add(MediaQuery.paddingOf(context)), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: FladderTheme.largeShape.borderRadius, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Container( + height: 8, + width: 35, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: FladderTheme.largeShape.borderRadius, + ), + ), + ) + else + const SizedBox(height: 8), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + IconsaxPlusLinear.people, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.localized.syncPlay, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (syncPlayState.isInGroup) + TextButton.icon( + onPressed: _leaveGroup, + icon: const Icon(IconsaxPlusLinear.logout), + label: Text(context.localized.leave), + ) + else + IconButton( + onPressed: _createGroup, + icon: const Icon(IconsaxPlusLinear.add), + tooltip: context.localized.create, + ), + ], + ), + ), + + const Divider(), + + // Content + Flexible( + child: _SyncPlaySheetContent( + syncPlayState: syncPlayState, + groupsState: groupsState, + onCreateGroup: _createGroup, + onJoinGroup: _joinGroup, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Content area of the SyncPlay group sheet (loading, error, empty, list, or active group). +class _SyncPlaySheetContent extends ConsumerWidget { + const _SyncPlaySheetContent({ + required this.syncPlayState, + required this.groupsState, + required this.onCreateGroup, + required this.onJoinGroup, + }); + + final SyncPlayState syncPlayState; + final SyncPlayGroupsState groupsState; + final VoidCallback onCreateGroup; + final void Function(GroupInfoDto) onJoinGroup; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (syncPlayState.isInGroup) { + return _ActiveGroupView(state: syncPlayState); + } + if (groupsState.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } + if (groupsState.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.warning_2, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayFailedToLoadGroups, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(syncPlayGroupsProvider.notifier).loadGroups(), + child: Text(context.localized.retry), + ), + ], + ), + ), + ); + } + if (groupsState.groups == null || groupsState.groups!.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + IconsaxPlusLinear.people, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + context.localized.syncPlayNoActiveGroups, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + context.localized.syncPlayCreateGroupHint, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + autofocus: true, + onPressed: onCreateGroup, + icon: const Icon(IconsaxPlusLinear.add), + label: Text(context.localized.syncPlayCreateGroupButton), + ), + ], + ), + ), + ); + } + final groups = groupsState.groups!; + return ListView.builder( + shrinkWrap: true, + itemCount: groups.length, + padding: const EdgeInsets.only(bottom: 16), + itemBuilder: (context, index) { + final group = groups[index]; + return _GroupListTile( + group: group, + onTap: () => onJoinGroup(group), + autofocus: index == 0, + ); + }, + ); + } +} + +class _ActiveGroupView extends StatelessWidget { + const _ActiveGroupView({required this.state}); + + final SyncPlayState state; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Group name + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + IconsaxPlusBold.people, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.groupName ?? context.localized.syncPlayGroupFallback, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + context.localized.syncPlayParticipants(state.participants.length), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + _StateIndicator(state: state), + const SizedBox(height: 16), + Text( + context.localized.syncPlayInstructions, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _StateIndicator extends StatelessWidget { + const _StateIndicator({required this.state}); + + final SyncPlayState state; + + @override + Widget build(BuildContext context) { + final (icon, label, color) = switch (state.groupState) { + SyncPlayGroupState.idle => ( + IconsaxPlusLinear.pause_circle, + context.localized.syncPlayStateIdle, + Theme.of(context).colorScheme.onSurfaceVariant, + ), + SyncPlayGroupState.waiting => ( + IconsaxPlusLinear.timer_1, + context.localized.syncPlayStateWaiting, + Theme.of(context).colorScheme.tertiary, + ), + SyncPlayGroupState.paused => ( + IconsaxPlusLinear.pause, + context.localized.syncPlayStatePaused, + Theme.of(context).colorScheme.secondary, + ), + SyncPlayGroupState.playing => ( + IconsaxPlusLinear.play, + context.localized.syncPlayStatePlaying, + Theme.of(context).colorScheme.primary, + ), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color), + ), + ], + ), + ); + } +} + +class _GroupListTile extends StatelessWidget { + final GroupInfoDto group; + final VoidCallback onTap; + final bool autofocus; + + const _GroupListTile({ + required this.group, + required this.onTap, + this.autofocus = false, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + autofocus: autofocus, + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + IconsaxPlusLinear.people, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: Text(group.groupName ?? context.localized.syncPlayUnnamedGroup), + subtitle: Text( + context.localized.syncPlayParticipants(group.participants?.length ?? 0), + 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 1e77f482c..8c7d81b96 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -1,15 +1,8 @@ import 'dart:async'; 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/channel_model.dart'; import 'package:fladder/models/items/media_streams_model.dart'; @@ -30,6 +23,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'; class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerControlsCallback { MediaControlsWrapper({required this.ref}); @@ -45,10 +43,12 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro }; Stream? get stateStream => _player?.stateStream; + PlayerState? get lastState => _player?.lastState; Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) => _player?.subtitles(showOverlay, controlsKey: controlsKey); + Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit); final Ref ref; @@ -128,6 +128,19 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } } + /// Check if the native Android player is currently active + bool get isNativePlayerActive => _player is NativePlayer; + + /// Update SyncPlay command state for the native player overlay + Future updateSyncPlayCommandState( + bool processing, + SyncPlayCommandType commandType, + ) async { + if (_player is NativePlayer) { + await (_player as NativePlayer).player.setSyncPlayCommandState(processing, commandType); + } + } + Future openPlayer(BuildContext context) async => _player?.open(context); void _subscribePlayer() { @@ -396,7 +409,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, + ); } } @@ -407,7 +423,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, + ); } } @@ -444,6 +463,22 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro .toList(); } + // SyncPlay-aware user actions from native player + @override + void onUserPlay() { + ref.read(videoPlayerProvider.notifier).userPlay(); + } + + @override + void onUserPause() { + ref.read(videoPlayerProvider.notifier).userPause(); + } + + @override + void onUserSeek(int positionMs) { + ref.read(videoPlayerProvider.notifier).userSeek(Duration(milliseconds: positionMs)); + } + Future takeScreenshot() { final player = _player; diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart index a3a6b8aae..aa3fa8692 100644 --- a/lib/wrappers/players/native_player.dart +++ b/lib/wrappers/players/native_player.dart @@ -107,6 +107,8 @@ class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { position: Duration(milliseconds: state.position), buffer: Duration(milliseconds: state.buffered), buffering: state.buffering, + changeSource: state.changeSource, + updateChangeSource: true, ); _stateController.add(lastState); } diff --git a/lib/wrappers/players/player_states.dart b/lib/wrappers/players/player_states.dart index a73e4f640..4bf3a4049 100644 --- a/lib/wrappers/players/player_states.dart +++ b/lib/wrappers/players/player_states.dart @@ -1,3 +1,5 @@ +import 'package:fladder/src/video_player_helper.g.dart' show PlaybackChangeSource; + class PlayerState { bool playing; bool completed; @@ -8,6 +10,9 @@ class PlayerState { bool buffering; Duration buffer; + /// Set when state came from native player (for SyncPlay: infer user actions from stream). + PlaybackChangeSource? changeSource; + PlayerState({ this.playing = false, this.completed = false, @@ -17,6 +22,7 @@ class PlayerState { this.rate = 1.0, this.buffering = true, this.buffer = Duration.zero, + this.changeSource, }); PlayerState update({ @@ -28,6 +34,8 @@ class PlayerState { double? volume, double? rate, Duration? buffer, + PlaybackChangeSource? changeSource, + bool updateChangeSource = false, }) { if (playing != null) this.playing = playing; if (completed != null) this.completed = completed; @@ -37,6 +45,7 @@ class PlayerState { if (volume != null) this.volume = volume; if (rate != null) this.rate = rate; if (buffer != null) this.buffer = buffer; + if (updateChangeSource) this.changeSource = changeSource; return this; } } diff --git a/pigeons/translations_pigeon.dart b/pigeons/translations_pigeon.dart index 52b136937..4fe214d7f 100644 --- a/pigeons/translations_pigeon.dart +++ b/pigeons/translations_pigeon.dart @@ -34,4 +34,12 @@ abstract class TranslationsPigeon { String watch(); String now(); String decline(); + + // SyncPlay overlay strings + String syncPlaySyncingWithGroup(); + String syncPlayCommandPausing(); + String syncPlayCommandPlaying(); + String syncPlayCommandSeeking(); + String syncPlayCommandStopping(); + String syncPlayCommandSyncing(); } diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart index 693228e86..4840facc1 100644 --- a/pigeons/video_player.dart +++ b/pigeons/video_player.dart @@ -34,6 +34,14 @@ enum PlaybackType { tv, } +enum SyncPlayCommandType { + none, + pause, + unpause, + seek, + stop, +} + class MediaInfo { final PlaybackType playbackType; final String videoInformation; @@ -139,6 +147,7 @@ class SubtitleTrack { class Chapter { final String name; final String url; + // Duration in milliseconds final int time; @@ -155,6 +164,7 @@ class TrickPlayModel { final int tileWidth; final int tileHeight; final int thumbnailCount; + //Duration in milliseconds final int interval; final List images; @@ -178,6 +188,7 @@ class StartResult { abstract class NativeVideoActivity { @async StartResult launchActivity(); + void disposeActivity(); bool isLeanBackEnabled(); @@ -213,13 +224,32 @@ abstract class VideoPlayerApi { void stop(); void setSubtitleSettings(SubtitleSettings settings); + + /// Sets the SyncPlay command state for the native player overlay. + /// [processing] indicates if a SyncPlay command is being processed. + /// [commandType] is the type of command. + void setSyncPlayCommandState(bool processing, SyncPlayCommandType commandType); +} + +/// Source of the last playback state change (for SyncPlay: infer user actions from stream). +enum PlaybackChangeSource { + /// No specific source (e.g. periodic update, buffering). + none, + + /// User tapped play/pause/seek on native; Flutter should send SyncPlay if active. + user, + + /// Change was caused by applying a SyncPlay command; do not send again. + syncplay, } class PlaybackState { //Milliseconds final int position; + //Milliseconds final int buffered; + //Milliseconds final int duration; final bool playing; @@ -227,6 +257,9 @@ class PlaybackState { final bool completed; final bool failed; + /// When set, indicates who caused this state update (for SyncPlay inference). + final PlaybackChangeSource? changeSource; + const PlaybackState({ required this.position, required this.buffered, @@ -235,6 +268,7 @@ class PlaybackState { required this.buffering, required this.completed, required this.failed, + this.changeSource, }); } @@ -320,11 +354,27 @@ abstract class VideoPlayerListenerCallback { @FlutterApi() abstract class VideoPlayerControlsCallback { void loadNextVideo(); + void loadPreviousVideo(); + void onStop(); + void swapSubtitleTrack(int value); + void swapAudioTrack(int value); + void loadProgram(GuideChannel selection); + @async List fetchProgramsForChannel(String channelId); + + /// User-initiated play action from native player (for SyncPlay integration) + void onUserPlay(); + + /// User-initiated pause action from native player (for SyncPlay integration) + void onUserPause(); + + /// User-initiated seek action from native player (for SyncPlay integration) + /// Position is in milliseconds + void onUserSeek(int positionMs); } diff --git a/pubspec.lock b/pubspec.lock index 7385c79a7..9952d596c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2435,7 +2435,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 263dead89..eddbc57ad 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