diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt index b6b8a4da2..6742aa46b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt @@ -20,16 +20,15 @@ internal class MpvEndFileDiagnostics { } fun onEndFile(event: MpvEvent.EndFile): Map? { - val reason = event.reason - if (reason == null) { - errorMessage = null - return null - } - val data = mutableMapOf("reason" to reason.id) - if (reason == EndFileReason.Error) { - errorMessage?.let { data["message"] = it } + val data = mutableMapOf() + event.sourceId?.let { data["sourceId"] = it } + event.reason?.let { reason -> + data["reason"] = reason.id + if (reason == EndFileReason.Error) { + errorMessage?.let { data["message"] = it } + } } errorMessage = null - return data + return data.takeIf { it.isNotEmpty() } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index d6d7b146a..35b30c902 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -510,6 +510,17 @@ class MpvPlayerCore private constructor( ) } + private fun lifecycleData( + sourceId: Long?, + positionSeconds: Double? = null + ): Map? { + if (sourceId == null && positionSeconds == null) return null + return buildMap { + sourceId?.let { put("sourceId", it) } + positionSeconds?.let { put("positionSeconds", it) } + } + } + private fun collectEvents(p: MpvPlayer) { scope.launch(start = CoroutineStart.UNDISPATCHED) { p.eventFlow.collect { event -> @@ -524,7 +535,7 @@ class MpvPlayerCore private constructor( // file. A genuine failure re-arms it, costing one switch per bad // file instead of the whole session's HDR/10-bit scanout. setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false) - delegate?.onEvent("start-file", null) + delegate?.onEvent("start-file", lifecycleData(event.sourceId)) } is MpvEvent.FileLoaded -> { if (usesMediaCodecVo) { @@ -538,9 +549,14 @@ class MpvPlayerCore private constructor( } } } - delegate?.onEvent("file-loaded", null) + delegate?.onEvent("file-loaded", lifecycleData(event.sourceId)) + } + is MpvEvent.PlaybackRestart -> { + delegate?.onEvent( + "playback-restart", + lifecycleData(event.sourceId, event.positionSeconds) + ) } - is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null) } } } @@ -563,7 +579,7 @@ class MpvPlayerCore private constructor( if (change.name == "pause" && change is PropertyChange.Flag) { cachedPaused = change.value } - delegate?.onPropertyChange(change.name, value) + delegate?.onPropertyChange(change.name, value, change.sourceId) } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index bfd1d74f3..775eecb2b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -608,8 +608,12 @@ open class MpvPlayerPlugin( // PlayerDelegate override fun onPropertyChange(name: String, value: Any?) { + onPropertyChange(name, value, null) + } + + override fun onPropertyChange(name: String, value: Any?, sourceId: Long?) { val propId = nameToId[name] ?: return - channels.emitProperty(propId, value) + channels.emitProperty(propId, value, sourceId) } override fun onEvent(name: String, data: Map?) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt index 9a84706c8..01e9a8258 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt @@ -62,6 +62,10 @@ internal class PlayerChannelBinding( runOnMain { eventSink?.success(listOf(id, value)) } } + fun emitProperty(id: Int, value: Any?, sourceId: Long?) { + runOnMain { eventSink?.success(listOf(id, value, sourceId)) } + } + fun emitEvent(name: String, data: Map? = null) { val event = mutableMapOf( "type" to "event", diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt index a023ac7d3..bb0422cb5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt @@ -2,5 +2,9 @@ package com.edde746.plezy.shared interface PlayerDelegate { fun onPropertyChange(name: String, value: Any?) + fun onPropertyChange(name: String, value: Any?, sourceId: Long?) { + onPropertyChange(name, value) + } + fun onEvent(name: String, data: Map?) } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt index a18e9b66c..920514365 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt @@ -778,10 +778,11 @@ class MpvPlayerPluginTest { assertEquals( mapOf( + "sourceId" to 73L, "reason" to 4, "message" to "Invalid data found when processing input" ), - diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error)) + diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 73L)) ) } @@ -791,9 +792,10 @@ class MpvPlayerPluginTest { diagnostics.onLogMessage(LogMessage("ffmpeg", LogLevel.Error, "old failure")) diagnostics.onStartFile() - assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof))) - assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error))) - assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null))) + assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof, null))) + assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, null))) + assertEquals(mapOf("sourceId" to 81L), diagnostics.onEndFile(MpvEvent.EndFile(null, 81L))) + assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null, null))) } @Test @@ -805,6 +807,7 @@ class MpvPlayerPluginTest { plugin.onEvent( "end-file", mapOf( + "sourceId" to 92L, "reason" to 4, "message" to "Failed to open stream" ) @@ -815,6 +818,7 @@ class MpvPlayerPluginTest { "type" to "event", "name" to "end-file", "data" to mapOf( + "sourceId" to 92L, "reason" to 4, "message" to "Failed to open stream" ) @@ -823,6 +827,52 @@ class MpvPlayerPluginTest { ) } + @Test + fun sourceQualifiedLifecycleAndPropertiesKeepTheirDequeueIdentity() { + val sink = RecordingEventSink() + val plugin = MpvPlayerPlugin() + plugin.onListen(null, sink) + plugin.onMethodCall( + MethodCall( + "observeProperty", + mapOf("name" to "time-pos", "format" to "double", "id" to 7) + ), + RecordingResult() + ) + + plugin.onPropertyChange("time-pos", 0.0) + plugin.onEvent("start-file", mapOf("sourceId" to 202L)) + plugin.onEvent("file-loaded", mapOf("sourceId" to 202L)) + plugin.onPropertyChange("time-pos", 12.5, 101L) + plugin.onEvent( + "playback-restart", + mapOf("sourceId" to 202L, "positionSeconds" to 18.75) + ) + + assertEquals( + listOf( + listOf(7, 0.0, null), + mapOf( + "type" to "event", + "name" to "start-file", + "data" to mapOf("sourceId" to 202L) + ), + mapOf( + "type" to "event", + "name" to "file-loaded", + "data" to mapOf("sourceId" to 202L) + ), + listOf(7, 12.5, 101L), + mapOf( + "type" to "event", + "name" to "playback-restart", + "data" to mapOf("sourceId" to 202L, "positionSeconds" to 18.75) + ) + ), + sink.successValues + ) + } + private fun propertyCall() = MethodCall( "setProperty", mapOf("name" to "volume", "value" to "50") @@ -1101,9 +1151,11 @@ class MpvPlayerPluginTest { private class RecordingEventSink : EventChannel.EventSink { var successValue: Any? = null + val successValues = mutableListOf() override fun success(event: Any?) { successValue = event + successValues += event } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit diff --git a/android/libmpv/consumer-rules.pro b/android/libmpv/consumer-rules.pro index 34b7190a5..ab940c646 100644 --- a/android/libmpv/consumer-rules.pro +++ b/android/libmpv/consumer-rules.pro @@ -7,8 +7,12 @@ # with GetStaticMethodID on the native event thread. R8 sees no reference to the # class name or the member names, so both must stay alive and un-renamed. -keep class com.edde746.plezy.libmpv.MpvPlayer { - public static void onPropertyChanged(...); - public static void onEvent(int); - public static void onEndFile(int); + public static void onPropertyChanged(java.lang.String, long, boolean); + public static void onPropertyChanged(java.lang.String, boolean, long, boolean); + public static void onPropertyChanged(java.lang.String, long, long, boolean); + public static void onPropertyChanged(java.lang.String, double, long, boolean); + public static void onPropertyChanged(java.lang.String, java.lang.String, long, boolean); + public static void onEvent(int, long, boolean, double, boolean); + public static void onEndFile(int, long, boolean); public static void onLogMessage(java.lang.String, int, java.lang.String); } diff --git a/android/libmpv/src/main/cpp/event.cpp b/android/libmpv/src/main/cpp/event.cpp index 1562c8efc..536712961 100644 --- a/android/libmpv/src/main/cpp/event.cpp +++ b/android/libmpv/src/main/cpp/event.cpp @@ -1,29 +1,40 @@ #include #include +#include + #include "globals.h" #include "jni_utils.h" #include "log.h" -static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop) { +static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop, int64_t source_id, bool has_source_id) { jstring jprop = new_java_string(env, prop->name); jstring jvalue = NULL; + const jboolean jhas_source_id = has_source_id ? JNI_TRUE : JNI_FALSE; switch (prop->format) { case MPV_FORMAT_NONE: - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_S, jprop); + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jprop, (jlong)source_id, jhas_source_id); break; case MPV_FORMAT_FLAG: - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sb, jprop, *(int*)prop->data); + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jprop, (jboolean) * (int*)prop->data, (jlong)source_id, + jhas_source_id); break; case MPV_FORMAT_INT64: - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sl, jprop, *(int64_t*)prop->data); + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jprop, (jlong) * (int64_t*)prop->data, (jlong)source_id, + jhas_source_id); break; case MPV_FORMAT_DOUBLE: - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sd, jprop, *(double*)prop->data); + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jprop, (jdouble) * (double*)prop->data, (jlong)source_id, + jhas_source_id); break; case MPV_FORMAT_STRING: jvalue = new_java_string(env, *(const char**)prop->data); - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SS, jprop, jvalue); + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jprop, jvalue, (jlong)source_id, jhas_source_id); break; default: break; @@ -32,14 +43,20 @@ static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop) { if (jvalue) env->DeleteLocalRef(jvalue); } -static void sendEventToJava(JNIEnv* env, int event) { - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEvent, event); +static void sendEventToJava( + JNIEnv* env, int event, int64_t source_id, bool has_source_id, double position_seconds = 0.0, + bool has_position_seconds = false) { + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jint)event, (jlong)source_id, has_source_id ? JNI_TRUE : JNI_FALSE, + (jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE); } static void sendEndFileToJava(JNIEnv* env, mpv_event* event) { mpv_event_end_file* end_file = (mpv_event_end_file*)event->data; - int reason = end_file ? end_file->reason : -1; - env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason); + const int reason = end_file ? end_file->reason : -1; + const int64_t source_id = end_file ? end_file->playlist_entry_id : 0; + env->CallStaticVoidMethod( + mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason, (jlong)source_id, end_file ? JNI_TRUE : JNI_FALSE); } static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) { @@ -57,6 +74,9 @@ void* event_thread(void* arg) { acquire_jni_env(g_vm, &env); if (!env) die("failed to acquire java env"); + int64_t source_id = 0; + bool has_source_id = false; + while (true) { mpv_event* mp_event; mpv_event_property* mp_property; @@ -76,17 +96,32 @@ void* event_thread(void* arg) { break; case MPV_EVENT_PROPERTY_CHANGE: mp_property = (mpv_event_property*)mp_event->data; - sendPropertyUpdateToJava(env, mp_property); + sendPropertyUpdateToJava(env, mp_property, source_id, has_source_id); break; case MPV_EVENT_END_FILE: sendEndFileToJava(env, mp_event); break; - case MPV_EVENT_START_FILE: - case MPV_EVENT_FILE_LOADED: - case MPV_EVENT_PLAYBACK_RESTART: + case MPV_EVENT_START_FILE: { + mpv_event_start_file* start_file = (mpv_event_start_file*)mp_event->data; + has_source_id = start_file != NULL; + source_id = start_file ? start_file->playlist_entry_id : 0; ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); - sendEventToJava(env, mp_event->event_id); + sendEventToJava(env, mp_event->event_id, source_id, has_source_id); break; + } + case MPV_EVENT_FILE_LOADED: + ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); + sendEventToJava(env, mp_event->event_id, source_id, has_source_id); + break; + case MPV_EVENT_PLAYBACK_RESTART: { + double position_seconds = 0.0; + const bool has_position_seconds = + mpv_get_property(g_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 && + std::isfinite(position_seconds); + ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); + sendEventToJava(env, mp_event->event_id, source_id, has_source_id, position_seconds, has_position_seconds); + break; + } default: // Nothing on the Kotlin side consumes the remaining ids (MpvEvent.fromId). break; diff --git a/android/libmpv/src/main/cpp/jni_utils.cpp b/android/libmpv/src/main/cpp/jni_utils.cpp index af537d4eb..c2e48f8da 100644 --- a/android/libmpv/src/main/cpp/jni_utils.cpp +++ b/android/libmpv/src/main/cpp/jni_utils.cpp @@ -50,18 +50,18 @@ void init_methods_cache(JNIEnv* env) { mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer"); mpv_MpvPlayer = reinterpret_cast(env->NewGlobalRef(mpv_MpvPlayer)); - mpv_MpvPlayer_onPropertyChanged_S = - env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;)V"); - mpv_MpvPlayer_onPropertyChanged_Sb = - env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Z)V"); - mpv_MpvPlayer_onPropertyChanged_Sl = - env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;J)V"); - mpv_MpvPlayer_onPropertyChanged_Sd = - env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;D)V"); - mpv_MpvPlayer_onPropertyChanged_SS = - env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;)V"); - mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(I)V"); - mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(I)V"); + mpv_MpvPlayer_onPropertyChanged_SJZ = + env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V"); + mpv_MpvPlayer_onPropertyChanged_SZJZ = + env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V"); + mpv_MpvPlayer_onPropertyChanged_SJJZ = + env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V"); + mpv_MpvPlayer_onPropertyChanged_SDJZ = + env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;DJZ)V"); + mpv_MpvPlayer_onPropertyChanged_SSJZ = + env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;JZ)V"); + mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(IJZDZ)V"); + mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(IJZ)V"); mpv_MpvPlayer_onLogMessage = env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); diff --git a/android/libmpv/src/main/cpp/jni_utils.h b/android/libmpv/src/main/cpp/jni_utils.h index 8d0c6b051..90add467d 100644 --- a/android/libmpv/src/main/cpp/jni_utils.h +++ b/android/libmpv/src/main/cpp/jni_utils.h @@ -25,6 +25,6 @@ UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean; UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init; UTIL_EXTERN jclass mpv_MpvPlayer; -UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_S, mpv_MpvPlayer_onPropertyChanged_Sb, - mpv_MpvPlayer_onPropertyChanged_Sl, mpv_MpvPlayer_onPropertyChanged_Sd, mpv_MpvPlayer_onPropertyChanged_SS, +UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_SJZ, mpv_MpvPlayer_onPropertyChanged_SZJZ, + mpv_MpvPlayer_onPropertyChanged_SJJZ, mpv_MpvPlayer_onPropertyChanged_SDJZ, mpv_MpvPlayer_onPropertyChanged_SSJZ, mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage; diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt index e76e02fc6..7a7978028 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt @@ -1,17 +1,29 @@ package com.edde746.plezy.libmpv sealed interface MpvEvent { - data object StartFile : MpvEvent - data class EndFile(val reason: EndFileReason?) : MpvEvent - data object FileLoaded : MpvEvent - data object PlaybackRestart : MpvEvent + val sourceId: Long? + + data class StartFile(override val sourceId: Long?) : MpvEvent + data class EndFile( + val reason: EndFileReason?, + override val sourceId: Long? + ) : MpvEvent + data class FileLoaded(override val sourceId: Long?) : MpvEvent + data class PlaybackRestart( + override val sourceId: Long?, + val positionSeconds: Double? + ) : MpvEvent companion object { // Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path. - internal fun fromId(id: Int): MpvEvent? = when (id) { - 6 -> StartFile - 8 -> FileLoaded - 21 -> PlaybackRestart + internal fun fromId( + id: Int, + sourceId: Long?, + positionSeconds: Double? + ): MpvEvent? = when (id) { + 6 -> StartFile(sourceId) + 8 -> FileLoaded(sourceId) + 21 -> PlaybackRestart(sourceId, positionSeconds) else -> null } } diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt index 53f65d2b3..0af191b83 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt @@ -61,40 +61,63 @@ class MpvPlayer private constructor() : AutoCloseable { // JNI callbacks — called from native event thread @JvmStatic - fun onPropertyChanged(name: String) { - instance.get()?.rawPropertyChanges?.trySend(PropertyChange.None(name)) + fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) { + instance.get()?.rawPropertyChanges?.trySend( + PropertyChange.None(name, sourceId.takeIf { hasSourceId }) + ) } @JvmStatic - fun onPropertyChanged(name: String, value: Boolean) { - instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Flag(name, value)) + fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) { + instance.get()?.rawPropertyChanges?.trySend( + PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId }) + ) } @JvmStatic - fun onPropertyChanged(name: String, value: Long) { - instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Int64(name, value)) + fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) { + instance.get()?.rawPropertyChanges?.trySend( + PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId }) + ) } @JvmStatic - fun onPropertyChanged(name: String, value: Double) { - instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Double(name, value)) + fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) { + instance.get()?.rawPropertyChanges?.trySend( + PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId }) + ) } @JvmStatic - fun onPropertyChanged(name: String, value: String) { - instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Str(name, value)) + fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) { + instance.get()?.rawPropertyChanges?.trySend( + PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId }) + ) } @JvmStatic - fun onEvent(eventId: Int) { - val event = MpvEvent.fromId(eventId) ?: return + fun onEvent( + eventId: Int, + sourceId: Long, + hasSourceId: Boolean, + positionSeconds: Double, + hasPositionSeconds: Boolean + ) { + val event = MpvEvent.fromId( + eventId, + sourceId.takeIf { hasSourceId }, + positionSeconds.takeIf { hasPositionSeconds && it.isFinite() } + ) ?: return instance.get()?.rawEvents?.trySend(event) } @JvmStatic - fun onEndFile(reason: Int) { + fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) { instance.get()?.rawEvents?.trySend( - MpvEvent.EndFile(EndFileReason.fromId(reason)) + MpvEvent.EndFile( + EndFileReason.fromId(reason), + sourceId.takeIf { hasSourceId } + ) ) } diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/PropertyChange.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/PropertyChange.kt index 69044dd07..b23040e83 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/PropertyChange.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/PropertyChange.kt @@ -2,10 +2,34 @@ package com.edde746.plezy.libmpv sealed interface PropertyChange { val name: String + val sourceId: Long? - data class None(override val name: String) : PropertyChange - data class Flag(override val name: String, val value: Boolean) : PropertyChange - data class Int64(override val name: String, val value: Long) : PropertyChange - data class Double(override val name: String, val value: kotlin.Double) : PropertyChange - data class Str(override val name: String, val value: String) : PropertyChange + data class None( + override val name: String, + override val sourceId: Long? + ) : PropertyChange + + data class Flag( + override val name: String, + val value: Boolean, + override val sourceId: Long? + ) : PropertyChange + + data class Int64( + override val name: String, + val value: Long, + override val sourceId: Long? + ) : PropertyChange + + data class Double( + override val name: String, + val value: kotlin.Double, + override val sourceId: Long? + ) : PropertyChange + + data class Str( + override val name: String, + val value: String, + override val sourceId: Long? + ) : PropertyChange } diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 4a4cb9187..c89ea30f0 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -54,7 +54,7 @@ final class RecordingLifecycleDelegate: MpvPlayerDelegate { private(set) var events: [String] = [] private(set) var properties: [String] = [] - func onPropertyChange(name: String, value: Any?) { + func onPropertyChange(name: String, value: Any?, sourceId: Int64?) { properties.append(name) } @@ -108,6 +108,41 @@ final class MpvPlayerContractTests: XCTestCase { userInfo: [NSLocalizedDescriptionKey: "controlled failure"] ) + func testSharedTransportEmitsSourceQualifiedPayloads() { + let plugin = RecordingMpvPlugin(core: nil) + plugin.nameToId["time-pos"] = 27 + var messages: [Any?] = [] + plugin.eventSink = { messages.append($0) } + let sourceId = Int64.max - 7 + + plugin.onPropertyChange(name: "time-pos", value: 12.5, sourceId: sourceId) + plugin.onPropertyChange(name: "time-pos", value: nil, sourceId: nil) + plugin.onEvent( + name: "playback-restart", + data: ["sourceId": sourceId, "positionSeconds": 12.5] + ) + + XCTAssertEqual(messages.count, 3) + guard + let sourcedProperty = messages[0] as? [Any?], + let preStartProperty = messages[1] as? [Any?], + let lifecycleEvent = messages[2] as? [String: Any], + let lifecycleData = lifecycleEvent["data"] as? [String: Any] + else { + return XCTFail("Expected property triples and a lifecycle event map") + } + XCTAssertEqual(sourcedProperty.count, 3) + XCTAssertEqual(sourcedProperty[0] as? Int, 27) + XCTAssertEqual(sourcedProperty[1] as? Double, 12.5) + XCTAssertEqual(sourcedProperty[2] as? Int64, sourceId) + XCTAssertEqual(preStartProperty.count, 3) + XCTAssertNil(preStartProperty[1]) + XCTAssertNil(preStartProperty[2]) + XCTAssertEqual(lifecycleEvent["name"] as? String, "playback-restart") + XCTAssertEqual(lifecycleData["sourceId"] as? Int64, sourceId) + XCTAssertEqual(lifecycleData["positionSeconds"] as? Double, 12.5) + } + func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() { let core = ControllablePropertyCore() let plugin = RecordingMpvPlugin(core: core) @@ -329,7 +364,7 @@ final class MpvPlayerContractTests: XCTestCase { core.delegate = delegate let enqueueAndDispose = { core.dispatchDelegateEvent(name: "file-loaded", data: nil) - core.dispatchDelegateProperty(name: "time-pos", value: 1.0) + core.dispatchDelegateProperty(name: "time-pos", value: 1.0, sourceId: 7) XCTAssertTrue(core.beginDisposal()) } if Thread.isMainThread { diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 2c02402cc..ebc3a626d 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -161,6 +161,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Map> _externalSubtitleMetadataByUri = const {}; bool _primaryMediaLoadStarted = false; bool _primaryMediaReadyEmitted = false; + int? _activeSourceId; + bool _activeSourceReadyEmitted = false; /// How long a disposing player waits for its predecessor's native release /// before force-disposing with its own [nativeInstanceId] (the native side @@ -303,12 +305,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { void _handleEvent(dynamic event) { if (_disposed) return; - if (event is List && event.length == 2) { + if (event is List && event.length >= 2) { final propertyId = event.first; if (propertyId is! int) return; final name = _propIdToName[propertyId]; if (name != null) { - handlePropertyChange(name, event[1]); + final sourceId = event.length >= 3 ? _finiteInt(event[2]) : null; + handlePropertyChange(name, event[1], sourceId: sourceId); } } else if (event is Map) { final type = event['type']; @@ -320,7 +323,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } } - void handlePropertyChange(String name, dynamic value) { + void handlePropertyChange(String name, dynamic value, {int? sourceId}) { if (_disposed) return; switch (name) { case 'pause': @@ -342,6 +345,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'time-pos': + if (sourceId != null && sourceId != _activeSourceId) break; final positionMs = _millisecondsFromSeconds(value, round: true); if (positionMs != null) { final pos = Duration(milliseconds: positionMs); @@ -536,14 +540,21 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { void handlePlayerEvent(String name, Map? data) { if (_disposed) return; + final sourceId = _finiteInt(data?['sourceId']); switch (name) { case 'start-file': + _activeSourceId = sourceId; + _activeSourceReadyEmitted = false; _primaryMediaLoadStarted = true; _primaryMediaReadyEmitted = false; fileStartedController.add(null); + if (sourceId != null) { + sourceStartedController.add(PlayerSourceStarted(sourceId)); + } break; case 'end-file': + if (sourceId != null && _activeSourceId != null && sourceId != _activeSourceId) break; _primaryMediaLoadStarted = false; setSeekable(false); final rawReason = data?['reason']; @@ -569,17 +580,38 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { cause: rawCause is String ? rawCause : null, ), ); + if (sourceId != null) { + sourceFailedController.add(PlayerSourceFailed(sourceId)); + } } + _activeSourceId = null; + _activeSourceReadyEmitted = false; break; case 'file-loaded': + if (sourceId != null && sourceId != _activeSourceId) break; _state = _state.copyWith(completed: false); completedController.add(false); fileLoadedController.add(null); break; case 'playback-restart': + if (sourceId != null && sourceId != _activeSourceId) break; playbackRestartController.add(null); + if (sourceId != null && !_activeSourceReadyEmitted) { + final positionMs = _millisecondsFromSeconds(data?['positionSeconds'], round: true); + if (positionMs != null) { + _positionMs = positionMs; + _lastPositionWriter = _backendReportedWriter; + _lastReportedPositionMs = positionMs; + _lastEmitMs = _throttleSw.elapsedMilliseconds; + final position = Duration(milliseconds: positionMs); + _state = _state.copyWith(position: position); + positionController.add(position); + _activeSourceReadyEmitted = true; + sourceReadyController.add(PlayerSourceReady(sourceId: sourceId, position: position)); + } + } break; case 'hdr-output-changed': diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 061403f15..db765a7ba 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -620,7 +620,7 @@ class PlayerNative extends PlayerBase { } @override - void handlePropertyChange(String name, dynamic value) { + void handlePropertyChange(String name, dynamic value, {int? sourceId}) { if (audioOnly && name == 'playlist-pos') { // Detection still belongs to _handleAudioFileLoaded, but this is the last // point ordered ahead of the new source's own position reports: they ride @@ -630,7 +630,7 @@ class PlayerNative extends PlayerBase { appLogger.d('MPV-audio: playlist-pos=$value (armed=$_hasArmedNext)'); return; } - super.handlePropertyChange(name, value); + super.handlePropertyChange(name, value, sourceId: sourceId); } @override diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index 94aa2529e..8addb1dc4 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -25,6 +25,9 @@ mixin PlayerStreamControllersMixin { final fileLoadedController = StreamController.broadcast(); final fileStartedController = StreamController.broadcast(); final fileLoadFailedController = StreamController.broadcast(); + final sourceStartedController = StreamController.broadcast(); + final sourceReadyController = StreamController.broadcast(); + final sourceFailedController = StreamController.broadcast(); final primaryMediaReadyController = StreamController.broadcast(); final hdrOutputChangedController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); @@ -53,6 +56,9 @@ mixin PlayerStreamControllersMixin { fileLoaded: fileLoadedController.stream, fileStarted: fileStartedController.stream, fileLoadFailed: fileLoadFailedController.stream, + sourceStarted: sourceStartedController.stream, + sourceReady: sourceReadyController.stream, + sourceFailed: sourceFailedController.stream, primaryMediaReady: primaryMediaReadyController.stream, backendSwitched: backendSwitchedController.stream, hdrOutputChanged: hdrOutputChangedController.stream, @@ -82,6 +88,9 @@ mixin PlayerStreamControllersMixin { await fileLoadedController.close(); await fileStartedController.close(); await fileLoadFailedController.close(); + await sourceStartedController.close(); + await sourceReadyController.close(); + await sourceFailedController.close(); await primaryMediaReadyController.close(); await backendSwitchedController.close(); await hdrOutputChangedController.close(); diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 49e844a40..a542ed2eb 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -1,5 +1,30 @@ import '../models.dart'; +/// A native MPV source identified by its lifetime-unique playlist entry id. +class PlayerSourceStarted { + const PlayerSourceStarted(this.sourceId); + + final int sourceId; +} + +/// The first rendered position for one native MPV source. +/// +/// [position] is sampled when MPV emits the source's first playback-restart, +/// after its playback clock has been updated from decoded audio/video PTS. +class PlayerSourceReady { + const PlayerSourceReady({required this.sourceId, required this.position}); + + final int sourceId; + final Duration position; +} + +/// A source-qualified MPV load failure. +class PlayerSourceFailed { + const PlayerSourceFailed(this.sourceId); + + final int sourceId; +} + /// Reactive streams for player state changes. /// /// Subscribe to these streams to receive updates when the player state changes. @@ -90,6 +115,18 @@ class PlayerStreams { /// mistaken for a media-open failure. final Stream fileLoadFailed; + /// Emits when MPV starts a source whose native identity is known. + /// + /// Unlike [fileStarted], this is source-qualified and can safely delimit + /// property updates that cross asynchronous native dispatch queues. + final Stream sourceStarted; + + /// Emits the first rendered player-clock position for an MPV source. + final Stream sourceReady; + + /// Emits when the active MPV source fails to load or play. + final Stream sourceFailed; + /// Emits once mpv has discovered a non-external audio or video track for /// the current file. Unlike [fileLoaded], this can fire before remote /// subtitle sidecars finish opening. @@ -137,6 +174,9 @@ class PlayerStreams { this.fileLoaded = const Stream.empty(), this.fileStarted = const Stream.empty(), this.fileLoadFailed = const Stream.empty(), + this.sourceStarted = const Stream.empty(), + this.sourceReady = const Stream.empty(), + this.sourceFailed = const Stream.empty(), this.primaryMediaReady = const Stream.empty(), this.hdrOutputChanged = const Stream.empty(), required this.backendSwitched, diff --git a/lib/screens/video_player/live_tv_session_state.dart b/lib/screens/video_player/live_tv_session_state.dart index 3f174ba68..3fe71c054 100644 --- a/lib/screens/video_player/live_tv_session_state.dart +++ b/lib/screens/video_player/live_tv_session_state.dart @@ -3,8 +3,19 @@ import 'dart:async'; import '../../media/live_tv_support.dart'; import '../../media/media_source_info.dart'; import '../../models/livetv_capture_buffer.dart'; +import '../../mpv/player/player_streams.dart'; import 'live_tv_session_args.dart'; +class _LiveClockOpen { + _LiveClockOpen({required this.generation, required this.targetEpoch}); + + final int generation; + final int targetEpoch; + final Completer result = Completer(); + int? sourceId; + bool canceled = false; +} + /// Mutable runtime state for one live TV playback: the current /// [LiveTvPlaybackSession] protocol handle, the timeline heartbeat /// machinery, the capture buffer used for time-shifting, and the @@ -47,6 +58,14 @@ class LiveTvSessionState { double streamStartEpoch = 0; bool atLiveEdge = true; + int _nextClockGeneration = 0; + int? _latestClockGeneration; + int? activeClockSourceId; + double? pendingStreamEpoch; + final List<_LiveClockOpen> _unboundClockOpens = []; + final Map _clockOpensBySource = {}; + final Map _clockOpensByGeneration = {}; + /// Fallback level for live TV stream errors (mirrors Plex web client /// behavior). 0 = directStream+directStreamAudio, 1 = no directStream, /// 2 = no DS + no DS audio. @@ -62,6 +81,117 @@ class LiveTvSessionState { /// The player route is closed instead of attempting to reuse that session. bool exitOnResume = false; + /// Register an offset-based MPV open before dispatching `loadfile`. + /// + /// Opens and MPV START_FILE events are ordered. Canceled entries stay in the + /// unbound queue until their START_FILE arrives so a late predecessor cannot + /// steal the next open's source identity. + int beginClockOpen(int targetEpoch) { + final previousOpens = <_LiveClockOpen>{ + ..._clockOpensByGeneration.values, + ..._unboundClockOpens, + ..._clockOpensBySource.values, + }; + for (final open in previousOpens) { + open.canceled = true; + if (!open.result.isCompleted) open.result.complete(false); + } + _clockOpensBySource.removeWhere((_, open) => open.canceled); + + final open = _LiveClockOpen(generation: ++_nextClockGeneration, targetEpoch: targetEpoch); + _clockOpensByGeneration[open.generation] = open; + _unboundClockOpens.add(open); + _latestClockGeneration = open.generation; + pendingStreamEpoch = targetEpoch.toDouble(); + return open.generation; + } + + /// Bind the oldest dispatched live open to the source MPV started next. + bool bindClockSource(PlayerSourceStarted source) { + if (_unboundClockOpens.isEmpty) return false; + final open = _unboundClockOpens.removeAt(0); + open.sourceId = source.sourceId; + if (open.canceled) return false; + _clockOpensBySource[source.sourceId] = open; + return true; + } + + /// Calibrate epoch time against the first decoded position of [source]. + bool calibrateClockSource(PlayerSourceReady source) { + final open = _clockOpensBySource.remove(source.sourceId); + if (open == null || open.canceled || open.generation != _latestClockGeneration) return false; + + streamStartEpoch = open.targetEpoch - source.position.inMilliseconds / 1000.0; + activeClockSourceId = source.sourceId; + pendingStreamEpoch = null; + _clockOpensByGeneration.remove(open.generation); + if (!open.result.isCompleted) open.result.complete(true); + return true; + } + + void failClockSource(PlayerSourceFailed source) { + final open = _clockOpensBySource.remove(source.sourceId); + if (open != null) _failClockOpen(open); + } + + void failClockOpen(int generation) { + final open = _clockOpensByGeneration[generation]; + if (open != null) _failClockOpen(open); + } + + /// Release an awaiter while keeping the requested epoch authoritative until + /// a delayed readiness event can still calibrate this source. + void timeoutClockOpen(int generation) { + final open = _clockOpensByGeneration[generation]; + if (open == null || open.canceled || generation != _latestClockGeneration) return; + pendingStreamEpoch = open.targetEpoch.toDouble(); + if (!open.result.isCompleted) open.result.complete(false); + } + + void _failClockOpen(_LiveClockOpen open) { + open.canceled = true; + _unboundClockOpens.remove(open); + final sourceId = open.sourceId; + if (sourceId != null && identical(_clockOpensBySource[sourceId], open)) { + _clockOpensBySource.remove(sourceId); + } + if (identical(_clockOpensByGeneration[open.generation], open)) { + _clockOpensByGeneration.remove(open.generation); + } + if (_latestClockGeneration == open.generation) { + _latestClockGeneration = null; + pendingStreamEpoch = null; + } + if (!open.result.isCompleted) open.result.complete(false); + } + + Future clockOpenResult(int generation) { + final open = _clockOpensByGeneration[generation]; + if (open == null) return Future.value(false); + return open.result.future.whenComplete(() { + if (identical(_clockOpensByGeneration[generation], open)) { + _clockOpensByGeneration.remove(generation); + } + }); + } + + void cancelClockOpens() { + final generations = _clockOpensByGeneration.keys.toList(growable: false); + for (final generation in generations) { + failClockOpen(generation); + } + _unboundClockOpens.clear(); + _clockOpensBySource.clear(); + _latestClockGeneration = null; + pendingStreamEpoch = null; + } + + int epochForPosition(Duration position) { + final pending = pendingStreamEpoch; + if (pending != null) return pending.round(); + return (streamStartEpoch + position.inMilliseconds / 1000.0).round(); + } + /// Make [newSession] current and seed the seekable window from its tune /// snapshot. Every flow that produces a session (start, retry, channel /// zap) adopts it here, so a field can't be forgotten in one copy. diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 2ec58e33b..7f702d2e7 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -334,8 +334,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { liveChannelName: _live.channelName, captureBuffer: _live.captureBuffer, isAtLiveEdge: _live.atLiveEdge, - streamStartEpoch: _live.streamStartEpoch, - currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, + liveEpochForPosition: widget.isLive ? _liveEpochForPosition : null, onLiveSeek: _live.captureBuffer != null ? _seekLiveToEpoch : null, onLiveSeekBy: _live.captureBuffer != null ? _liveSeek.seekBy : null, onJumpToLive: _live.captureBuffer != null && !_live.atLiveEdge ? _jumpToLiveEdge : null, diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 25f21aa75..eaf1f3191 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -1,5 +1,7 @@ part of '../../video_player_screen.dart'; +const _liveClockReadyTimeout = Duration(seconds: 15); + extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { /// Start periodic timeline heartbeats for live TV transcode session. void _startLiveTimelineUpdates() { @@ -172,9 +174,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { // is re-mapped onto the recovered session's track list. Recovering the // video outranks keeping subtitles — a failed burn re-apply drops them. MediaSubtitleTrack? recoveredSubtitle; + var recoveredHasCaptureBuffer = false; final result = await runLiveStreamRetry( recover: () => session.recover(directStream: ds, directStreamAudio: dsa), lookupStreamUrl: (recovered) async { + recoveredHasCaptureBuffer = recovered.captureBuffer != null; recoveredSubtitle = LiveTvSessionState.remapSubtitleSelection(recovered.subtitleTracks, _live.selectedSubtitle); if (recoveredSubtitle != null) { final url = await recovered.streamUrlAt(subtitleTrack: recoveredSubtitle); @@ -185,16 +189,15 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { return recovered.streamUrlAt(); }, applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer), - open: (streamUrl) => currentPlayer.open( - Media(streamUrl, headers: const {'Accept-Language': 'en'}), - play: automotivePlaybackAllowedNow(), - isLive: true, - ), + open: (streamUrl) async { + _live.markStreamRestartedAtLiveEdge(); + final targetEpoch = recoveredHasCaptureBuffer ? _live.streamStartEpoch.round() : null; + await _openLiveStream(currentPlayer, streamUrl, targetEpoch: targetEpoch, applyOptions: false); + }, isCurrent: isCurrent, adoptSession: (recovered) { _live.adoptSession(recovered); _live.selectedSubtitle = recoveredSubtitle; - _live.markStreamRestartedAtLiveEdge(); }, // Jellyfin's recover() returns the receiver, so the recovered object can // be the still-current session; the retry helper skips the discard by @@ -220,30 +223,56 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { /// reconnection is handled by the server's transcoder on the input side. Future _setLiveStreamOptions(Player player) => player.setProperty('force-seekable', 'no'); - /// Re-opens the current session's live stream at [streamUrl]: options, then - /// `open(isLive: true)` honouring the automotive playback gate. - Future _openLiveStream(Player player, String streamUrl) async { - await _setLiveStreamOptions(player); - await player.open( - Media(streamUrl, headers: const {'Accept-Language': 'en'}), - play: automotivePlaybackAllowedNow(), - isLive: true, + /// Re-opens the current session's live stream at [streamUrl]. + /// + /// Offset-based MPV opens register their requested absolute [targetEpoch] + /// before `loadfile`. When [awaitClock] is true, success means the new + /// source's first rendered player position has been mapped to that epoch. + Future _openLiveStream( + Player player, + String streamUrl, { + int? targetEpoch, + bool awaitClock = false, + bool? play, + bool applyOptions = true, + }) async { + final clockGeneration = targetEpoch != null && player is PlayerNative ? _live.beginClockOpen(targetEpoch) : null; + final clockResult = clockGeneration == null ? null : _live.clockOpenResult(clockGeneration); + try { + if (applyOptions) await _setLiveStreamOptions(player); + await player.open( + Media(streamUrl, headers: const {'Accept-Language': 'en'}), + play: play ?? automotivePlaybackAllowedNow(), + isLive: true, + ); + } catch (_) { + if (clockGeneration != null) _live.failClockOpen(clockGeneration); + rethrow; + } + + if (clockResult == null || clockGeneration == null) return true; + if (!awaitClock) { + unawaited(clockResult); + return true; + } + return clockResult.timeout( + _liveClockReadyTimeout, + onTimeout: () { + _live.timeoutClockOpen(clockGeneration); + appLogger.w('Live time-shift source did not report a rendered clock position'); + return false; + }, ); } - /// The raw live playback position as an absolute epoch second - /// (`_live.streamStartEpoch + player position`). - int get _rawPositionEpoch => (_live.streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round(); + int _liveEpochForPosition(Duration position) => _liveSeek.pendingEpoch ?? _live.epochForPosition(position); - /// The current playback position as an absolute epoch second (for live TV time-shift). - /// - /// While a relative skip is pending/settling, this returns the accumulator's - /// target rather than the raw sum. During a live re-open `_live.streamStartEpoch` - /// is advanced to the target before the new stream's position resets to ~0, - /// so the raw sum transiently overshoots; pinning to the pending target keeps - /// seek accumulation and the live-edge heartbeat ([_sendLiveTimeline]) correct - /// (close #1253). - int get _currentPositionEpoch => _liveSeek.pendingEpoch ?? _rawPositionEpoch; + /// Current playback position in absolute epoch seconds. + int get _rawPositionEpoch => _live.epochForPosition(player?.currentPosition ?? Duration.zero); + + /// While a relative skip is queued, its accumulated target remains + /// authoritative until the replacement source clock is calibrated. + int get _currentPositionEpoch => _liveEpochForPosition(player?.currentPosition ?? Duration.zero); /// Show "Watch from Start" / "Watch Live" dialog. /// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed. @@ -276,13 +305,21 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds, subtitleTrack: _live.selectedSubtitle); if (streamUrl == null || !mounted || player != currentPlayer) return false; - _live.streamStartEpoch = buffer.startedAt + offsetSeconds; + if (currentPlayer is! PlayerNative) { + _live.streamStartEpoch = buffer.startedAt + offsetSeconds; + } _live.atLiveEdge = (clamped >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); _live.playbackStartTime = DateTime.now(); - await _openLiveStream(currentPlayer, streamUrl); - if (mounted) _setPlayerState(() {}); - return true; + final opened = await _openLiveStream( + currentPlayer, + streamUrl, + targetEpoch: clamped, + awaitClock: currentPlayer is PlayerNative, + ); + if (!mounted || player != currentPlayer) return false; + _setPlayerState(() {}); + return opened; } /// Apply a source subtitle choice to the live stream by rebuilding it with @@ -326,8 +363,12 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _live.selectedSubtitle = previous; return PlaybackSourceChangeOutcome.failed; } - await _openLiveStream(currentPlayer, streamUrl); _live.markStreamRestartedAtLiveEdge(); + await _openLiveStream( + currentPlayer, + streamUrl, + targetEpoch: _live.captureBuffer == null ? null : _live.streamStartEpoch.round(), + ); if (mounted) _setPlayerState(() {}); return PlaybackSourceChangeOutcome.applied; } @@ -353,15 +394,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { }); } - /// Re-open the live stream at [targetEpochSeconds], logging (rather than - /// throwing) on failure. A throw is rethrown so [_liveSeek] releases its - /// pending pin; direct callers catch it. - Future _runLiveSeek(int targetEpochSeconds) async { + /// Re-open the live stream at [targetEpochSeconds], logging failures. + Future _runLiveSeek(int targetEpochSeconds) async { try { - await _seekLivePosition(targetEpochSeconds); + final opened = await _seekLivePosition(targetEpochSeconds); + if (!opened) { + appLogger.w('Live time-shift seek did not reach a calibrated source'); + } + return opened; } catch (e, st) { appLogger.w('Live time-shift seek failed', error: e, stackTrace: st); - rethrow; + return false; } } @@ -369,11 +412,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { /// any pending relative-skip burst first so a queued seek can't override it. Future _seekLiveToEpoch(int targetEpochSeconds) async { _liveSeek.cancel(); - try { - await _runLiveSeek(targetEpochSeconds); - } catch (_) { - // Already logged; an absolute live seek is best-effort. - } + await _runLiveSeek(targetEpochSeconds); } /// Jump to the live edge of the capture buffer. @@ -429,21 +468,13 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { return; } - await _setLiveStreamOptions(currentPlayer); - if (!isCurrentChannelSwitch()) { - _abandonLiveSession(session); - return; - } - _setPlayerState(() { _firstFrame.reset(); }); + _live.markStreamRestartedAtLiveEdge(); + final targetEpoch = session.captureBuffer == null ? null : _live.streamStartEpoch.round(); replacementOpenStarted = true; - await currentPlayer.open( - Media(streamUrl, headers: const {'Accept-Language': 'en'}), - play: automotivePlaybackAllowedNow(), - isLive: true, - ); + await _openLiveStream(currentPlayer, streamUrl, targetEpoch: targetEpoch); if (!isCurrentChannelSwitch()) { _abandonLiveSession(session); return; @@ -462,7 +493,6 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _live.adoptSession(session); _live.fallbackLevel = 0; - _live.markStreamRestartedAtLiveEdge(); if (!mounted) return; _setPlayerState(() { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 33e3cea39..029c3a1d7 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -145,6 +145,30 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } } + if (widget.isLive) { + _playerStreamSubscriptions.add( + currentPlayer.streams.sourceStarted.listen((source) { + if (!mounted || player != currentPlayer) return; + _live.bindClockSource(source); + }), + ); + _playerStreamSubscriptions.add( + currentPlayer.streams.sourceReady.listen((source) { + if (!mounted || player != currentPlayer) return; + if (_live.calibrateClockSource(source)) { + _setPlayerState(() {}); + } + }), + ); + _playerStreamSubscriptions.add( + currentPlayer.streams.sourceFailed.listen((source) { + if (!mounted || player != currentPlayer) return; + _live.failClockSource(source); + _setPlayerState(() {}); + }), + ); + } + _playerStreamSubscriptions.add( currentPlayer.streams.playbackRestart.listen((_) async { if (!mounted || player != currentPlayer) return; diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index bfc9dc4ed..e953d4ea1 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -64,20 +64,25 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { throw PlaybackException(t.liveTv.failedToBuildStreamUrl, reason: PlaybackFailureReason.noPlayableSource); } - // Track stream start epoch for position calculations + // Track the requested epoch separately from MPV's source-local clock. + int? targetEpoch; if (offsetSeconds != null) { - _live.streamStartEpoch = captureBuffer!.startedAt + offsetSeconds; + targetEpoch = (captureBuffer!.startedAt + offsetSeconds).round(); + if (currentPlayer is! PlayerNative) { + _live.streamStartEpoch = captureBuffer.startedAt + offsetSeconds; + } _live.atLiveEdge = false; _live.playbackStartTime = DateTime.now(); } else { _live.markStreamRestartedAtLiveEdge(); + targetEpoch = captureBuffer == null ? null : _live.streamStartEpoch.round(); } - await _setLiveStreamOptions(currentPlayer); - await currentPlayer.open( - Media(streamUrl, headers: const {'Accept-Language': 'en'}), + await _openLiveStream( + currentPlayer, + streamUrl, + targetEpoch: targetEpoch, play: !PlatformDetector.isAutomotive(), - isLive: true, ); if (!attempt.isCurrent) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ebb0b4b9f..e566c292d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -537,7 +537,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin late final LiveSeekAccumulator _liveSeek = LiveSeekAccumulator( seek: _runLiveSeek, currentEpoch: () => _rawPositionEpoch, - positionSeconds: () => player?.state.position.inSeconds ?? 0, bounds: _liveSeekBounds, onChanged: _onLiveSeekTargetChanged, ); @@ -1855,6 +1854,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _stillWatchingCountdown.dispose(); _liveSeek.dispose(); + _live.cancelClockOpens(); _playNextCancelFocusNode.dispose(); _playNextConfirmFocusNode.dispose(); diff --git a/lib/services/live_seek_accumulator.dart b/lib/services/live_seek_accumulator.dart index 75be49b16..dc87d3aa6 100644 --- a/lib/services/live_seek_accumulator.dart +++ b/lib/services/live_seek_accumulator.dart @@ -7,45 +7,34 @@ typedef LiveSeekBounds = ({int start, int end}); /// Coalesces rapid relative live-TV skips into a single transcode re-open. /// /// Live time-shift seeks don't use `player.seek()` — each one re-opens a fresh -/// Plex transcode session at an epoch offset, and the new stream's reported -/// position lags behind the new origin for a second or two. Deriving each skip -/// target from the live `streamStart + position` epoch therefore compounds into -/// wild overshoots when the user mashes skip-forward, occasionally jumping all -/// the way to live (#1253). +/// Plex transcode session at an epoch offset. This accumulates a stable +/// in-memory target ([pendingEpoch]) so every press adds onto the previous +/// target rather than re-reading player state while a source replacement is +/// in flight, then debounces the actual re-open so a whole burst collapses +/// into one [seek] (#1253). /// -/// This accumulates a stable in-memory target ([pendingEpoch]) — every press -/// adds onto the previous target, never re-reading the laggy live epoch — and -/// debounces the actual re-open so a whole burst collapses into one [seek]. -/// The pending target is held until the re-opened stream's position settles -/// near zero, so a subsequent idle press still bases off a correct value. -/// -/// Pure-Dart and timer-driven (no wall-clock reads), so it virtualizes cleanly -/// under `fakeAsync` in tests. +/// [seek] completes only after the replacement source's player clock has been +/// calibrated. The pending target therefore remains authoritative until raw +/// player position can be mapped back to epoch time without assuming that a +/// newly opened HLS source starts at position zero (#2100). class LiveSeekAccumulator { LiveSeekAccumulator({ required this.seek, required this.currentEpoch, - required this.positionSeconds, required this.bounds, this.onChanged, this.debounce = const Duration(milliseconds: 300), - this.settleCeiling = const Duration(milliseconds: 1500), - this.settlePoll = const Duration(milliseconds: 100), }); - /// Re-open the live stream at the target epoch (a fresh transcode session). - /// Should log its own errors; if it throws, the pending pin is released so a - /// failed re-open can't freeze the masked position. - final Future Function(int targetEpoch) seek; + /// Re-open and calibrate the live stream at the target epoch. + /// + /// Returns false when URL resolution, open, or clock calibration fails. + final Future Function(int targetEpoch) seek; - /// The live playback position as an absolute epoch second - /// (`streamStart + position`) — used as the base for a fresh burst. + /// The calibrated live playback position as an absolute epoch second, used + /// as the base for a fresh burst. final int Function() currentEpoch; - /// Player position in seconds — used to detect that a re-opened stream has - /// settled (position reset to ~0) before unpinning [pendingEpoch]. - final int Function() positionSeconds; - /// Current seekable window, or null when there is no live capture buffer. final LiveSeekBounds? Function() bounds; @@ -56,18 +45,11 @@ class LiveSeekAccumulator { /// How long after the last press to wait before executing the seek. final Duration debounce; - /// Upper bound on how long [pendingEpoch] stays pinned after a re-open before - /// it is cleared regardless of whether the position has settled. - final Duration settleCeiling; - - /// Interval at which the post-seek settle is polled. - final Duration settlePoll; - int? _pendingEpoch; Timer? _debounceTimer; - Timer? _settleTimer; bool _flushing = false; bool _disposed = false; + int _operationGeneration = 0; /// The accumulated target while a skip is pending or settling, else null. /// Callers mask their "current position" with this so accumulation and the @@ -105,18 +87,21 @@ class LiveSeekAccumulator { _debounceTimer?.cancel(); _flushing = true; + final operationGeneration = _operationGeneration; var failed = false; try { - await seek(target); + failed = !await seek(target); } catch (_) { // A failed re-open must release the pin, or the masked position would // freeze at a target the stream never reached. `seek` is expected to log // its own errors; here we only guarantee forward progress. failed = true; } finally { - _flushing = false; + if (operationGeneration == _operationGeneration) { + _flushing = false; + } } - if (_disposed) return; + if (_disposed || operationGeneration != _operationGeneration) return; if (failed) { if (_pendingEpoch == target) { @@ -126,43 +111,26 @@ class LiveSeekAccumulator { return; } - // A press landed during the network round-trip + open: flush the newer - // target immediately rather than waiting for another debounce. + // A press landed during the network round-trip + calibration: flush the + // newer target immediately rather than waiting for another debounce. if (_pendingEpoch != target) { unawaited(_flush()); return; } - _scheduleClear(target); - } - /// Hold the pinned target until the fresh transcode's position resets to ~0 - /// (then `streamStart + position` == target and unpinning is seamless), with - /// [settleCeiling] as a backstop in case it never settles. - void _scheduleClear(int target) { - _settleTimer?.cancel(); - var elapsed = Duration.zero; - void tick() { - if (_disposed || _pendingEpoch != target) return; - elapsed += settlePoll; - if (positionSeconds() < 2 || elapsed >= settleCeiling) { - _pendingEpoch = null; - onChanged?.call(); - return; - } - _settleTimer = Timer(settlePoll, tick); - } - - _settleTimer = Timer(settlePoll, tick); + // `seek` returning true proves the replacement clock is calibrated, so + // raw epoch reads are coherent and the pending target can be released. + _pendingEpoch = null; + onChanged?.call(); } /// Drop any queued/settling seek. Used when the session is about to be /// replaced (channel switch, retry) or superseded by an absolute seek, so a /// stale debounced seek can't fire against the new stream. void cancel() { + _operationGeneration++; _debounceTimer?.cancel(); _debounceTimer = null; - _settleTimer?.cancel(); - _settleTimer = null; _flushing = false; if (_pendingEpoch != null) { _pendingEpoch = null; @@ -172,7 +140,7 @@ class LiveSeekAccumulator { void dispose() { _disposed = true; + _operationGeneration++; _debounceTimer?.cancel(); - _settleTimer?.cancel(); } } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 6ecfd1605..37dd91797 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -79,8 +79,7 @@ class DesktopVideoControls extends StatefulWidget { // Live TV time-shift final CaptureBuffer? captureBuffer; final bool isAtLiveEdge; - final double streamStartEpoch; - final int? currentPositionEpoch; + final int Function(Duration position)? liveEpochForPosition; final ValueChanged? onLiveSeek; /// Relative live-TV skip callback (delta seconds); parent accumulates+debounces. @@ -147,8 +146,7 @@ class DesktopVideoControls extends StatefulWidget { this.liveChannelName, this.captureBuffer, this.isAtLiveEdge = true, - this.streamStartEpoch = 0, - this.currentPositionEpoch, + this.liveEpochForPosition, this.onLiveSeek, this.onLiveSeekBy, this.onJumpToLive, @@ -726,7 +724,7 @@ class DesktopVideoControlsState extends State { LiveTimelineBar( player: widget.player, captureBuffer: widget.captureBuffer!, - streamStartEpoch: widget.streamStartEpoch, + epochForPosition: widget.liveEpochForPosition!, isAtLiveEdge: widget.isAtLiveEdge, onSeekEnd: widget.onLiveSeek, horizontalLayout: true, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 2277f4841..0d867283b 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -68,7 +68,7 @@ class MobileVideoControls extends StatefulWidget { // Live TV time-shift final CaptureBuffer? captureBuffer; final bool isAtLiveEdge; - final double streamStartEpoch; + final int Function(Duration position)? liveEpochForPosition; final ValueChanged? onLiveSeek; /// Server ID for chapter thumbnails in the content strip @@ -117,7 +117,7 @@ class MobileVideoControls extends StatefulWidget { this.liveChannelName, this.captureBuffer, this.isAtLiveEdge = true, - this.streamStartEpoch = 0, + this.liveEpochForPosition, this.onLiveSeek, this.serverId, this.showQueueTab = false, @@ -404,7 +404,7 @@ class _MobileVideoControlsState extends State with SingleTi builder: (context) => LiveTimelineBar( player: widget.player, captureBuffer: widget.captureBuffer!, - streamStartEpoch: widget.streamStartEpoch, + epochForPosition: widget.liveEpochForPosition!, isAtLiveEdge: widget.isAtLiveEdge, onSeekEnd: widget.onLiveSeek, horizontalLayout: false, diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index fba61bab6..d6f710028 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -44,8 +44,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { liveChannelName: widget.liveChannelName, captureBuffer: widget.captureBuffer, isAtLiveEdge: widget.isAtLiveEdge, - streamStartEpoch: widget.streamStartEpoch, - currentPositionEpoch: widget.currentPositionEpoch, + liveEpochForPosition: widget.liveEpochForPosition, onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek), onLiveSeekBy: widget.onLiveSeekBy, onJumpToLive: _abandoningBurst(widget.onJumpToLive), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index cb69ba62b..7deeedfd2 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -650,11 +650,8 @@ class PlexVideoControls extends StatefulWidget { /// Whether playback is at the live edge final bool isAtLiveEdge; - /// Epoch seconds corresponding to player position 0 (for live TV) - final double streamStartEpoch; - - /// Current playback position as absolute epoch seconds (for live TV) - final int? currentPositionEpoch; + /// Maps a player-local position to absolute epoch seconds for live TV. + final int Function(Duration position)? liveEpochForPosition; /// Seek callback for live TV time-shift (absolute epoch seconds; scrubber) final ValueChanged? onLiveSeek; @@ -744,8 +741,7 @@ class PlexVideoControls extends StatefulWidget { this.liveChannelName, this.captureBuffer, this.isAtLiveEdge = true, - this.streamStartEpoch = 0, - this.currentPositionEpoch, + this.liveEpochForPosition, this.onLiveSeek, this.onLiveSeekBy, this.onJumpToLive, @@ -1367,7 +1363,7 @@ class _PlexVideoControlsState extends State liveChannelName: widget.liveChannelName, captureBuffer: widget.captureBuffer, isAtLiveEdge: widget.isAtLiveEdge, - streamStartEpoch: widget.streamStartEpoch, + liveEpochForPosition: widget.liveEpochForPosition, onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek), serverId: widget.metadata.serverId, showQueueTab: canShowQueue, diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index 422273989..2b18d87c2 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -11,13 +11,13 @@ import '../helpers/eager_horizontal_drag_recognizer.dart'; /// Timeline bar for live TV time-shift. /// -/// Listens to the player's position stream and computes the absolute epoch -/// position from [streamStartEpoch] + player position. The slider range -/// covers the capture buffer's seekable window. +/// Listens to player position while delegating the player-clock-to-epoch +/// mapping to [epochForPosition], the same mapping used by seek commands and +/// timeline heartbeats. The slider range covers the capture buffer. class LiveTimelineBar extends StatefulWidget { final Player player; final CaptureBuffer captureBuffer; - final double streamStartEpoch; + final int Function(Duration position) epochForPosition; final bool isAtLiveEdge; final ValueChanged? onSeekEnd; final bool horizontalLayout; @@ -30,7 +30,7 @@ class LiveTimelineBar extends StatefulWidget { super.key, required this.player, required this.captureBuffer, - required this.streamStartEpoch, + required this.epochForPosition, this.isAtLiveEdge = true, this.onSeekEnd, this.horizontalLayout = true, @@ -50,9 +50,7 @@ class _LiveTimelineBarState extends State { /// Position emits ~4x/sec but everything rendered is whole seconds, so /// rebuild only when the second changes (see ContentStrip's chapter index - /// stream for the same pattern). The stream carries player seconds rather - /// than the epoch so a `streamStartEpoch` change (live reopen) is applied - /// on the very next build instead of waiting for the next position tick. + /// stream for the same pattern). late Stream _positionSecondsStream; @override @@ -74,7 +72,7 @@ class _LiveTimelineBarState extends State { int get _rangeStart => widget.captureBuffer.seekableStartEpoch; int get _rangeEnd => widget.captureBuffer.seekableEndEpoch; - int _currentEpoch(int positionSeconds) => (widget.streamStartEpoch + positionSeconds).round(); + int _currentEpoch(int positionSeconds) => widget.epochForPosition(Duration(seconds: positionSeconds)); int _displayPosition(int positionSeconds) => _isDragging ? _dragPositionEpoch : _currentEpoch(positionSeconds); diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index be1507823..245e0db43 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -22,6 +22,7 @@ #endif #include +#include #include #include @@ -1128,6 +1129,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { auto* end = static_cast(event->data); if (!end) break; FlValue* data = fl_value_new_map(); + fl_value_set_string_take(data, "sourceId", fl_value_new_int(end->playlist_entry_id)); fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast(end->reason))); if (end->reason == MPV_END_FILE_REASON_ERROR) { fl_value_set_string_take(data, "error", fl_value_new_int(static_cast(end->error))); @@ -1139,17 +1141,26 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } case MPV_EVENT_START_FILE: { - SendEvent("start-file"); + auto* start = static_cast(event->data); + if (!start) break; + active_source_id_ = start->playlist_entry_id; + has_active_source_id_ = true; + SendActiveSourceEvent("start-file"); break; } case MPV_EVENT_FILE_LOADED: { audio_recovery_.SetFileLoaded(true); EnsureAudioRecoveryTimer(); - SendEvent("file-loaded"); + SendActiveSourceEvent("file-loaded"); break; } case MPV_EVENT_PLAYBACK_RESTART: { - SendEvent("playback-restart"); + double position_seconds = 0.0; + const double* position = nullptr; + if (mpv_ && mpv_get_property(mpv_, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0) { + position = &position_seconds; + } + SendPlaybackRestartEvent(position); break; } default: @@ -1206,6 +1217,11 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { } else { fl_value_append_take(list, fl_value_new_null()); } + if (has_active_source_id_) { + fl_value_append_take(list, fl_value_new_int(active_source_id_)); + } else { + fl_value_append_take(list, fl_value_new_null()); + } EventCallback callback; { @@ -1216,6 +1232,32 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { fl_value_unref(list); } +void MpvPlayer::SendActiveSourceEvent(const std::string& name) { + FlValue* data = nullptr; + if (has_active_source_id_) { + data = fl_value_new_map(); + fl_value_set_string_take(data, "sourceId", fl_value_new_int(active_source_id_)); + } + SendEvent(name, data); + if (data) fl_value_unref(data); +} + +void MpvPlayer::SendPlaybackRestartEvent(const double* position_seconds) { + const bool has_position = position_seconds && std::isfinite(*position_seconds); + FlValue* data = nullptr; + if (has_active_source_id_ || has_position) { + data = fl_value_new_map(); + if (has_active_source_id_) { + fl_value_set_string_take(data, "sourceId", fl_value_new_int(active_source_id_)); + } + if (has_position) { + fl_value_set_string_take(data, "positionSeconds", fl_value_new_float(*position_seconds)); + } + } + SendEvent("playback-restart", data); + if (data) fl_value_unref(data); +} + void MpvPlayer::SendEvent(const std::string& name, FlValue* data) { FlValue* event_map = fl_value_new_map(); fl_value_set_string_take(event_map, "type", fl_value_new_string("event")); diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 4e71c5380..04ac5c2d5 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -330,6 +331,12 @@ class MpvPlayer { /// Sends a property change notification. void SendPropertyChange(const char* name, mpv_node* data); + /// Sends a lifecycle event for the active playlist entry. + void SendActiveSourceEvent(const std::string& name); + + /// Sends playback-restart with a finite position when libmpv supplied one. + void SendPlaybackRestartEvent(const double* position_seconds); + /// Reparses the `video-params` payload into source_hdr_metadata_ and tells /// the source-metadata callback that it moved. The parse happens under /// native_mutex_; the callback runs outside it, because what it goes on to do @@ -456,6 +463,11 @@ class MpvPlayer { plezy::mpv_common::AudioRecoveryState audio_recovery_; plezy::mpv_common::AsyncRequestRegistry pending_requests_; plezy::mpv_common::PropertyObservationRegistry observed_properties_; + // The playlist entry whose START_FILE event was most recently dequeued. + // Payload construction consumes this value synchronously, before any + // EventChannel fanout can outlive the corresponding mpv event. + int64_t active_source_id_ = 0; + bool has_active_source_id_ = false; bool hdr_enabled_ = true; // All player-carrying sources are attached to CallbackContext::main_context() diff --git a/linux/runner/mpv/mpv_player_lifecycle_test.cc b/linux/runner/mpv/mpv_player_lifecycle_test.cc index 54060225f..75a8c71ea 100644 --- a/linux/runner/mpv/mpv_player_lifecycle_test.cc +++ b/linux/runner/mpv/mpv_player_lifecycle_test.cc @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include "mpv_player.h" @@ -59,6 +61,9 @@ class MpvPlayerLifecycleTestPeer { player.observed_properties_.Register(name, "node", id); } static void HandleEvent(MpvPlayer& player, mpv_event* event) { player.HandleMpvEvent(event); } + static void SendPlaybackRestart(MpvPlayer& player, const double* position_seconds) { + player.SendPlaybackRestartEvent(position_seconds); + } static void HoldLease( const std::shared_ptr& context, std::mutex& mutex, std::condition_variable& condition, @@ -257,11 +262,14 @@ void TestNullNodePropertyPayloadDecodesAsNull() { bool delivered = false; player.SetEventCallback([&delivered](FlValue* event) { Check(fl_value_get_type(event) == FL_VALUE_TYPE_LIST, "property event must remain a list"); - Check(fl_value_get_length(event) == 2, "property event must contain the ID and value"); + Check(fl_value_get_length(event) == 3, "property event must contain the ID, value, and source ID"); Check(fl_value_get_int(fl_value_get_list_value(event, 0)) == 42, "property event ID changed"); Check( fl_value_get_type(fl_value_get_list_value(event, 1)) == FL_VALUE_TYPE_NULL, "a missing MPV node payload must decode as null"); + Check( + fl_value_get_type(fl_value_get_list_value(event, 2)) == FL_VALUE_TYPE_NULL, + "property source must be null before START_FILE"); delivered = true; }); @@ -276,6 +284,150 @@ void TestNullNodePropertyPayloadDecodesAsNull() { Check(delivered, "null node property event was not delivered"); } +FlValue* RequireMapField(FlValue* map, const char* key, const char* message) { + Check(map && fl_value_get_type(map) == FL_VALUE_TYPE_MAP, "event payload must be a map"); + FlValue* value = fl_value_lookup_string(map, key); + Check(value != nullptr, message); + return value; +} + +FlValue* RequireEventData(FlValue* event, const char* expected_name) { + Check(event && fl_value_get_type(event) == FL_VALUE_TYPE_MAP, "lifecycle event must be a map"); + FlValue* name = RequireMapField(event, "name", "lifecycle event name is missing"); + Check( + fl_value_get_type(name) == FL_VALUE_TYPE_STRING && std::string(fl_value_get_string(name)) == expected_name, + "lifecycle event name changed"); + return RequireMapField(event, "data", "source-qualified lifecycle event data is missing"); +} + +void TestSourceQualifiedEventPayloads() { + MpvPlayer player; + MpvPlayerLifecycleTestPeer::RegisterObservedNode(player, "track-list", 42); + std::vector events; + player.SetEventCallback([&events](FlValue* event) { events.push_back(fl_value_ref(event)); }); + + mpv_event_property property{}; + property.name = "track-list"; + property.format = MPV_FORMAT_NODE; + mpv_event property_event{}; + property_event.event_id = MPV_EVENT_PROPERTY_CHANGE; + property_event.data = &property; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &property_event); + + constexpr int64_t kFirstSourceId = -5000000001LL; + mpv_event_start_file start{}; + start.playlist_entry_id = kFirstSourceId; + mpv_event start_event{}; + start_event.event_id = MPV_EVENT_START_FILE; + start_event.data = &start; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &start_event); + MpvPlayerLifecycleTestPeer::HandleEvent(player, &property_event); + + mpv_event file_loaded{}; + file_loaded.event_id = MPV_EVENT_FILE_LOADED; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &file_loaded); + + mpv_event playback_restart{}; + playback_restart.event_id = MPV_EVENT_PLAYBACK_RESTART; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &playback_restart); + const double position_seconds = 17.25; + MpvPlayerLifecycleTestPeer::SendPlaybackRestart(player, &position_seconds); + const double invalid_position = std::numeric_limits::infinity(); + MpvPlayerLifecycleTestPeer::SendPlaybackRestart(player, &invalid_position); + + constexpr int64_t kEndedSourceId = 6000000002LL; + mpv_event_end_file end{}; + end.reason = MPV_END_FILE_REASON_ERROR; + end.error = MPV_ERROR_LOADING_FAILED; + end.playlist_entry_id = kEndedSourceId; + mpv_event end_event{}; + end_event.event_id = MPV_EVENT_END_FILE; + end_event.data = &end; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &end_event); + + constexpr int64_t kNextSourceId = 7000000003LL; + start.playlist_entry_id = kNextSourceId; + MpvPlayerLifecycleTestPeer::HandleEvent(player, &start_event); + + Check(events.size() == 9, "source-qualified event sequence changed"); + + Check(fl_value_get_type(events[0]) == FL_VALUE_TYPE_LIST, "property event must remain a list"); + Check(fl_value_get_length(events[0]) == 3, "property event must contain ID, value, and source ID"); + Check(fl_value_get_int(fl_value_get_list_value(events[0], 0)) == 42, "property event ID changed"); + Check( + fl_value_get_type(fl_value_get_list_value(events[0], 1)) == FL_VALUE_TYPE_NULL, + "missing property data must remain null"); + Check( + fl_value_get_type(fl_value_get_list_value(events[0], 2)) == FL_VALUE_TYPE_NULL, + "property source must be null before START_FILE"); + + FlValue* start_data = RequireEventData(events[1], "start-file"); + Check( + fl_value_get_int(RequireMapField(start_data, "sourceId", "start-file source ID is missing")) == kFirstSourceId, + "start-file source ID lost signed 64-bit precision"); + + Check(fl_value_get_length(events[2]) == 3, "source-qualified property event must remain a triple"); + Check( + fl_value_get_int(fl_value_get_list_value(events[2], 2)) == kFirstSourceId, + "property event did not retain the active source ID"); + + FlValue* loaded_data = RequireEventData(events[3], "file-loaded"); + Check( + fl_value_get_int(RequireMapField(loaded_data, "sourceId", "file-loaded source ID is missing")) == kFirstSourceId, + "file-loaded source ID changed"); + + FlValue* restart_without_position = RequireEventData(events[4], "playback-restart"); + Check( + fl_value_get_int(RequireMapField( + restart_without_position, "sourceId", "playback-restart source ID is missing")) == kFirstSourceId, + "playback-restart source ID changed"); + Check( + fl_value_lookup_string(restart_without_position, "positionSeconds") == nullptr, + "unavailable playback position must not be manufactured"); + + FlValue* restart_data = RequireEventData(events[5], "playback-restart"); + Check( + fl_value_get_int(RequireMapField(restart_data, "sourceId", "positioned playback-restart source ID is missing")) == + kFirstSourceId, + "positioned playback-restart source ID changed"); + FlValue* restart_position = RequireMapField(restart_data, "positionSeconds", "finite playback position is missing"); + Check( + fl_value_get_type(restart_position) == FL_VALUE_TYPE_FLOAT && + fl_value_get_float(restart_position) == position_seconds, + "playback-restart position changed"); + + FlValue* invalid_restart_data = RequireEventData(events[6], "playback-restart"); + Check( + fl_value_lookup_string(invalid_restart_data, "positionSeconds") == nullptr, + "non-finite playback position must not enter the channel payload"); + + FlValue* end_data = RequireEventData(events[7], "end-file"); + Check( + fl_value_get_int(RequireMapField(end_data, "sourceId", "end-file source ID is missing")) == kEndedSourceId, + "end-file must use its event-specific source ID"); + Check( + fl_value_get_int(RequireMapField(end_data, "reason", "end-file reason is missing")) == MPV_END_FILE_REASON_ERROR, + "end-file reason changed"); + Check( + fl_value_get_int(RequireMapField(end_data, "error", "end-file error is missing")) == MPV_ERROR_LOADING_FAILED, + "end-file error changed"); + Check( + fl_value_get_type(RequireMapField(end_data, "message", "end-file message is missing")) == FL_VALUE_TYPE_STRING, + "end-file message changed type"); + + FlValue* next_start_data = RequireEventData(events[8], "start-file"); + Check( + fl_value_get_int(RequireMapField(next_start_data, "sourceId", "replacement source ID is missing")) == + kNextSourceId, + "replacement source ID changed"); + Check( + fl_value_get_int(fl_value_get_list_value(events[2], 2)) == kFirstSourceId, + "later START_FILE relabeled an already-dispatched property"); + + player.SetEventCallback(nullptr); + for (FlValue* event : events) fl_value_unref(event); +} + void TestUnavailableCommandFails() { MpvPlayer player; int callback_count = 0; @@ -577,6 +729,7 @@ int main() { mpv::TestRenderTeardownRetainsOwnershipUntilContextIsCurrent(); mpv::TestRenderTeardownDoesNotDestroyAStillCurrentContext(); mpv::TestNullNodePropertyPayloadDecodesAsNull(); + mpv::TestSourceQualifiedEventPayloads(); mpv::TestFailedTeardownIsRetriedAndConsumedExactlyOnce(); } catch (const std::exception& error) { g_main_context_pop_thread_default(context); diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 5236d998d..30eff450f 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -52,7 +52,7 @@ final class RecordingLifecycleDelegate: MpvPlayerDelegate { private(set) var events: [String] = [] private(set) var properties: [String] = [] - func onPropertyChange(name: String, value: Any?) { properties.append(name) } + func onPropertyChange(name: String, value: Any?, sourceId: Int64?) { properties.append(name) } func onEvent(name: String, data: [String: Any]?) { events.append(name) } } @@ -63,6 +63,41 @@ final class MpvPlayerContractTests: XCTestCase { userInfo: [NSLocalizedDescriptionKey: "controlled failure"] ) + func testSharedTransportEmitsSourceQualifiedPayloads() { + let plugin = RecordingMpvPlugin(core: nil) + plugin.nameToId["time-pos"] = 27 + var messages: [Any?] = [] + plugin.eventSink = { messages.append($0) } + let sourceId = Int64.max - 7 + + plugin.onPropertyChange(name: "time-pos", value: 12.5, sourceId: sourceId) + plugin.onPropertyChange(name: "time-pos", value: nil, sourceId: nil) + plugin.onEvent( + name: "playback-restart", + data: ["sourceId": sourceId, "positionSeconds": 12.5] + ) + + XCTAssertEqual(messages.count, 3) + guard + let sourcedProperty = messages[0] as? [Any?], + let preStartProperty = messages[1] as? [Any?], + let lifecycleEvent = messages[2] as? [String: Any], + let lifecycleData = lifecycleEvent["data"] as? [String: Any] + else { + return XCTFail("Expected property triples and a lifecycle event map") + } + XCTAssertEqual(sourcedProperty.count, 3) + XCTAssertEqual(sourcedProperty[0] as? Int, 27) + XCTAssertEqual(sourcedProperty[1] as? Double, 12.5) + XCTAssertEqual(sourcedProperty[2] as? Int64, sourceId) + XCTAssertEqual(preStartProperty.count, 3) + XCTAssertNil(preStartProperty[1]) + XCTAssertNil(preStartProperty[2]) + XCTAssertEqual(lifecycleEvent["name"] as? String, "playback-restart") + XCTAssertEqual(lifecycleData["sourceId"] as? Int64, sourceId) + XCTAssertEqual(lifecycleData["positionSeconds"] as? Double, 12.5) + } + func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() { let core = ControllablePropertyCore() let plugin = RecordingMpvPlugin(core: core) @@ -242,7 +277,7 @@ final class MpvPlayerContractTests: XCTestCase { core.delegate = delegate let enqueueAndDispose = { core.dispatchDelegateEvent(name: "file-loaded", data: nil) - core.dispatchDelegateProperty(name: "time-pos", value: 1.0) + core.dispatchDelegateProperty(name: "time-pos", value: 1.0, sourceId: 7) XCTAssertTrue(core.beginDisposal()) } if Thread.isMainThread { diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index bae8617d9..6ea952abd 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -19,7 +19,7 @@ struct MpvLifecycleUnavailableError: LocalizedError { } protocol MpvPlayerDelegate: AnyObject { - func onPropertyChange(name: String, value: Any?) + func onPropertyChange(name: String, value: Any?, sourceId: Int64?) func onEvent(name: String, data: [String: Any]?) } @@ -196,6 +196,10 @@ class MpvPlayerCoreBase: NSObject { let queue = DispatchQueue(label: "mpv", qos: .userInitiated) private let queueKey = DispatchSpecificKey() + /// The most recent playlist entry announced by START_FILE. Event dequeue + /// runs serially on `queue`; pass this value into delegate dispatch rather + /// than reading it later on the main queue. + private var activeSourceId: Int64? private enum PendingRequest { case void((Result) -> Void) @@ -1089,17 +1093,23 @@ class MpvPlayerCoreBase: NSObject { } } - func dispatchDelegateEvent(name: String, data: [String: Any]?) { + func dispatchDelegateEvent(name: String, data: [String: Any]?, sourceId: Int64? = nil) { + var sourcedData = data + if let sourceId { + if sourcedData == nil { sourcedData = [:] } + sourcedData?["sourceId"] = sourceId + } + let eventData = sourcedData DispatchQueue.main.async { [weak self] in guard let self, self.isLifecycleActive else { return } - self.delegate?.onEvent(name: name, data: data) + self.delegate?.onEvent(name: name, data: eventData) } } - func dispatchDelegateProperty(name: String, value: Any?) { + func dispatchDelegateProperty(name: String, value: Any?, sourceId: Int64?) { DispatchQueue.main.async { [weak self] in guard let self, self.isLifecycleActive else { return } - self.delegate?.onPropertyChange(name: name, value: value) + self.delegate?.onPropertyChange(name: name, value: value, sourceId: sourceId) } } @@ -1109,7 +1119,12 @@ class MpvPlayerCoreBase: NSObject { guard let data = event.data else { break } let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee let name = safeString(property.name) - handlePropertyChange(name: name, property: property, replyUserdata: event.reply_userdata) + handlePropertyChange( + name: name, + property: property, + replyUserdata: event.reply_userdata, + sourceId: activeSourceId + ) case MPV_EVENT_COMMAND_REPLY: completeVoidRequest(requestId: event.reply_userdata, error: event.error) @@ -1121,10 +1136,17 @@ class MpvPlayerCoreBase: NSObject { completeGetPropertyRequest(event) case MPV_EVENT_START_FILE: - dispatchDelegateEvent(name: "start-file", data: nil) + if let startFilePtr = event.data?.assumingMemoryBound(to: mpv_event_start_file.self) { + let sourceId = startFilePtr.pointee.playlist_entry_id + activeSourceId = sourceId + dispatchDelegateEvent(name: "start-file", data: nil, sourceId: sourceId) + } else { + activeSourceId = nil + dispatchDelegateEvent(name: "start-file", data: nil) + } case MPV_EVENT_FILE_LOADED: - dispatchDelegateEvent(name: "file-loaded", data: nil) + dispatchDelegateEvent(name: "file-loaded", data: nil, sourceId: activeSourceId) case MPV_EVENT_END_FILE: if let endFilePtr = event.data?.assumingMemoryBound(to: mpv_event_end_file.self) { @@ -1134,7 +1156,11 @@ class MpvPlayerCoreBase: NSObject { data["error"] = Int(endFile.error) data["message"] = safeString(mpv_error_string(endFile.error)) } - dispatchDelegateEvent(name: "end-file", data: data) + dispatchDelegateEvent( + name: "end-file", + data: data, + sourceId: endFile.playlist_entry_id + ) } else { dispatchDelegateEvent(name: "end-file", data: nil) } @@ -1143,7 +1169,15 @@ class MpvPlayerCoreBase: NSObject { print("[MpvPlayerCore] MPV shutdown event") case MPV_EVENT_PLAYBACK_RESTART: - dispatchDelegateEvent(name: "playback-restart", data: nil) + var data: [String: Any]? + if let position = playbackRestartPosition() { + data = ["positionSeconds": position] + } + dispatchDelegateEvent( + name: "playback-restart", + data: data, + sourceId: activeSourceId + ) case MPV_EVENT_LOG_MESSAGE: if isLifecycleBackgrounded { break } @@ -1164,7 +1198,21 @@ class MpvPlayerCoreBase: NSObject { } } - private func handlePropertyChange(name: String, property: mpv_event_property, replyUserdata: UInt64) { + private func playbackRestartPosition() -> Double? { + var position = 0.0 + let status = withActiveMpv { mpv in + mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &position) + } + guard let status, status >= 0, position.isFinite else { return nil } + return position + } + + private func handlePropertyChange( + name: String, + property: mpv_event_property, + replyUserdata: UInt64, + sourceId: Int64? + ) { var value: Any? switch property.format { @@ -1251,7 +1299,7 @@ class MpvPlayerCoreBase: NSObject { if Self.internalObserverIds.contains(replyUserdata) { return } if isLifecycleBackgrounded && !Self.criticalProperties.contains(name) { return } - dispatchDelegateProperty(name: name, value: value) + dispatchDelegateProperty(name: name, value: value, sourceId: sourceId) } private func updateCachedProperty(name: String, value: Any?) { diff --git a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift index a5bad3474..3b8b34899 100644 --- a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift +++ b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift @@ -160,9 +160,10 @@ extension MpvPluginShared { // MARK: - MpvPlayerDelegate - func onPropertyChange(name: String, value: Any?) { + func onPropertyChange(name: String, value: Any?, sourceId: Int64?) { guard let eventSink = eventSink, let propId = nameToId[name] else { return } - eventSink([propId, value as Any]) + let message: [Any?] = [propId, value, sourceId] + eventSink(message) } func onEvent(name: String, data: [String: Any]?) { diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 2a1ff25bc..8cc937bbe 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -955,6 +955,60 @@ void main() { ); }); + test('MPV source readiness carries the first rendered non-zero clock position once', () async { + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + testBody: () async { + final player = PlayerNative(); + final started = []; + final ready = []; + final startedSubscription = player.streams.sourceStarted.listen(started.add); + final readySubscription = player.streams.sourceReady.listen(ready.add); + try { + player.handlePlayerEvent('start-file', {'sourceId': 17}); + player.handlePlayerEvent('playback-restart', {'sourceId': 17, 'positionSeconds': 47.25}); + player.handlePlayerEvent('playback-restart', {'sourceId': 17, 'positionSeconds': 52.0}); + await Future.delayed(Duration.zero); + + expect(started.single.sourceId, 17); + expect(ready, hasLength(1)); + expect(ready.single.sourceId, 17); + expect(ready.single.position, const Duration(milliseconds: 47250)); + expect(player.currentPosition, const Duration(milliseconds: 47250)); + } finally { + await startedSubscription.cancel(); + await readySubscription.cancel(); + await player.dispose(); + } + }, + ); + }); + + test('MPV ignores delayed positions from a replaced source', () async { + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + testBody: () async { + final player = PlayerNative(); + try { + player.handlePlayerEvent('start-file', {'sourceId': 17}); + player.handlePropertyChange('time-pos', 47.0, sourceId: 17); + expect(player.currentPosition, const Duration(seconds: 47)); + + player.handlePlayerEvent('start-file', {'sourceId': 18}); + player.handlePropertyChange('time-pos', 99.0, sourceId: 17); + expect(player.currentPosition, const Duration(seconds: 47)); + + player.handlePropertyChange('time-pos', 5.0, sourceId: 18); + expect(player.currentPosition, const Duration(seconds: 5)); + } finally { + await player.dispose(); + } + }, + ); + }); + test('MPV exposes primary media readiness before external subtitles finish', () async { await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', diff --git a/test/screens/video_player/live_tv_session_state_test.dart b/test/screens/video_player/live_tv_session_state_test.dart index 0bf427129..48cd91352 100644 --- a/test/screens/video_player/live_tv_session_state_test.dart +++ b/test/screens/video_player/live_tv_session_state_test.dart @@ -1,8 +1,11 @@ +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/live_tv_support.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/models/livetv_capture_buffer.dart'; +import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/video_player/live_tv_session_state.dart'; +import 'package:plezy/services/live_seek_accumulator.dart'; MediaSubtitleTrack _track({required int id, int? index, String? languageCode}) => MediaSubtitleTrack(id: id, index: index, languageCode: languageCode, selected: false, forced: false); @@ -53,6 +56,117 @@ void main() { expect(state.selectedSubtitle, isNull); }); }); + + group('LiveTvSessionState source clock', () { + test('subtracts a non-zero source baseline from subsequent positions', () async { + final state = LiveTvSessionState(null); + final generation = state.beginClockOpen(1093); + final result = state.clockOpenResult(generation); + + expect(state.epochForPosition(const Duration(seconds: 52)), 1093); + expect(state.bindClockSource(const PlayerSourceStarted(7)), isTrue); + expect(state.calibrateClockSource(const PlayerSourceReady(sourceId: 7, position: Duration(seconds: 47))), isTrue); + + expect(await result, isTrue); + expect(state.streamStartEpoch, 1046); + expect(state.epochForPosition(const Duration(seconds: 52)), 1098); + }); + + test('a superseded source cannot calibrate the latest open', () async { + final state = LiveTvSessionState(null); + final firstGeneration = state.beginClockOpen(1085); + final firstResult = state.clockOpenResult(firstGeneration); + final secondGeneration = state.beginClockOpen(1070); + final secondResult = state.clockOpenResult(secondGeneration); + + expect(state.bindClockSource(const PlayerSourceStarted(11)), isFalse); + expect(state.bindClockSource(const PlayerSourceStarted(12)), isTrue); + expect( + state.calibrateClockSource(const PlayerSourceReady(sourceId: 11, position: Duration(seconds: 52))), + isFalse, + ); + expect( + state.calibrateClockSource(const PlayerSourceReady(sourceId: 12, position: Duration(seconds: 40))), + isTrue, + ); + + expect(await firstResult, isFalse); + expect(await secondResult, isTrue); + expect(state.epochForPosition(const Duration(seconds: 45)), 1075); + }); + + test('a calibration timeout keeps the target until late readiness arrives', () async { + final state = LiveTvSessionState(null); + final generation = state.beginClockOpen(1093); + final result = state.clockOpenResult(generation); + state.bindClockSource(const PlayerSourceStarted(7)); + + state.timeoutClockOpen(generation); + + expect(await result, isFalse); + expect(state.epochForPosition(const Duration(seconds: 52)), 1093); + expect(state.calibrateClockSource(const PlayerSourceReady(sourceId: 7, position: Duration(seconds: 47))), isTrue); + expect(state.epochForPosition(const Duration(seconds: 52)), 1098); + }); + + test('a zero-based source preserves the existing epoch mapping', () async { + final state = LiveTvSessionState(null); + final generation = state.beginClockOpen(1093); + final result = state.clockOpenResult(generation); + state.bindClockSource(const PlayerSourceStarted(7)); + state.calibrateClockSource(const PlayerSourceReady(sourceId: 7, position: Duration.zero)); + + expect(await result, isTrue); + expect(state.epochForPosition(const Duration(seconds: 5)), 1098); + }); + + test('two backward skips compound from the calibrated source clock', () { + fakeAsync((async) { + final state = LiveTvSessionState(null)..streamStartEpoch = 1000; + var position = const Duration(seconds: 100); + var sourceId = 0; + final requestedEpochs = []; + final accumulator = LiveSeekAccumulator( + seek: (targetEpoch) async { + requestedEpochs.add(targetEpoch); + final generation = state.beginClockOpen(targetEpoch); + final result = state.clockOpenResult(generation); + final currentSourceId = ++sourceId; + state.bindClockSource(PlayerSourceStarted(currentSourceId)); + if (requestedEpochs.length == 1) { + state.calibrateClockSource( + PlayerSourceReady(sourceId: currentSourceId, position: const Duration(seconds: 52)), + ); + position = const Duration(seconds: 57); + } else { + state.calibrateClockSource( + PlayerSourceReady(sourceId: currentSourceId, position: const Duration(seconds: 40)), + ); + position = const Duration(seconds: 45); + } + return result; + }, + currentEpoch: () => state.epochForPosition(position), + bounds: () => (start: 0, end: 2000), + debounce: Duration.zero, + ); + + accumulator.seekBy(-15); + async.elapse(Duration.zero); + async.flushMicrotasks(); + expect(requestedEpochs, [1085]); + expect(state.epochForPosition(position), 1090); + + accumulator.seekBy(-15); + async.elapse(Duration.zero); + async.flushMicrotasks(); + expect(requestedEpochs, [1085, 1075]); + expect(state.epochForPosition(position), 1080); + + accumulator.dispose(); + }); + }); + }); } class _FakeSession implements LiveTvPlaybackSession { diff --git a/test/services/live_seek_accumulator_test.dart b/test/services/live_seek_accumulator_test.dart index de6569746..dbdbc706f 100644 --- a/test/services/live_seek_accumulator_test.dart +++ b/test/services/live_seek_accumulator_test.dart @@ -8,10 +8,10 @@ void main() { group('LiveSeekAccumulator', () { late List seeks; // recorded re-open targets late int currentEpoch; // mutable "live" epoch (streamStart + position) - late int positionSeconds; // mutable player position, drives settle late LiveSeekBounds? window; // mutable seekable window late int changes; // onChanged call count late bool seekThrows; // make the seek re-open fail + late bool seekSucceeds; // make the calibrated re-open report failure Completer? gate; // optionally stalls a seek mid-flight LiveSeekAccumulator build() => LiveSeekAccumulator( @@ -19,23 +19,21 @@ void main() { seeks.add(target); if (gate != null) await gate!.future; if (seekThrows) throw Exception('seek failed'); + return seekSucceeds; }, currentEpoch: () => currentEpoch, - positionSeconds: () => positionSeconds, bounds: () => window, onChanged: () => changes++, debounce: const Duration(milliseconds: 300), - settleCeiling: const Duration(milliseconds: 1500), - settlePoll: const Duration(milliseconds: 100), ); setUp(() { seeks = []; currentEpoch = 1000; - positionSeconds = 0; // re-opened stream settles immediately by default window = (start: 0, end: 1000000); changes = 0; seekThrows = false; + seekSucceeds = true; gate = null; }); @@ -154,31 +152,20 @@ void main() { }); }); - test('unpins the pending target once the re-opened stream settles', () { + test('unpins the pending target only after clock calibration completes', () { fakeAsync((async) { - positionSeconds = 0; // settled - final acc = build(); - acc.seekBy(15); - - async.elapse(const Duration(milliseconds: 300)); - expect(acc.pendingEpoch, 1015); // still pinned right after the re-open - - async.elapse(const Duration(milliseconds: 100)); // settle poll - expect(acc.pendingEpoch, isNull); - acc.dispose(); - }); - }); - - test('unpins via the ceiling if the position never settles', () { - fakeAsync((async) { - positionSeconds = 100; // never below the settle threshold + gate = Completer(); final acc = build(); acc.seekBy(15); async.elapse(const Duration(milliseconds: 300)); expect(acc.pendingEpoch, 1015); - async.elapse(const Duration(milliseconds: 1500)); // ceiling + async.elapse(const Duration(seconds: 10)); + expect(acc.pendingEpoch, 1015, reason: 'elapsed time is not evidence that a source clock is calibrated'); + + gate!.complete(); + async.flushMicrotasks(); expect(acc.pendingEpoch, isNull); acc.dispose(); }); @@ -189,7 +176,7 @@ void main() { final acc = build(); acc.seekBy(15); // 1000 -> 1015 async.elapse(const Duration(milliseconds: 300)); - async.elapse(const Duration(milliseconds: 100)); // settle clears pending + async.flushMicrotasks(); expect(acc.pendingEpoch, isNull); // New stream origin: raw epoch now reflects the previous target. @@ -216,6 +203,21 @@ void main() { }); }); + test('releases the pending pin when calibration returns false', () { + fakeAsync((async) { + seekSucceeds = false; + final acc = build(); + acc.seekBy(15); + + async.elapse(const Duration(milliseconds: 300)); + async.flushMicrotasks(); + + expect(seeks, [1015]); + expect(acc.pendingEpoch, isNull); + acc.dispose(); + }); + }); + test('cancel drops the pending target and prevents the debounced seek', () { fakeAsync((async) { final acc = build(); @@ -246,13 +248,12 @@ void main() { test('notifies onChanged when the target changes and when it clears', () { fakeAsync((async) { - positionSeconds = 0; final acc = build(); acc.seekBy(15); expect(changes, 1); // accumulate async.elapse(const Duration(milliseconds: 300)); - async.elapse(const Duration(milliseconds: 100)); // settle clears + async.flushMicrotasks(); expect(changes, 2); // clear acc.dispose(); }); diff --git a/test/widgets/live_timeline_bar_test.dart b/test/widgets/live_timeline_bar_test.dart index 5c799bc69..5d5bddf71 100644 --- a/test/widgets/live_timeline_bar_test.dart +++ b/test/widgets/live_timeline_bar_test.dart @@ -145,6 +145,26 @@ void main() { }); }); + testWidgets('uses the calibrated source clock instead of adding its non-zero origin', (tester) async { + final semantics = tester.ensureSemantics(); + final harness = await _pumpTimeline( + tester, + seeks: [], + currentOffset: 52, + rangeEndOffset: 200, + epochForPosition: (startEpoch, position) { + const requestedOffset = 93; + const sourceBaseline = 47; + return startEpoch + requestedOffset + position.inSeconds - sourceBaseline; + }, + ); + + final data = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)).getSemanticsData(); + expect(data.value, _clock(harness.startEpoch + 98)); + expect(data.decreasedValue, _clock(harness.startEpoch + 88)); + semantics.dispose(); + }); + testWidgets('pointer seek and desktop key routing remain intact', (tester) async { final seeks = []; final focusNode = FocusNode(); @@ -197,6 +217,7 @@ Future<({int startEpoch, FakeSyncPlayer player})> _pumpTimeline( bool horizontalLayout = true, FocusNode? focusNode, KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, + int Function(int startEpoch, Duration position)? epochForPosition, }) async { final startEpoch = DateTime(2026, 1, 1, 12).millisecondsSinceEpoch ~/ 1000; final player = FakeSyncPlayer(position: Duration(seconds: currentOffset)); @@ -219,7 +240,8 @@ Future<({int startEpoch, FakeSyncPlayer player})> _pumpTimeline( seekStartSeconds: 0, seekEndSeconds: rangeEndOffset.toDouble(), ), - streamStartEpoch: startEpoch.toDouble(), + epochForPosition: (position) => + epochForPosition?.call(startEpoch, position) ?? startEpoch + position.inSeconds, isAtLiveEdge: isAtLiveEdge, onSeekEnd: provideSeekCallback ? seeks.add : null, focusNode: focusNode, diff --git a/tvos/RunnerTests/MpvPlayerContractTests.swift b/tvos/RunnerTests/MpvPlayerContractTests.swift index 62cbcdeb1..926e32411 100644 --- a/tvos/RunnerTests/MpvPlayerContractTests.swift +++ b/tvos/RunnerTests/MpvPlayerContractTests.swift @@ -36,6 +36,41 @@ private final class TvosRecordingMpvPlugin: MpvPluginShared { } final class MpvPlayerContractTests: XCTestCase { + func testSharedTransportEmitsSourceQualifiedPayloads() { + let plugin = TvosRecordingMpvPlugin(core: nil) + plugin.nameToId["time-pos"] = 27 + var messages: [Any?] = [] + plugin.eventSink = { messages.append($0) } + let sourceId = Int64.max - 7 + + plugin.onPropertyChange(name: "time-pos", value: 12.5, sourceId: sourceId) + plugin.onPropertyChange(name: "time-pos", value: nil, sourceId: nil) + plugin.onEvent( + name: "playback-restart", + data: ["sourceId": sourceId, "positionSeconds": 12.5] + ) + + XCTAssertEqual(messages.count, 3) + guard + let sourcedProperty = messages[0] as? [Any?], + let preStartProperty = messages[1] as? [Any?], + let lifecycleEvent = messages[2] as? [String: Any], + let lifecycleData = lifecycleEvent["data"] as? [String: Any] + else { + return XCTFail("Expected property triples and a lifecycle event map") + } + XCTAssertEqual(sourcedProperty.count, 3) + XCTAssertEqual(sourcedProperty[0] as? Int, 27) + XCTAssertEqual(sourcedProperty[1] as? Double, 12.5) + XCTAssertEqual(sourcedProperty[2] as? Int64, sourceId) + XCTAssertEqual(preStartProperty.count, 3) + XCTAssertNil(preStartProperty[1]) + XCTAssertNil(preStartProperty[2]) + XCTAssertEqual(lifecycleEvent["name"] as? String, "playback-restart") + XCTAssertEqual(lifecycleData["sourceId"] as? Int64, sourceId) + XCTAssertEqual(lifecycleData["positionSeconds"] as? Double, 12.5) + } + func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() { let core = TvosControllablePropertyCore() let plugin = TvosRecordingMpvPlugin(core: core) diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 08099ac85..2e286864a 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "sanitize_utf8.h" @@ -557,6 +558,8 @@ bool MpvPlayer::Initialize(HWND view) { if (mpv_) { return true; // Already initialized. } + active_source_id_ = 0; + has_active_source_id_ = false; // Create mpv instance. mpv_ = mpv_create(); @@ -899,6 +902,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { audio_recovery_.SetFileLoaded(false); auto* end = static_cast(event->data); flutter::EncodableMap data; + data[flutter::EncodableValue("sourceId")] = flutter::EncodableValue(end->playlist_entry_id); data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast(end->reason)); if (end->reason == MPV_END_FILE_REASON_ERROR) { data[flutter::EncodableValue("error")] = flutter::EncodableValue(static_cast(end->error)); @@ -908,7 +912,10 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } case MPV_EVENT_START_FILE: { - SendEvent("start-file"); + auto* start = static_cast(event->data); + active_source_id_ = start->playlist_entry_id; + has_active_source_id_ = true; + SendActiveSourceEvent("start-file"); break; } case MPV_EVENT_FILE_LOADED: { @@ -919,15 +926,20 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { // uploaded log (#2191 was undiagnosable without this). LogHdrPipelineOnce(); if (hdr_probe_) hdr_probe_->OnFileLoaded(); - SendEvent("file-loaded"); + SendActiveSourceEvent("file-loaded"); break; } case MPV_EVENT_PLAYBACK_RESTART: { + double position_seconds = 0.0; + const double* position = nullptr; + if (mpv_ && mpv_get_property(mpv_, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0) { + position = &position_seconds; + } // mpv's inner window exists by now (vo is configured); make sure the // DComp-mode input forwarding subclass is installed. SetRect alone can // miss it: the rect often settles before mpv creates the window. EnsureMpvInnerSubclassed(); - SendEvent("playback-restart"); + SendPlaybackRestartEvent(position); break; } default: @@ -946,6 +958,11 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { flutter::EncodableList list; list.push_back(flutter::EncodableValue(id)); list.push_back(NodeToEncodableValue(data)); + if (has_active_source_id_) { + list.push_back(flutter::EncodableValue(active_source_id_)); + } else { + list.push_back(flutter::EncodableValue()); + } std::lock_guard lock(callback_mutex_); if (event_callback_) { @@ -953,6 +970,25 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { } } +void MpvPlayer::SendActiveSourceEvent(const std::string& name) { + flutter::EncodableMap data; + if (has_active_source_id_) { + data[flutter::EncodableValue("sourceId")] = flutter::EncodableValue(active_source_id_); + } + SendEvent(name, data); +} + +void MpvPlayer::SendPlaybackRestartEvent(const double* position_seconds) { + flutter::EncodableMap data; + if (has_active_source_id_) { + data[flutter::EncodableValue("sourceId")] = flutter::EncodableValue(active_source_id_); + } + if (position_seconds && std::isfinite(*position_seconds)) { + data[flutter::EncodableValue("positionSeconds")] = flutter::EncodableValue(*position_seconds); + } + SendEvent("playback-restart", data); +} + void MpvPlayer::SendEvent(const std::string& name, const flutter::EncodableMap& data) { flutter::EncodableMap event; event[flutter::EncodableValue("type")] = flutter::EncodableValue("event"); diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 4a11c18fa..761975c12 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -101,6 +102,8 @@ class MpvPlayer { void EventLoop(); void HandleMpvEvent(mpv_event* event); void SendPropertyChange(const char* name, mpv_node* data); + void SendActiveSourceEvent(const std::string& name); + void SendPlaybackRestartEvent(const double* position_seconds); void SendEvent(const std::string& name, const flutter::EncodableMap& data = {}); void MaybeRunAudioRecovery(); void TryAudioReload(const char* reason, int attempt, uint64_t request_generation); @@ -126,6 +129,11 @@ class MpvPlayer { plezy::mpv_common::AsyncRequestRegistry pending_requests_; plezy::mpv_common::PropertyObservationRegistry observed_properties_; + // The playlist entry whose START_FILE event was most recently dequeued. + // Event payloads copy this value before the plugin queues them to the + // platform thread, so a later START_FILE cannot relabel delayed properties. + int64_t active_source_id_ = 0; + bool has_active_source_id_ = false; // HDR state bool hdr_enabled_ = true; diff --git a/windows/runner/mpv/mpv_player_property_contract_test.cpp b/windows/runner/mpv/mpv_player_property_contract_test.cpp index eb37af29e..67eb33ef5 100644 --- a/windows/runner/mpv/mpv_player_property_contract_test.cpp +++ b/windows/runner/mpv/mpv_player_property_contract_test.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include "mpv_player.h" @@ -21,6 +23,15 @@ class MpvPlayerPropertyContractTestPeer { static void RegisterPendingPropertyRead(MpvPlayer& player, MpvPlayer::GetPropertyCallback callback) { player.pending_requests_.RegisterProperty(std::move(callback)); } + static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) { + player.observed_properties_.Register(name, "node", id); + } + + static void HandleEvent(MpvPlayer& player, mpv_event* event) { player.HandleMpvEvent(event); } + + static void SendPlaybackRestart(MpvPlayer& player, const double* position_seconds) { + player.SendPlaybackRestartEvent(position_seconds); + } static void ConfigureInnerSubclass(MpvPlayer& player, HWND host, HWND target) { player.hwnd_ = host; player.forward_target_view_ = target; @@ -47,6 +58,160 @@ void Check(bool condition, const char* message) { } } +const flutter::EncodableMap& RequireMap(const flutter::EncodableValue& value, const char* message) { + Check(std::holds_alternative(value), message); + return std::get(value); +} + +const flutter::EncodableValue& RequireMapField(const flutter::EncodableMap& map, const char* key, const char* message) { + auto value = map.find(flutter::EncodableValue(key)); + Check(value != map.end(), message); + return value->second; +} + +const flutter::EncodableMap& RequireEventData(const flutter::EncodableValue& event, const char* expected_name) { + const auto& envelope = RequireMap(event, "lifecycle event must be a map"); + const auto& name = RequireMapField(envelope, "name", "lifecycle event name is missing"); + Check( + std::holds_alternative(name) && std::get(name) == expected_name, + "lifecycle event name changed"); + return RequireMap( + RequireMapField(envelope, "data", "source-qualified lifecycle event data is missing"), + "lifecycle event data must be a map"); +} + +int64_t RequireSourceId(const flutter::EncodableMap& data, const char* message) { + const auto& source_id = RequireMapField(data, "sourceId", message); + Check(std::holds_alternative(source_id), "source ID must use the signed 64-bit codec type"); + return std::get(source_id); +} + +void TestSourceQualifiedEventPayloads() { + MpvPlayer player; + MpvPlayerPropertyContractTestPeer::RegisterObservedNode(player, "track-list", 42); + std::vector events; + player.SetEventCallback([&events](const flutter::EncodableValue& event) { events.push_back(event); }); + + mpv_event_property property{}; + property.name = "track-list"; + property.format = MPV_FORMAT_NODE; + mpv_event property_event{}; + property_event.event_id = MPV_EVENT_PROPERTY_CHANGE; + property_event.data = &property; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event); + + constexpr int64_t kFirstSourceId = -5000000001LL; + mpv_event_start_file start{}; + start.playlist_entry_id = kFirstSourceId; + mpv_event start_event{}; + start_event.event_id = MPV_EVENT_START_FILE; + start_event.data = &start; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event); + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event); + + mpv_event file_loaded{}; + file_loaded.event_id = MPV_EVENT_FILE_LOADED; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &file_loaded); + + mpv_event playback_restart{}; + playback_restart.event_id = MPV_EVENT_PLAYBACK_RESTART; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &playback_restart); + const double position_seconds = 17.25; + MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &position_seconds); + const double invalid_position = std::numeric_limits::infinity(); + MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &invalid_position); + + constexpr int64_t kEndedSourceId = 6000000002LL; + mpv_event_end_file end{}; + end.reason = MPV_END_FILE_REASON_ERROR; + end.error = MPV_ERROR_LOADING_FAILED; + end.playlist_entry_id = kEndedSourceId; + mpv_event end_event{}; + end_event.event_id = MPV_EVENT_END_FILE; + end_event.data = &end; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &end_event); + + constexpr int64_t kNextSourceId = 7000000003LL; + start.playlist_entry_id = kNextSourceId; + MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event); + + Check(events.size() == 9, "source-qualified event sequence changed"); + + const auto& property_before_start = std::get(events[0]); + Check(property_before_start.size() == 3, "property event must contain ID, value, and source ID"); + Check( + std::holds_alternative(property_before_start[0]) && std::get(property_before_start[0]) == 42, + "property event ID changed"); + Check(std::holds_alternative(property_before_start[1]), "missing property data must remain null"); + Check( + std::holds_alternative(property_before_start[2]), + "property source must be null before START_FILE"); + + const auto& start_data = RequireEventData(events[1], "start-file"); + Check( + RequireSourceId(start_data, "start-file source ID is missing") == kFirstSourceId, + "start-file source ID lost signed 64-bit precision"); + + const auto& source_property = std::get(events[2]); + Check(source_property.size() == 3, "source-qualified property event must remain a triple"); + Check( + std::holds_alternative(source_property[2]) && std::get(source_property[2]) == kFirstSourceId, + "property event did not retain the active source ID"); + + const auto& loaded_data = RequireEventData(events[3], "file-loaded"); + Check( + RequireSourceId(loaded_data, "file-loaded source ID is missing") == kFirstSourceId, + "file-loaded source ID changed"); + + const auto& restart_without_position = RequireEventData(events[4], "playback-restart"); + Check( + RequireSourceId(restart_without_position, "playback-restart source ID is missing") == kFirstSourceId, + "playback-restart source ID changed"); + Check( + restart_without_position.find(flutter::EncodableValue("positionSeconds")) == restart_without_position.end(), + "unavailable playback position must not be manufactured"); + + const auto& restart_data = RequireEventData(events[5], "playback-restart"); + Check( + RequireSourceId(restart_data, "positioned playback-restart source ID is missing") == kFirstSourceId, + "positioned playback-restart source ID changed"); + const auto& restart_position = + RequireMapField(restart_data, "positionSeconds", "finite playback position is missing"); + Check( + std::holds_alternative(restart_position) && std::get(restart_position) == position_seconds, + "playback-restart position changed"); + + const auto& invalid_restart_data = RequireEventData(events[6], "playback-restart"); + Check( + invalid_restart_data.find(flutter::EncodableValue("positionSeconds")) == invalid_restart_data.end(), + "non-finite playback position must not enter the channel payload"); + + const auto& end_data = RequireEventData(events[7], "end-file"); + Check( + RequireSourceId(end_data, "end-file source ID is missing") == kEndedSourceId, + "end-file must use its event-specific source ID"); + const auto& end_reason = RequireMapField(end_data, "reason", "end-file reason is missing"); + Check( + std::holds_alternative(end_reason) && std::get(end_reason) == MPV_END_FILE_REASON_ERROR, + "end-file reason changed"); + const auto& end_error = RequireMapField(end_data, "error", "end-file error is missing"); + Check( + std::holds_alternative(end_error) && std::get(end_error) == MPV_ERROR_LOADING_FAILED, + "end-file error changed"); + Check( + std::holds_alternative(RequireMapField(end_data, "message", "end-file message is missing")), + "end-file message changed type"); + + const auto& next_start_data = RequireEventData(events[8], "start-file"); + Check( + RequireSourceId(next_start_data, "replacement source ID is missing") == kNextSourceId, + "replacement source ID changed"); + Check( + std::get(source_property[2]) == kFirstSourceId, "later START_FILE relabeled an already-queued property"); + + player.SetEventCallback(nullptr); +} + std::atomic g_forwarded_mouse_messages{0}; std::atomic g_forwarded_touch_moves{0}; std::atomic g_forwarded_mouse_down_messages{0}; @@ -803,6 +968,7 @@ void TestDisabledVideoHostRoutesRealPressesToParentView() { } // namespace mpv int main() { + mpv::TestSourceQualifiedEventPayloads(); mpv::TestUnavailablePropertyWriteFails(); mpv::TestPendingPropertyWriteFailsOnDispose(); mpv::TestPendingRequestTypesRemainDistinctOnDispose();