fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<!-- Android TV Watch Next integration -->
|
<!-- Android TV Watch Next integration -->
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<!-- Touchscreen not required for TV -->
|
<!-- Touchscreen not required for TV -->
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
@@ -91,13 +92,14 @@
|
|||||||
<provider
|
<provider
|
||||||
android:name=".watchnext.SystemShelfArtworkProvider"
|
android:name=".watchnext.SystemShelfArtworkProvider"
|
||||||
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:grantUriPermissions="true" />
|
android:grantUriPermissions="true" />
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".watchnext.SystemShelfUpdateReceiver"
|
android:name=".watchnext.SystemShelfUpdateReceiver"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
@@ -130,5 +132,10 @@
|
|||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- Required to identify only the active HOME launcher as a shelf artwork consumer. -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.HOME" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
+27
-7
@@ -3,6 +3,7 @@ package com.edde746.plezy.exoplayer
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.DataReader
|
import androidx.media3.common.DataReader
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.ParserException
|
||||||
import androidx.media3.common.util.ParsableByteArray
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
import androidx.media3.extractor.TrackOutput
|
import androidx.media3.extractor.TrackOutput
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
@@ -11,7 +12,8 @@ import java.io.EOFException
|
|||||||
abstract class BufferedTransformingTrackOutput(
|
abstract class BufferedTransformingTrackOutput(
|
||||||
protected val delegate: TrackOutput,
|
protected val delegate: TrackOutput,
|
||||||
initialBufferSize: Int,
|
initialBufferSize: Int,
|
||||||
initialReadBufferSize: Int = initialBufferSize
|
initialReadBufferSize: Int = initialBufferSize,
|
||||||
|
private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
|
||||||
) : TrackOutput {
|
) : TrackOutput {
|
||||||
protected var inputBuffer = ByteArray(initialBufferSize)
|
protected var inputBuffer = ByteArray(initialBufferSize)
|
||||||
private set
|
private set
|
||||||
@@ -26,6 +28,12 @@ abstract class BufferedTransformingTrackOutput(
|
|||||||
|
|
||||||
/** Returns transformed length, or a negative value to drop the sample. */
|
/** Returns transformed length, or a negative value to drop the sample. */
|
||||||
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
|
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(initialBufferSize > 0)
|
||||||
|
require(initialReadBufferSize > 0)
|
||||||
|
require(maxBufferedSampleBytes >= initialBufferSize)
|
||||||
|
}
|
||||||
open override fun format(format: Format) = delegate.format(format)
|
open override fun format(format: Format) = delegate.format(format)
|
||||||
|
|
||||||
override fun sampleData(
|
override fun sampleData(
|
||||||
@@ -39,8 +47,10 @@ abstract class BufferedTransformingTrackOutput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
buffering = true
|
buffering = true
|
||||||
if (readBuffer.size < length) readBuffer = ByteArray(length)
|
val remainingCapacity = maxBufferedSampleBytes - inputLength
|
||||||
val bytesRead = input.read(readBuffer, 0, length)
|
if (length < 0 || remainingCapacity <= 0) throw sampleTooLarge()
|
||||||
|
val requested = minOf(length, remainingCapacity, readBuffer.size)
|
||||||
|
val bytesRead = input.read(readBuffer, 0, requested)
|
||||||
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
|
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
|
||||||
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
|
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
|
||||||
return bytesRead
|
return bytesRead
|
||||||
@@ -53,7 +63,7 @@ abstract class BufferedTransformingTrackOutput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
buffering = true
|
buffering = true
|
||||||
ensureInputCapacity(inputLength + length)
|
ensureInputCapacity(length)
|
||||||
data.readBytes(inputBuffer, inputLength, length)
|
data.readBytes(inputBuffer, inputLength, length)
|
||||||
inputLength += length
|
inputLength += length
|
||||||
}
|
}
|
||||||
@@ -82,14 +92,24 @@ abstract class BufferedTransformingTrackOutput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun appendInput(source: ByteArray, length: Int) {
|
private fun appendInput(source: ByteArray, length: Int) {
|
||||||
ensureInputCapacity(inputLength + length)
|
ensureInputCapacity(length)
|
||||||
System.arraycopy(source, 0, inputBuffer, inputLength, length)
|
System.arraycopy(source, 0, inputBuffer, inputLength, length)
|
||||||
inputLength += length
|
inputLength += length
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureInputCapacity(needed: Int) {
|
private fun ensureInputCapacity(additionalBytes: Int) {
|
||||||
|
if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
|
||||||
|
throw sampleTooLarge()
|
||||||
|
}
|
||||||
|
val needed = inputLength + additionalBytes
|
||||||
if (inputBuffer.size < needed) {
|
if (inputBuffer.size < needed) {
|
||||||
inputBuffer = inputBuffer.copyOf(maxOf(needed, inputBuffer.size * 2))
|
val doubledSize = minOf(maxBufferedSampleBytes.toLong(), inputBuffer.size.toLong() * 2).toInt()
|
||||||
|
inputBuffer = inputBuffer.copyOf(maxOf(needed, doubledSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sampleTooLarge(): ParserException = ParserException.createForMalformedContainer(
|
||||||
|
"Buffered sample exceeds the maximum size of $maxBufferedSampleBytes bytes",
|
||||||
|
null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,8 +128,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
private val hwAudioDecoderCache = HashMap<String, Boolean>()
|
private val hwAudioDecoderCache = HashMap<String, Boolean>()
|
||||||
private val tunneledPlaybackCache = HashMap<String, Boolean>()
|
private val tunneledPlaybackCache = HashMap<String, Boolean>()
|
||||||
|
|
||||||
private var assGlCrashHandlerInstalled = false
|
|
||||||
|
|
||||||
@Volatile private var cronetEngine: CronetEngine? = null
|
@Volatile private var cronetEngine: CronetEngine? = null
|
||||||
|
|
||||||
@Volatile private var cronetUnavailable = false
|
@Volatile private var cronetUnavailable = false
|
||||||
@@ -713,24 +711,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
// assView.requestRender directly from the listener below.
|
// assView.requestRender directly from the listener below.
|
||||||
handler.init(exoPlayer!!)
|
handler.init(exoPlayer!!)
|
||||||
|
|
||||||
// Suppress ass-media GL thread crash when EGL init partially fails (e.g. Tegra).
|
|
||||||
// AssRender.onSurfaceDestroyed() accesses uninitialized glProgram lateinit property
|
|
||||||
// during error cleanup, which is a bug in the library. The render thread dying only
|
|
||||||
// affects ASS subtitle GPU rendering; non-ASS subtitles are unaffected.
|
|
||||||
if (!assGlCrashHandlerInstalled) {
|
|
||||||
assGlCrashHandlerInstalled = true
|
|
||||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
if (thread.name.contains("AssTexRenderThread") &&
|
|
||||||
throwable is UninitializedPropertyAccessException
|
|
||||||
) {
|
|
||||||
Log.e(TAG, "ASS GL thread crash suppressed (EGL init failure)", throwable)
|
|
||||||
} else {
|
|
||||||
previousHandler?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exoPlayer!!.addListener(this)
|
exoPlayer!!.addListener(this)
|
||||||
exoPlayer!!.addAnalyticsListener(decoderHangListener)
|
exoPlayer!!.addAnalyticsListener(decoderHangListener)
|
||||||
exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
|
exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ExoPlayerPlugin :
|
|||||||
|
|
||||||
private val channels = PlayerChannelBinding(METHOD_CHANNEL, this, this, TAG)
|
private val channels = PlayerChannelBinding(METHOD_CHANNEL, this, this, TAG)
|
||||||
private val mainHandler get() = channels.mainHandler
|
private val mainHandler get() = channels.mainHandler
|
||||||
|
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
|
||||||
private var playerCore: ExoPlayerCore? = null
|
private var playerCore: ExoPlayerCore? = null
|
||||||
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
||||||
private var usingMpvFallback: Boolean = false
|
private var usingMpvFallback: Boolean = false
|
||||||
@@ -65,9 +66,28 @@ class ExoPlayerPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
teardownSession(clearActivity = true)
|
||||||
channels.detach()
|
channels.detach()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun teardownSession(clearActivity: Boolean) {
|
||||||
|
++sessionGeneration
|
||||||
|
val exoCore = playerCore
|
||||||
|
val fallbackCore = mpvCore
|
||||||
|
playerCore = null
|
||||||
|
mpvCore = null
|
||||||
|
usingMpvFallback = false
|
||||||
|
fallbackInProgress = false
|
||||||
|
currentExternalSubtitles = null
|
||||||
|
pendingMpvProperties.clear()
|
||||||
|
if (clearActivity) {
|
||||||
|
activity = null
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
exoCore?.dispose()
|
||||||
|
fallbackCore?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
// ActivityAware
|
// ActivityAware
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
@@ -77,17 +97,7 @@ class ExoPlayerPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
sessionGeneration++
|
teardownSession(clearActivity = true)
|
||||||
playerCore?.dispose()
|
|
||||||
playerCore = null
|
|
||||||
mpvCore?.dispose()
|
|
||||||
mpvCore = null
|
|
||||||
usingMpvFallback = false
|
|
||||||
fallbackInProgress = false
|
|
||||||
currentExternalSubtitles = null
|
|
||||||
pendingMpvProperties.clear()
|
|
||||||
activity = null
|
|
||||||
activityBinding = null
|
|
||||||
Log.d(TAG, "Detached from activity")
|
Log.d(TAG, "Detached from activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +108,10 @@ class ExoPlayerPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
sessionGeneration++
|
// MainActivity owns a self-created engine which is destroyed with the old
|
||||||
fallbackInProgress = false
|
// Activity. There is no cached-engine transfer contract, so retaining an
|
||||||
activity = null
|
// Activity-bound core here would orphan its views and native resources.
|
||||||
activityBinding = null
|
teardownSession(clearActivity = true)
|
||||||
Log.d(TAG, "Detached from activity for config changes")
|
Log.d(TAG, "Detached from activity for config changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +190,7 @@ class ExoPlayerPlugin :
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val requestGeneration = sessionGeneration
|
||||||
if (playerCore?.isInitialized == true) {
|
if (playerCore?.isInitialized == true) {
|
||||||
Log.d(TAG, "Already initialized")
|
Log.d(TAG, "Already initialized")
|
||||||
result.success(true)
|
result.success(true)
|
||||||
@@ -198,7 +209,11 @@ class ExoPlayerPlugin :
|
|||||||
AssHandler.setRenderScale(subtitleRenderScale)
|
AssHandler.setRenderScale(subtitleRenderScale)
|
||||||
|
|
||||||
currentActivity.runOnUiThread {
|
currentActivity.runOnUiThread {
|
||||||
sessionGeneration++
|
if (requestGeneration != sessionGeneration || activity !== currentActivity) {
|
||||||
|
result.success(false)
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
++sessionGeneration
|
||||||
// Do NOT clear pendingMpvProperties here: Dart queues its startup
|
// Do NOT clear pendingMpvProperties here: Dart queues its startup
|
||||||
// properties (sub-ass, subtitle fonts, ...) before initialize, and the
|
// properties (sub-ass, subtitle fonts, ...) before initialize, and the
|
||||||
// fallback replay in setupMpvFallback needs them. Dispose/detach clear.
|
// fallback replay in setupMpvFallback needs them. Dispose/detach clear.
|
||||||
@@ -245,19 +260,11 @@ class ExoPlayerPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDispose(result: MethodChannel.Result) {
|
private fun handleDispose(result: MethodChannel.Result) {
|
||||||
activity?.runOnUiThread {
|
runOnMain {
|
||||||
sessionGeneration++
|
teardownSession(clearActivity = false)
|
||||||
playerCore?.dispose()
|
|
||||||
playerCore = null
|
|
||||||
mpvCore?.dispose()
|
|
||||||
mpvCore = null
|
|
||||||
usingMpvFallback = false
|
|
||||||
fallbackInProgress = false
|
|
||||||
currentExternalSubtitles = null
|
|
||||||
pendingMpvProperties.clear()
|
|
||||||
Log.d(TAG, "Disposed")
|
Log.d(TAG, "Disposed")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} ?: result.success(null)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -331,9 +338,11 @@ class ExoPlayerPlugin :
|
|||||||
appendExternalSubtitleOptions(options, externalSubtitles)
|
appendExternalSubtitleOptions(options, externalSubtitles)
|
||||||
appendHttpHeaderOptions(options, headers)
|
appendHttpHeaderOptions(options, headers)
|
||||||
val optionsStr = options.joinToString(",")
|
val optionsStr = options.joinToString(",")
|
||||||
mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
|
val core = mpvCore ?: return
|
||||||
|
core.setPauseIntentForLoad(paused = !autoPlay)
|
||||||
|
core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
|
||||||
if (success && autoPlay) {
|
if (success && autoPlay) {
|
||||||
mpvCore?.setProperty("pause", "no")
|
core.setProperty("pause", "no")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -931,6 +940,7 @@ class ExoPlayerPlugin :
|
|||||||
appendExternalSubtitleOptions(options, externalSubtitles)
|
appendExternalSubtitleOptions(options, externalSubtitles)
|
||||||
appendHttpHeaderOptions(options, headers)
|
appendHttpHeaderOptions(options, headers)
|
||||||
val optionsStr = options.joinToString(",")
|
val optionsStr = options.joinToString(",")
|
||||||
|
core.setPauseIntentForLoad(paused = !playWhenReady)
|
||||||
notifyBackendSwitched()
|
notifyBackendSwitched()
|
||||||
core.command(arrayOf("loadfile", source.value, "replace", "-1", optionsStr))
|
core.command(arrayOf("loadfile", source.value, "replace", "-1", optionsStr))
|
||||||
|
|
||||||
|
|||||||
+50
-22
@@ -1,6 +1,6 @@
|
|||||||
package com.edde746.plezy.exoplayer
|
package com.edde746.plezy.exoplayer
|
||||||
|
|
||||||
import android.util.Log
|
import androidx.media3.common.ParserException
|
||||||
import androidx.media3.extractor.TrackOutput
|
import androidx.media3.extractor.TrackOutput
|
||||||
import java.util.zip.DataFormatException
|
import java.util.zip.DataFormatException
|
||||||
import java.util.zip.Inflater
|
import java.util.zip.Inflater
|
||||||
@@ -14,18 +14,27 @@ import java.util.zip.Inflater
|
|||||||
*/
|
*/
|
||||||
class ZlibInflatingTrackOutput(
|
class ZlibInflatingTrackOutput(
|
||||||
delegate: TrackOutput
|
delegate: TrackOutput
|
||||||
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE, INFLATE_CHUNK) {
|
) : BufferedTransformingTrackOutput(
|
||||||
|
delegate,
|
||||||
|
INITIAL_BUFFER_SIZE,
|
||||||
|
INFLATE_CHUNK,
|
||||||
|
MAX_COMPRESSED_SAMPLE_SIZE
|
||||||
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ZlibTrackOutput"
|
|
||||||
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
||||||
private const val INFLATE_CHUNK = 64 * 1024
|
private const val INFLATE_CHUNK = 64 * 1024
|
||||||
|
private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||||
|
private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
|
||||||
|
private const val MAX_COMPRESSION_RATIO = 1024L
|
||||||
|
private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
var active = false
|
var active = false
|
||||||
|
|
||||||
private val inflater = Inflater()
|
private val inflater = Inflater()
|
||||||
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||||
|
private val overflowProbe = ByteArray(1)
|
||||||
|
|
||||||
override val transformEnabled: Boolean
|
override val transformEnabled: Boolean
|
||||||
get() = active
|
get() = active
|
||||||
@@ -33,31 +42,50 @@ class ZlibInflatingTrackOutput(
|
|||||||
override val transformedBuffer: ByteArray
|
override val transformedBuffer: ByteArray
|
||||||
get() = inflateBuf
|
get() = inflateBuf
|
||||||
|
|
||||||
override fun transformSample(inputLength: Int, flags: Int): Int = try {
|
override fun transformSample(inputLength: Int, flags: Int): Int {
|
||||||
inflater.reset()
|
inflater.reset()
|
||||||
inflater.setInput(inputBuffer, 0, inputLength)
|
inflater.setInput(inputBuffer, 0, inputLength)
|
||||||
var written = 0
|
var written = 0
|
||||||
while (!inflater.finished()) {
|
val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
|
||||||
if (written == inflateBuf.size) growInflateBuf()
|
|
||||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
|
||||||
if (count == 0 && !inflater.finished()) break
|
|
||||||
written += count
|
|
||||||
}
|
|
||||||
written
|
|
||||||
} catch (e: DataFormatException) {
|
|
||||||
Log.e(TAG, "Zlib inflate failed (${inputLength}B), passing raw", e)
|
|
||||||
ensureInflateCapacity(inputLength)
|
|
||||||
System.arraycopy(inputBuffer, 0, inflateBuf, 0, inputLength)
|
|
||||||
inputLength
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureInflateCapacity(needed: Int) {
|
try {
|
||||||
if (inflateBuf.size < needed) {
|
while (true) {
|
||||||
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
|
if (written == inflateBuf.size) {
|
||||||
|
if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
|
||||||
|
val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
|
||||||
|
inflateBuf = inflateBuf.copyOf(nextSize)
|
||||||
|
} else {
|
||||||
|
val overflow = inflater.inflate(overflowProbe, 0, 1)
|
||||||
|
if (overflow > 0) {
|
||||||
|
throw malformed("Inflated sample exceeds the maximum size")
|
||||||
|
}
|
||||||
|
if (inflater.finished()) return written
|
||||||
|
throw stalledInflate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||||
|
written += count
|
||||||
|
if (written.toLong() > ratioBound) {
|
||||||
|
throw malformed("Inflated sample exceeds the maximum compression ratio")
|
||||||
|
}
|
||||||
|
if (inflater.finished()) return written
|
||||||
|
if (count == 0) throw stalledInflate()
|
||||||
|
}
|
||||||
|
} catch (_: DataFormatException) {
|
||||||
|
// Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
|
||||||
|
// by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
|
||||||
|
if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
|
||||||
|
inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
|
||||||
|
return inputLength
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun growInflateBuf() {
|
private fun stalledInflate(): ParserException = when {
|
||||||
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
|
inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
|
||||||
|
inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
|
||||||
|
else -> malformed("Zlib decompressor made no progress")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,12 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
@Volatile private var cachedPaused: Boolean = true
|
@Volatile private var cachedPaused: Boolean = true
|
||||||
|
|
||||||
|
@Volatile private var desiredPaused: Boolean = true
|
||||||
|
|
||||||
@Volatile private var pausedForSurfaceLoss: Boolean = false
|
@Volatile private var pausedForSurfaceLoss: Boolean = false
|
||||||
|
|
||||||
|
@Volatile private var pausedForAudioFocusLoss: Boolean = false
|
||||||
|
|
||||||
@Volatile private var hasAttachedSurface: Boolean = false
|
@Volatile private var hasAttachedSurface: Boolean = false
|
||||||
|
|
||||||
@Volatile private var attachedToPlaceholder: Boolean = false
|
@Volatile private var attachedToPlaceholder: Boolean = false
|
||||||
@@ -125,6 +129,16 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
@Volatile private var resumeBlockedByPublicPause: Boolean = false
|
@Volatile private var resumeBlockedByPublicPause: Boolean = false
|
||||||
|
|
||||||
|
private data class PublicPauseIntent(
|
||||||
|
val generation: Long,
|
||||||
|
val previousBlocked: Boolean,
|
||||||
|
val previousDesiredPaused: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private val publicPauseIntentLock = Any()
|
||||||
|
private var publicPauseIntentGeneration = 0L
|
||||||
|
private val publicPauseWriteMutex = Mutex()
|
||||||
|
|
||||||
@Volatile private var videoOutputEpoch: Long = 0L
|
@Volatile private var videoOutputEpoch: Long = 0L
|
||||||
private val videoOutputMutex = Mutex()
|
private val videoOutputMutex = Mutex()
|
||||||
private var pendingVideoOutputDisableJob: Job? = null
|
private var pendingVideoOutputDisableJob: Job? = null
|
||||||
@@ -199,14 +213,19 @@ class MpvPlayerCore private constructor(
|
|||||||
disposing = false
|
disposing = false
|
||||||
endFileDiagnostics.onStartFile()
|
endFileDiagnostics.onStartFile()
|
||||||
cachedPaused = true
|
cachedPaused = true
|
||||||
|
desiredPaused = true
|
||||||
pausedForSurfaceLoss = false
|
pausedForSurfaceLoss = false
|
||||||
|
pausedForAudioFocusLoss = false
|
||||||
pendingSurface = null
|
pendingSurface = null
|
||||||
attachedSurface = null
|
attachedSurface = null
|
||||||
attachedToPlaceholder = false
|
attachedToPlaceholder = false
|
||||||
hasAttachedSurface = false
|
hasAttachedSurface = false
|
||||||
videoOutputRestoring = false
|
videoOutputRestoring = false
|
||||||
deferredResumeRequested = false
|
deferredResumeRequested = false
|
||||||
resumeBlockedByPublicPause = false
|
synchronized(publicPauseIntentLock) {
|
||||||
|
publicPauseIntentGeneration += 1L
|
||||||
|
resumeBlockedByPublicPause = false
|
||||||
|
}
|
||||||
videoOutputEpoch = 0L
|
videoOutputEpoch = 0L
|
||||||
pendingVideoOutputDisableJob?.cancel()
|
pendingVideoOutputDisableJob?.cancel()
|
||||||
pendingVideoOutputDisableJob = null
|
pendingVideoOutputDisableJob = null
|
||||||
@@ -223,18 +242,12 @@ class MpvPlayerCore private constructor(
|
|||||||
handler = handler,
|
handler = handler,
|
||||||
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
|
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
|
||||||
onPause = {
|
onPause = {
|
||||||
scope.launch {
|
pauseForAudioFocusLoss()
|
||||||
try {
|
|
||||||
player?.setProperty("pause", true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to pause on focus loss", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onResume = {
|
onResume = {
|
||||||
requestAutoResume("audio focus gain")
|
resumeAfterAudioFocusGain("audio focus gain")
|
||||||
},
|
},
|
||||||
isPaused = { cachedPaused }
|
isPaused = { desiredPaused }
|
||||||
)
|
)
|
||||||
if (!audioOnly) {
|
if (!audioOnly) {
|
||||||
frameRateManager = FrameRateManager(
|
frameRateManager = FrameRateManager(
|
||||||
@@ -392,7 +405,13 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
// Audio Focus
|
// Audio Focus
|
||||||
|
|
||||||
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
fun requestAudioFocus(): Boolean {
|
||||||
|
val granted = audioFocusManager?.requestAudioFocus() ?: false
|
||||||
|
if (granted && pausedForAudioFocusLoss) {
|
||||||
|
resumeAfterAudioFocusGain("audio focus request granted")
|
||||||
|
}
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
fun abandonAudioFocus() {
|
fun abandonAudioFocus() {
|
||||||
audioFocusManager?.abandonAudioFocus()
|
audioFocusManager?.abandonAudioFocus()
|
||||||
@@ -587,23 +606,25 @@ class MpvPlayerCore private constructor(
|
|||||||
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
|
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val wasPaused = try {
|
publicPauseWriteMutex.withLock {
|
||||||
p.getFlag("pause") == true
|
val wasPaused = try {
|
||||||
} catch (e: Exception) {
|
p.getFlag("pause") == true
|
||||||
cachedPaused
|
|
||||||
}
|
|
||||||
if (!wasPaused) {
|
|
||||||
try {
|
|
||||||
p.setProperty("pause", true)
|
|
||||||
cachedPaused = true
|
|
||||||
pausedForSurfaceLoss = true
|
|
||||||
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
cachedPaused
|
||||||
|
}
|
||||||
|
if (!wasPaused) {
|
||||||
|
try {
|
||||||
|
p.setProperty("pause", true)
|
||||||
|
cachedPaused = true
|
||||||
|
pausedForSurfaceLoss = true
|
||||||
|
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pausedForSurfaceLoss = false
|
||||||
|
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
pausedForSurfaceLoss = false
|
pausedForSurfaceLoss = false
|
||||||
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
pausedForSurfaceLoss = false
|
|
||||||
}
|
}
|
||||||
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
|
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
|
||||||
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
|
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
|
||||||
@@ -654,29 +675,88 @@ class MpvPlayerCore private constructor(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pauseForAudioFocusLoss() {
|
||||||
|
val shouldPause = synchronized(publicPauseIntentLock) {
|
||||||
|
(!desiredPaused).also { pausedForAudioFocusLoss = it }
|
||||||
|
}
|
||||||
|
if (!shouldPause) {
|
||||||
|
Log.d(TAG, "Skipping audio-focus pause because playback is already desirably paused")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||||
|
try {
|
||||||
|
publicPauseWriteMutex.withLock {
|
||||||
|
if (!pausedForAudioFocusLoss || disposing) return@withLock
|
||||||
|
writeProperty("pause", "yes")
|
||||||
|
cachedPaused = true
|
||||||
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
Log.d(TAG, "Canceled audio-focus pause")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Failed to pause on focus loss", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeAfterAudioFocusGain(reason: String) {
|
||||||
|
pausedForAudioFocusLoss = false
|
||||||
|
requestAutoResume(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
|
||||||
|
synchronized(publicPauseIntentLock) {
|
||||||
|
if (publicPauseIntentGeneration == intent.generation) {
|
||||||
|
resumeBlockedByPublicPause = intent.previousBlocked
|
||||||
|
desiredPaused = intent.previousDesiredPaused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestAutoResume(reason: String) {
|
private fun requestAutoResume(reason: String) {
|
||||||
val p = player ?: return
|
val p = player
|
||||||
|
if (p == null && propertyWriterOverride == null) return
|
||||||
if (disposing) return
|
if (disposing) return
|
||||||
|
|
||||||
if (resumeBlockedByPublicPause) {
|
val intentGeneration = synchronized(publicPauseIntentLock) {
|
||||||
deferredResumeRequested = false
|
if (resumeBlockedByPublicPause) {
|
||||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
|
deferredResumeRequested = false
|
||||||
return
|
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasReadyVideoOutput()) {
|
||||||
|
deferredResumeRequested = true
|
||||||
|
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publicPauseIntentGeneration
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasReadyVideoOutput()) {
|
scope.launch(mpvWriteDispatcher) {
|
||||||
deferredResumeRequested = true
|
|
||||||
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
try {
|
try {
|
||||||
if (p.getFlag("pause") == true) {
|
publicPauseWriteMutex.withLock {
|
||||||
Log.d(TAG, "Auto-resuming playback after $reason")
|
val shouldResume = synchronized(publicPauseIntentLock) {
|
||||||
p.setProperty("pause", false)
|
!pausedForAudioFocusLoss &&
|
||||||
} else {
|
!resumeBlockedByPublicPause &&
|
||||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
|
publicPauseIntentGeneration == intentGeneration
|
||||||
|
}
|
||||||
|
if (!shouldResume) {
|
||||||
|
Log.d(TAG, "Skipping stale auto-resume after $reason")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
val isPaused = p?.getFlag("pause") ?: cachedPaused
|
||||||
|
if (isPaused) {
|
||||||
|
Log.d(TAG, "Auto-resuming playback after $reason")
|
||||||
|
if (p != null) {
|
||||||
|
p.setProperty("pause", false)
|
||||||
|
} else {
|
||||||
|
writeProperty("pause", "no")
|
||||||
|
}
|
||||||
|
cachedPaused = false
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to resume after $reason", e)
|
Log.w(TAG, "Failed to resume after $reason", e)
|
||||||
@@ -685,41 +765,108 @@ class MpvPlayerCore private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
|
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
|
||||||
if (!deferredResumeRequested) return
|
publicPauseWriteMutex.withLock {
|
||||||
|
val shouldResume = synchronized(publicPauseIntentLock) {
|
||||||
if (resumeBlockedByPublicPause) {
|
if (!deferredResumeRequested) {
|
||||||
deferredResumeRequested = false
|
false
|
||||||
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
|
} else if (pausedForAudioFocusLoss) {
|
||||||
return
|
Log.d(TAG, "Keeping deferred auto-resume pending after $reason until audio focus returns")
|
||||||
|
false
|
||||||
|
} else if (resumeBlockedByPublicPause) {
|
||||||
|
deferredResumeRequested = false
|
||||||
|
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
deferredResumeRequested = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldResume) return@withLock
|
||||||
|
if (p.getFlag("pause") == true) {
|
||||||
|
Log.d(TAG, "Applying deferred auto-resume after $reason")
|
||||||
|
p.setProperty("pause", false)
|
||||||
|
cachedPaused = false
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deferredResumeRequested = false
|
private suspend fun writeProperty(name: String, value: String) {
|
||||||
if (p.getFlag("pause") == true) {
|
val writer = propertyWriterOverride
|
||||||
Log.d(TAG, "Applying deferred auto-resume after $reason")
|
if (writer != null) {
|
||||||
p.setProperty("pause", false)
|
writer(name, value)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
|
val currentPlayer = player ?: throw CancellationException("MPV player unavailable")
|
||||||
|
currentPlayer.setProperty(name, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
|
/**
|
||||||
|
* Atomically records the public pause intent applied by the next loadfile
|
||||||
|
* operation. The load owns the native state transition, so this deliberately
|
||||||
|
* does not enqueue a second pause property write.
|
||||||
|
*/
|
||||||
|
fun setPauseIntentForLoad(paused: Boolean) {
|
||||||
|
if (!isInitialized || disposing || !scope.isActive) return
|
||||||
|
|
||||||
|
synchronized(publicPauseIntentLock) {
|
||||||
|
publicPauseIntentGeneration += 1L
|
||||||
|
desiredPaused = paused
|
||||||
|
resumeBlockedByPublicPause = paused
|
||||||
|
if (paused) {
|
||||||
|
cachedPaused = true
|
||||||
|
pausedForSurfaceLoss = false
|
||||||
|
pausedForAudioFocusLoss = false
|
||||||
|
deferredResumeRequested = false
|
||||||
|
} else if (!pausedForSurfaceLoss && !pausedForAudioFocusLoss && !deferredResumeRequested) {
|
||||||
|
cachedPaused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Load pause intent updated: paused=$paused")
|
||||||
|
}
|
||||||
|
|
||||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||||
if (!isInitialized || disposing || !scope.isActive) {
|
if (!isInitialized || disposing || !scope.isActive) {
|
||||||
onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
|
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||||
if (paused == false && !hasReadyVideoOutput()) {
|
val pauseIntent = paused?.let {
|
||||||
|
synchronized(publicPauseIntentLock) {
|
||||||
|
PublicPauseIntent(
|
||||||
|
generation = ++publicPauseIntentGeneration,
|
||||||
|
previousBlocked = resumeBlockedByPublicPause,
|
||||||
|
previousDesiredPaused = desiredPaused
|
||||||
|
).also {
|
||||||
|
resumeBlockedByPublicPause = paused
|
||||||
|
desiredPaused = paused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paused == false && (pausedForAudioFocusLoss || !hasReadyVideoOutput())) {
|
||||||
runOnMain {
|
runOnMain {
|
||||||
if (!isInitialized || disposing || !scope.isActive) {
|
if (!isInitialized || disposing || !scope.isActive) {
|
||||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||||
return@runOnMain
|
return@runOnMain
|
||||||
}
|
}
|
||||||
resumeBlockedByPublicPause = false
|
val isCurrent = synchronized(publicPauseIntentLock) {
|
||||||
deferredResumeRequested = true
|
pauseIntent != null && publicPauseIntentGeneration == pauseIntent.generation
|
||||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
deferredResumeRequested = true
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
if (pausedForAudioFocusLoss) {
|
||||||
|
"Deferring public resume until audio focus returns"
|
||||||
|
} else {
|
||||||
|
"Deferring public resume until video output is ready"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
onComplete?.invoke(Result.success(Unit))
|
onComplete?.invoke(Result.success(Unit))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -727,12 +874,15 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||||
val writeResult = try {
|
val writeResult = try {
|
||||||
val writer = propertyWriterOverride
|
if (pauseIntent == null) {
|
||||||
if (writer != null) {
|
writeProperty(name, value)
|
||||||
writer(name, value)
|
|
||||||
} else {
|
} else {
|
||||||
val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
|
publicPauseWriteMutex.withLock {
|
||||||
currentPlayer.setProperty(name, value)
|
val shouldWrite = synchronized(publicPauseIntentLock) {
|
||||||
|
publicPauseIntentGeneration == pauseIntent.generation
|
||||||
|
}
|
||||||
|
if (shouldWrite) writeProperty(name, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
@@ -742,23 +892,29 @@ class MpvPlayerCore private constructor(
|
|||||||
Result.failure(error)
|
Result.failure(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (writeResult.isFailure && pauseIntent != null) {
|
||||||
|
rollbackFailedPublicPauseIntent(pauseIntent)
|
||||||
|
}
|
||||||
|
|
||||||
withContext(NonCancellable + Dispatchers.Main) {
|
withContext(NonCancellable + Dispatchers.Main) {
|
||||||
val completion = if (disposing || !isInitialized) {
|
val completion = if (disposing || !isInitialized) {
|
||||||
Result.failure(CancellationException("MPV core unavailable"))
|
Result.failure(CancellationException("MPV core unavailable"))
|
||||||
} else {
|
} else {
|
||||||
writeResult
|
writeResult
|
||||||
}
|
}
|
||||||
if (completion.isSuccess) {
|
val isCurrent = pauseIntent == null ||
|
||||||
|
synchronized(publicPauseIntentLock) {
|
||||||
|
publicPauseIntentGeneration == pauseIntent.generation
|
||||||
|
}
|
||||||
|
if (isCurrent && completion.isSuccess) {
|
||||||
if (paused == true) {
|
if (paused == true) {
|
||||||
cachedPaused = true
|
cachedPaused = true
|
||||||
pausedForSurfaceLoss = false
|
pausedForSurfaceLoss = false
|
||||||
resumeBlockedByPublicPause = true
|
|
||||||
deferredResumeRequested = false
|
deferredResumeRequested = false
|
||||||
Log.d(TAG, "Public pause state updated: paused=true")
|
Log.d(TAG, "Public pause state updated: paused=true")
|
||||||
} else if (paused == false) {
|
} else if (paused == false) {
|
||||||
cachedPaused = false
|
cachedPaused = false
|
||||||
pausedForSurfaceLoss = false
|
pausedForSurfaceLoss = false
|
||||||
resumeBlockedByPublicPause = false
|
|
||||||
deferredResumeRequested = false
|
deferredResumeRequested = false
|
||||||
Log.d(TAG, "Public pause state updated: paused=false")
|
Log.d(TAG, "Public pause state updated: paused=false")
|
||||||
}
|
}
|
||||||
@@ -1042,10 +1198,15 @@ class MpvPlayerCore private constructor(
|
|||||||
placeholderImageReader?.close()
|
placeholderImageReader?.close()
|
||||||
placeholderImageReader = null
|
placeholderImageReader = null
|
||||||
pausedForSurfaceLoss = false
|
pausedForSurfaceLoss = false
|
||||||
|
pausedForAudioFocusLoss = false
|
||||||
attachedToPlaceholder = false
|
attachedToPlaceholder = false
|
||||||
videoOutputRestoring = false
|
videoOutputRestoring = false
|
||||||
deferredResumeRequested = false
|
deferredResumeRequested = false
|
||||||
resumeBlockedByPublicPause = false
|
synchronized(publicPauseIntentLock) {
|
||||||
|
publicPauseIntentGeneration += 1L
|
||||||
|
resumeBlockedByPublicPause = false
|
||||||
|
desiredPaused = true
|
||||||
|
}
|
||||||
videoOutputEpoch = 0L
|
videoOutputEpoch = 0L
|
||||||
pendingVideoOutputDisableJob = null
|
pendingVideoOutputDisableJob = null
|
||||||
isInitialized = false
|
isInitialized = false
|
||||||
|
|||||||
@@ -12,20 +12,18 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
|||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
|
|
||||||
internal fun completeMpvPropertyResult(
|
internal fun completeMpvPropertyResult(
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
outcome: Result<Unit>,
|
outcome: Result<Unit>,
|
||||||
successValue: Any? = null
|
successValue: Any? = null
|
||||||
) {
|
) {
|
||||||
if (outcome.isSuccess) {
|
val failure = outcome.exceptionOrNull()
|
||||||
result.success(successValue)
|
when {
|
||||||
} else {
|
failure == null -> result.success(successValue)
|
||||||
result.error(
|
failure is CancellationException -> completeMpvPropertyNotInitialized(result)
|
||||||
"SET_PROPERTY_FAILED",
|
else -> result.error("SET_PROPERTY_FAILED", "MPV property write was rejected", null)
|
||||||
"MPV property write was rejected or cancelled",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +71,8 @@ open class MpvPlayerPlugin(
|
|||||||
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
|
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
|
||||||
|
|
||||||
@Volatile private var isInitializing = false
|
@Volatile private var isInitializing = false
|
||||||
|
private var initAttemptCounter = 0
|
||||||
|
private var activeInitAttempt: Int? = null
|
||||||
|
|
||||||
// FlutterPlugin
|
// FlutterPlugin
|
||||||
|
|
||||||
@@ -82,22 +82,26 @@ open class MpvPlayerPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
channels.detach()
|
// Engine detach is terminal for both video and audio plugin instances.
|
||||||
if (audioOnly) {
|
// Dispose before detaching channels so no native work can publish into a
|
||||||
// The audio core is not activity-bound; engine detach is its terminal
|
// dead messenger.
|
||||||
// native lifecycle event (mirrors the video core's activity detach).
|
disposeCoreForTeardown()
|
||||||
disposeCoreForTeardown()
|
activity = null
|
||||||
}
|
activityBinding = null
|
||||||
applicationContext = null
|
applicationContext = null
|
||||||
|
channels.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun takeCoreForTeardown(): MpvPlayerCore? {
|
||||||
|
++sessionGeneration
|
||||||
|
val core = playerCore
|
||||||
|
playerCore = null
|
||||||
|
cancelPendingInits()
|
||||||
|
return core
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disposeCoreForTeardown() {
|
private fun disposeCoreForTeardown() {
|
||||||
++sessionGeneration
|
takeCoreForTeardown()?.dispose()
|
||||||
playerCore?.dispose()
|
|
||||||
playerCore = null
|
|
||||||
// Any in-flight init callback would never fire (its scope is cancelled
|
|
||||||
// by dispose), so close out queued callers explicitly.
|
|
||||||
completePendingInits(success = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivityAware
|
// ActivityAware
|
||||||
@@ -126,6 +130,12 @@ open class MpvPlayerPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
// The video core owns views and window services from this Activity. Plezy
|
||||||
|
// does not retain the engine across configuration recreation, so there is
|
||||||
|
// no Activity-transfer contract under which that core may survive.
|
||||||
|
if (!audioOnly) {
|
||||||
|
disposeCoreForTeardown()
|
||||||
|
}
|
||||||
activity = null
|
activity = null
|
||||||
activityBinding = null
|
activityBinding = null
|
||||||
Log.d(tag, "Detached from activity for config changes")
|
Log.d(tag, "Detached from activity for config changes")
|
||||||
@@ -189,16 +199,29 @@ open class MpvPlayerPlugin(
|
|||||||
// call's outcome instead of disposing the in-flight core. The Dart
|
// call's outcome instead of disposing the in-flight core. The Dart
|
||||||
// side memoizes too, but this is defense in depth for any direct
|
// side memoizes too, but this is defense in depth for any direct
|
||||||
// `invoke('initialize')` that bypasses _ensureInitialized.
|
// `invoke('initialize')` that bypasses _ensureInitialized.
|
||||||
synchronized(pendingInitResults) {
|
val attempt = synchronized(pendingInitResults) {
|
||||||
pendingInitResults += result
|
pendingInitResults += result
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
Log.d(tag, "Init already in flight, queuing caller")
|
null
|
||||||
return
|
} else {
|
||||||
|
isInitializing = true
|
||||||
|
(++initAttemptCounter).also { activeInitAttempt = it }
|
||||||
}
|
}
|
||||||
isInitializing = true
|
}
|
||||||
|
if (attempt == null) {
|
||||||
|
Log.d(tag, "Init already in flight, queuing caller")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runOnMain {
|
runOnMain {
|
||||||
|
if (!isCurrentInitAttempt(attempt) ||
|
||||||
|
(!audioOnly && activity !== coreContext) ||
|
||||||
|
(audioOnly && applicationContext !== coreContext)
|
||||||
|
) {
|
||||||
|
completePendingInits(attempt, success = false)
|
||||||
|
return@runOnMain
|
||||||
|
}
|
||||||
|
|
||||||
val gen: Int
|
val gen: Int
|
||||||
val core: MpvPlayerCore
|
val core: MpvPlayerCore
|
||||||
try {
|
try {
|
||||||
@@ -218,28 +241,58 @@ open class MpvPlayerPlugin(
|
|||||||
playerCore = core
|
playerCore = core
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
Log.e(tag, "Failed to initialize: ${e.message}", e)
|
||||||
completePendingInits(success = false, errorMessage = e.message)
|
completePendingInits(attempt, success = false, errorMessage = e.message)
|
||||||
return@runOnMain
|
return@runOnMain
|
||||||
}
|
}
|
||||||
|
|
||||||
core.initialize { success ->
|
core.initialize { success ->
|
||||||
val stale = gen != sessionGeneration || playerCore !== core
|
val stale = gen != sessionGeneration ||
|
||||||
if (stale) {
|
playerCore !== core ||
|
||||||
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
!isCurrentInitAttempt(attempt)
|
||||||
|
if (stale || !success) {
|
||||||
|
if (playerCore === core) playerCore = null
|
||||||
|
core.dispose()
|
||||||
|
if (stale) {
|
||||||
|
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Initialized: false")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Start hidden - now safe because setVisible operates on the container,
|
// Start hidden - now safe because setVisible operates on the container,
|
||||||
// not the SurfaceView directly (matching ExoPlayer's approach).
|
// not the SurfaceView directly (matching ExoPlayer's approach).
|
||||||
// No-op on the audio-only core, which has no render layer.
|
// No-op on the audio-only core, which has no render layer.
|
||||||
core.setVisible(false)
|
core.setVisible(false)
|
||||||
Log.d(tag, "Initialized: $success")
|
Log.d(tag, "Initialized: true")
|
||||||
}
|
}
|
||||||
completePendingInits(success = !stale && success)
|
completePendingInits(attempt, success = !stale && success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
|
private fun isCurrentInitAttempt(attempt: Int): Boolean = synchronized(pendingInitResults) {
|
||||||
|
isInitializing && activeInitAttempt == attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelPendingInits() {
|
||||||
val pending = synchronized(pendingInitResults) {
|
val pending = synchronized(pendingInitResults) {
|
||||||
|
++initAttemptCounter
|
||||||
|
activeInitAttempt = null
|
||||||
|
isInitializing = false
|
||||||
|
val copy = pendingInitResults.toList()
|
||||||
|
pendingInitResults.clear()
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
pending.forEach { it.success(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun completePendingInits(
|
||||||
|
attempt: Int,
|
||||||
|
success: Boolean,
|
||||||
|
errorMessage: String? = null
|
||||||
|
) {
|
||||||
|
val pending = synchronized(pendingInitResults) {
|
||||||
|
if (activeInitAttempt != attempt) return
|
||||||
|
activeInitAttempt = null
|
||||||
isInitializing = false
|
isInitializing = false
|
||||||
val copy = pendingInitResults.toList()
|
val copy = pendingInitResults.toList()
|
||||||
pendingInitResults.clear()
|
pendingInitResults.clear()
|
||||||
@@ -256,14 +309,7 @@ open class MpvPlayerPlugin(
|
|||||||
|
|
||||||
private fun handleDispose(result: MethodChannel.Result) {
|
private fun handleDispose(result: MethodChannel.Result) {
|
||||||
runOnMain {
|
runOnMain {
|
||||||
val core = playerCore
|
val core = takeCoreForTeardown()
|
||||||
++sessionGeneration
|
|
||||||
playerCore = null
|
|
||||||
|
|
||||||
// Any in-flight init callback is cancelled with the scope, so
|
|
||||||
// close out queued callers here instead of leaking them.
|
|
||||||
completePendingInits(success = false)
|
|
||||||
|
|
||||||
core?.dispose {
|
core?.dispose {
|
||||||
Log.d(tag, "Disposed")
|
Log.d(tag, "Disposed")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -287,6 +333,9 @@ open class MpvPlayerPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
core.setProperty(name, value) { outcome ->
|
core.setProperty(name, value) { outcome ->
|
||||||
|
if (outcome.isFailure && outcome.exceptionOrNull() !is CancellationException) {
|
||||||
|
Log.w(tag, "MPV rejected property '$name'; keeping the previous value")
|
||||||
|
}
|
||||||
completeMpvPropertyResult(result, outcome)
|
completeMpvPropertyResult(result, outcome)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,28 @@ internal class PlayerChannelBinding(
|
|||||||
private val streamHandler: EventChannel.StreamHandler,
|
private val streamHandler: EventChannel.StreamHandler,
|
||||||
private val logTag: String
|
private val logTag: String
|
||||||
) {
|
) {
|
||||||
private lateinit var methodChannel: MethodChannel
|
private var methodChannel: MethodChannel? = null
|
||||||
private lateinit var eventChannel: EventChannel
|
private var eventChannel: EventChannel? = null
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
val mainHandler = Handler(Looper.getMainLooper())
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
|
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
|
methodChannel = MethodChannel(binding.binaryMessenger, channelBase).also {
|
||||||
methodChannel.setMethodCallHandler(methodCallHandler)
|
it.setMethodCallHandler(methodCallHandler)
|
||||||
|
}
|
||||||
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
|
|
||||||
eventChannel.setStreamHandler(streamHandler)
|
|
||||||
|
|
||||||
|
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events").also {
|
||||||
|
it.setStreamHandler(streamHandler)
|
||||||
|
}
|
||||||
Log.d(logTag, "Attached to engine")
|
Log.d(logTag, "Attached to engine")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detach() {
|
fun detach() {
|
||||||
methodChannel.setMethodCallHandler(null)
|
methodChannel?.setMethodCallHandler(null)
|
||||||
eventChannel.setStreamHandler(null)
|
eventChannel?.setStreamHandler(null)
|
||||||
|
methodChannel = null
|
||||||
|
eventChannel = null
|
||||||
eventSink = null
|
eventSink = null
|
||||||
Log.d(logTag, "Detached from engine")
|
Log.d(logTag, "Detached from engine")
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-13
@@ -5,7 +5,9 @@ import android.content.ContentValues
|
|||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Binder
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.os.Process
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
@@ -13,6 +15,9 @@ import java.net.HttpURLConnection
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class SystemShelfArtworkProvider : ContentProvider() {
|
class SystemShelfArtworkProvider : ContentProvider() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -24,6 +29,19 @@ class SystemShelfArtworkProvider : ContentProvider() {
|
|||||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
||||||
if (mode != "r") throw FileNotFoundException("Read-only artwork")
|
if (mode != "r") throw FileNotFoundException("Read-only artwork")
|
||||||
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
|
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
|
||||||
|
val callingUid = Binder.getCallingUid()
|
||||||
|
if (callingUid != Process.myUid()) {
|
||||||
|
val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||||
|
.addCategory(android.content.Intent.CATEGORY_HOME)
|
||||||
|
val homePackage = appContext.packageManager
|
||||||
|
.resolveActivity(homeIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
|
?.activityInfo
|
||||||
|
?.packageName
|
||||||
|
val callerPackages = appContext.packageManager.getPackagesForUid(callingUid)
|
||||||
|
if (homePackage == null || callerPackages == null || homePackage !in callerPackages) {
|
||||||
|
throw FileNotFoundException("Artwork caller is not the active HOME launcher")
|
||||||
|
}
|
||||||
|
}
|
||||||
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
|
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
|
||||||
?: throw FileNotFoundException("Unknown artwork")
|
?: throw FileNotFoundException("Unknown artwork")
|
||||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
@@ -42,54 +60,108 @@ class SystemShelfArtworkProvider : ContentProvider() {
|
|||||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class SystemShelfSyncSession(
|
||||||
|
internal val ownership: SystemShelfLifecycle.Ownership,
|
||||||
|
durationMillis: Long,
|
||||||
|
private val nanoTime: () -> Long = System::nanoTime,
|
||||||
|
internal val budget: SystemShelfArtworkStore.Budget = SystemShelfArtworkStore.Budget()
|
||||||
|
) {
|
||||||
|
private val deadlineNanos = nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis)
|
||||||
|
|
||||||
|
fun isExpired(): Boolean = nanoTime() >= deadlineNanos
|
||||||
|
|
||||||
|
fun isActive(): Boolean = !isExpired() && SystemShelfLifecycle.isCurrent(ownership)
|
||||||
|
|
||||||
|
fun remainingNanos(): Long = (deadlineNanos - nanoTime()).coerceAtLeast(0)
|
||||||
|
|
||||||
|
fun commitIfActive(block: () -> Boolean): Boolean = SystemShelfLifecycle.whileCurrent(ownership) {
|
||||||
|
if (isExpired()) false else block()
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||||
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
|
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
|
||||||
const val MAX_ITEMS = 20
|
const val MAX_ITEMS = 20
|
||||||
|
const val MAX_SYNC_DURATION_MS = 10_000L
|
||||||
const val CONNECT_TIMEOUT_MS = 2_500
|
const val CONNECT_TIMEOUT_MS = 2_500
|
||||||
const val READ_TIMEOUT_MS = 2_500
|
const val READ_TIMEOUT_MS = 2_500
|
||||||
private val opaquePart = Regex("^[a-f0-9]{64}$")
|
private val opaquePart = Regex("^[a-f0-9]{64}$")
|
||||||
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
|
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
|
||||||
|
private val deadlineAborter: ScheduledExecutorService =
|
||||||
|
Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||||
|
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Materialized(val key: String, val uri: Uri, val file: File)
|
data class Materialized(val key: String, val uri: Uri, val file: File)
|
||||||
class Budget(var remaining: Int = MAX_SYNC_BYTES)
|
class Budget(var remaining: Int = MAX_SYNC_BYTES) {
|
||||||
|
var consumed: Long = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun charge(bytes: Int): Boolean {
|
||||||
|
if (bytes <= 0) return true
|
||||||
|
consumed += bytes
|
||||||
|
if (bytes > remaining) {
|
||||||
|
remaining = 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remaining -= bytes
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val root: File get() = File(cacheDir, "system_shelf_artwork")
|
private val root: File get() = File(cacheDir, "system_shelf_artwork")
|
||||||
|
|
||||||
fun materialize(ownerId: String, source: String, budget: Budget): Materialized? {
|
fun materialize(ownerId: String, source: String, session: SystemShelfSyncSession): Materialized? {
|
||||||
if (ownerId.isBlank() || budget.remaining <= 0) return null
|
if (ownerId.isBlank() || session.budget.remaining <= 0 || !session.isActive()) return null
|
||||||
val url = runCatching { URL(source) }.getOrNull() ?: return null
|
val url = runCatching { URL(source) }.getOrNull() ?: return null
|
||||||
if (url.protocol != "https" && url.protocol != "http") return null
|
if (url.protocol != "https" && url.protocol != "http") return null
|
||||||
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
|
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
|
||||||
|
val remainingNanos = session.remainingNanos()
|
||||||
|
if (remainingNanos <= 0) return null
|
||||||
|
val abort = deadlineAborter.schedule(
|
||||||
|
{ connection.disconnect() },
|
||||||
|
remainingNanos,
|
||||||
|
TimeUnit.NANOSECONDS
|
||||||
|
)
|
||||||
return try {
|
return try {
|
||||||
|
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos).coerceIn(1, Int.MAX_VALUE.toLong()).toInt()
|
||||||
connection.instanceFollowRedirects = true
|
connection.instanceFollowRedirects = true
|
||||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
|
||||||
connection.readTimeout = READ_TIMEOUT_MS
|
connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
|
||||||
connection.useCaches = false
|
connection.useCaches = false
|
||||||
connection.setRequestProperty("Accept", "image/*")
|
connection.setRequestProperty("Accept", "image/*")
|
||||||
val status = connection.responseCode
|
val status = connection.responseCode
|
||||||
if (status !in 200..299) return null
|
if (!session.isActive() || status !in 200..299) return null
|
||||||
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
|
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
|
||||||
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
|
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
|
||||||
val contentLength = connection.contentLengthLong
|
val contentLength = connection.contentLengthLong
|
||||||
val cap = minOf(MAX_IMAGE_BYTES, budget.remaining)
|
val cap = minOf(MAX_IMAGE_BYTES, session.budget.remaining)
|
||||||
if (contentLength > cap) return null
|
if (contentLength > cap) return null
|
||||||
val bytes = connection.inputStream.use { input ->
|
val bytes = connection.inputStream.use { input ->
|
||||||
val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap))
|
val output = ByteArrayOutputStream(
|
||||||
|
minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap)
|
||||||
|
)
|
||||||
val buffer = ByteArray(16 * 1024)
|
val buffer = ByteArray(16 * 1024)
|
||||||
var total = 0
|
var total = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = input.read(buffer)
|
if (!session.isActive()) return null
|
||||||
|
val remaining = minOf(MAX_IMAGE_BYTES - total, session.budget.remaining)
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (contentLength >= 0 && total.toLong() == contentLength) break
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
|
||||||
if (read < 0) break
|
if (read < 0) break
|
||||||
|
session.budget.charge(read)
|
||||||
total += read
|
total += read
|
||||||
if (total > cap) return null
|
|
||||||
output.write(buffer, 0, read)
|
output.write(buffer, 0, read)
|
||||||
}
|
}
|
||||||
output.toByteArray()
|
output.toByteArray()
|
||||||
}
|
}
|
||||||
if (!isSupportedImage(bytes)) return null
|
if (!session.isActive() || !isSupportedImage(bytes)) return null
|
||||||
val ownerKey = sha256(ownerId)
|
val ownerKey = sha256(ownerId)
|
||||||
val directory = File(root, ownerKey)
|
val directory = File(root, ownerKey)
|
||||||
if (!directory.mkdirs() && !directory.isDirectory) return null
|
if (!directory.mkdirs() && !directory.isDirectory) return null
|
||||||
@@ -100,16 +172,20 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
|||||||
output.flush()
|
output.flush()
|
||||||
output.fd.sync()
|
output.fd.sync()
|
||||||
}
|
}
|
||||||
|
if (!session.isActive()) {
|
||||||
|
staged.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
val destination = File(directory, key)
|
val destination = File(directory, key)
|
||||||
if (!staged.renameTo(destination)) {
|
if (!session.commitIfActive { staged.renameTo(destination) }) {
|
||||||
staged.delete()
|
staged.delete()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
budget.remaining -= bytes.size
|
|
||||||
Materialized(key, contentUri(ownerKey, key), destination)
|
Materialized(key, contentUri(ownerKey, key), destination)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
} finally {
|
} finally {
|
||||||
|
abort.cancel(false)
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +221,17 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun delete(files: Set<File>) {
|
||||||
|
val canonicalRoot = root.canonicalFile
|
||||||
|
files.forEach { file ->
|
||||||
|
val candidate = runCatching { file.canonicalFile }.getOrNull() ?: return@forEach
|
||||||
|
if (candidate.parentFile?.parentFile == canonicalRoot) candidate.delete()
|
||||||
|
}
|
||||||
|
root.listFiles()?.forEach { directory ->
|
||||||
|
if (directory.listFiles().isNullOrEmpty()) directory.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
|
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
|
||||||
|
|
||||||
private fun isSupportedImage(bytes: ByteArray): Boolean {
|
private fun isSupportedImage(bytes: ByteArray): Boolean {
|
||||||
|
|||||||
+9
-3
@@ -7,7 +7,7 @@ import java.util.concurrent.Executor
|
|||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */
|
/** Migrates versioned shelf state after updates and restores volatile launcher grants after boot. */
|
||||||
class SystemShelfUpdateReceiver private constructor(
|
class SystemShelfUpdateReceiver private constructor(
|
||||||
private val executor: Executor,
|
private val executor: Executor,
|
||||||
private val ownsExecutor: Boolean
|
private val ownsExecutor: Boolean
|
||||||
@@ -16,11 +16,17 @@ class SystemShelfUpdateReceiver private constructor(
|
|||||||
internal constructor(executor: Executor) : this(executor, false)
|
internal constructor(executor: Executor) : this(executor, false)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
val action = intent.action
|
||||||
|
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
executor.execute {
|
executor.execute {
|
||||||
try {
|
try {
|
||||||
WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
|
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
||||||
|
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||||
|
provider.migrateShelfSchema()
|
||||||
|
} else {
|
||||||
|
provider.restoreReadGrants()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending?.finish()
|
pending?.finish()
|
||||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ import androidx.tvprovider.media.tv.TvContractCompat
|
|||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
|
class WatchNextPlugin() :
|
||||||
class WatchNextPlugin :
|
|
||||||
FlutterPlugin,
|
FlutterPlugin,
|
||||||
MethodChannel.MethodCallHandler {
|
MethodChannel.MethodCallHandler {
|
||||||
|
internal constructor(executorFactory: () -> ExecutorService) : this() {
|
||||||
|
this.executorFactory = executorFactory
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WatchNextPlugin"
|
private const val TAG = "WatchNextPlugin"
|
||||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||||
@@ -32,24 +37,64 @@ class WatchNextPlugin :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var executorFactory: () -> ExecutorService = { Executors.newSingleThreadExecutor() }
|
||||||
|
|
||||||
|
private class EngineSession(val context: Context) {
|
||||||
|
private val closed = AtomicBoolean(false)
|
||||||
|
var lease: SystemShelfLifecycle.Lease? = null
|
||||||
|
var provider: WatchNextProvider? = null
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closed.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOpen(): Boolean = !closed.get()
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var methodChannel: MethodChannel
|
private lateinit var methodChannel: MethodChannel
|
||||||
private var applicationContext: Context? = null
|
private var applicationContext: Context? = null
|
||||||
private var watchNextProvider: WatchNextProvider? = null
|
private var engineSession: EngineSession? = null
|
||||||
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
|
private var ioExecutor: ExecutorService? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
val executor = executorFactory()
|
||||||
|
val session = EngineSession(binding.applicationContext)
|
||||||
|
ioExecutor = executor
|
||||||
|
engineSession = session
|
||||||
applicationContext = binding.applicationContext
|
applicationContext = binding.applicationContext
|
||||||
watchNextProvider = WatchNextProvider(binding.applicationContext)
|
|
||||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||||
methodChannel.setMethodCallHandler(this)
|
methodChannel.setMethodCallHandler(this)
|
||||||
|
executor.execute {
|
||||||
|
val lease = SystemShelfLifecycle.acquireIf(session::isOpen) ?: return@execute
|
||||||
|
session.lease = lease
|
||||||
|
if (session.isOpen()) {
|
||||||
|
session.provider = WatchNextProvider(session.context, lease)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
methodChannel.setMethodCallHandler(null)
|
methodChannel.setMethodCallHandler(null)
|
||||||
|
val session = engineSession
|
||||||
|
val executor = ioExecutor
|
||||||
|
session?.close()
|
||||||
|
engineSession = null
|
||||||
|
ioExecutor = null
|
||||||
applicationContext = null
|
applicationContext = null
|
||||||
watchNextProvider = null
|
if (session != null && executor != null) {
|
||||||
ioExecutor.shutdown()
|
try {
|
||||||
|
executor.execute {
|
||||||
|
session.lease?.let(SystemShelfLifecycle::invalidate)
|
||||||
|
session.lease = null
|
||||||
|
session.provider = null
|
||||||
|
}
|
||||||
|
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
||||||
|
Log.e(TAG, "System shelf lifecycle executor rejected detach")
|
||||||
|
} finally {
|
||||||
|
executor.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
@@ -76,7 +121,8 @@ class WatchNextPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
|
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
val (owner, generation) = ownerArguments(call)
|
val (owner, generation) = ownerArguments(call)
|
||||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||||
@@ -85,28 +131,48 @@ class WatchNextPlugin :
|
|||||||
return result.error("INVALID_ARGS", "Too many items", null)
|
return result.error("INVALID_ARGS", "Too many items", null)
|
||||||
}
|
}
|
||||||
val items = itemsData.mapNotNull(::parseWatchNextItem)
|
val items = itemsData.mapNotNull(::parseWatchNextItem)
|
||||||
executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
|
executeOnIo(executor, result) {
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
val provider = session.provider ?: return@executeOnIo false
|
||||||
|
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
|
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
|
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
val (owner, generation) = ownerArguments(call)
|
val (owner, generation) = ownerArguments(call)
|
||||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||||
executeOnIo(result) { provider.clearAll(owner, generation) }
|
executeOnIo(executor, result) {
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
val provider = session.provider ?: return@executeOnIo false
|
||||||
|
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
|
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||||
val (owner, generation) = ownerArguments(call)
|
val (owner, generation) = ownerArguments(call)
|
||||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||||
val contentId = call.argument<String>("contentId")
|
val contentId = call.argument<String>("contentId")
|
||||||
?: return result.error("INVALID_ARGS", "Missing contentId", null)
|
?: return result.error("INVALID_ARGS", "Missing contentId", null)
|
||||||
executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
|
executeOnIo(executor, result) {
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
val provider = session.provider ?: return@executeOnIo false
|
||||||
|
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||||
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
|
provider.removeItem(owner, generation, contentId, ownership, session::isOpen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
|
private fun executeOnIo(executor: ExecutorService, result: MethodChannel.Result, block: () -> Any?) {
|
||||||
try {
|
try {
|
||||||
ioExecutor.execute {
|
executor.execute {
|
||||||
try {
|
try {
|
||||||
val value = block()
|
val value = block()
|
||||||
mainHandler.post { result.success(value) }
|
mainHandler.post { result.success(value) }
|
||||||
|
|||||||
@@ -6,16 +6,99 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||||
|
|
||||||
|
internal object SystemShelfLifecycle {
|
||||||
|
data class Lease internal constructor(internal val token: Long)
|
||||||
|
data class Ownership internal constructor(
|
||||||
|
internal val engineToken: Long,
|
||||||
|
internal val claimToken: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val operationLock = Any()
|
||||||
|
private var token = 0L
|
||||||
|
private var claimToken = 0L
|
||||||
|
private var currentOwner = ""
|
||||||
|
private var currentGeneration = 0L
|
||||||
|
|
||||||
|
fun acquire(): Lease = acquireIf { true }!!
|
||||||
|
|
||||||
|
fun acquireIf(isActive: () -> Boolean): Lease? = synchronized(operationLock) {
|
||||||
|
if (!isActive()) return@synchronized null
|
||||||
|
synchronized(lock) {
|
||||||
|
token += 1
|
||||||
|
claimToken += 1
|
||||||
|
currentOwner = ""
|
||||||
|
currentGeneration = 0
|
||||||
|
Lease(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate(lease: Lease) {
|
||||||
|
synchronized(operationLock) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (token == lease.token) {
|
||||||
|
token += 1
|
||||||
|
claimToken += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (
|
||||||
|
token != lease.token ||
|
||||||
|
ownerId.isBlank() ||
|
||||||
|
generation <= 0 ||
|
||||||
|
generation < currentGeneration ||
|
||||||
|
generation == currentGeneration &&
|
||||||
|
currentOwner.isNotEmpty() &&
|
||||||
|
currentOwner != ownerId
|
||||||
|
) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
currentOwner = ownerId
|
||||||
|
currentGeneration = generation
|
||||||
|
claimToken += 1
|
||||||
|
Ownership(token, claimToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCurrent(ownership: Ownership): Boolean = synchronized(lock) {
|
||||||
|
token == ownership.engineToken && claimToken == ownership.claimToken
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> whileCurrent(ownership: Ownership, block: () -> T): T? = synchronized(operationLock) {
|
||||||
|
if (!isCurrent(ownership)) return@synchronized null
|
||||||
|
val result = block()
|
||||||
|
if (isCurrent(ownership)) result else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> exclusive(block: () -> T): T = synchronized(operationLock, block)
|
||||||
|
}
|
||||||
|
|
||||||
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
|
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
|
||||||
class WatchNextProvider(private val context: Context) {
|
class WatchNextProvider internal constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val lifecycleLease: SystemShelfLifecycle.Lease?,
|
||||||
|
private val syncDurationMillis: Long = SystemShelfArtworkStore.MAX_SYNC_DURATION_MS
|
||||||
|
) {
|
||||||
|
constructor(context: Context) : this(context, SystemShelfLifecycle.acquire())
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WatchNextProvider"
|
private const val TAG = "WatchNextProvider"
|
||||||
private const val PREFS = "system_shelf_state"
|
private const val PREFS = "system_shelf_state"
|
||||||
private const val GRANTED_URIS = "granted_uris"
|
private const val GRANTED_URIS = "granted_uris"
|
||||||
|
private const val GRANTED_PACKAGES = "granted_packages"
|
||||||
|
internal const val SHELF_SCHEMA_VERSION = 1
|
||||||
|
private const val SHELF_SCHEMA_VERSION_KEY = "shelf_schema_version"
|
||||||
|
|
||||||
|
internal fun forMaintenance(context: Context) = WatchNextProvider(context, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class WatchNextItem(
|
data class WatchNextItem(
|
||||||
@@ -37,72 +120,157 @@ class WatchNextProvider(private val context: Context) {
|
|||||||
|
|
||||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
private val artwork = SystemShelfArtworkStore(context.cacheDir)
|
private val artwork = SystemShelfArtworkStore(context.cacheDir)
|
||||||
private var currentOwner = ""
|
|
||||||
private var currentGeneration = 0L
|
|
||||||
|
|
||||||
/** Materializes transient art, then atomically replaces the durable rows. */
|
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
|
||||||
fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List<WatchNextItem>): Boolean {
|
|
||||||
if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
|
internal fun syncWatchNextPrograms(
|
||||||
|
ownerId: String,
|
||||||
|
generation: Long,
|
||||||
|
items: List<WatchNextItem>,
|
||||||
|
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||||
|
isOperationActive: () -> Boolean = { true }
|
||||||
|
): Boolean {
|
||||||
|
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
|
||||||
|
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||||
|
if (!isOperationActive()) return false
|
||||||
|
|
||||||
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
||||||
val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) }
|
val oldPackages = storedPackages()
|
||||||
val budget = SystemShelfArtworkStore.Budget()
|
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
|
||||||
val prepared = items.map { item ->
|
val session = SystemShelfSyncSession(
|
||||||
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) }
|
operationOwnership,
|
||||||
PreparedWatchNextItem(item, materialized?.uri)
|
syncDurationMillis
|
||||||
}
|
)
|
||||||
if (!accepts(ownerId, generation)) {
|
val materializedFiles = LinkedHashSet<java.io.File>()
|
||||||
artwork.deleteExcept(oldFiles)
|
var committed = false
|
||||||
return false
|
try {
|
||||||
}
|
val prepared = items.map { item ->
|
||||||
|
if (!isOperationActive() || !session.isActive()) {
|
||||||
|
PreparedWatchNextItem(item, null)
|
||||||
|
} else {
|
||||||
|
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, session) }
|
||||||
|
materialized?.file?.let(materializedFiles::add)
|
||||||
|
PreparedWatchNextItem(item, materialized?.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
|
||||||
|
val newPackages = consumerPackages()
|
||||||
|
if (!isOperationActive() || !session.isActive()) return false
|
||||||
|
committed = SystemShelfLifecycle.whileCurrent(session.ownership) {
|
||||||
|
if (!isOperationActive() || session.isExpired()) return@whileCurrent false
|
||||||
|
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
|
||||||
|
if (session.isExpired()) {
|
||||||
|
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||||
|
return@whileCurrent false
|
||||||
|
}
|
||||||
|
val preferencesCommitted = prefs.edit()
|
||||||
|
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||||
|
.putStringSet(GRANTED_PACKAGES, newPackages)
|
||||||
|
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||||
|
.commit()
|
||||||
|
if (!preferencesCommitted) {
|
||||||
|
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||||
|
return@whileCurrent false
|
||||||
|
}
|
||||||
|
if (session.isExpired() || !replaceRows(prepared)) {
|
||||||
|
val rollback = prefs.edit()
|
||||||
|
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||||
|
.putStringSet(GRANTED_PACKAGES, oldPackages)
|
||||||
|
if (oldSchemaVersion > 0) {
|
||||||
|
rollback.putInt(SHELF_SCHEMA_VERSION_KEY, oldSchemaVersion)
|
||||||
|
} else {
|
||||||
|
rollback.remove(SHELF_SCHEMA_VERSION_KEY)
|
||||||
|
}
|
||||||
|
rollback.commit()
|
||||||
|
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
|
||||||
|
return@whileCurrent false
|
||||||
|
}
|
||||||
|
|
||||||
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
|
artwork.deleteExcept(materializedFiles)
|
||||||
grantReadAccess(newUris)
|
true
|
||||||
val committed = replaceRows(prepared)
|
} ?: false
|
||||||
if (!committed) {
|
return committed
|
||||||
revokeReadAccess(newUris - oldUris)
|
} finally {
|
||||||
artwork.deleteExcept(oldFiles)
|
if (!committed) {
|
||||||
return false
|
materializedFiles.forEach { it.delete() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes rows first, then revokes grants and owned files. */
|
||||||
|
internal fun clearAll(
|
||||||
|
ownerId: String,
|
||||||
|
generation: Long,
|
||||||
|
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||||
|
isOperationActive: () -> Boolean = { true }
|
||||||
|
): Boolean {
|
||||||
|
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||||
|
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
|
||||||
|
if (!isOperationActive()) return@whileCurrent false
|
||||||
|
val rowsCleared = deleteRows()
|
||||||
|
if (!rowsCleared) return@whileCurrent false
|
||||||
|
val uris = storedUris()
|
||||||
|
val packages = storedPackages()
|
||||||
|
reconcileReadAccess(uris, packages, emptySet(), emptySet())
|
||||||
|
artwork.deleteAll()
|
||||||
|
prefs.edit()
|
||||||
|
.remove(GRANTED_URIS)
|
||||||
|
.remove(GRANTED_PACKAGES)
|
||||||
|
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||||
|
.commit()
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes only data from a shelf schema older than the current on-device contract. */
|
||||||
|
fun migrateShelfSchema(): Boolean = SystemShelfLifecycle.exclusive {
|
||||||
|
if (prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0) >= SHELF_SCHEMA_VERSION) {
|
||||||
|
return@exclusive restoreReadGrantsOwned()
|
||||||
|
}
|
||||||
|
val rowsCleared = deleteRows()
|
||||||
|
if (!rowsCleared) return@exclusive false
|
||||||
|
val uris = storedUris()
|
||||||
|
val packages = storedPackages()
|
||||||
|
reconcileReadAccess(uris, packages, emptySet(), emptySet())
|
||||||
|
artwork.deleteAll()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
|
.clear()
|
||||||
|
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
|
||||||
.commit()
|
.commit()
|
||||||
currentOwner = ownerId
|
|
||||||
currentGeneration = generation
|
|
||||||
revokeReadAccess(oldUris - newUris)
|
|
||||||
artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes rows first, then grants, then owned files. */
|
/** Re-establishes reboot-volatile grants only for persisted, confined artwork files. */
|
||||||
fun clearAll(ownerId: String, generation: Long): Boolean {
|
fun restoreReadGrants(): Boolean = SystemShelfLifecycle.exclusive {
|
||||||
if (!acceptsClear(ownerId, generation)) return false
|
restoreReadGrantsOwned()
|
||||||
val rowsCleared = deleteRows()
|
|
||||||
if (!rowsCleared) return false
|
|
||||||
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
|
||||||
revokeReadAccess(uris)
|
|
||||||
artwork.deleteAll()
|
|
||||||
prefs.edit().remove(GRANTED_URIS).commit()
|
|
||||||
currentOwner = ""
|
|
||||||
currentGeneration = generation
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package replacement is a clean cutover: remote legacy rows cannot survive. */
|
private fun restoreReadGrantsOwned(): Boolean {
|
||||||
fun clearLegacyOnPackageUpdate(): Boolean {
|
val previousUris = storedUris()
|
||||||
val rowsCleared = deleteRows()
|
val previousPackages = storedPackages()
|
||||||
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
val validUris = previousUris.filterTo(LinkedHashSet()) { artwork.resolve(it) != null }
|
||||||
revokeReadAccess(uris)
|
val currentPackages = consumerPackages()
|
||||||
artwork.deleteAll()
|
reconcileReadAccess(previousUris, previousPackages, validUris, currentPackages)
|
||||||
prefs.edit().clear().commit()
|
return prefs.edit()
|
||||||
currentOwner = ""
|
.putStringSet(GRANTED_URIS, validUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||||
currentGeneration = 0
|
.putStringSet(GRANTED_PACKAGES, currentPackages)
|
||||||
return rowsCleared
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
|
internal fun removeItem(
|
||||||
if (!accepts(ownerId, generation)) return false
|
ownerId: String,
|
||||||
|
generation: Long,
|
||||||
|
contentId: String,
|
||||||
|
ownership: SystemShelfLifecycle.Ownership? = null,
|
||||||
|
isOperationActive: () -> Boolean = { true }
|
||||||
|
): Boolean {
|
||||||
|
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
|
||||||
|
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
|
||||||
|
if (!isOperationActive()) return@whileCurrent false
|
||||||
|
removeItemOwned(contentId)
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeItemOwned(contentId: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val cursor = context.contentResolver.query(
|
val cursor = context.contentResolver.query(
|
||||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||||
@@ -144,16 +312,6 @@ class WatchNextProvider(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accepts(ownerId: String, generation: Long): Boolean {
|
|
||||||
if (ownerId.isBlank() || generation <= 0) return false
|
|
||||||
return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun acceptsClear(ownerId: String, generation: Long): Boolean {
|
|
||||||
if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false
|
|
||||||
return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
|
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
|
||||||
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
|
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
|
||||||
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
|
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
|
||||||
@@ -177,20 +335,39 @@ class WatchNextProvider(private val context: Context) {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumerPackages(): Set<String> {
|
internal fun consumerPackages(): Set<String> {
|
||||||
val packages = LinkedHashSet<String>()
|
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||||
context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
|
val packageManager = context.packageManager
|
||||||
?.let(packages::add)
|
val selectedHome = packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
|
?.activityInfo ?: return emptySet()
|
||||||
context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
val packageName = selectedHome.packageName?.takeIf(String::isNotBlank) ?: return emptySet()
|
||||||
.mapTo(packages) { it.activityInfo.packageName }
|
val activityName = selectedHome.name?.takeIf(String::isNotBlank) ?: return emptySet()
|
||||||
return packages
|
val isHomeHandler = packageManager.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL).any { candidate ->
|
||||||
|
candidate.activityInfo?.let { it.packageName == packageName && it.name == activityName } == true
|
||||||
|
}
|
||||||
|
return if (isHomeHandler) setOf(packageName) else emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun grantReadAccess(uris: Set<Uri>) {
|
private fun reconcileReadAccess(
|
||||||
|
previousUris: Set<Uri>,
|
||||||
|
previousPackages: Set<String>,
|
||||||
|
currentUris: Set<Uri>,
|
||||||
|
currentPackages: Set<String>
|
||||||
|
) {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
consumerPackages().forEach { packageName ->
|
if (previousPackages.isEmpty() && previousUris.isNotEmpty()) {
|
||||||
uris.forEach { uri ->
|
previousUris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
||||||
|
} else {
|
||||||
|
previousPackages.forEach { packageName ->
|
||||||
|
previousUris.forEach { uri ->
|
||||||
|
if (packageName !in currentPackages || uri !in currentUris) {
|
||||||
|
revokeReadAccess(packageName, uri, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentPackages.forEach { packageName ->
|
||||||
|
currentUris.forEach { uri ->
|
||||||
runCatching { context.grantUriPermission(packageName, uri, flags) }
|
runCatching { context.grantUriPermission(packageName, uri, flags) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,9 +375,30 @@ class WatchNextProvider(private val context: Context) {
|
|||||||
|
|
||||||
private fun revokeReadAccess(uris: Set<Uri>) {
|
private fun revokeReadAccess(uris: Set<Uri>) {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
val packages = storedPackages()
|
||||||
|
if (packages.isEmpty()) {
|
||||||
|
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packages.forEach { packageName ->
|
||||||
|
uris.forEach { uri -> revokeReadAccess(packageName, uri, flags) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun revokeReadAccess(packageName: String, uri: Uri, flags: Int) {
|
||||||
|
runCatching {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.revokeUriPermission(packageName, uri, flags)
|
||||||
|
} else {
|
||||||
|
context.revokeUriPermission(uri, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedUris(): Set<Uri> = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNullTo(LinkedHashSet(), Uri::parse)
|
||||||
|
|
||||||
|
private fun storedPackages(): Set<String> = prefs.getStringSet(GRANTED_PACKAGES, emptySet()).orEmpty()
|
||||||
|
|
||||||
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
|
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
|
||||||
val metadata = item.metadata
|
val metadata = item.metadata
|
||||||
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
|
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
|
||||||
|
|||||||
+34
-1
@@ -3,6 +3,7 @@ package com.edde746.plezy.exoplayer
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.DataReader
|
import androidx.media3.common.DataReader
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.ParserException
|
||||||
import androidx.media3.common.util.ParsableByteArray
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
import androidx.media3.extractor.TrackOutput
|
import androidx.media3.extractor.TrackOutput
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -43,7 +44,39 @@ class BufferedTransformingTrackOutputTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class IncrementingTrackOutput(delegate: TrackOutput) : BufferedTransformingTrackOutput(delegate, initialBufferSize = 2) {
|
@Test
|
||||||
|
fun activeTransformRejectsParsableDataBeyondConfiguredBound() {
|
||||||
|
val output = IncrementingTrackOutput(RecordingTrackOutput(), maxBufferedSampleBytes = 4)
|
||||||
|
|
||||||
|
output.sampleData(ParsableByteArray(byteArrayOf(1, 2, 3)), 3, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||||
|
|
||||||
|
assertThrows(ParserException::class.java) {
|
||||||
|
output.sampleData(ParsableByteArray(byteArrayOf(4, 5)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dataReaderRequestIsChunkedWithoutAllocatingTheDeclaredLength() {
|
||||||
|
val requestedLengths = mutableListOf<Int>()
|
||||||
|
val reader = DataReader { buffer, offset, length ->
|
||||||
|
requestedLengths += length
|
||||||
|
repeat(length) { buffer[offset + it] = it.toByte() }
|
||||||
|
length
|
||||||
|
}
|
||||||
|
val output = IncrementingTrackOutput(RecordingTrackOutput(), maxBufferedSampleBytes = 4)
|
||||||
|
|
||||||
|
assertEquals(2, output.sampleData(reader, Int.MAX_VALUE, false, TrackOutput.SAMPLE_DATA_PART_MAIN))
|
||||||
|
assertEquals(listOf(2), requestedLengths)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class IncrementingTrackOutput(
|
||||||
|
delegate: TrackOutput,
|
||||||
|
maxBufferedSampleBytes: Int = Int.MAX_VALUE
|
||||||
|
) : BufferedTransformingTrackOutput(
|
||||||
|
delegate,
|
||||||
|
initialBufferSize = 2,
|
||||||
|
maxBufferedSampleBytes = maxBufferedSampleBytes
|
||||||
|
) {
|
||||||
private var transformed = ByteArray(2)
|
private var transformed = ByteArray(2)
|
||||||
|
|
||||||
override val transformEnabled = true
|
override val transformEnabled = true
|
||||||
|
|||||||
+32
@@ -7,13 +7,17 @@ import android.view.ViewTreeObserver
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.Robolectric
|
import org.robolectric.Robolectric
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.Shadows.shadowOf
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [28])
|
||||||
class ExoPlayerInitializationCleanupTest {
|
class ExoPlayerInitializationCleanupTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -40,6 +44,34 @@ class ExoPlayerInitializationCleanupTest {
|
|||||||
assertNull(core.getPrivateField("overlayLayoutListener"))
|
assertNull(core.getPrivateField("overlayLayoutListener"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializePreservesDefaultUncaughtExceptionHandlerAcrossPlayerLifecycles() {
|
||||||
|
val original = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
val sentinel = Thread.UncaughtExceptionHandler { _, _ -> Unit }
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler(sentinel)
|
||||||
|
val cores = mutableListOf<ExoPlayerCore>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
repeat(2) {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
activity.setContentView(FrameLayout(activity))
|
||||||
|
val core = ExoPlayerCore(activity)
|
||||||
|
cores += core
|
||||||
|
|
||||||
|
assertTrue(core.initialize())
|
||||||
|
assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler())
|
||||||
|
|
||||||
|
core.dispose()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cores.forEach { it.dispose() }
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Any.setPrivateField(name: String, value: Any?) {
|
private fun Any.setPrivateField(name: String, value: Any?) {
|
||||||
javaClass.getDeclaredField(name).apply {
|
javaClass.getDeclaredField(name).apply {
|
||||||
isAccessible = true
|
isAccessible = true
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ package com.edde746.plezy.exoplayer
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.widget.FrameLayout
|
||||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -78,7 +85,7 @@ class ExoPlayerPluginTest {
|
|||||||
assertEquals(1, writes.get())
|
assertEquals(1, writes.get())
|
||||||
assertEquals(1, result.completionCount)
|
assertEquals(1, result.completionCount)
|
||||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||||
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
|
assertEquals("MPV property write was rejected", result.errorMessage)
|
||||||
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
|
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
|
||||||
assertEquals(null, result.successValue)
|
assertEquals(null, result.successValue)
|
||||||
assertEquals(null, result.errorDetails)
|
assertEquals(null, result.errorDetails)
|
||||||
@@ -86,7 +93,7 @@ class ExoPlayerPluginTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fallbackCancellationReturnsSetPropertyFailedOnce() {
|
fun fallbackCancellationReturnsNotInitializedOnce() {
|
||||||
val plugin = fallbackPlugin { _, _ ->
|
val plugin = fallbackPlugin { _, _ ->
|
||||||
throw CancellationException("secret-cancellation")
|
throw CancellationException("secret-cancellation")
|
||||||
}
|
}
|
||||||
@@ -99,7 +106,7 @@ class ExoPlayerPluginTest {
|
|||||||
awaitCompletion(result)
|
awaitCompletion(result)
|
||||||
|
|
||||||
assertEquals(1, result.completionCount)
|
assertEquals(1, result.completionCount)
|
||||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||||
assertTrue(result.errorMessage?.contains("secret") == false)
|
assertTrue(result.errorMessage?.contains("secret") == false)
|
||||||
assertEquals(null, result.successValue)
|
assertEquals(null, result.successValue)
|
||||||
}
|
}
|
||||||
@@ -158,6 +165,207 @@ class ExoPlayerPluginTest {
|
|||||||
assertEquals(null, second.errorCode)
|
assertEquals(null, second.errorCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||||
|
core.setPrivateField("desiredPaused", false)
|
||||||
|
core.setPrivateField("cachedPaused", false)
|
||||||
|
core.setPrivateField("resumeBlockedByPublicPause", false)
|
||||||
|
core.setPrivateField("pausedForSurfaceLoss", true)
|
||||||
|
core.setPrivateField("pausedForAudioFocusLoss", true)
|
||||||
|
core.setPrivateField("deferredResumeRequested", true)
|
||||||
|
val plugin = initialFallbackPlugin(activity, core)
|
||||||
|
|
||||||
|
invokeSetupMpvFallback(plugin, core, activity, playWhenReady = false)
|
||||||
|
invokeAutoResume(core, "audio focus gain")
|
||||||
|
invokeAutoResume(core, "surface attached")
|
||||||
|
|
||||||
|
assertEquals(true, core.getPrivateField("desiredPaused"))
|
||||||
|
assertEquals(true, core.getPrivateField("cachedPaused"))
|
||||||
|
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||||
|
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
|
||||||
|
assertEquals(false, core.getPrivateField("pausedForAudioFocusLoss"))
|
||||||
|
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
|
||||||
|
assertTrue(awaitQueueEntry(writes, "ao" to "audiotrack"))
|
||||||
|
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||||
|
core.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialAutoplayFallbackClearsPublicPauseBlockWithoutPausePropertyWrite() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||||
|
core.setPrivateField("desiredPaused", true)
|
||||||
|
core.setPrivateField("cachedPaused", true)
|
||||||
|
core.setPrivateField("resumeBlockedByPublicPause", true)
|
||||||
|
val plugin = initialFallbackPlugin(activity, core)
|
||||||
|
|
||||||
|
invokeSetupMpvFallback(plugin, core, activity, playWhenReady = true)
|
||||||
|
|
||||||
|
assertEquals(false, core.getPrivateField("desiredPaused"))
|
||||||
|
assertEquals(false, core.getPrivateField("cachedPaused"))
|
||||||
|
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||||
|
assertTrue(awaitQueueEntry(writes, "ao" to "audiotrack"))
|
||||||
|
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||||
|
core.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||||
|
core.setPrivateField("desiredPaused", false)
|
||||||
|
core.setPrivateField("cachedPaused", false)
|
||||||
|
core.setPrivateField("resumeBlockedByPublicPause", false)
|
||||||
|
core.setPrivateField("pausedForSurfaceLoss", true)
|
||||||
|
core.setPrivateField("deferredResumeRequested", true)
|
||||||
|
val plugin = reusedFallbackPlugin(activity, core)
|
||||||
|
val result = RecordingResult()
|
||||||
|
|
||||||
|
plugin.onMethodCall(
|
||||||
|
MethodCall(
|
||||||
|
"open",
|
||||||
|
mapOf("uri" to "https://example.test/video.mkv", "autoPlay" to false)
|
||||||
|
),
|
||||||
|
result
|
||||||
|
)
|
||||||
|
invokeAutoResume(core, "surface attached")
|
||||||
|
|
||||||
|
assertEquals(1, result.completionCount)
|
||||||
|
assertNull(result.errorCode)
|
||||||
|
assertEquals(true, core.getPrivateField("desiredPaused"))
|
||||||
|
assertEquals(true, core.getPrivateField("cachedPaused"))
|
||||||
|
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||||
|
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
|
||||||
|
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
|
||||||
|
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||||
|
core.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reusedAutoplayFallbackClearsIntentBeforeLoadAndClearsPersistedNativePauseAfterSuccess() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||||
|
core.setPrivateField("desiredPaused", true)
|
||||||
|
core.setPrivateField("cachedPaused", true)
|
||||||
|
core.setPrivateField("resumeBlockedByPublicPause", true)
|
||||||
|
val plugin = reusedFallbackPlugin(activity, core)
|
||||||
|
val result = RecordingResult()
|
||||||
|
|
||||||
|
plugin.onMethodCall(
|
||||||
|
MethodCall(
|
||||||
|
"open",
|
||||||
|
mapOf("uri" to "https://example.test/video.mkv", "autoPlay" to true)
|
||||||
|
),
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.completionCount)
|
||||||
|
assertNull(result.errorCode)
|
||||||
|
assertEquals(false, core.getPrivateField("desiredPaused"))
|
||||||
|
assertEquals(false, core.getPrivateField("cachedPaused"))
|
||||||
|
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||||
|
assertTrue(awaitQueueEntry(writes, "pause" to "no"))
|
||||||
|
assertFalse(awaitPauseWriteCount(writes, 2))
|
||||||
|
assertEquals(listOf("pause" to "no"), writes.filter { it.first == "pause" })
|
||||||
|
core.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
activity.setContentView(FrameLayout(activity))
|
||||||
|
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
val container = FrameLayout(activity)
|
||||||
|
content.addView(container)
|
||||||
|
var layoutCallbacks = 0
|
||||||
|
val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
|
||||||
|
content.viewTreeObserver.addOnGlobalLayoutListener(listener)
|
||||||
|
val core = ExoPlayerCore(activity)
|
||||||
|
core.setPrivateField("surfaceContainer", container)
|
||||||
|
core.setPrivateField("overlayLayoutListener", listener)
|
||||||
|
val plugin = ExoPlayerPlugin()
|
||||||
|
setField(plugin, "activity", activity)
|
||||||
|
setField(plugin, "playerCore", core)
|
||||||
|
setField(plugin, "usingMpvFallback", true)
|
||||||
|
setField(plugin, "fallbackInProgress", true)
|
||||||
|
setField(plugin, "currentExternalSubtitles", listOf(mapOf("uri" to "content://subtitle")))
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(getField(plugin, "pendingMpvProperties") as MutableMap<String, String>)["pause"] = "yes"
|
||||||
|
|
||||||
|
plugin.onDetachedFromActivityForConfigChanges()
|
||||||
|
content.viewTreeObserver.dispatchOnGlobalLayout()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
plugin.onDetachedFromEngine(pluginBinding(activity))
|
||||||
|
|
||||||
|
assertEquals(0, layoutCallbacks)
|
||||||
|
assertNull(container.parent)
|
||||||
|
assertNull(getField(plugin, "playerCore"))
|
||||||
|
assertNull(getField(plugin, "mpvCore"))
|
||||||
|
assertFalse(getField(plugin, "usingMpvFallback") as Boolean)
|
||||||
|
assertFalse(getField(plugin, "fallbackInProgress") as Boolean)
|
||||||
|
assertNull(getField(plugin, "currentExternalSubtitles"))
|
||||||
|
assertTrue((getField(plugin, "pendingMpvProperties") as Map<*, *>).isEmpty())
|
||||||
|
assertNull(getField(plugin, "activity"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun engineDetachReleasesPublishedMpvFallbackActivityOwnership() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
activity.setContentView(FrameLayout(activity))
|
||||||
|
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
val container = FrameLayout(activity)
|
||||||
|
content.addView(container)
|
||||||
|
var layoutCallbacks = 0
|
||||||
|
val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
|
||||||
|
content.viewTreeObserver.addOnGlobalLayoutListener(listener)
|
||||||
|
val core = MpvPlayerCore(activity)
|
||||||
|
core.setPrivateField("surfaceContainer", container)
|
||||||
|
core.setPrivateField("overlayLayoutListener", listener)
|
||||||
|
val plugin = ExoPlayerPlugin()
|
||||||
|
setField(plugin, "activity", activity)
|
||||||
|
setField(plugin, "mpvCore", core)
|
||||||
|
setField(plugin, "usingMpvFallback", true)
|
||||||
|
|
||||||
|
plugin.onDetachedFromEngine(pluginBinding(activity))
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
content.viewTreeObserver.dispatchOnGlobalLayout()
|
||||||
|
|
||||||
|
assertEquals(0, layoutCallbacks)
|
||||||
|
assertNull(container.parent)
|
||||||
|
assertNull(getField(plugin, "mpvCore"))
|
||||||
|
assertFalse(getField(plugin, "usingMpvFallback") as Boolean)
|
||||||
|
assertTrue(core.getPrivateField("disposing") as Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun configDetachRejectsQueuedInitializationFromOldActivityGeneration() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
activity.setContentView(FrameLayout(activity))
|
||||||
|
val plugin = ExoPlayerPlugin()
|
||||||
|
setField(plugin, "activity", activity)
|
||||||
|
val result = RecordingResult()
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
plugin.onMethodCall(MethodCall("initialize", emptyMap<String, Any?>()), result)
|
||||||
|
}.apply {
|
||||||
|
start()
|
||||||
|
join()
|
||||||
|
}
|
||||||
|
plugin.onDetachedFromActivityForConfigChanges()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
|
||||||
|
assertEquals(false, result.successValue)
|
||||||
|
assertEquals(1, result.completionCount)
|
||||||
|
assertNull(getField(plugin, "playerCore"))
|
||||||
|
assertNull(getField(plugin, "activity"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
|
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
|
||||||
val plugin = ExoPlayerPlugin()
|
val plugin = ExoPlayerPlugin()
|
||||||
@@ -215,6 +423,87 @@ class ExoPlayerPluginTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initialFallbackPlugin(
|
||||||
|
activity: Activity,
|
||||||
|
core: MpvPlayerCore
|
||||||
|
): ExoPlayerPlugin = ExoPlayerPlugin().also { plugin ->
|
||||||
|
setField(plugin, "activity", activity)
|
||||||
|
setField(plugin, "mpvCore", core)
|
||||||
|
setField(plugin, "usingMpvFallback", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reusedFallbackPlugin(
|
||||||
|
activity: Activity,
|
||||||
|
core: MpvPlayerCore
|
||||||
|
): ExoPlayerPlugin = ExoPlayerPlugin().also { plugin ->
|
||||||
|
setField(plugin, "activity", activity)
|
||||||
|
setField(plugin, "mpvCore", core)
|
||||||
|
setField(plugin, "usingMpvFallback", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeSetupMpvFallback(
|
||||||
|
plugin: ExoPlayerPlugin,
|
||||||
|
core: MpvPlayerCore,
|
||||||
|
activity: Activity,
|
||||||
|
playWhenReady: Boolean
|
||||||
|
) {
|
||||||
|
ExoPlayerPlugin::class.java.getDeclaredMethod(
|
||||||
|
"setupMpvFallback",
|
||||||
|
MpvPlayerCore::class.java,
|
||||||
|
Activity::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Map::class.java,
|
||||||
|
java.lang.Long.TYPE,
|
||||||
|
List::class.java,
|
||||||
|
java.lang.Boolean.TYPE,
|
||||||
|
java.lang.Integer.TYPE
|
||||||
|
).apply {
|
||||||
|
isAccessible = true
|
||||||
|
invoke(
|
||||||
|
plugin,
|
||||||
|
core,
|
||||||
|
activity,
|
||||||
|
"https://example.test/video.mkv",
|
||||||
|
null,
|
||||||
|
0L,
|
||||||
|
null,
|
||||||
|
playWhenReady,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
|
||||||
|
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
|
||||||
|
isAccessible = true
|
||||||
|
invoke(core, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitQueueEntry(
|
||||||
|
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||||
|
expected: Pair<String, String>
|
||||||
|
): Boolean {
|
||||||
|
repeat(100) {
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
if (queue.contains(expected)) return true
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitPauseWriteCount(
|
||||||
|
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||||
|
expectedCount: Int
|
||||||
|
): Boolean {
|
||||||
|
repeat(100) {
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
if (queue.count { it.first == "pause" } >= expectedCount) return true
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
|
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
|
||||||
plugin.javaClass.getDeclaredField(name).apply {
|
plugin.javaClass.getDeclaredField(name).apply {
|
||||||
isAccessible = true
|
isAccessible = true
|
||||||
@@ -227,6 +516,23 @@ class ExoPlayerPluginTest {
|
|||||||
get(plugin)
|
get(plugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Any.setPrivateField(name: String, value: Any?) {
|
||||||
|
javaClass.getDeclaredField(name).apply {
|
||||||
|
isAccessible = true
|
||||||
|
set(this@setPrivateField, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any.getPrivateField(name: String): Any? = javaClass.getDeclaredField(name).run {
|
||||||
|
isAccessible = true
|
||||||
|
get(this@getPrivateField)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pluginBinding(activity: Activity): FlutterPlugin.FlutterPluginBinding {
|
||||||
|
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
|
||||||
|
return constructor.newInstance(activity, null, null, null, null, null, null) as FlutterPlugin.FlutterPluginBinding
|
||||||
|
}
|
||||||
|
|
||||||
private fun awaitCompletion(result: RecordingResult) {
|
private fun awaitCompletion(result: RecordingResult) {
|
||||||
var completed = false
|
var completed = false
|
||||||
repeat(100) {
|
repeat(100) {
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
package com.edde746.plezy.exoplayer
|
||||||
|
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.DataReader
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.ParserException
|
||||||
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
|
import androidx.media3.extractor.TrackOutput
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.DeflaterOutputStream
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ZlibInflatingTrackOutputTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
|
||||||
|
val original = ByteArray(512 * 1024) { (it % 251).toByte() }
|
||||||
|
val compressed = deflate(original)
|
||||||
|
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
val split = compressed.size / 2
|
||||||
|
|
||||||
|
output.sampleData(
|
||||||
|
ParsableByteArray(compressed.copyOfRange(0, split)),
|
||||||
|
split,
|
||||||
|
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||||
|
)
|
||||||
|
output.sampleData(
|
||||||
|
ParsableByteArray(compressed.copyOfRange(split, compressed.size)),
|
||||||
|
compressed.size - split,
|
||||||
|
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||||
|
)
|
||||||
|
output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 7, null)
|
||||||
|
|
||||||
|
assertArrayEquals(original, delegate.retained.toByteArray())
|
||||||
|
assertEquals(42L, delegate.timeUs)
|
||||||
|
assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags)
|
||||||
|
assertEquals(original.size, delegate.sampleSize)
|
||||||
|
assertEquals(0, delegate.offset)
|
||||||
|
assertEquals(1, delegate.metadataCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactlySixteenMiBInflatedSampleIsAccepted() {
|
||||||
|
val size = 16 * 1024 * 1024
|
||||||
|
val compressed = deflateRepeated(size)
|
||||||
|
val delegate = RecordingTrackOutput()
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
|
||||||
|
feed(output, compressed)
|
||||||
|
|
||||||
|
assertEquals(size, delegate.byteCount)
|
||||||
|
assertEquals(size, delegate.sampleSize)
|
||||||
|
assertEquals(1, delegate.metadataCount)
|
||||||
|
assertTrue(compressed.size < 64 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneByteOverInflatedLimitFailsBeforeDelegation() {
|
||||||
|
val compressed = deflateRepeated(16 * 1024 * 1024 + 1)
|
||||||
|
val delegate = RecordingTrackOutput()
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
|
||||||
|
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||||
|
|
||||||
|
assertEquals(0, delegate.byteCount)
|
||||||
|
assertEquals(0, delegate.metadataCount)
|
||||||
|
assertTrue(compressed.size < 64 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() {
|
||||||
|
val compressed = deflate(ByteArray(8 * 1024 * 1024))
|
||||||
|
val delegate = RecordingTrackOutput()
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
|
||||||
|
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||||
|
|
||||||
|
assertEquals(0, delegate.byteCount)
|
||||||
|
assertEquals(0, delegate.metadataCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun corruptStreamPassesTheBoundedSampleThroughUnchanged() {
|
||||||
|
val corrupt = byteArrayOf(0, 1, 2, 3)
|
||||||
|
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
|
||||||
|
feed(output, corrupt)
|
||||||
|
|
||||||
|
assertArrayEquals(corrupt, delegate.retained.toByteArray())
|
||||||
|
assertEquals(corrupt.size, delegate.sampleSize)
|
||||||
|
assertEquals(1, delegate.metadataCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun truncatedAndDictionaryStreamsFailClosed() {
|
||||||
|
val valid = deflate(ByteArray(4096) { 7 })
|
||||||
|
val dictionary = "shared-zlib-dictionary".toByteArray()
|
||||||
|
val cases = listOf(
|
||||||
|
valid.copyOf(valid.size - 2),
|
||||||
|
deflate(ByteArray(4096) { 3 }, dictionary)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (compressed in cases) {
|
||||||
|
val delegate = RecordingTrackOutput()
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
|
||||||
|
|
||||||
|
assertThrows(ParserException::class.java) { feed(output, compressed) }
|
||||||
|
assertEquals(0, delegate.byteCount)
|
||||||
|
assertEquals(0, delegate.metadataCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
|
||||||
|
val bytes = byteArrayOf(9, 8, 7, 6)
|
||||||
|
val delegate = RecordingTrackOutput(retainBytes = true)
|
||||||
|
val output = ZlibInflatingTrackOutput(delegate)
|
||||||
|
|
||||||
|
output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||||
|
output.sampleMetadata(99L, 3, bytes.size, 2, null)
|
||||||
|
|
||||||
|
assertArrayEquals(bytes, delegate.retained.toByteArray())
|
||||||
|
assertEquals(bytes.size, delegate.sampleSize)
|
||||||
|
assertEquals(2, delegate.offset)
|
||||||
|
assertEquals(99L, delegate.timeUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) {
|
||||||
|
output.sampleData(
|
||||||
|
ParsableByteArray(compressed),
|
||||||
|
compressed.size,
|
||||||
|
TrackOutput.SAMPLE_DATA_PART_MAIN
|
||||||
|
)
|
||||||
|
output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray {
|
||||||
|
val target = ByteArrayOutputStream()
|
||||||
|
val deflater = Deflater().apply {
|
||||||
|
if (dictionary != null) setDictionary(dictionary)
|
||||||
|
}
|
||||||
|
DeflaterOutputStream(target, deflater).use { it.write(bytes) }
|
||||||
|
return target.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deflateRepeated(size: Int): ByteArray {
|
||||||
|
val target = ByteArrayOutputStream()
|
||||||
|
val chunk = ByteArray(8192) { (it % 7).toByte() }
|
||||||
|
DeflaterOutputStream(target).use { stream ->
|
||||||
|
var remaining = size
|
||||||
|
while (remaining > 0) {
|
||||||
|
val count = minOf(remaining, chunk.size)
|
||||||
|
stream.write(chunk, 0, count)
|
||||||
|
remaining -= count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingTrackOutput(
|
||||||
|
private val retainBytes: Boolean = false
|
||||||
|
) : TrackOutput {
|
||||||
|
val retained = ByteArrayOutputStream()
|
||||||
|
var byteCount = 0
|
||||||
|
var metadataCount = 0
|
||||||
|
var timeUs = C.TIME_UNSET
|
||||||
|
var flags = 0
|
||||||
|
var sampleSize = -1
|
||||||
|
var offset = -1
|
||||||
|
|
||||||
|
override fun format(format: Format) = Unit
|
||||||
|
|
||||||
|
override fun sampleData(
|
||||||
|
input: DataReader,
|
||||||
|
length: Int,
|
||||||
|
allowEndOfInput: Boolean,
|
||||||
|
sampleDataPart: Int
|
||||||
|
): Int {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
val read = input.read(buffer, 0, length)
|
||||||
|
if (read > 0) record(buffer, read)
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
data.readBytes(buffer, 0, length)
|
||||||
|
record(buffer, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun record(buffer: ByteArray, length: Int) {
|
||||||
|
byteCount += length
|
||||||
|
if (retainBytes) retained.write(buffer, 0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sampleMetadata(
|
||||||
|
timeUs: Long,
|
||||||
|
flags: Int,
|
||||||
|
size: Int,
|
||||||
|
offset: Int,
|
||||||
|
cryptoData: TrackOutput.CryptoData?
|
||||||
|
) {
|
||||||
|
metadataCount++
|
||||||
|
this.timeUs = timeUs
|
||||||
|
this.flags = flags
|
||||||
|
sampleSize = size
|
||||||
|
this.offset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
package com.edde746.plezy.mpv
|
package com.edde746.plezy.mpv
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import com.edde746.plezy.shared.AudioFocusManager
|
||||||
import dev.jdtech.mpv.EndFileReason
|
import dev.jdtech.mpv.EndFileReason
|
||||||
import dev.jdtech.mpv.LogLevel
|
import dev.jdtech.mpv.LogLevel
|
||||||
import dev.jdtech.mpv.LogMessage
|
import dev.jdtech.mpv.LogMessage
|
||||||
import dev.jdtech.mpv.MpvEvent
|
import dev.jdtech.mpv.MpvEvent
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -70,7 +79,7 @@ class MpvPlayerPluginTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
|
fun rejectedSetPropertyReportsBoundedErrorForVideoAndAudio() {
|
||||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||||
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
|
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
|
||||||
val result = RecordingResult()
|
val result = RecordingResult()
|
||||||
@@ -80,15 +89,15 @@ class MpvPlayerPluginTest {
|
|||||||
|
|
||||||
assertEquals(1, result.completionCount)
|
assertEquals(1, result.completionCount)
|
||||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||||
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
|
assertEquals("MPV property write was rejected", result.errorMessage)
|
||||||
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
|
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
|
||||||
assertNull(result.successValue)
|
|
||||||
assertNull(result.errorDetails)
|
assertNull(result.errorDetails)
|
||||||
|
assertNull(result.successValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
|
fun cancelledSetPropertyReportsNotInitializedOnceForVideoAndAudio() {
|
||||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||||
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
|
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
|
||||||
val result = RecordingResult()
|
val result = RecordingResult()
|
||||||
@@ -97,8 +106,9 @@ class MpvPlayerPluginTest {
|
|||||||
awaitCompletion(result)
|
awaitCompletion(result)
|
||||||
|
|
||||||
assertEquals(1, result.completionCount)
|
assertEquals(1, result.completionCount)
|
||||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||||
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
|
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
|
||||||
|
assertEquals("Player not initialized", result.errorMessage)
|
||||||
assertNull(result.successValue)
|
assertNull(result.successValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +122,7 @@ class MpvPlayerPluginTest {
|
|||||||
awaitCondition { outcome != null }
|
awaitCondition { outcome != null }
|
||||||
|
|
||||||
assertTrue(outcome?.isFailure == true)
|
assertTrue(outcome?.isFailure == true)
|
||||||
|
assertTrue(outcome?.exceptionOrNull() is CancellationException)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -134,6 +145,7 @@ class MpvPlayerPluginTest {
|
|||||||
|
|
||||||
assertEquals(2, outcomes.size)
|
assertEquals(2, outcomes.size)
|
||||||
assertTrue(outcomes.all { it.isFailure })
|
assertTrue(outcomes.all { it.isFailure })
|
||||||
|
assertTrue(outcomes.all { it.exceptionOrNull() is CancellationException })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -155,6 +167,175 @@ class MpvPlayerPluginTest {
|
|||||||
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
|
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedResumeRestoresThePreviousPublicPauseIntent() {
|
||||||
|
val core = testCore { _, _ -> error("rejected") }
|
||||||
|
setBoolean(core, "cachedPaused", true)
|
||||||
|
setBoolean(core, "resumeBlockedByPublicPause", true)
|
||||||
|
var outcome: Result<Unit>? = null
|
||||||
|
|
||||||
|
core.setProperty("pause", "no") { outcome = it }
|
||||||
|
awaitCondition { outcome != null }
|
||||||
|
|
||||||
|
assertTrue(outcome?.isFailure == true)
|
||||||
|
assertEquals(true, getBoolean(core, "cachedPaused"))
|
||||||
|
assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedOlderPauseWriteDoesNotRollbackANewerResumeIntent() {
|
||||||
|
val firstStarted = CountDownLatch(1)
|
||||||
|
val releaseFirst = CountDownLatch(1)
|
||||||
|
val writes = AtomicInteger()
|
||||||
|
val core = testCore { _, _ ->
|
||||||
|
if (writes.incrementAndGet() == 1) {
|
||||||
|
firstStarted.countDown()
|
||||||
|
releaseFirst.await(1, TimeUnit.SECONDS)
|
||||||
|
error("rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var firstOutcome: Result<Unit>? = null
|
||||||
|
var secondOutcome: Result<Unit>? = null
|
||||||
|
|
||||||
|
core.setProperty("pause", "yes") { firstOutcome = it }
|
||||||
|
assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
core.setProperty("pause", "no") { secondOutcome = it }
|
||||||
|
releaseFirst.countDown()
|
||||||
|
awaitCondition { firstOutcome != null && secondOutcome != null }
|
||||||
|
|
||||||
|
assertTrue(firstOutcome?.isFailure == true)
|
||||||
|
assertTrue(secondOutcome?.isSuccess == true)
|
||||||
|
assertEquals(2, writes.get())
|
||||||
|
assertEquals(false, getBoolean(core, "cachedPaused"))
|
||||||
|
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pauseIntentBlocksAudioFocusAutoResumeBeforeNativeWriteCompletes() {
|
||||||
|
val writeStarted = CountDownLatch(1)
|
||||||
|
val releaseWrite = CountDownLatch(1)
|
||||||
|
val writes = AtomicInteger()
|
||||||
|
val unexpectedResumeWrite = CountDownLatch(1)
|
||||||
|
val core = testCore { name, value ->
|
||||||
|
if (writes.incrementAndGet() > 1) unexpectedResumeWrite.countDown()
|
||||||
|
if (name == "pause" && value == "yes") {
|
||||||
|
writeStarted.countDown()
|
||||||
|
releaseWrite.await(1, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBoolean(core, "resumeBlockedByPublicPause", false)
|
||||||
|
var outcome: Result<Unit>? = null
|
||||||
|
|
||||||
|
core.setProperty("pause", "yes") { outcome = it }
|
||||||
|
assertTrue(writeStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
invokeAutoResume(core, "audio focus gain")
|
||||||
|
|
||||||
|
assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
assertNull(outcome)
|
||||||
|
assertEquals(1, writes.get())
|
||||||
|
|
||||||
|
releaseWrite.countDown()
|
||||||
|
awaitCondition { outcome != null }
|
||||||
|
assertFalse(unexpectedResumeWrite.await(100, TimeUnit.MILLISECONDS))
|
||||||
|
assertTrue(outcome?.isSuccess == true)
|
||||||
|
assertEquals(1, writes.get())
|
||||||
|
assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heldLoadPauseIntentSynchronouslyBlocksFocusAndSurfaceAutoResumeWithoutNativeWrite() {
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = testVideoCore { name, value -> writes += name to value }
|
||||||
|
setBoolean(core, "desiredPaused", false)
|
||||||
|
setBoolean(core, "cachedPaused", false)
|
||||||
|
setBoolean(core, "resumeBlockedByPublicPause", false)
|
||||||
|
setBoolean(core, "pausedForSurfaceLoss", true)
|
||||||
|
setBoolean(core, "pausedForAudioFocusLoss", true)
|
||||||
|
setBoolean(core, "deferredResumeRequested", true)
|
||||||
|
|
||||||
|
core.setPauseIntentForLoad(paused = true)
|
||||||
|
invokeAutoResume(core, "audio focus gain")
|
||||||
|
invokeAutoResume(core, "surface attached")
|
||||||
|
|
||||||
|
assertEquals(true, getBoolean(core, "desiredPaused"))
|
||||||
|
assertEquals(true, getBoolean(core, "cachedPaused"))
|
||||||
|
assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
assertEquals(false, getBoolean(core, "pausedForSurfaceLoss"))
|
||||||
|
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
|
||||||
|
assertEquals(false, getBoolean(core, "deferredResumeRequested"))
|
||||||
|
assertFalse(awaitQueueEntry(writes, "pause" to "no"))
|
||||||
|
assertTrue(writes.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun autoplayLoadIntentClearsPublicPauseBlockWithoutPrematureNativeResumeWrite() {
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val core = testCore { name, value -> writes += name to value }
|
||||||
|
setBoolean(core, "desiredPaused", true)
|
||||||
|
setBoolean(core, "cachedPaused", true)
|
||||||
|
setBoolean(core, "resumeBlockedByPublicPause", true)
|
||||||
|
|
||||||
|
core.setPauseIntentForLoad(paused = false)
|
||||||
|
|
||||||
|
assertEquals(false, getBoolean(core, "desiredPaused"))
|
||||||
|
assertEquals(false, getBoolean(core, "cachedPaused"))
|
||||||
|
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||||
|
assertFalse(awaitQueueEntry(writes, "pause" to "no"))
|
||||||
|
|
||||||
|
invokeAudioFocusPause(core)
|
||||||
|
assertTrue(awaitQueueEntry(writes, "pause" to "yes"))
|
||||||
|
assertEquals(listOf("pause" to "yes"), writes.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() {
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val focusResumeCallbacks = AtomicInteger()
|
||||||
|
val core = testCore { name, value -> writes += name to value }
|
||||||
|
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
|
||||||
|
var pauseOutcome: Result<Unit>? = null
|
||||||
|
var resumeOutcome: Result<Unit>? = null
|
||||||
|
|
||||||
|
core.setProperty("pause", "yes") { pauseOutcome = it }
|
||||||
|
awaitCondition { pauseOutcome != null }
|
||||||
|
|
||||||
|
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
|
||||||
|
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
|
||||||
|
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
assertEquals(0, focusResumeCallbacks.get())
|
||||||
|
|
||||||
|
core.setProperty("pause", "no") { resumeOutcome = it }
|
||||||
|
awaitCondition { resumeOutcome != null }
|
||||||
|
|
||||||
|
assertTrue(pauseOutcome?.isSuccess == true)
|
||||||
|
assertTrue(resumeOutcome?.isSuccess == true)
|
||||||
|
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
|
||||||
|
assertEquals(1, writes.count { it == "pause" to "no" })
|
||||||
|
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun synchronousFocusReacquisitionClearsLossMarkerAndResumesOnce() {
|
||||||
|
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||||
|
val focusResumeCallbacks = AtomicInteger()
|
||||||
|
val core = testCore { name, value -> writes += name to value }
|
||||||
|
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
|
||||||
|
setCoreField(core, "audioFocusManager", focusManager)
|
||||||
|
setBoolean(core, "desiredPaused", false)
|
||||||
|
setBoolean(core, "cachedPaused", false)
|
||||||
|
|
||||||
|
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
|
||||||
|
awaitCondition { writes.contains("pause" to "yes") }
|
||||||
|
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
|
||||||
|
|
||||||
|
assertTrue(core.requestAudioFocus())
|
||||||
|
awaitCondition { writes.count { it == "pause" to "no" } == 1 }
|
||||||
|
|
||||||
|
assertEquals(0, focusResumeCallbacks.get())
|
||||||
|
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
|
||||||
|
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
|
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
|
||||||
val writes = AtomicInteger()
|
val writes = AtomicInteger()
|
||||||
@@ -201,6 +382,83 @@ class MpvPlayerPluginTest {
|
|||||||
assertEquals(0, pending.size)
|
assertEquals(0, pending.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
activity.setContentView(FrameLayout(activity))
|
||||||
|
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
val container = FrameLayout(activity)
|
||||||
|
content.addView(container)
|
||||||
|
var layoutCallbacks = 0
|
||||||
|
val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
|
||||||
|
content.viewTreeObserver.addOnGlobalLayoutListener(listener)
|
||||||
|
val core = MpvPlayerCore(activity)
|
||||||
|
setCoreField(core, "surfaceContainer", container)
|
||||||
|
setCoreField(core, "overlayLayoutListener", listener)
|
||||||
|
val plugin = MpvPlayerPlugin()
|
||||||
|
installCore(plugin, core)
|
||||||
|
setPluginField(plugin, "activity", activity)
|
||||||
|
val pendingResult = RecordingResult()
|
||||||
|
pendingInitResults(plugin) += pendingResult
|
||||||
|
setPluginField(plugin, "isInitializing", true)
|
||||||
|
setPluginField(plugin, "activeInitAttempt", 7)
|
||||||
|
setPluginField(plugin, "initAttemptCounter", 7)
|
||||||
|
|
||||||
|
plugin.onDetachedFromActivityForConfigChanges()
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
plugin.onDetachedFromEngine(pluginBinding(activity))
|
||||||
|
content.viewTreeObserver.dispatchOnGlobalLayout()
|
||||||
|
|
||||||
|
assertEquals(false, pendingResult.successValue)
|
||||||
|
assertEquals(1, pendingResult.completionCount)
|
||||||
|
assertEquals(0, layoutCallbacks)
|
||||||
|
assertNull(container.parent)
|
||||||
|
assertNull(getPluginField(plugin, "playerCore"))
|
||||||
|
assertNull(getPluginField(plugin, "activity"))
|
||||||
|
assertTrue(getCoreField(core, "disposing") as Boolean)
|
||||||
|
assertFalse(getPluginField(plugin, "isInitializing") as Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleInitCompletionCannotConsumeReplacementAttemptResults() {
|
||||||
|
val plugin = MpvPlayerPlugin()
|
||||||
|
val stale = RecordingResult()
|
||||||
|
pendingInitResults(plugin) += stale
|
||||||
|
setPluginField(plugin, "isInitializing", true)
|
||||||
|
setPluginField(plugin, "activeInitAttempt", 1)
|
||||||
|
setPluginField(plugin, "initAttemptCounter", 1)
|
||||||
|
|
||||||
|
plugin.onDetachedFromActivityForConfigChanges()
|
||||||
|
assertEquals(false, stale.successValue)
|
||||||
|
assertEquals(1, stale.completionCount)
|
||||||
|
|
||||||
|
val replacement = RecordingResult()
|
||||||
|
pendingInitResults(plugin) += replacement
|
||||||
|
setPluginField(plugin, "isInitializing", true)
|
||||||
|
setPluginField(plugin, "activeInitAttempt", 3)
|
||||||
|
setPluginField(plugin, "initAttemptCounter", 3)
|
||||||
|
|
||||||
|
plugin.completePendingInits(1, success = true)
|
||||||
|
assertEquals(0, replacement.completionCount)
|
||||||
|
|
||||||
|
plugin.completePendingInits(3, success = true)
|
||||||
|
assertEquals(true, replacement.successValue)
|
||||||
|
assertEquals(1, replacement.completionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun engineDetachAlsoTerminatesApplicationContextAudioCore() {
|
||||||
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
|
val plugin = MpvAudioPlayerPlugin()
|
||||||
|
val core = testCore { _, _ -> Unit }
|
||||||
|
installCore(plugin, core)
|
||||||
|
|
||||||
|
plugin.onDetachedFromEngine(pluginBinding(activity))
|
||||||
|
|
||||||
|
assertNull(getPluginField(plugin, "playerCore"))
|
||||||
|
assertTrue(getCoreField(core, "disposing") as Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun setLogLevelReportsUnsupported() {
|
fun setLogLevelReportsUnsupported() {
|
||||||
val result = RecordingResult()
|
val result = RecordingResult()
|
||||||
@@ -295,6 +553,84 @@ class MpvPlayerPluginTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun pendingInitResults(plugin: MpvPlayerPlugin): MutableList<MethodChannel.Result> = getPluginField(plugin, "pendingInitResults") as MutableList<MethodChannel.Result>
|
||||||
|
|
||||||
|
private fun setPluginField(plugin: MpvPlayerPlugin, name: String, value: Any?) {
|
||||||
|
MpvPlayerPlugin::class.java.getDeclaredField(name).apply {
|
||||||
|
isAccessible = true
|
||||||
|
set(plugin, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPluginField(plugin: MpvPlayerPlugin, name: String): Any? = MpvPlayerPlugin::class.java.getDeclaredField(name).run {
|
||||||
|
isAccessible = true
|
||||||
|
get(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCoreField(core: MpvPlayerCore, name: String, value: Any?) {
|
||||||
|
MpvPlayerCore::class.java.getDeclaredField(name).apply {
|
||||||
|
isAccessible = true
|
||||||
|
set(core, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCoreField(core: MpvPlayerCore, name: String): Any? = MpvPlayerCore::class.java.getDeclaredField(name).run {
|
||||||
|
isAccessible = true
|
||||||
|
get(core)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pluginBinding(activity: Activity): FlutterPlugin.FlutterPluginBinding {
|
||||||
|
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
|
||||||
|
return constructor.newInstance(activity, null, null, null, null, null, null) as FlutterPlugin.FlutterPluginBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testAudioFocusManager(
|
||||||
|
core: MpvPlayerCore,
|
||||||
|
resumeCallbacks: AtomicInteger
|
||||||
|
): AudioFocusManager = AudioFocusManager(
|
||||||
|
context = Robolectric.buildActivity(Activity::class.java).setup().get(),
|
||||||
|
handler = Handler(Looper.getMainLooper()),
|
||||||
|
onPause = { invokeAudioFocusPause(core) },
|
||||||
|
onResume = {
|
||||||
|
resumeCallbacks.incrementAndGet()
|
||||||
|
invokeAudioFocusResume(core, "audio focus gain")
|
||||||
|
},
|
||||||
|
isPaused = { getBoolean(core, "desiredPaused") }
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun invokeAudioFocusPause(core: MpvPlayerCore) {
|
||||||
|
MpvPlayerCore::class.java.getDeclaredMethod("pauseForAudioFocusLoss").apply {
|
||||||
|
isAccessible = true
|
||||||
|
invoke(core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeAudioFocusResume(core: MpvPlayerCore, reason: String) {
|
||||||
|
MpvPlayerCore::class.java.getDeclaredMethod(
|
||||||
|
"resumeAfterAudioFocusGain",
|
||||||
|
String::class.java
|
||||||
|
).apply {
|
||||||
|
isAccessible = true
|
||||||
|
invoke(core, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchAudioFocusChange(manager: AudioFocusManager, focusChange: Int) {
|
||||||
|
val listener = AudioFocusManager::class.java.getDeclaredField("audioFocusChangeListener").run {
|
||||||
|
isAccessible = true
|
||||||
|
get(manager) as AudioManager.OnAudioFocusChangeListener
|
||||||
|
}
|
||||||
|
listener.onAudioFocusChange(focusChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
|
||||||
|
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
|
||||||
|
isAccessible = true
|
||||||
|
invoke(core, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
|
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
|
||||||
MpvPlayerCore::class.java.getDeclaredField(name).apply {
|
MpvPlayerCore::class.java.getDeclaredField(name).apply {
|
||||||
isAccessible = true
|
isAccessible = true
|
||||||
@@ -307,6 +643,18 @@ class MpvPlayerPluginTest {
|
|||||||
getBoolean(core)
|
getBoolean(core)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun awaitQueueEntry(
|
||||||
|
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||||
|
expected: Pair<String, String>
|
||||||
|
): Boolean {
|
||||||
|
repeat(10) {
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
if (queue.contains(expected)) return true
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun awaitCompletion(result: RecordingResult) {
|
private fun awaitCompletion(result: RecordingResult) {
|
||||||
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
|
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
package com.edde746.plezy.watchnext
|
package com.edde746.plezy.watchnext
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.ContentProvider
|
import android.content.ContentProvider
|
||||||
import android.content.ContentProviderOperation
|
import android.content.ContentProviderOperation
|
||||||
import android.content.ContentProviderResult
|
import android.content.ContentProviderResult
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.ResolveInfo
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
|
import java.util.ArrayDeque
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.AbstractExecutorService
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
@@ -26,6 +45,8 @@ import org.junit.runner.RunWith
|
|||||||
import org.robolectric.Robolectric
|
import org.robolectric.Robolectric
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.shadows.ShadowContentResolver
|
import org.robolectric.shadows.ShadowContentResolver
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@@ -101,6 +122,405 @@ class WatchNextProviderTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replacementEngineInvalidatesArtworkWorkBeforeRowsCanCommit() {
|
||||||
|
val requestReceived = CountDownLatch(1)
|
||||||
|
val releaseResponse = CountDownLatch(1)
|
||||||
|
val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
|
||||||
|
val responder = thread(start = true, name = "system-shelf-owner-test-http") {
|
||||||
|
server.accept().use { socket ->
|
||||||
|
val reader = socket.getInputStream().bufferedReader()
|
||||||
|
while (reader.readLine()?.isNotEmpty() == true) {
|
||||||
|
// Consume request headers before handing ownership to a replacement engine.
|
||||||
|
}
|
||||||
|
requestReceived.countDown()
|
||||||
|
releaseResponse.await(2, TimeUnit.SECONDS)
|
||||||
|
val headers = (
|
||||||
|
"HTTP/1.1 200 OK\r\n" +
|
||||||
|
"Content-Type: image/png\r\n" +
|
||||||
|
"Content-Length: ${imageBytes.size}\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).toByteArray()
|
||||||
|
socket.getOutputStream().use { output ->
|
||||||
|
output.write(headers)
|
||||||
|
output.write(imageBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val oldLease = SystemShelfLifecycle.acquire()
|
||||||
|
val oldProvider = WatchNextProvider(context, oldLease)
|
||||||
|
val result = AtomicReference<Boolean>()
|
||||||
|
val worker = thread(start = true, name = "system-shelf-stale-owner") {
|
||||||
|
result.set(
|
||||||
|
oldProvider.syncWatchNextPrograms(
|
||||||
|
"owner-old",
|
||||||
|
1,
|
||||||
|
listOf(item("http://127.0.0.1:${server.localPort}/art"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(requestReceived.await(2, TimeUnit.SECONDS))
|
||||||
|
SystemShelfLifecycle.acquire()
|
||||||
|
releaseResponse.countDown()
|
||||||
|
worker.join(2_000)
|
||||||
|
|
||||||
|
assertFalse(worker.isAlive)
|
||||||
|
assertEquals(false, result.get())
|
||||||
|
assertTrue(tvProvider.inserted.isEmpty())
|
||||||
|
assertFalse(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
|
||||||
|
} finally {
|
||||||
|
releaseResponse.countDown()
|
||||||
|
server.close()
|
||||||
|
responder.join(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supersededSyncDeletesOnlyArtworkMaterializedByThatOperation() {
|
||||||
|
val secondRequestReceived = CountDownLatch(1)
|
||||||
|
val releaseSecondResponse = CountDownLatch(1)
|
||||||
|
val server = ServerSocket(0, 2, InetAddress.getByName("127.0.0.1"))
|
||||||
|
val responder = thread(start = true, name = "system-shelf-superseded-http") {
|
||||||
|
runCatching {
|
||||||
|
repeat(2) { requestIndex ->
|
||||||
|
server.accept().use { socket ->
|
||||||
|
val reader = socket.getInputStream().bufferedReader()
|
||||||
|
while (reader.readLine()?.isNotEmpty() == true) {
|
||||||
|
// Consume request headers.
|
||||||
|
}
|
||||||
|
if (requestIndex == 1) {
|
||||||
|
secondRequestReceived.countDown()
|
||||||
|
releaseSecondResponse.await(2, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
val headers = (
|
||||||
|
"HTTP/1.1 200 OK\r\n" +
|
||||||
|
"Content-Type: image/png\r\n" +
|
||||||
|
"Content-Length: ${imageBytes.size}\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).toByteArray()
|
||||||
|
socket.getOutputStream().use { output ->
|
||||||
|
output.write(headers)
|
||||||
|
output.write(imageBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val provider = WatchNextProvider(context)
|
||||||
|
val result = AtomicReference<Boolean>()
|
||||||
|
val worker = thread(start = true, name = "system-shelf-superseded-sync") {
|
||||||
|
val source = "http://127.0.0.1:${server.localPort}"
|
||||||
|
result.set(
|
||||||
|
provider.syncWatchNextPrograms(
|
||||||
|
"owner-a",
|
||||||
|
1,
|
||||||
|
listOf(item("$source/first"), item("$source/second").copy(contentId = "second"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS))
|
||||||
|
assertEquals(1, artworkFiles().size)
|
||||||
|
SystemShelfLifecycle.acquire()
|
||||||
|
releaseSecondResponse.countDown()
|
||||||
|
worker.join(2_000)
|
||||||
|
|
||||||
|
assertFalse(worker.isAlive)
|
||||||
|
assertEquals(false, result.get())
|
||||||
|
assertTrue(tvProvider.inserted.isEmpty())
|
||||||
|
assertTrue(artworkFiles().isEmpty())
|
||||||
|
} finally {
|
||||||
|
releaseSecondResponse.countDown()
|
||||||
|
server.close()
|
||||||
|
worker.join(2_000)
|
||||||
|
responder.join(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ownershipClaimWaitsForCurrentCommitBoundary() {
|
||||||
|
val lease = SystemShelfLifecycle.acquire()
|
||||||
|
val ownership = SystemShelfLifecycle.claim(lease, "owner-a", 1)
|
||||||
|
assertTrue(ownership != null)
|
||||||
|
val operationStarted = CountDownLatch(1)
|
||||||
|
val releaseOperation = CountDownLatch(1)
|
||||||
|
val claimAttempted = CountDownLatch(1)
|
||||||
|
val claimCompleted = CountDownLatch(1)
|
||||||
|
val replacement = AtomicReference<SystemShelfLifecycle.Ownership?>()
|
||||||
|
val operation = thread(start = true, name = "system-shelf-blocked-commit") {
|
||||||
|
SystemShelfLifecycle.whileCurrent(ownership!!) {
|
||||||
|
operationStarted.countDown()
|
||||||
|
releaseOperation.await(2, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(operationStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
val claimant = thread(start = true, name = "system-shelf-owner-claim") {
|
||||||
|
claimAttempted.countDown()
|
||||||
|
replacement.set(SystemShelfLifecycle.claim(lease, "owner-b", 2))
|
||||||
|
claimCompleted.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertTrue(claimAttempted.await(1, TimeUnit.SECONDS))
|
||||||
|
assertFalse("ownership changed during an active commit", claimCompleted.await(100, TimeUnit.MILLISECONDS))
|
||||||
|
releaseOperation.countDown()
|
||||||
|
assertTrue("claim did not resume after commit", claimCompleted.await(1, TimeUnit.SECONDS))
|
||||||
|
assertTrue(replacement.get() != null)
|
||||||
|
} finally {
|
||||||
|
releaseOperation.countDown()
|
||||||
|
operation.join(2_000)
|
||||||
|
claimant.join(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detachFencesQueuedSyncWithoutWaitingForBlockedActiveCommit() {
|
||||||
|
val plugin = WatchNextPlugin()
|
||||||
|
val binding = pluginBinding()
|
||||||
|
plugin.onAttachedToEngine(binding)
|
||||||
|
val executor = pluginIoExecutor(plugin)
|
||||||
|
tvProvider.blockNextBatch = true
|
||||||
|
|
||||||
|
val activeResult = RecordingResult()
|
||||||
|
plugin.onMethodCall(
|
||||||
|
MethodCall(
|
||||||
|
"sync",
|
||||||
|
mapOf(
|
||||||
|
"schemaVersion" to 2,
|
||||||
|
"ownerId" to "active-owner",
|
||||||
|
"generation" to 1L,
|
||||||
|
"items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
activeResult
|
||||||
|
)
|
||||||
|
assertTrue(tvProvider.batchStarted.await(1, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
val queuedResult = RecordingResult()
|
||||||
|
val channelReturned = CountDownLatch(1)
|
||||||
|
thread(start = true, name = "system-shelf-plugin-queued-channel") {
|
||||||
|
plugin.onMethodCall(
|
||||||
|
MethodCall(
|
||||||
|
"sync",
|
||||||
|
mapOf(
|
||||||
|
"schemaVersion" to 2,
|
||||||
|
"ownerId" to "queued-owner",
|
||||||
|
"generation" to 2L,
|
||||||
|
"items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
queuedResult
|
||||||
|
)
|
||||||
|
channelReturned.countDown()
|
||||||
|
}
|
||||||
|
assertTrue("queued channel claim waited for provider work", channelReturned.await(500, TimeUnit.MILLISECONDS))
|
||||||
|
|
||||||
|
val detachReturned = CountDownLatch(1)
|
||||||
|
thread(start = true, name = "system-shelf-plugin-detach") {
|
||||||
|
plugin.onDetachedFromEngine(binding)
|
||||||
|
detachReturned.countDown()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assertTrue("engine detach waited for provider work", detachReturned.await(500, TimeUnit.MILLISECONDS))
|
||||||
|
} finally {
|
||||||
|
tvProvider.releaseBatch.countDown()
|
||||||
|
}
|
||||||
|
assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS))
|
||||||
|
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
|
||||||
|
assertTrue(activeResult.completed.await(1, TimeUnit.SECONDS))
|
||||||
|
assertTrue(queuedResult.completed.await(1, TimeUnit.SECONDS))
|
||||||
|
assertEquals(true, activeResult.successValue)
|
||||||
|
assertEquals(false, queuedResult.successValue)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Active"),
|
||||||
|
tvProvider.inserted.map { it.getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closedStaleEngineInitializationCannotInvalidateNewEngineLease() {
|
||||||
|
val staleExecutor = ManualExecutorService()
|
||||||
|
val stalePlugin = WatchNextPlugin { staleExecutor }
|
||||||
|
val staleBinding = pluginBinding()
|
||||||
|
stalePlugin.onAttachedToEngine(staleBinding)
|
||||||
|
stalePlugin.onDetachedFromEngine(staleBinding)
|
||||||
|
|
||||||
|
val currentPlugin = WatchNextPlugin()
|
||||||
|
val currentBinding = pluginBinding()
|
||||||
|
currentPlugin.onAttachedToEngine(currentBinding)
|
||||||
|
val firstResult = RecordingResult()
|
||||||
|
currentPlugin.onMethodCall(syncCall("current-owner", 1), firstResult)
|
||||||
|
awaitResult(firstResult)
|
||||||
|
assertEquals(true, firstResult.successValue)
|
||||||
|
|
||||||
|
staleExecutor.runNext()
|
||||||
|
|
||||||
|
val secondResult = RecordingResult()
|
||||||
|
currentPlugin.onMethodCall(syncCall("current-owner", 2), secondResult)
|
||||||
|
awaitResult(secondResult)
|
||||||
|
assertEquals(true, secondResult.successValue)
|
||||||
|
|
||||||
|
staleExecutor.runNext()
|
||||||
|
val currentExecutor = pluginIoExecutor(currentPlugin)
|
||||||
|
currentPlugin.onDetachedFromEngine(currentBinding)
|
||||||
|
assertTrue(currentExecutor.awaitTermination(2, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneSynchronizationDeadlineCoversSerialArtworkAndAbortsDripResponses() {
|
||||||
|
val server = ServerSocket(0, 2, InetAddress.getByName("127.0.0.1"))
|
||||||
|
val responder = thread(start = true, name = "system-shelf-drip-test-http") {
|
||||||
|
runCatching {
|
||||||
|
repeat(2) { requestIndex ->
|
||||||
|
server.accept().use { socket ->
|
||||||
|
val reader = socket.getInputStream().bufferedReader()
|
||||||
|
while (reader.readLine()?.isNotEmpty() == true) {
|
||||||
|
// Consume request headers.
|
||||||
|
}
|
||||||
|
val output = socket.getOutputStream()
|
||||||
|
if (requestIndex == 0) {
|
||||||
|
Thread.sleep(500)
|
||||||
|
output.write(
|
||||||
|
(
|
||||||
|
"HTTP/1.1 200 OK\r\n" +
|
||||||
|
"Content-Type: image/png\r\n" +
|
||||||
|
"Content-Length: ${imageBytes.size}\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).toByteArray()
|
||||||
|
)
|
||||||
|
output.write(imageBytes)
|
||||||
|
output.flush()
|
||||||
|
} else {
|
||||||
|
output.write(
|
||||||
|
(
|
||||||
|
"HTTP/1.1 200 OK\r\n" +
|
||||||
|
"Content-Type: image/png\r\n" +
|
||||||
|
"Transfer-Encoding: chunked\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).toByteArray()
|
||||||
|
)
|
||||||
|
repeat(100) {
|
||||||
|
output.write("1\r\nX\r\n".toByteArray())
|
||||||
|
output.flush()
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val lease = SystemShelfLifecycle.acquire()
|
||||||
|
val provider = WatchNextProvider(context, lease, syncDurationMillis = 800)
|
||||||
|
val source = "http://127.0.0.1:${server.localPort}"
|
||||||
|
val started = System.nanoTime()
|
||||||
|
val committed = provider.syncWatchNextPrograms(
|
||||||
|
"owner",
|
||||||
|
1,
|
||||||
|
listOf(item("$source/first"), item("$source/second").copy(contentId = "second"))
|
||||||
|
)
|
||||||
|
val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)
|
||||||
|
|
||||||
|
assertFalse(committed)
|
||||||
|
assertTrue(tvProvider.inserted.isEmpty())
|
||||||
|
assertTrue("shared deadline took ${elapsedMillis}ms", elapsedMillis < 1_100)
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
responder.join(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedOrUnwritableImageBytesStillConsumeTheSharedSyncBudget() {
|
||||||
|
val malformed = "not an image".toByteArray()
|
||||||
|
withServer("image/png", malformed) { source ->
|
||||||
|
val budget = SystemShelfArtworkStore.Budget(100)
|
||||||
|
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
|
||||||
|
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
|
||||||
|
|
||||||
|
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
|
||||||
|
assertEquals(malformed.size.toLong(), budget.consumed)
|
||||||
|
assertEquals(100 - malformed.size, budget.remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
val blockedRoot = context.cacheDir.resolve("system_shelf_artwork")
|
||||||
|
blockedRoot.writeText("not a directory")
|
||||||
|
withServer("image/png", imageBytes) { source ->
|
||||||
|
val budget = SystemShelfArtworkStore.Budget(100)
|
||||||
|
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
|
||||||
|
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
|
||||||
|
|
||||||
|
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
|
||||||
|
assertEquals(imageBytes.size.toLong(), budget.consumed)
|
||||||
|
assertEquals(100 - imageBytes.size, budget.remaining)
|
||||||
|
}
|
||||||
|
blockedRoot.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlySelectedHomeHandlerIsAnArtworkGrantConsumer() {
|
||||||
|
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||||
|
val selected = registerHandler(homeIntent, "selected.home.launcher")
|
||||||
|
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
|
||||||
|
selectDefaultHome(selected, selected, inactive)
|
||||||
|
registerHandler(
|
||||||
|
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER),
|
||||||
|
"unrelated.leanback.app"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf("selected.home.launcher"), WatchNextProvider(context).consumerPackages())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bootRestoresConfinedArtworkGrantOnlyToSelectedHomeLauncher() {
|
||||||
|
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||||
|
val selected = registerHandler(homeIntent, "selected.home.launcher")
|
||||||
|
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
|
||||||
|
selectDefaultHome(selected, selected, inactive)
|
||||||
|
val owner = "a".repeat(64)
|
||||||
|
val key = "${"b".repeat(32)}.art"
|
||||||
|
context.cacheDir.resolve("system_shelf_artwork/$owner").mkdirs()
|
||||||
|
context.cacheDir.resolve("system_shelf_artwork/$owner/$key").writeBytes(imageBytes)
|
||||||
|
val valid = SystemShelfArtworkStore(context.cacheDir).contentUri(owner, key)
|
||||||
|
val invalid = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/not/confined.art")
|
||||||
|
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||||
|
.putStringSet("granted_uris", setOf(valid.toString(), invalid.toString()))
|
||||||
|
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
|
||||||
|
.commit()
|
||||||
|
val recordingContext = RecordingGrantContext(context)
|
||||||
|
|
||||||
|
SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||||
|
.onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(Grant("selected.home.launcher", valid, Intent.FLAG_GRANT_READ_URI_PERMISSION)),
|
||||||
|
recordingContext.grants
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(valid.toString()),
|
||||||
|
context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", emptySet())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(sdk = [25])
|
||||||
|
fun api25RevocationUsesUriWideFallback() {
|
||||||
|
val stale = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/stale/file.art")
|
||||||
|
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||||
|
.putStringSet("granted_uris", setOf(stale.toString()))
|
||||||
|
.putStringSet("granted_packages", setOf("selected.home.launcher"))
|
||||||
|
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
|
||||||
|
.commit()
|
||||||
|
val recordingContext = RecordingGrantContext(context)
|
||||||
|
|
||||||
|
assertTrue(WatchNextProvider.forMaintenance(recordingContext).restoreReadGrants())
|
||||||
|
|
||||||
|
assertEquals(listOf(stale), recordingContext.uriWideRevocations)
|
||||||
|
assertTrue(recordingContext.packageRevocations.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
|
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
|
||||||
withServer("image/png", imageBytes) { source ->
|
withServer("image/png", imageBytes) { source ->
|
||||||
@@ -116,6 +536,22 @@ class WatchNextProviderTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() {
|
||||||
|
val file = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}/${"b".repeat(32)}.art")
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeBytes(imageBytes)
|
||||||
|
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||||
|
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
|
||||||
|
.commit()
|
||||||
|
|
||||||
|
val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||||
|
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||||
|
|
||||||
|
assertEquals(0, tvProvider.deleteCount)
|
||||||
|
assertTrue(file.isFile)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
|
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
|
||||||
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
|
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
|
||||||
@@ -126,6 +562,80 @@ class WatchNextProviderTest {
|
|||||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
||||||
|
val provider = WatchNextProvider(context)
|
||||||
|
withServer("image/png", imageBytes) { source ->
|
||||||
|
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||||
|
}
|
||||||
|
val committedArtwork = artworkFiles().single().canonicalFile
|
||||||
|
tvProvider.failBatch = true
|
||||||
|
|
||||||
|
withServer("image/png", imageBytes) { source ->
|
||||||
|
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source))))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerHandler(intent: Intent, packageName: String): ComponentName {
|
||||||
|
val component = ComponentName(packageName, "$packageName.HomeActivity")
|
||||||
|
val result = ResolveInfo().apply {
|
||||||
|
activityInfo = ActivityInfo().apply {
|
||||||
|
this.packageName = component.packageName
|
||||||
|
name = component.className
|
||||||
|
applicationInfo = ApplicationInfo().apply { this.packageName = component.packageName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shadowOf(context.packageManager).addResolveInfoForIntent(intent, result)
|
||||||
|
return component
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectDefaultHome(selected: ComponentName, vararg candidates: ComponentName) {
|
||||||
|
val filter = IntentFilter(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) }
|
||||||
|
context.packageManager.addPreferredActivity(filter, IntentFilter.MATCH_CATEGORY_EMPTY, candidates, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun artworkFiles() = context.cacheDir.resolve("system_shelf_artwork").walkTopDown().filter { it.isFile }.toList()
|
||||||
|
|
||||||
|
private fun pluginBinding(): FlutterPlugin.FlutterPluginBinding {
|
||||||
|
val messenger = Proxy.newProxyInstance(
|
||||||
|
BinaryMessenger::class.java.classLoader,
|
||||||
|
arrayOf(BinaryMessenger::class.java)
|
||||||
|
) { _, _, _ -> null } as BinaryMessenger
|
||||||
|
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
|
||||||
|
val arguments = constructor.parameterTypes.map { type ->
|
||||||
|
when {
|
||||||
|
Context::class.java.isAssignableFrom(type) -> context
|
||||||
|
BinaryMessenger::class.java.isAssignableFrom(type) -> messenger
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.toTypedArray()
|
||||||
|
return constructor.newInstance(*arguments) as FlutterPlugin.FlutterPluginBinding
|
||||||
|
}
|
||||||
|
private fun syncCall(ownerId: String, generation: Long) = MethodCall(
|
||||||
|
"sync",
|
||||||
|
mapOf(
|
||||||
|
"schemaVersion" to 2,
|
||||||
|
"ownerId" to ownerId,
|
||||||
|
"generation" to generation,
|
||||||
|
"items" to emptyList<Map<String, Any?>>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun awaitResult(result: RecordingResult) {
|
||||||
|
repeat(100) {
|
||||||
|
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
if (result.completed.await(10, TimeUnit.MILLISECONDS)) return
|
||||||
|
}
|
||||||
|
assertTrue("Watch Next result never completed", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pluginIoExecutor(plugin: WatchNextPlugin): ExecutorService = WatchNextPlugin::class.java.getDeclaredField("ioExecutor").run {
|
||||||
|
isAccessible = true
|
||||||
|
get(plugin) as ExecutorService
|
||||||
|
}
|
||||||
|
|
||||||
private fun item(source: String) = WatchNextProvider.WatchNextItem(
|
private fun item(source: String) = WatchNextProvider.WatchNextItem(
|
||||||
contentId = "plezy_server_item",
|
contentId = "plezy_server_item",
|
||||||
title = "Private title",
|
title = "Private title",
|
||||||
@@ -177,10 +687,39 @@ class WatchNextProviderTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class Grant(val packageName: String, val uri: Uri, val modeFlags: Int)
|
||||||
|
|
||||||
|
private data class PackageRevocation(val packageName: String, val uri: Uri, val modeFlags: Int)
|
||||||
|
|
||||||
|
private class RecordingGrantContext(base: Context) : ContextWrapper(base) {
|
||||||
|
val grants = mutableListOf<Grant>()
|
||||||
|
val uriWideRevocations = mutableListOf<Uri>()
|
||||||
|
val packageRevocations = mutableListOf<PackageRevocation>()
|
||||||
|
|
||||||
|
override fun getApplicationContext(): Context = this
|
||||||
|
|
||||||
|
override fun grantUriPermission(toPackage: String?, uri: Uri?, modeFlags: Int) {
|
||||||
|
if (toPackage != null && uri != null) grants += Grant(toPackage, uri, modeFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun revokeUriPermission(uri: Uri?, modeFlags: Int) {
|
||||||
|
if (uri != null) uriWideRevocations += uri
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun revokeUriPermission(targetPackage: String?, uri: Uri?, modeFlags: Int) {
|
||||||
|
if (targetPackage != null && uri != null) {
|
||||||
|
packageRevocations += PackageRevocation(targetPackage, uri, modeFlags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class CapturingTvProvider : ContentProvider() {
|
private class CapturingTvProvider : ContentProvider() {
|
||||||
val inserted = mutableListOf<ContentValues>()
|
val inserted = mutableListOf<ContentValues>()
|
||||||
var deleteCount = 0
|
var deleteCount = 0
|
||||||
|
var failBatch = false
|
||||||
|
var blockNextBatch = false
|
||||||
|
val batchStarted = CountDownLatch(1)
|
||||||
|
val releaseBatch = CountDownLatch(1)
|
||||||
override fun onCreate(): Boolean = true
|
override fun onCreate(): Boolean = true
|
||||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||||
inserted += ContentValues(values)
|
inserted += ContentValues(values)
|
||||||
@@ -191,8 +730,63 @@ private class CapturingTvProvider : ContentProvider() {
|
|||||||
inserted.clear()
|
inserted.clear()
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> = super.applyBatch(operations)
|
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
|
||||||
|
if (failBatch) throw IllegalStateException("Injected provider failure")
|
||||||
|
if (blockNextBatch) {
|
||||||
|
blockNextBatch = false
|
||||||
|
batchStarted.countDown()
|
||||||
|
releaseBatch.await(2, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
return super.applyBatch(operations)
|
||||||
|
}
|
||||||
override fun getType(uri: Uri): String? = null
|
override fun getType(uri: Uri): String? = null
|
||||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class RecordingResult : MethodChannel.Result {
|
||||||
|
val completed = CountDownLatch(1)
|
||||||
|
var successValue: Any? = null
|
||||||
|
|
||||||
|
override fun success(result: Any?) {
|
||||||
|
successValue = result
|
||||||
|
completed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||||
|
completed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun notImplemented() {
|
||||||
|
completed.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ManualExecutorService : AbstractExecutorService() {
|
||||||
|
private val tasks = ArrayDeque<Runnable>()
|
||||||
|
private var shutdown = false
|
||||||
|
|
||||||
|
override fun execute(command: Runnable) {
|
||||||
|
if (shutdown) throw RejectedExecutionException()
|
||||||
|
tasks.addLast(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shutdown() {
|
||||||
|
shutdown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shutdownNow(): MutableList<Runnable> {
|
||||||
|
shutdown = true
|
||||||
|
return tasks.toMutableList().also { tasks.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isShutdown(): Boolean = shutdown
|
||||||
|
|
||||||
|
override fun isTerminated(): Boolean = shutdown && tasks.isEmpty()
|
||||||
|
|
||||||
|
override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = isTerminated
|
||||||
|
|
||||||
|
fun runNext() {
|
||||||
|
tasks.removeFirst().run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssAddFont(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssClearFonts(JNIEnv* env, jclass clazz, jlong ass) {
|
||||||
|
if (ass) {
|
||||||
|
ass_clear_fonts((ASS_Library*)ass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssDeinit(JNIEnv* env, jclass clazz, jlong ass) {
|
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssDeinit(JNIEnv* env, jclass clazz, jlong ass) {
|
||||||
if (ass) {
|
if (ass) {
|
||||||
ass_library_done((ASS_Library*)ass);
|
ass_library_done((ASS_Library*)ass);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class Ass {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun nativeAssAddFont(ptr: Long, name: String, buffer: ByteArray)
|
external fun nativeAssAddFont(ptr: Long, name: String, buffer: ByteArray)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun nativeAssClearFonts(ptr: Long)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun nativeAssDeinit(ptr: Long)
|
external fun nativeAssDeinit(ptr: Long)
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,12 @@ class Ass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun clearFonts() {
|
||||||
|
lock.withLock {
|
||||||
|
if (!released && nativeAss != 0L) nativeAssClearFonts(nativeAss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (released) return
|
if (released) return
|
||||||
|
|||||||
@@ -19,6 +19,39 @@ import com.edde746.plezy.libass.AssTrack
|
|||||||
import com.edde746.plezy.libass.media.parser.AssHeaderParser
|
import com.edde746.plezy.libass.media.parser.AssHeaderParser
|
||||||
import com.edde746.plezy.libass.media.widget.AssAtlasPipelineConfig
|
import com.edde746.plezy.libass.media.widget.AssAtlasPipelineConfig
|
||||||
|
|
||||||
|
internal class AssFontStore {
|
||||||
|
private val pendingFonts = mutableListOf<Pair<String, ByteArray>>()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(
|
||||||
|
name: String,
|
||||||
|
data: ByteArray,
|
||||||
|
nativeReady: Boolean,
|
||||||
|
addToNative: (String, ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
if (nativeReady) {
|
||||||
|
addToNative(name, data)
|
||||||
|
} else {
|
||||||
|
pendingFonts.add(name to data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun flush(addToNative: (String, ByteArray) -> Unit) {
|
||||||
|
pendingFonts.forEach { (name, data) -> addToNative(name, data) }
|
||||||
|
pendingFonts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun reset(nativeInitialized: Boolean, clearNative: () -> Unit) {
|
||||||
|
pendingFonts.clear()
|
||||||
|
if (nativeInitialized) clearNative()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
internal fun pendingSnapshot(): List<Pair<String, ByteArray>> = pendingFonts.toList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ASS subtitle rendering and integration with ExoPlayer.
|
* Handles ASS subtitle rendering and integration with ExoPlayer.
|
||||||
*
|
*
|
||||||
@@ -52,8 +85,8 @@ class AssHandler(
|
|||||||
/** The available ASS tracks in the current media. */
|
/** The available ASS tracks in the current media. */
|
||||||
private val availableTracks = mutableMapOf<String, AssTrack>()
|
private val availableTracks = mutableMapOf<String, AssTrack>()
|
||||||
|
|
||||||
/** Fonts encountered before any ASS track was created. Flushed in [createTrack]. */
|
/** Owns pre-track Java font buffers and the per-media native clear boundary. */
|
||||||
private val pendingFonts = mutableListOf<Pair<String, ByteArray>>()
|
internal val fontStore = AssFontStore()
|
||||||
|
|
||||||
/** The size of the video track. */
|
/** The size of the video track. */
|
||||||
var videoSize = Size.ZERO
|
var videoSize = Size.ZERO
|
||||||
@@ -119,6 +152,7 @@ class AssHandler(
|
|||||||
resetMediaState(releaseNative = true)
|
resetMediaState(releaseNative = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
private fun resetMediaState(releaseNative: Boolean) {
|
private fun resetMediaState(releaseNative: Boolean) {
|
||||||
val oldRender = render
|
val oldRender = render
|
||||||
val oldTracks = availableTracks.values.toList()
|
val oldTracks = availableTracks.values.toList()
|
||||||
@@ -127,7 +161,6 @@ class AssHandler(
|
|||||||
track = null
|
track = null
|
||||||
format = null
|
format = null
|
||||||
availableTracks.clear()
|
availableTracks.clear()
|
||||||
pendingFonts.clear()
|
|
||||||
videoSize = Size.ZERO
|
videoSize = Size.ZERO
|
||||||
renderCallback?.invoke(null)
|
renderCallback?.invoke(null)
|
||||||
|
|
||||||
@@ -135,6 +168,7 @@ class AssHandler(
|
|||||||
oldRender?.release()
|
oldRender?.release()
|
||||||
oldTracks.forEach { it.release() }
|
oldTracks.forEach { it.release() }
|
||||||
}
|
}
|
||||||
|
fontStore.reset(assDelegate.isInitialized()) { ass.clearFonts() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,10 +308,8 @@ class AssHandler(
|
|||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addFont(name: String, data: ByteArray) {
|
fun addFont(name: String, data: ByteArray) {
|
||||||
if (hasTracks()) {
|
fontStore.add(name, data, hasTracks()) { fontName, fontData ->
|
||||||
ass.addFont(name, data)
|
ass.addFont(fontName, fontData)
|
||||||
} else {
|
|
||||||
pendingFonts.add(name to data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,12 +326,7 @@ class AssHandler(
|
|||||||
createRenderIfNeeded()
|
createRenderIfNeeded()
|
||||||
|
|
||||||
// Flush any fonts that were buffered before the first track was created.
|
// Flush any fonts that were buffered before the first track was created.
|
||||||
if (pendingFonts.isNotEmpty()) {
|
fontStore.flush { name, data -> ass.addFont(name, data) }
|
||||||
for ((name, data) in pendingFonts) {
|
|
||||||
ass.addFont(name, data)
|
|
||||||
}
|
|
||||||
pendingFonts.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
val track = ass.createTrack()
|
val track = ass.createTrack()
|
||||||
if (format.initializationData.size > 0) {
|
if (format.initializationData.size > 0) {
|
||||||
@@ -375,6 +402,7 @@ class AssHandler(
|
|||||||
/**
|
/**
|
||||||
* Releases all native resources held by this handler.
|
* Releases all native resources held by this handler.
|
||||||
*/
|
*/
|
||||||
|
@Synchronized
|
||||||
fun release() {
|
fun release() {
|
||||||
videoFrameCallback = null
|
videoFrameCallback = null
|
||||||
player?.clearVideoFrameMetadataListener(videoFrameMetadataListener)
|
player?.clearVideoFrameMetadataListener(videoFrameMetadataListener)
|
||||||
|
|||||||
+48
-6
@@ -1,6 +1,8 @@
|
|||||||
package com.edde746.plezy.libass.media.extractor
|
package com.edde746.plezy.libass.media.extractor
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.ParserException
|
||||||
import androidx.media3.common.util.ParsableByteArray
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.extractor.ExtractorInput
|
import androidx.media3.extractor.ExtractorInput
|
||||||
@@ -20,6 +22,8 @@ open class AssMatroskaExtractor(
|
|||||||
|
|
||||||
private var currentAttachmentName: String? = null
|
private var currentAttachmentName: String? = null
|
||||||
private var currentAttachmentMime: String? = null
|
private var currentAttachmentMime: String? = null
|
||||||
|
internal var acceptedFontBytes = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray
|
internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray
|
||||||
|
|
||||||
@@ -75,27 +79,65 @@ open class AssMatroskaExtractor(
|
|||||||
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||||
when (id) {
|
when (id) {
|
||||||
ID_FILE_DATA -> {
|
ID_FILE_DATA -> {
|
||||||
|
if (contentSize < 0) {
|
||||||
|
throw ParserException.createForMalformedContainer(
|
||||||
|
"Negative Matroska attachment size",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val attachmentName = requireNotNull(currentAttachmentName)
|
val attachmentName = requireNotNull(currentAttachmentName)
|
||||||
val attachmentMime = requireNotNull(currentAttachmentMime)
|
val attachmentMime = requireNotNull(currentAttachmentMime)
|
||||||
|
if (attachmentMime !in fontMimeTypes) {
|
||||||
if (attachmentMime in fontMimeTypes) {
|
|
||||||
val data = ByteArray(contentSize)
|
|
||||||
input.readFully(data, 0, contentSize)
|
|
||||||
assHandler.addFont(attachmentName, data)
|
|
||||||
} else {
|
|
||||||
input.skipFully(contentSize)
|
input.skipFully(contentSize)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (contentSize == 0) {
|
||||||
|
input.skipFully(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val size = contentSize.toLong()
|
||||||
|
val rejectionReason = when {
|
||||||
|
size > MAX_FONT_BYTES -> "per-font limit"
|
||||||
|
size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (rejectionReason != null) {
|
||||||
|
onFontRejected(contentSize, acceptedFontBytes, rejectionReason)
|
||||||
|
input.skipFully(contentSize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val data = ByteArray(contentSize)
|
||||||
|
input.readFully(data, 0, contentSize)
|
||||||
|
acceptedFontBytes += size
|
||||||
|
assHandler.addFont(attachmentName, data)
|
||||||
}
|
}
|
||||||
else -> super.binaryElement(id, contentSize, input)
|
else -> super.binaryElement(id, contentSize, input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected open fun onFontRejected(
|
||||||
|
contentSize: Int,
|
||||||
|
acceptedBytes: Long,
|
||||||
|
reason: String
|
||||||
|
) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearAttachment() {
|
private fun clearAttachment() {
|
||||||
currentAttachmentName = null
|
currentAttachmentName = null
|
||||||
currentAttachmentMime = null
|
currentAttachmentMime = null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "AssMatroskaExtractor"
|
||||||
|
internal const val MAX_FONT_BYTES = 16L * 1024 * 1024
|
||||||
|
internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
|
||||||
const val ID_EBML = 0x1A45DFA3
|
const val ID_EBML = 0x1A45DFA3
|
||||||
const val ID_VIDEO = 0xE0
|
const val ID_VIDEO = 0xE0
|
||||||
const val ID_ATTACHMENTS = 0x1941A469
|
const val ID_ATTACHMENTS = 0x1941A469
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.edde746.plezy.libass.media
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AssFontStoreTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queuedFontsFlushOnceAndResetDropsPendingBuffers() {
|
||||||
|
val store = AssFontStore()
|
||||||
|
val delivered = mutableListOf<Pair<String, ByteArray>>()
|
||||||
|
val deliver: (String, ByteArray) -> Unit = { name, data -> delivered.add(name to data) }
|
||||||
|
store.add("first", byteArrayOf(1, 2), nativeReady = false, deliver)
|
||||||
|
store.add("second", byteArrayOf(3), nativeReady = false, deliver)
|
||||||
|
|
||||||
|
store.reset(nativeInitialized = false) { error("native clear must stay lazy") }
|
||||||
|
store.flush(deliver)
|
||||||
|
assertEquals(0, delivered.size)
|
||||||
|
|
||||||
|
store.add("third", byteArrayOf(4, 5), nativeReady = false, deliver)
|
||||||
|
store.flush(deliver)
|
||||||
|
store.flush(deliver)
|
||||||
|
|
||||||
|
assertEquals(1, delivered.size)
|
||||||
|
assertEquals("third", delivered.single().first)
|
||||||
|
assertArrayEquals(byteArrayOf(4, 5), delivered.single().second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetClearsNativeFontsAndNewMediaCanAddAfterward() {
|
||||||
|
val store = AssFontStore()
|
||||||
|
val delivered = mutableListOf<Pair<String, ByteArray>>()
|
||||||
|
val deliver: (String, ByteArray) -> Unit = { name, data -> delivered.add(name to data) }
|
||||||
|
var clearCount = 0
|
||||||
|
|
||||||
|
store.add("old", byteArrayOf(1), nativeReady = false, deliver)
|
||||||
|
store.flush(deliver)
|
||||||
|
store.reset(nativeInitialized = true) { clearCount++ }
|
||||||
|
|
||||||
|
store.add("new", byteArrayOf(2), nativeReady = true, deliver)
|
||||||
|
store.reset(nativeInitialized = true) { clearCount++ }
|
||||||
|
|
||||||
|
assertEquals(listOf("old", "new"), delivered.map { it.first })
|
||||||
|
assertEquals(2, clearCount)
|
||||||
|
assertEquals(0, store.pendingSnapshot().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package com.edde746.plezy.libass.media.extractor
|
||||||
|
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.DataReader
|
||||||
|
import androidx.media3.common.ParserException
|
||||||
|
import androidx.media3.extractor.DefaultExtractorInput
|
||||||
|
import androidx.media3.extractor.ExtractorInput
|
||||||
|
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
|
||||||
|
import com.edde746.plezy.libass.media.AssHandler
|
||||||
|
import java.io.EOFException
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AssMatroskaExtractorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() {
|
||||||
|
val handler = AssHandler()
|
||||||
|
val extractor = extractor(handler)
|
||||||
|
|
||||||
|
attachment(extractor, "font/ttf")
|
||||||
|
extractor.fileData(4, input(4, seed = 11))
|
||||||
|
attachment(extractor, "application/octet-stream")
|
||||||
|
extractor.fileData(9, input(9, seed = 22))
|
||||||
|
|
||||||
|
val pending = handler.fontStore.pendingSnapshot()
|
||||||
|
assertEquals(1, pending.size)
|
||||||
|
assertEquals("fixture-font", pending.single().first)
|
||||||
|
assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second)
|
||||||
|
assertEquals(4L, extractor.acceptedFontBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() {
|
||||||
|
val handler = AssHandler()
|
||||||
|
val extractor = extractor(handler)
|
||||||
|
val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
|
||||||
|
|
||||||
|
attachment(extractor, "font/otf")
|
||||||
|
extractor.fileData(limit, input(limit))
|
||||||
|
attachment(extractor, "font/otf")
|
||||||
|
extractor.fileData(limit + 1, input(limit + 1))
|
||||||
|
|
||||||
|
assertEquals(limit.toLong(), extractor.acceptedFontBytes)
|
||||||
|
assertEquals(1, handler.fontStore.pendingSnapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aggregateLimitIsDeterministicAcrossAttachmentEntries() {
|
||||||
|
val handler = AssHandler()
|
||||||
|
val extractor = extractor(handler)
|
||||||
|
val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
attachment(extractor, "font/ttf")
|
||||||
|
extractor.fileData(perFont, input(perFont, seed = it))
|
||||||
|
extractor.endAttachment()
|
||||||
|
}
|
||||||
|
attachment(extractor, "font/ttf")
|
||||||
|
extractor.fileData(1, input(1))
|
||||||
|
|
||||||
|
assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes)
|
||||||
|
assertEquals(2, handler.fontStore.pendingSnapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negativeAndZeroSizesAllocateAndDeliverNothing() {
|
||||||
|
val handler = AssHandler()
|
||||||
|
val extractor = extractor(handler)
|
||||||
|
attachment(extractor, "font/woff2")
|
||||||
|
|
||||||
|
assertThrows(ParserException::class.java) {
|
||||||
|
extractor.fileData(-1, input(0))
|
||||||
|
}
|
||||||
|
attachment(extractor, "font/woff2")
|
||||||
|
extractor.fileData(0, input(0))
|
||||||
|
|
||||||
|
assertEquals(0L, extractor.acceptedFontBytes)
|
||||||
|
assertEquals(0, handler.fontStore.pendingSnapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() {
|
||||||
|
val handler = AssHandler()
|
||||||
|
val extractor = extractor(handler)
|
||||||
|
attachment(extractor, "font/ttf")
|
||||||
|
|
||||||
|
assertThrows(EOFException::class.java) {
|
||||||
|
extractor.fileData(1024, input(1024, available = 4))
|
||||||
|
}
|
||||||
|
assertEquals(0L, extractor.acceptedFontBytes)
|
||||||
|
assertEquals(0, handler.fontStore.pendingSnapshot().size)
|
||||||
|
|
||||||
|
attachment(extractor, "font/ttf")
|
||||||
|
extractor.fileData(1024, input(1024))
|
||||||
|
assertEquals(1024L, extractor.acceptedFontBytes)
|
||||||
|
assertEquals(1, handler.fontStore.pendingSnapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractor(handler: AssHandler) = TestExtractor(handler)
|
||||||
|
|
||||||
|
private fun attachment(extractor: TestExtractor, mime: String) {
|
||||||
|
extractor.setAttachment(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestExtractor(handler: AssHandler) :
|
||||||
|
AssMatroskaExtractor(
|
||||||
|
DefaultSubtitleParserFactory(),
|
||||||
|
handler
|
||||||
|
) {
|
||||||
|
fun setAttachment(mime: String) {
|
||||||
|
startMasterElement(ID_ATTACHED_FILE, 0, 0)
|
||||||
|
stringElement(ID_FILE_NAME, "fixture-font")
|
||||||
|
stringElement(ID_FILE_MIME_TYPE, mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileData(contentSize: Int, input: ExtractorInput) {
|
||||||
|
binaryElement(ID_FILE_DATA, contentSize, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endAttachment() {
|
||||||
|
endMasterElement(ID_ATTACHED_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun input(
|
||||||
|
declared: Int,
|
||||||
|
available: Int = declared,
|
||||||
|
seed: Int = 0
|
||||||
|
): ExtractorInput = DefaultExtractorInput(
|
||||||
|
PatternDataReader(available, seed),
|
||||||
|
0,
|
||||||
|
declared.toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class PatternDataReader(
|
||||||
|
private val size: Int,
|
||||||
|
private val seed: Int
|
||||||
|
) : DataReader {
|
||||||
|
private var position = 0
|
||||||
|
|
||||||
|
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||||
|
if (position >= size) return C.RESULT_END_OF_INPUT
|
||||||
|
val count = minOf(length, size - position)
|
||||||
|
for (index in 0 until count) {
|
||||||
|
buffer[offset + index] = ((seed + position + index) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
position += count
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,20 @@ import UIKit
|
|||||||
/// Get total duration in seconds
|
/// Get total duration in seconds
|
||||||
var pipDuration: Double { get }
|
var pipDuration: Double { get }
|
||||||
}
|
}
|
||||||
|
protocol MpvPictureInPictureControlling: AnyObject {
|
||||||
|
var isPictureInPicturePossible: Bool { get }
|
||||||
|
func startPictureInPicture()
|
||||||
|
func stopPictureInPicture()
|
||||||
|
func setAutomaticStart(_ enabled: Bool)
|
||||||
|
func invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
extension AVPictureInPictureController: MpvPictureInPictureControlling {
|
||||||
|
func setAutomaticStart(_ enabled: Bool) {
|
||||||
|
canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||||
@@ -61,18 +75,69 @@ import UIKit
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private var pipController: AVPictureInPictureController?
|
private var pipController: MpvPictureInPictureControlling?
|
||||||
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
|
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
|
||||||
weak var delegate: MpvPipDelegate?
|
weak var delegate: MpvPipDelegate?
|
||||||
|
private var startGeneration = 0
|
||||||
|
private var pendingStartCompletion: ((Bool) -> Void)?
|
||||||
|
private var startRequested = false
|
||||||
|
private var systemStartExpected = false
|
||||||
|
private var hasActiveSession = false
|
||||||
|
private var restoreRequested = false
|
||||||
|
private var isTornDown = false
|
||||||
|
private let readinessOverride: (() -> (possible: Bool, timebase: Bool, frame: Bool))?
|
||||||
|
private let retryScheduler: (@escaping () -> Void) -> Void
|
||||||
|
private let startTimeoutScheduler: (@escaping () -> Void) -> Void
|
||||||
|
private let replacementControllerFactory: ((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)?
|
||||||
|
private var autoStartEnabled = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||||
self.sampleBufferLayer = sampleBufferDisplayLayer
|
self.sampleBufferLayer = sampleBufferDisplayLayer
|
||||||
|
self.readinessOverride = nil
|
||||||
|
self.retryScheduler = { work in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||||
|
}
|
||||||
|
self.startTimeoutScheduler = { work in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
|
||||||
|
}
|
||||||
|
self.replacementControllerFactory = nil
|
||||||
super.init()
|
super.init()
|
||||||
setup()
|
setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
controller: MpvPictureInPictureControlling,
|
||||||
|
sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? = nil,
|
||||||
|
readiness: @escaping () -> (possible: Bool, timebase: Bool, frame: Bool),
|
||||||
|
retryScheduler: @escaping (@escaping () -> Void) -> Void,
|
||||||
|
startTimeoutScheduler: @escaping (@escaping () -> Void) -> Void = { work in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
|
||||||
|
},
|
||||||
|
replacementControllerFactory:
|
||||||
|
((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)? = nil
|
||||||
|
) {
|
||||||
|
self.pipController = controller
|
||||||
|
self.sampleBufferLayer = sampleBufferDisplayLayer
|
||||||
|
self.readinessOverride = readiness
|
||||||
|
self.retryScheduler = retryScheduler
|
||||||
|
self.startTimeoutScheduler = startTimeoutScheduler
|
||||||
|
self.replacementControllerFactory = replacementControllerFactory
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let completion = pendingStartCompletion {
|
||||||
|
pendingStartCompletion = nil
|
||||||
|
if Thread.isMainThread {
|
||||||
|
completion(false)
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { completion(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setup() {
|
private func setup() {
|
||||||
guard #available(iOS 15.0, *) else { return }
|
guard #available(iOS 15.0, *) else { return }
|
||||||
|
|
||||||
@@ -98,14 +163,17 @@ import UIKit
|
|||||||
)
|
)
|
||||||
self.delegateHelper = helper
|
self.delegateHelper = helper
|
||||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||||
pipController?.delegate = helper
|
(pipController as? AVPictureInPictureController)?.delegate = helper
|
||||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
pipController?.setAutomaticStart(autoStartEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
|
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
|
||||||
func setAutoStart(_ enabled: Bool) {
|
func setAutoStart(_ enabled: Bool) {
|
||||||
guard #available(iOS 14.2, *) else { return }
|
guard !isTornDown else { return }
|
||||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
let wasEnabled = autoStartEnabled
|
||||||
|
autoStartEnabled = enabled
|
||||||
|
if enabled && !wasEnabled { systemStartExpected = false }
|
||||||
|
pipController?.setAutomaticStart(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
|
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
|
||||||
@@ -126,63 +194,219 @@ import UIKit
|
|||||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
|
fileprivate func isCurrentController(_ controller: MpvPictureInPictureControlling) -> Bool {
|
||||||
/// readiness check since the scene is about to deactivate.
|
guard let pipController else { return false }
|
||||||
|
return ObjectIdentifier(pipController) == ObjectIdentifier(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retireCurrentPipController() {
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
(delegateHelper as? PipDelegateHelper)?.controller = nil
|
||||||
|
(pipController as? AVPictureInPictureController)?.delegate = nil
|
||||||
|
}
|
||||||
|
pipController?.setAutomaticStart(false)
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
pipController = nil
|
||||||
|
delegateHelper = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recreatePipController() {
|
||||||
|
if let replacementControllerFactory {
|
||||||
|
pipController = replacementControllerFactory(sampleBufferLayer)
|
||||||
|
pipController?.setAutomaticStart(autoStartEnabled)
|
||||||
|
} else if sampleBufferLayer != nil {
|
||||||
|
createPipController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishStart(generation: Int, success: Bool) {
|
||||||
|
guard generation == startGeneration, let completion = pendingStartCompletion else { return }
|
||||||
|
pendingStartCompletion = nil
|
||||||
|
startRequested = false
|
||||||
|
completion(success)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingStart() {
|
||||||
|
startGeneration &+= 1
|
||||||
|
startRequested = false
|
||||||
|
guard let completion = pendingStartCompletion else { return }
|
||||||
|
pendingStartCompletion = nil
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readiness(waitForFrame: Bool) -> (possible: Bool, timebase: Bool, frame: Bool) {
|
||||||
|
if let readinessOverride {
|
||||||
|
return readinessOverride()
|
||||||
|
}
|
||||||
|
let possible = pipController?.isPictureInPicturePossible ?? false
|
||||||
|
let timebase = sampleBufferLayer?.controlTimebase != nil
|
||||||
|
let frame: Bool
|
||||||
|
if !waitForFrame {
|
||||||
|
frame = true
|
||||||
|
} else if #available(iOS 17.4, *) {
|
||||||
|
frame = sampleBufferLayer?.isReadyForDisplay ?? false
|
||||||
|
} else {
|
||||||
|
frame = true
|
||||||
|
}
|
||||||
|
return (possible, timebase, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleStartTimeout(
|
||||||
|
generation: Int,
|
||||||
|
controllerIdentifier: ObjectIdentifier
|
||||||
|
) {
|
||||||
|
startTimeoutScheduler { [weak self] in
|
||||||
|
guard let self, !isTornDown, generation == startGeneration,
|
||||||
|
startRequested, let completion = pendingStartCompletion,
|
||||||
|
let pipController,
|
||||||
|
ObjectIdentifier(pipController) == controllerIdentifier
|
||||||
|
else { return }
|
||||||
|
print("[MpvPipController] PiP start produced no delegate outcome before the deadline")
|
||||||
|
pendingStartCompletion = nil
|
||||||
|
startRequested = false
|
||||||
|
systemStartExpected = false
|
||||||
|
retireCurrentPipController()
|
||||||
|
recreatePipController()
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryStart(generation: Int, waitForFrame: Bool, attempts: Int) {
|
||||||
|
guard !isTornDown, generation == startGeneration, pendingStartCompletion != nil,
|
||||||
|
let pipController
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let readiness = readiness(waitForFrame: waitForFrame)
|
||||||
|
if readiness.possible && readiness.timebase && readiness.frame {
|
||||||
|
guard !startRequested else { return }
|
||||||
|
startRequested = true
|
||||||
|
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
|
||||||
|
pipController.startPictureInPicture()
|
||||||
|
scheduleStartTimeout(
|
||||||
|
generation: generation,
|
||||||
|
controllerIdentifier: ObjectIdentifier(pipController)
|
||||||
|
)
|
||||||
|
} else if attempts < 40 {
|
||||||
|
retryScheduler { [weak self] in
|
||||||
|
self?.retryStart(
|
||||||
|
generation: generation, waitForFrame: waitForFrame, attempts: attempts + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
"[MpvPipController] PiP not ready after \(attempts) retries "
|
||||||
|
+ "(possible=\(readiness.possible), timebase=\(readiness.timebase))"
|
||||||
|
)
|
||||||
|
finishStart(generation: generation, success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureWillStart(from controller: MpvPictureInPictureControlling) {
|
||||||
|
guard !isTornDown, isCurrentController(controller) else { return }
|
||||||
|
systemStartExpected = true
|
||||||
|
delegate?.pipWillStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureWillStart() {
|
||||||
|
guard let pipController else { return }
|
||||||
|
pictureInPictureWillStart(from: pipController)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureDidStart(from controller: MpvPictureInPictureControlling) {
|
||||||
|
guard !isTornDown, isCurrentController(controller) else { return }
|
||||||
|
guard systemStartExpected || pendingStartCompletion != nil else {
|
||||||
|
controller.stopPictureInPicture()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasActiveSession = true
|
||||||
|
systemStartExpected = false
|
||||||
|
// Resolve the pending manual method call before the delegate publishes
|
||||||
|
// PiP state: the plugin's delegate path may suspend the application.
|
||||||
|
finishStart(generation: startGeneration, success: true)
|
||||||
|
delegate?.pipDidStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureDidStart() {
|
||||||
|
guard let pipController else { return }
|
||||||
|
pictureInPictureDidStart(from: pipController)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureFailedToStart(
|
||||||
|
from controller: MpvPictureInPictureControlling,
|
||||||
|
error: Error
|
||||||
|
) {
|
||||||
|
guard !isTornDown, isCurrentController(controller),
|
||||||
|
systemStartExpected || pendingStartCompletion != nil
|
||||||
|
else { return }
|
||||||
|
systemStartExpected = false
|
||||||
|
delegate?.pipDidFailToStart(error: error)
|
||||||
|
finishStart(generation: startGeneration, success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureFailedToStart(error: Error) {
|
||||||
|
guard let pipController else { return }
|
||||||
|
pictureInPictureFailedToStart(from: pipController, error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureDidStop(from controller: MpvPictureInPictureControlling) {
|
||||||
|
guard !isTornDown, isCurrentController(controller), hasActiveSession else { return }
|
||||||
|
hasActiveSession = false
|
||||||
|
let restored = restoreRequested
|
||||||
|
restoreRequested = false
|
||||||
|
delegate?.pipDidStop(restored: restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureDidStop() {
|
||||||
|
guard let pipController else { return }
|
||||||
|
pictureInPictureDidStop(from: pipController)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreUserInterface(completion: @escaping (Bool) -> Void) {
|
||||||
|
let canRestore = !isTornDown && hasActiveSession && delegate != nil
|
||||||
|
restoreRequested = canRestore
|
||||||
|
completion(canRestore)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start PiP. Completion reports the delegate-confirmed terminal outcome.
|
||||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||||
guard let pipController = pipController else {
|
guard !isTornDown, pipController != nil else {
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard pendingStartCompletion == nil else {
|
||||||
var attempts = 0
|
completion(false)
|
||||||
func tryStart() {
|
return
|
||||||
let possible = pipController.isPictureInPicturePossible
|
|
||||||
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
|
|
||||||
|
|
||||||
let hasFrame: Bool
|
|
||||||
if !waitForFrame {
|
|
||||||
hasFrame = true // Skip frame check for auto-PiP
|
|
||||||
} else if #available(iOS 17.4, *) {
|
|
||||||
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
|
|
||||||
} else {
|
|
||||||
hasFrame = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if possible && hasTimebase && hasFrame {
|
|
||||||
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
|
|
||||||
pipController.startPictureInPicture()
|
|
||||||
completion(true)
|
|
||||||
} else if attempts < 40 {
|
|
||||||
attempts += 1
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
|
|
||||||
} else {
|
|
||||||
print(
|
|
||||||
"[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))"
|
|
||||||
)
|
|
||||||
completion(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tryStart()
|
startGeneration &+= 1
|
||||||
|
let generation = startGeneration
|
||||||
|
pendingStartCompletion = completion
|
||||||
|
startRequested = false
|
||||||
|
systemStartExpected = false
|
||||||
|
retryStart(generation: generation, waitForFrame: waitForFrame, attempts: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopPip() {
|
func stopPip() {
|
||||||
|
cancelPendingStart()
|
||||||
|
systemStartExpected = false
|
||||||
|
restoreRequested = false
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate the playback state so PiP updates its UI (play/pause button)
|
/// Invalidate the playback state so PiP updates its UI (play/pause button)
|
||||||
func invalidatePlaybackState() {
|
func invalidatePlaybackState() {
|
||||||
guard #available(iOS 15.0, *) else { return }
|
guard !isTornDown else { return }
|
||||||
pipController?.invalidatePlaybackState()
|
pipController?.invalidatePlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fully tear down PiP without touching the shared inline display layer.
|
/// Fully tear down PiP without touching the shared inline display layer.
|
||||||
func teardown() {
|
func teardown() {
|
||||||
pipController?.stopPictureInPicture()
|
guard !isTornDown else { return }
|
||||||
if #available(iOS 14.2, *) {
|
cancelPendingStart()
|
||||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
isTornDown = true
|
||||||
}
|
systemStartExpected = false
|
||||||
pipController = nil
|
hasActiveSession = false
|
||||||
delegateHelper = nil
|
restoreRequested = false
|
||||||
|
retireCurrentPipController()
|
||||||
|
delegate = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -197,7 +421,6 @@ import UIKit
|
|||||||
AVPictureInPictureSampleBufferPlaybackDelegate
|
AVPictureInPictureSampleBufferPlaybackDelegate
|
||||||
{
|
{
|
||||||
weak var controller: MpvPipController?
|
weak var controller: MpvPipController?
|
||||||
private var isRestoring = false
|
|
||||||
|
|
||||||
init(controller: MpvPipController) {
|
init(controller: MpvPipController) {
|
||||||
self.controller = controller
|
self.controller = controller
|
||||||
@@ -210,23 +433,21 @@ import UIKit
|
|||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
print("[MpvPipController] PiP will start")
|
print("[MpvPipController] PiP will start")
|
||||||
controller?.delegate?.pipWillStart()
|
controller?.pictureInPictureWillStart(from: pictureInPictureController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerDidStartPictureInPicture(
|
func pictureInPictureControllerDidStartPictureInPicture(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
print("[MpvPipController] PiP did start")
|
print("[MpvPipController] PiP did start")
|
||||||
controller?.delegate?.pipDidStart()
|
controller?.pictureInPictureDidStart(from: pictureInPictureController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerDidStopPictureInPicture(
|
func pictureInPictureControllerDidStopPictureInPicture(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
let restored = isRestoring
|
print("[MpvPipController] PiP did stop")
|
||||||
isRestoring = false
|
controller?.pictureInPictureDidStop(from: pictureInPictureController)
|
||||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
|
||||||
controller?.delegate?.pipDidStop(restored: restored)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
@@ -234,7 +455,10 @@ import UIKit
|
|||||||
failedToStartPictureInPictureWithError error: Error
|
failedToStartPictureInPictureWithError error: Error
|
||||||
) {
|
) {
|
||||||
print("[MpvPipController] PiP failed to start: \(error)")
|
print("[MpvPipController] PiP failed to start: \(error)")
|
||||||
controller?.delegate?.pipDidFailToStart(error: error)
|
controller?.pictureInPictureFailedToStart(
|
||||||
|
from: pictureInPictureController,
|
||||||
|
error: error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
@@ -243,30 +467,43 @@ import UIKit
|
|||||||
@escaping (Bool) -> Void
|
@escaping (Bool) -> Void
|
||||||
) {
|
) {
|
||||||
print("[MpvPipController] PiP restore user interface")
|
print("[MpvPipController] PiP restore user interface")
|
||||||
isRestoring = true
|
guard let controller,
|
||||||
completionHandler(true)
|
controller.isCurrentController(pictureInPictureController)
|
||||||
|
else {
|
||||||
|
completionHandler(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.restoreUserInterface(completion: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerWillStopPictureInPicture(
|
func pictureInPictureControllerWillStopPictureInPicture(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) {
|
) {
|
||||||
|
guard controller?.isCurrentController(pictureInPictureController) == true else { return }
|
||||||
print("[MpvPipController] PiP will stop")
|
print("[MpvPipController] PiP will stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
_ pictureInPictureController: AVPictureInPictureController,
|
_ pictureInPictureController: AVPictureInPictureController,
|
||||||
setPlaying playing: Bool
|
setPlaying playing: Bool
|
||||||
) {
|
) {
|
||||||
|
guard let controller,
|
||||||
|
controller.isCurrentController(pictureInPictureController)
|
||||||
|
else { return }
|
||||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||||
controller?.delegate?.pipSetPlaying(playing)
|
controller.delegate?.pipSetPlaying(playing)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerTimeRangeForPlayback(
|
func pictureInPictureControllerTimeRangeForPlayback(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) -> CMTimeRange {
|
) -> CMTimeRange {
|
||||||
let duration = controller?.delegate?.pipDuration ?? 0
|
guard let controller,
|
||||||
|
controller.isCurrentController(pictureInPictureController)
|
||||||
|
else {
|
||||||
|
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||||
|
}
|
||||||
|
let duration = controller.delegate?.pipDuration ?? 0
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
return CMTimeRange(
|
return CMTimeRange(
|
||||||
start: .zero,
|
start: .zero,
|
||||||
@@ -279,7 +516,10 @@ import UIKit
|
|||||||
func pictureInPictureControllerIsPlaybackPaused(
|
func pictureInPictureControllerIsPlaybackPaused(
|
||||||
_ pictureInPictureController: AVPictureInPictureController
|
_ pictureInPictureController: AVPictureInPictureController
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
guard let controller,
|
||||||
|
controller.isCurrentController(pictureInPictureController)
|
||||||
|
else { return true }
|
||||||
|
return !(controller.delegate?.isPipPlaying ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pictureInPictureController(
|
func pictureInPictureController(
|
||||||
@@ -292,9 +532,15 @@ import UIKit
|
|||||||
skipByInterval skipInterval: CMTime,
|
skipByInterval skipInterval: CMTime,
|
||||||
completion completionHandler: @escaping () -> Void
|
completion completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
|
guard let controller,
|
||||||
|
controller.isCurrentController(pictureInPictureController)
|
||||||
|
else {
|
||||||
|
completionHandler()
|
||||||
|
return
|
||||||
|
}
|
||||||
let seconds = CMTimeGetSeconds(skipInterval)
|
let seconds = CMTimeGetSeconds(skipInterval)
|
||||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||||
guard let delegate = controller?.delegate else {
|
guard let delegate = controller.delegate else {
|
||||||
completionHandler()
|
completionHandler()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
|||||||
guard let window = containerView?.window ?? self.window else { return false }
|
guard let window = containerView?.window ?? self.window else { return false }
|
||||||
let displayManager = window.avDisplayManager
|
let displayManager = window.avDisplayManager
|
||||||
|
|
||||||
if width <= 0 || height <= 0 {
|
if !self.validateSideDataDimensions(width: Int64(width), height: Int64(height)) {
|
||||||
clearDisplayCriteria(displayManager, reason: "no video dimensions")
|
clearDisplayCriteria(displayManager, reason: "invalid video dimensions")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
|
|
||||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||||
eventChannel.setStreamHandler(instance)
|
eventChannel.setStreamHandler(instance)
|
||||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
pipChannel.setMethodCallHandler { [weak instance] call, result in
|
||||||
|
guard let instance else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance.handlePipCall(call, result: result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - FlutterStreamHandler
|
// MARK: - FlutterStreamHandler
|
||||||
@@ -210,6 +216,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if manual && isManualPipRequest {
|
||||||
|
result?([
|
||||||
|
"success": false, "errorCode": "failed",
|
||||||
|
"errorMessage": "A PiP start request is already pending",
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let pip = preparePip() else {
|
guard let pip = preparePip() else {
|
||||||
result?([
|
result?([
|
||||||
"success": false, "errorCode": "pip_prepare_failed",
|
"success": false, "errorCode": "pip_prepare_failed",
|
||||||
@@ -220,10 +233,18 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
|
|
||||||
isManualPipRequest = manual
|
isManualPipRequest = manual
|
||||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||||
|
guard let self else {
|
||||||
|
result?([
|
||||||
|
"success": false, "errorCode": "failed", "errorMessage": "Player disposed",
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
if started {
|
if started {
|
||||||
result?(["success": true])
|
result?(["success": true])
|
||||||
} else {
|
} else {
|
||||||
self?.cleanupPip(notify: false)
|
if self.playerCore?.isPipStarting == true {
|
||||||
|
self.cleanupPip(notify: false)
|
||||||
|
}
|
||||||
result?([
|
result?([
|
||||||
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
|
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
|
||||||
])
|
])
|
||||||
@@ -310,6 +331,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A partially torn-down core must not survive a rapid route replacement.
|
||||||
|
self.pipController?.teardown()
|
||||||
|
self.pipController = nil
|
||||||
|
self.pendingInlineRestoreAfterPip = false
|
||||||
|
self.stopPipTimebaseSync()
|
||||||
|
self.playerCore?.dispose()
|
||||||
|
self.playerCore = nil
|
||||||
|
|
||||||
let core = MpvPlayerCore()
|
let core = MpvPlayerCore()
|
||||||
core.delegate = self
|
core.delegate = self
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Libmpv
|
||||||
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@@ -47,6 +50,97 @@ final class RecordingMpvPlugin: MpvPluginShared {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class RecordingLifecycleDelegate: MpvPlayerDelegate {
|
||||||
|
private(set) var events: [String] = []
|
||||||
|
private(set) var properties: [String] = []
|
||||||
|
|
||||||
|
func onPropertyChange(name: String, value: Any?) {
|
||||||
|
properties.append(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onEvent(name: String, data: [String: Any]?) {
|
||||||
|
events.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakePictureInPictureController: MpvPictureInPictureControlling {
|
||||||
|
var isPictureInPicturePossible = false
|
||||||
|
private(set) var startCount = 0
|
||||||
|
private(set) var stopCount = 0
|
||||||
|
private(set) var automaticStartValues: [Bool] = []
|
||||||
|
private(set) var invalidateCount = 0
|
||||||
|
|
||||||
|
func startPictureInPicture() { startCount += 1 }
|
||||||
|
func stopPictureInPicture() { stopCount += 1 }
|
||||||
|
func setAutomaticStart(_ enabled: Bool) { automaticStartValues.append(enabled) }
|
||||||
|
func invalidatePlaybackState() { invalidateCount += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RecordingPipDelegate: MpvPipDelegate {
|
||||||
|
private(set) var events: [String] = []
|
||||||
|
var onDidStart: (() -> Void)?
|
||||||
|
func pipWillStart() { events.append("willStart") }
|
||||||
|
func pipDidStart() {
|
||||||
|
onDidStart?()
|
||||||
|
events.append("didStart")
|
||||||
|
}
|
||||||
|
func pipDidStop(restored: Bool) { events.append("didStop:\(restored)") }
|
||||||
|
func pipDidFailToStart(error: Error?) { events.append("failed") }
|
||||||
|
func pipSetPlaying(_ playing: Bool) {}
|
||||||
|
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { completion() }
|
||||||
|
var isPipPlaying: Bool { true }
|
||||||
|
var pipDuration: Double { 60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ReleaseTrackingCore: MpvPlayerCoreBase {
|
||||||
|
let onDeinit: () -> Void
|
||||||
|
init(onDeinit: @escaping () -> Void) {
|
||||||
|
self.onDeinit = onDeinit
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
deinit { onDeinit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ProbeURLProtocol: URLProtocol {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var startHandler: ((ProbeURLProtocol) -> Void)?
|
||||||
|
private static var stopHandler: (() -> Void)?
|
||||||
|
|
||||||
|
static func configure(
|
||||||
|
start: @escaping (ProbeURLProtocol) -> Void,
|
||||||
|
stop: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
lock.lock()
|
||||||
|
startHandler = start
|
||||||
|
stopHandler = stop
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
lock.lock()
|
||||||
|
startHandler = nil
|
||||||
|
stopHandler = nil
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
|
||||||
|
override func startLoading() {
|
||||||
|
Self.lock.lock()
|
||||||
|
let handler = Self.startHandler
|
||||||
|
Self.lock.unlock()
|
||||||
|
handler?(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func stopLoading() {
|
||||||
|
Self.lock.lock()
|
||||||
|
let handler = Self.stopHandler
|
||||||
|
Self.lock.unlock()
|
||||||
|
handler?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class MpvPlayerContractTests: XCTestCase {
|
final class MpvPlayerContractTests: XCTestCase {
|
||||||
private let failure = NSError(
|
private let failure = NSError(
|
||||||
domain: "MpvPlayerContractTests",
|
domain: "MpvPlayerContractTests",
|
||||||
@@ -108,6 +202,103 @@ final class MpvPlayerContractTests: XCTestCase {
|
|||||||
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() {
|
||||||
|
let core = MpvAudioPlayerCore()
|
||||||
|
XCTAssertTrue(core.initialize())
|
||||||
|
defer {
|
||||||
|
core.dispose()
|
||||||
|
core.queue.sync {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let queueEntered = expectation(description: "mpv queue blocked")
|
||||||
|
let releaseQueue = DispatchSemaphore(value: 0)
|
||||||
|
core.queue.async {
|
||||||
|
queueEntered.fulfill()
|
||||||
|
releaseQueue.wait()
|
||||||
|
}
|
||||||
|
wait(for: [queueEntered], timeout: 2)
|
||||||
|
|
||||||
|
let completion = expectation(description: "pause write completed")
|
||||||
|
core.setPropertyAsync("pause", value: "no") { result in
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
XCTFail("Pause write failed: \(error)")
|
||||||
|
}
|
||||||
|
completion.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes")
|
||||||
|
releaseQueue.signal()
|
||||||
|
wait(for: [completion], timeout: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOlderPauseReplyCannotOverwriteNewerUserIntent() {
|
||||||
|
let core = ControllablePropertyCore()
|
||||||
|
let olderResume = core.beginCachedPauseIntent(false)
|
||||||
|
let newerPause = core.beginCachedPauseIntent(true)
|
||||||
|
XCTAssertTrue(core.isPaused)
|
||||||
|
|
||||||
|
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
||||||
|
XCTAssertTrue(
|
||||||
|
core.isPaused,
|
||||||
|
"An older resume reply must not overwrite a newer pending pause intent"
|
||||||
|
)
|
||||||
|
|
||||||
|
core.finishCachedPauseIntent(newerPause, result: .success(()))
|
||||||
|
XCTAssertTrue(core.isPaused)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPauseObservationAndUserIntentResolveInEventOrder() {
|
||||||
|
let core = ControllablePropertyCore()
|
||||||
|
let olderResume = core.beginCachedPauseIntent(false)
|
||||||
|
|
||||||
|
core.observeCachedPauseForTesting(true)
|
||||||
|
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
||||||
|
XCTAssertTrue(
|
||||||
|
core.isPaused,
|
||||||
|
"A native pause observation must invalidate the older resume write's delayed reply"
|
||||||
|
)
|
||||||
|
|
||||||
|
let newerResume = core.beginCachedPauseIntent(false)
|
||||||
|
core.finishCachedPauseIntent(newerResume, result: .success(()))
|
||||||
|
XCTAssertFalse(
|
||||||
|
core.isPaused,
|
||||||
|
"A user intent created after the native observation must remain authoritative"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPauseObservationRetiresOutOfOrderIntentsForSuccessAndFailure() {
|
||||||
|
let newerResults: [Result<Void, Error>] = [
|
||||||
|
.success(()),
|
||||||
|
.failure(failure),
|
||||||
|
]
|
||||||
|
|
||||||
|
for newerResult in newerResults {
|
||||||
|
let core = ControllablePropertyCore()
|
||||||
|
let generationOneResume = core.beginCachedPauseIntent(false)
|
||||||
|
let generationTwoPause = core.beginCachedPauseIntent(true)
|
||||||
|
|
||||||
|
core.observeCachedPauseForTesting(true)
|
||||||
|
core.finishCachedPauseIntent(generationTwoPause, result: newerResult)
|
||||||
|
XCTAssertTrue(
|
||||||
|
core.isPaused,
|
||||||
|
"The observed native pause must survive the newer pending pause's completion"
|
||||||
|
)
|
||||||
|
|
||||||
|
core.finishCachedPauseIntent(generationOneResume, result: .success(()))
|
||||||
|
XCTAssertTrue(
|
||||||
|
core.isPaused,
|
||||||
|
"A late older resume must be inert after a newer intent resolves"
|
||||||
|
)
|
||||||
|
|
||||||
|
let postObservationResume = core.beginCachedPauseIntent(false)
|
||||||
|
core.finishCachedPauseIntent(postObservationResume, result: .success(()))
|
||||||
|
XCTAssertFalse(
|
||||||
|
core.isPaused,
|
||||||
|
"A resume created after the native observation must remain authoritative"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
||||||
let core = MpvAudioPlayerCore()
|
let core = MpvAudioPlayerCore()
|
||||||
XCTAssertTrue(core.initialize())
|
XCTAssertTrue(core.initialize())
|
||||||
@@ -152,6 +343,401 @@ final class MpvPlayerContractTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() {
|
||||||
|
let core = MpvPlayerCoreBase()
|
||||||
|
let delegate = RecordingLifecycleDelegate()
|
||||||
|
core.delegate = delegate
|
||||||
|
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
|
||||||
|
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
|
||||||
|
XCTAssertTrue(core.beginDisposal())
|
||||||
|
|
||||||
|
let drained = expectation(description: "main delivery drained")
|
||||||
|
DispatchQueue.main.async { drained.fulfill() }
|
||||||
|
wait(for: [drained], timeout: 2)
|
||||||
|
XCTAssertTrue(delegate.events.isEmpty)
|
||||||
|
XCTAssertTrue(delegate.properties.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWakeupContextDoesNotRetainCallbackTarget() {
|
||||||
|
let released = expectation(description: "callback target released")
|
||||||
|
var core: ReleaseTrackingCore? = ReleaseTrackingCore { released.fulfill() }
|
||||||
|
weak var weakCore = core
|
||||||
|
let context = MpvWakeupCallbackContext(core: core!)
|
||||||
|
|
||||||
|
core = nil
|
||||||
|
wait(for: [released], timeout: 2)
|
||||||
|
XCTAssertNil(weakCore)
|
||||||
|
context.dispatchWakeup()
|
||||||
|
context.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() {
|
||||||
|
let core = MpvAudioPlayerCore()
|
||||||
|
XCTAssertTrue(core.initialize())
|
||||||
|
core.dispose()
|
||||||
|
core.queue.sync {}
|
||||||
|
|
||||||
|
let completed = expectation(description: "unavailable property completed")
|
||||||
|
completed.assertForOverFulfill = true
|
||||||
|
var completionCount = 0
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
core.getPropertyAsync("volume") { result in
|
||||||
|
XCTAssertTrue(Thread.isMainThread)
|
||||||
|
if case .success = result { XCTFail("Expected unavailable property failure") }
|
||||||
|
completionCount += 1
|
||||||
|
completed.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait(for: [completed], timeout: 2)
|
||||||
|
XCTAssertEqual(completionCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNormalizedPlaybackDelayStringsPassThroughUnchanged() {
|
||||||
|
let core = ControllablePropertyCore()
|
||||||
|
let plugin = RecordingMpvPlugin(core: core)
|
||||||
|
let values = ["0.25", "-0.5", "0", "0.25"]
|
||||||
|
|
||||||
|
for value in values {
|
||||||
|
core.nextResult = .success(())
|
||||||
|
let result = invokeSetProperty(plugin, name: "audio-delay", value: value)
|
||||||
|
XCTAssertEqual(result.count, 1)
|
||||||
|
XCTAssertNil(result[0])
|
||||||
|
}
|
||||||
|
XCTAssertEqual(core.propertyCalls.map(\.1), values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNodeConversionBoundsAndDiscardsMalformedSiblings() {
|
||||||
|
let core = MpvPlayerCoreBase()
|
||||||
|
var valid = mpv_node()
|
||||||
|
valid.format = MPV_FORMAT_INT64
|
||||||
|
valid.u.int64 = 7
|
||||||
|
var malformed = mpv_node()
|
||||||
|
malformed.format = MPV_FORMAT_NONE
|
||||||
|
var values = [valid, malformed, valid]
|
||||||
|
var decoded: Any?
|
||||||
|
|
||||||
|
let valueCount = values.count
|
||||||
|
values.withUnsafeMutableBufferPointer { valuesPointer in
|
||||||
|
var list = mpv_node_list()
|
||||||
|
list.num = Int32(valueCount)
|
||||||
|
list.values = valuesPointer.baseAddress
|
||||||
|
withUnsafeMutablePointer(to: &list) { listPointer in
|
||||||
|
var root = mpv_node()
|
||||||
|
root.format = MPV_FORMAT_NODE_ARRAY
|
||||||
|
root.u.list = listPointer
|
||||||
|
decoded = core.convertNode(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded as? [Int64], [7, 7])
|
||||||
|
|
||||||
|
var oversizedBytes = mpv_byte_array()
|
||||||
|
oversizedBytes.size = 16 * 1_024 * 1_024 + 1
|
||||||
|
withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in
|
||||||
|
var root = mpv_node()
|
||||||
|
root.format = MPV_FORMAT_BYTE_ARRAY
|
||||||
|
root.u.ba = bytePointer
|
||||||
|
XCTAssertNil(core.convertNode(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalidList = mpv_node_list()
|
||||||
|
invalidList.num = -1
|
||||||
|
withUnsafeMutablePointer(to: &invalidList) { listPointer in
|
||||||
|
var root = mpv_node()
|
||||||
|
root.format = MPV_FORMAT_NODE_ARRAY
|
||||||
|
root.u.list = listPointer
|
||||||
|
XCTAssertNil(core.convertNode(root))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() {
|
||||||
|
for finiteLength in [false, true] {
|
||||||
|
let loader = RawEc3Loader(
|
||||||
|
source: URL(string: "https://example.invalid/test.ec3")!,
|
||||||
|
finiteLength: finiteLength,
|
||||||
|
maximumBufferedBytes: 8,
|
||||||
|
sessionConfiguration: .ephemeral
|
||||||
|
)
|
||||||
|
let session = URLSession(configuration: .ephemeral)
|
||||||
|
let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!)
|
||||||
|
|
||||||
|
loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
||||||
|
var snapshot = loader.statusSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||||
|
XCTAssertEqual(snapshot.retainedBytes, 4)
|
||||||
|
XCTAssertNil(snapshot.errorCode)
|
||||||
|
|
||||||
|
loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9]))
|
||||||
|
snapshot = loader.statusSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||||
|
XCTAssertEqual(snapshot.retainedBytes, 0)
|
||||||
|
XCTAssertEqual(snapshot.errorCode, "response_too_large")
|
||||||
|
|
||||||
|
loader.urlSession(session, dataTask: task, didReceive: Data([10]))
|
||||||
|
let lateSnapshot = loader.statusSnapshot()
|
||||||
|
XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived)
|
||||||
|
XCTAssertEqual(lateSnapshot.retainedBytes, 0)
|
||||||
|
loader.cancel()
|
||||||
|
loader.cancel()
|
||||||
|
XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0)
|
||||||
|
let cancelledLoader = RawEc3Loader(
|
||||||
|
source: URL(string: "https://example.invalid/cancel.ec3")!,
|
||||||
|
finiteLength: finiteLength,
|
||||||
|
maximumBufferedBytes: 8,
|
||||||
|
sessionConfiguration: .ephemeral
|
||||||
|
)
|
||||||
|
cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
||||||
|
XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4)
|
||||||
|
cancelledLoader.cancel()
|
||||||
|
cancelledLoader.cancel()
|
||||||
|
let cancelledSnapshot = cancelledLoader.statusSnapshot()
|
||||||
|
XCTAssertEqual(cancelledSnapshot.retainedBytes, 0)
|
||||||
|
XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0)
|
||||||
|
session.invalidateAndCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() {
|
||||||
|
defer { ProbeURLProtocol.reset() }
|
||||||
|
for finiteLength in [false, true] {
|
||||||
|
let requestStarted = expectation(description: "probe request started")
|
||||||
|
let loaderFinished = expectation(description: "probe loader finished")
|
||||||
|
ProbeURLProtocol.configure { protocolInstance in
|
||||||
|
let response = URLResponse(
|
||||||
|
url: protocolInstance.request.url!,
|
||||||
|
mimeType: "audio/eac3",
|
||||||
|
expectedContentLength: -1,
|
||||||
|
textEncodingName: nil
|
||||||
|
)
|
||||||
|
protocolInstance.client?.urlProtocol(
|
||||||
|
protocolInstance,
|
||||||
|
didReceive: response,
|
||||||
|
cacheStoragePolicy: .notAllowed
|
||||||
|
)
|
||||||
|
protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4]))
|
||||||
|
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
|
||||||
|
requestStarted.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [ProbeURLProtocol.self]
|
||||||
|
let loader = RawEc3Loader(
|
||||||
|
source: URL(string: "https://probe.test/audio.ec3")!,
|
||||||
|
finiteLength: finiteLength,
|
||||||
|
maximumBufferedBytes: 8,
|
||||||
|
sessionConfiguration: configuration,
|
||||||
|
terminalHandlerForTesting: { loaderFinished.fulfill() }
|
||||||
|
)
|
||||||
|
loader.begin()
|
||||||
|
wait(for: [requestStarted, loaderFinished], timeout: 2)
|
||||||
|
|
||||||
|
let snapshot = loader.statusSnapshot()
|
||||||
|
XCTAssertTrue(snapshot.isFinished)
|
||||||
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
||||||
|
XCTAssertEqual(snapshot.retainedBytes, 4)
|
||||||
|
XCTAssertNil(snapshot.errorCode)
|
||||||
|
|
||||||
|
loader.cancel()
|
||||||
|
let cancelled = loader.statusSnapshot()
|
||||||
|
XCTAssertEqual(cancelled.retainedBytes, 0)
|
||||||
|
XCTAssertEqual(cancelled.pendingRequestCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipStartWaitsForDelegateAndCompletesOnce() {
|
||||||
|
let fake = FakePictureInPictureController()
|
||||||
|
fake.isPictureInPicturePossible = true
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: fake,
|
||||||
|
readiness: { (true, true, true) },
|
||||||
|
retryScheduler: { $0() }
|
||||||
|
)
|
||||||
|
let delegate = RecordingPipDelegate()
|
||||||
|
controller.delegate = delegate
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
delegate.onDidStart = {
|
||||||
|
XCTAssertEqual(results, [true], "Manual result must resolve before delegate suspension")
|
||||||
|
}
|
||||||
|
controller.startPip { results.append($0) }
|
||||||
|
XCTAssertEqual(fake.startCount, 1)
|
||||||
|
XCTAssertTrue(results.isEmpty)
|
||||||
|
controller.pictureInPictureWillStart()
|
||||||
|
controller.pictureInPictureDidStart()
|
||||||
|
XCTAssertEqual(Array(delegate.events.prefix(2)), ["willStart", "didStart"])
|
||||||
|
XCTAssertEqual(results, [true])
|
||||||
|
|
||||||
|
controller.pictureInPictureDidStart()
|
||||||
|
controller.teardown()
|
||||||
|
XCTAssertEqual(results, [true])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepeatedAutoStartDuringCurrentControllerStartDoesNotRejectDidStart() {
|
||||||
|
let fake = FakePictureInPictureController()
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: fake,
|
||||||
|
readiness: { (true, true, true) },
|
||||||
|
retryScheduler: { $0() }
|
||||||
|
)
|
||||||
|
let delegate = RecordingPipDelegate()
|
||||||
|
controller.delegate = delegate
|
||||||
|
|
||||||
|
controller.setAutoStart(true)
|
||||||
|
controller.pictureInPictureWillStart(from: fake)
|
||||||
|
controller.setAutoStart(true)
|
||||||
|
controller.pictureInPictureDidStart(from: fake)
|
||||||
|
|
||||||
|
XCTAssertEqual(fake.automaticStartValues, [true, true])
|
||||||
|
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
fake.stopCount,
|
||||||
|
0,
|
||||||
|
"Reasserting an enabled auto-start setting must not reject the in-flight system start"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipStartTimesOutWithoutDelegateOutcome() {
|
||||||
|
let fake = FakePictureInPictureController()
|
||||||
|
var timeouts: [() -> Void] = []
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: fake,
|
||||||
|
readiness: { (true, true, true) },
|
||||||
|
retryScheduler: { $0() },
|
||||||
|
startTimeoutScheduler: { timeouts.append($0) }
|
||||||
|
)
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
controller.startPip { results.append($0) }
|
||||||
|
XCTAssertEqual(fake.startCount, 1)
|
||||||
|
XCTAssertTrue(results.isEmpty)
|
||||||
|
XCTAssertEqual(timeouts.count, 1)
|
||||||
|
|
||||||
|
timeouts[0]()
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
XCTAssertEqual(fake.stopCount, 1)
|
||||||
|
|
||||||
|
controller.pictureInPictureDidStart()
|
||||||
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "late", code: 1))
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipTimeoutRecreatesControllerAndRejectsRetiredCallbacks() {
|
||||||
|
let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
|
let retired = FakePictureInPictureController()
|
||||||
|
let replacement = FakePictureInPictureController()
|
||||||
|
retired.isPictureInPicturePossible = true
|
||||||
|
replacement.isPictureInPicturePossible = true
|
||||||
|
var timeouts: [() -> Void] = []
|
||||||
|
var replacementLayers: [AVSampleBufferDisplayLayer?] = []
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: retired,
|
||||||
|
sampleBufferDisplayLayer: displayLayer,
|
||||||
|
readiness: { (true, true, true) },
|
||||||
|
retryScheduler: { $0() },
|
||||||
|
startTimeoutScheduler: { timeouts.append($0) },
|
||||||
|
replacementControllerFactory: { layer in
|
||||||
|
replacementLayers.append(layer)
|
||||||
|
return replacement
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let delegate = RecordingPipDelegate()
|
||||||
|
controller.delegate = delegate
|
||||||
|
controller.setAutoStart(true)
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
controller.startPip { results.append($0) }
|
||||||
|
XCTAssertEqual(retired.startCount, 1)
|
||||||
|
XCTAssertEqual(timeouts.count, 1)
|
||||||
|
|
||||||
|
timeouts[0]()
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
XCTAssertEqual(retired.stopCount, 1)
|
||||||
|
XCTAssertEqual(retired.automaticStartValues, [true, false])
|
||||||
|
XCTAssertEqual(replacementLayers.count, 1)
|
||||||
|
XCTAssertTrue(replacementLayers[0] === displayLayer)
|
||||||
|
XCTAssertEqual(replacement.automaticStartValues, [true])
|
||||||
|
|
||||||
|
controller.startPip { results.append($0) }
|
||||||
|
XCTAssertEqual(replacement.startCount, 1)
|
||||||
|
XCTAssertEqual(timeouts.count, 2)
|
||||||
|
|
||||||
|
controller.pictureInPictureWillStart(from: retired)
|
||||||
|
controller.pictureInPictureDidStart(from: retired)
|
||||||
|
controller.pictureInPictureFailedToStart(
|
||||||
|
from: retired,
|
||||||
|
error: NSError(domain: "late-retired-controller", code: 1)
|
||||||
|
)
|
||||||
|
controller.pictureInPictureDidStop(from: retired)
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
XCTAssertTrue(delegate.events.isEmpty)
|
||||||
|
XCTAssertEqual(retired.stopCount, 1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
replacement.startCount,
|
||||||
|
1,
|
||||||
|
"A retired controller callback must not disturb the replacement's pending start"
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.pictureInPictureWillStart(from: replacement)
|
||||||
|
controller.pictureInPictureDidStart(from: replacement)
|
||||||
|
XCTAssertEqual(results, [false, true])
|
||||||
|
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
||||||
|
XCTAssertEqual(replacementLayers.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipTeardownCancelsRetryAndLateWork() {
|
||||||
|
let fake = FakePictureInPictureController()
|
||||||
|
var possible = false
|
||||||
|
var retries: [() -> Void] = []
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: fake,
|
||||||
|
readiness: { (possible, true, true) },
|
||||||
|
retryScheduler: { retries.append($0) }
|
||||||
|
)
|
||||||
|
var results: [Bool] = []
|
||||||
|
|
||||||
|
controller.startPip { results.append($0) }
|
||||||
|
XCTAssertEqual(retries.count, 1)
|
||||||
|
controller.teardown()
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
possible = true
|
||||||
|
retries.forEach { $0() }
|
||||||
|
XCTAssertEqual(fake.startCount, 0)
|
||||||
|
XCTAssertEqual(results, [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipFailureAndLateRestoreRemainSingleShot() {
|
||||||
|
let fake = FakePictureInPictureController()
|
||||||
|
let controller = MpvPipController(
|
||||||
|
controller: fake,
|
||||||
|
readiness: { (true, true, true) },
|
||||||
|
retryScheduler: { $0() }
|
||||||
|
)
|
||||||
|
let delegate = RecordingPipDelegate()
|
||||||
|
controller.delegate = delegate
|
||||||
|
var startResults: [Bool] = []
|
||||||
|
|
||||||
|
controller.startPip { startResults.append($0) }
|
||||||
|
controller.pictureInPictureWillStart()
|
||||||
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 1))
|
||||||
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 2))
|
||||||
|
XCTAssertEqual(startResults, [false])
|
||||||
|
XCTAssertEqual(delegate.events.filter { $0 == "failed" }.count, 1)
|
||||||
|
|
||||||
|
controller.startPip { startResults.append($0) }
|
||||||
|
controller.pictureInPictureWillStart()
|
||||||
|
controller.pictureInPictureDidStart()
|
||||||
|
XCTAssertEqual(startResults, [false, true])
|
||||||
|
|
||||||
|
var restoreResults: [Bool] = []
|
||||||
|
controller.restoreUserInterface { restoreResults.append($0) }
|
||||||
|
controller.teardown()
|
||||||
|
controller.restoreUserInterface { restoreResults.append($0) }
|
||||||
|
XCTAssertEqual(restoreResults, [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
private func invokeSetProperty(
|
private func invokeSetProperty(
|
||||||
_ plugin: RecordingMpvPlugin,
|
_ plugin: RecordingMpvPlugin,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
/// Decodes an mpv node delivered either as a platform-channel value or JSON.
|
/// Decodes an mpv node delivered either as a platform-channel value or JSON.
|
||||||
|
///
|
||||||
|
/// Native payloads are bounded before traversal so a malformed backend cannot
|
||||||
|
/// turn a property update into unbounded allocation or recursion on the UI
|
||||||
|
/// isolate.
|
||||||
abstract final class MpvNodeDecoder {
|
abstract final class MpvNodeDecoder {
|
||||||
|
static const _maximumDepth = 32;
|
||||||
|
static const _maximumEntries = 16384;
|
||||||
|
static const _maximumStringBytes = 16 * 1024 * 1024;
|
||||||
|
|
||||||
static List<Object?>? decodeList(Object? value) {
|
static List<Object?>? decodeList(Object? value) {
|
||||||
final decoded = _decode(value);
|
final decoded = _decode(value);
|
||||||
return decoded is List<Object?> ? decoded : null;
|
return decoded is List<Object?> ? decoded : null;
|
||||||
@@ -13,13 +21,112 @@ abstract final class MpvNodeDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Object? _decode(Object? value) {
|
static Object? _decode(Object? value) {
|
||||||
if (value is List || value is Map) return value;
|
if (value is List || value is Map) {
|
||||||
if (value is! String || value.isEmpty) return null;
|
return _isBoundedStructure(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (value is! String || value.isEmpty || !_isPlausiblyBoundedJson(value)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return jsonDecode(value);
|
final decoded = jsonDecode(value);
|
||||||
|
return _isBoundedStructure(decoded) ? decoded : null;
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isPlausiblyBoundedJson(String value) {
|
||||||
|
if (value.length > _maximumStringBytes) return false;
|
||||||
|
|
||||||
|
var depth = 0;
|
||||||
|
var separators = 0;
|
||||||
|
var inString = false;
|
||||||
|
var escaped = false;
|
||||||
|
for (var i = 0; i < value.length; i++) {
|
||||||
|
final codeUnit = value.codeUnitAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (codeUnit == 0x5c) {
|
||||||
|
escaped = true;
|
||||||
|
} else if (codeUnit == 0x22) {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (codeUnit == 0x22) {
|
||||||
|
inString = true;
|
||||||
|
} else if (codeUnit == 0x5b || codeUnit == 0x7b) {
|
||||||
|
depth++;
|
||||||
|
if (depth > _maximumDepth) return false;
|
||||||
|
} else if (codeUnit == 0x5d || codeUnit == 0x7d) {
|
||||||
|
depth--;
|
||||||
|
if (depth < 0) return false;
|
||||||
|
} else if (codeUnit == 0x2c) {
|
||||||
|
separators++;
|
||||||
|
if (separators >= _maximumEntries) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !inString && depth == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isBoundedStructure(Object? root) {
|
||||||
|
var remainingEntries = _maximumEntries;
|
||||||
|
var remainingStringBytes = _maximumStringBytes;
|
||||||
|
final pending = <(Object?, int)>[(root, 0)];
|
||||||
|
|
||||||
|
while (pending.isNotEmpty) {
|
||||||
|
final (value, depth) = pending.removeLast();
|
||||||
|
if (remainingEntries == 0 || depth >= _maximumDepth) return false;
|
||||||
|
remainingEntries--;
|
||||||
|
|
||||||
|
if (value is String) {
|
||||||
|
final byteLength = _utf8LengthAtMost(value, remainingStringBytes);
|
||||||
|
if (byteLength == null) return false;
|
||||||
|
remainingStringBytes -= byteLength;
|
||||||
|
} else if (value is num) {
|
||||||
|
if (value is double && !value.isFinite) return false;
|
||||||
|
} else if (value is List) {
|
||||||
|
if (value.length > remainingEntries) return false;
|
||||||
|
for (var i = value.length - 1; i >= 0; i--) {
|
||||||
|
pending.add((value[i], depth + 1));
|
||||||
|
}
|
||||||
|
} else if (value is Map) {
|
||||||
|
if (value.length > remainingEntries) return false;
|
||||||
|
for (final entry in value.entries) {
|
||||||
|
final key = entry.key;
|
||||||
|
if (key is! String) return false;
|
||||||
|
final byteLength = _utf8LengthAtMost(key, remainingStringBytes);
|
||||||
|
if (byteLength == null) return false;
|
||||||
|
remainingStringBytes -= byteLength;
|
||||||
|
pending.add((entry.value, depth + 1));
|
||||||
|
}
|
||||||
|
} else if (value != null && value is! bool) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _utf8LengthAtMost(String value, int limit) {
|
||||||
|
var length = 0;
|
||||||
|
for (var i = 0; i < value.length; i++) {
|
||||||
|
final codeUnit = value.codeUnitAt(i);
|
||||||
|
if (codeUnit <= 0x7f) {
|
||||||
|
length++;
|
||||||
|
} else if (codeUnit <= 0x7ff) {
|
||||||
|
length += 2;
|
||||||
|
} else if (codeUnit >= 0xd800 &&
|
||||||
|
codeUnit <= 0xdbff &&
|
||||||
|
i + 1 < value.length &&
|
||||||
|
value.codeUnitAt(i + 1) >= 0xdc00 &&
|
||||||
|
value.codeUnitAt(i + 1) <= 0xdfff) {
|
||||||
|
length += 4;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
length += 3;
|
||||||
|
}
|
||||||
|
if (length > limit) return null;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
.read(SettingsService.subtitleRenderResolution)
|
.read(SettingsService.subtitleRenderResolution)
|
||||||
.androidRenderScale,
|
.androidRenderScale,
|
||||||
});
|
});
|
||||||
|
if (disposed) throw StateError('Player was disposed during initialization');
|
||||||
if (result != true) {
|
if (result != true) {
|
||||||
throw Exception('Failed to initialize ExoPlayer');
|
throw Exception('Failed to initialize ExoPlayer');
|
||||||
}
|
}
|
||||||
@@ -123,11 +124,23 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
// future would falsely treat as ready.
|
// future would falsely treat as ready.
|
||||||
await observeCoreProperties(trackListFormat: 'string');
|
await observeCoreProperties(trackListFormat: 'string');
|
||||||
await observeProperty('demuxer-cache-time', 'double');
|
await observeProperty('demuxer-cache-time', 'double');
|
||||||
|
if (disposed) throw StateError('Player was disposed during initialization');
|
||||||
|
|
||||||
|
// These settings can be queued before any operation initializes the
|
||||||
|
// native core. Apply the latest requested values now so ExoPlayer and
|
||||||
|
// the already-queued mpv fallback properties start in the same state.
|
||||||
|
await invoke('setAudioNormalization', {'enabled': _audioNormalizationEnabled});
|
||||||
|
await invoke('setAudioDownmix', {
|
||||||
|
'enabled': _downmixEnabled,
|
||||||
|
'centerBoostDb': _downmixCenterBoostDb,
|
||||||
|
'normalize': _downmixNormalize,
|
||||||
|
});
|
||||||
|
if (disposed) throw StateError('Player was disposed during initialization');
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_initFuture = null;
|
_initFuture = null;
|
||||||
errorController.add(PlayerError('Initialization failed: $e'));
|
if (!disposed) errorController.add(PlayerError('Initialization failed: $e'));
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-71
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart' show protected;
|
import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../media/media_display_criteria.dart';
|
import '../../media/media_display_criteria.dart';
|
||||||
@@ -48,12 +48,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
@override
|
@override
|
||||||
PlayerStreams get streams => _streams;
|
PlayerStreams get streams => _streams;
|
||||||
|
|
||||||
|
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int? get textureId => null;
|
int? get textureId => _textureId.value;
|
||||||
|
|
||||||
|
ValueListenable<int?> get textureIdListenable => _textureId;
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void setTextureId(int? value) {
|
||||||
|
if (!_disposed) _textureId.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _logSubscription;
|
StreamSubscription? _logSubscription;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
late final Future<void>? _nativeOwnershipReady;
|
||||||
|
final Completer<void> _nativeRelease = Completer<void>();
|
||||||
final _throttleSw = Stopwatch()..start();
|
final _throttleSw = Stopwatch()..start();
|
||||||
int _lastEmitMs = 0;
|
int _lastEmitMs = 0;
|
||||||
int _lastCacheStateMs = 0;
|
int _lastCacheStateMs = 0;
|
||||||
@@ -65,6 +76,36 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
bool _primaryMediaLoadStarted = false;
|
bool _primaryMediaLoadStarted = false;
|
||||||
bool _primaryMediaReadyEmitted = false;
|
bool _primaryMediaReadyEmitted = false;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static Duration debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3);
|
||||||
|
|
||||||
|
static const _maximumDurationMilliseconds = 9223372036854775;
|
||||||
|
|
||||||
|
static double? _finiteDouble(Object? value) {
|
||||||
|
if (value is! num) return null;
|
||||||
|
final result = value.toDouble();
|
||||||
|
return result.isFinite ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _millisecondsFromSeconds(Object? value, {bool round = false}) {
|
||||||
|
final seconds = _finiteDouble(value);
|
||||||
|
if (seconds == null) return null;
|
||||||
|
final milliseconds = seconds * Duration.millisecondsPerSecond;
|
||||||
|
if (!milliseconds.isFinite ||
|
||||||
|
milliseconds < -_maximumDurationMilliseconds ||
|
||||||
|
milliseconds > _maximumDurationMilliseconds) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return round ? milliseconds.round() : milliseconds.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _finiteInt(Object? value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
final result = _finiteDouble(value);
|
||||||
|
if (result == null || result < -9007199254740991 || result > 9007199254740991) return null;
|
||||||
|
return result.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
bool initialized = false;
|
bool initialized = false;
|
||||||
|
|
||||||
@@ -78,6 +119,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
String get logPrefix;
|
String get logPrefix;
|
||||||
|
|
||||||
PlayerBase() {
|
PlayerBase() {
|
||||||
|
_nativeOwnershipReady = _eventChannelOwners[eventChannel.name]?._nativeRelease.future;
|
||||||
_streams = createStreams();
|
_streams = createStreams();
|
||||||
_setupEventListener();
|
_setupEventListener();
|
||||||
_logSubscription = logController.stream.listen(_forwardToAppLogger);
|
_logSubscription = logController.stream.listen(_forwardToAppLogger);
|
||||||
@@ -161,15 +203,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
void _handleEvent(dynamic event) {
|
void _handleEvent(dynamic event) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
if (event is List && event.length == 2) {
|
if (event is List && event.length == 2) {
|
||||||
final name = _propIdToName[event.first as int];
|
final propertyId = event.first;
|
||||||
|
if (propertyId is! int) return;
|
||||||
|
final name = _propIdToName[propertyId];
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
handlePropertyChange(name, event[1]);
|
handlePropertyChange(name, event[1]);
|
||||||
}
|
}
|
||||||
} else if (event is Map) {
|
} else if (event is Map) {
|
||||||
final type = event['type'] as String?;
|
final type = event['type'];
|
||||||
final name = event['name'] as String?;
|
final name = event['name'];
|
||||||
if (type == 'event' && name != null) {
|
if (type == 'event' && name is String) {
|
||||||
handlePlayerEvent(name, event['data'] as Map?);
|
final rawData = event['data'];
|
||||||
|
handlePlayerEvent(name, rawData is Map ? rawData : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,11 +241,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'time-pos':
|
case 'time-pos':
|
||||||
if (value is num) {
|
final positionMs = _millisecondsFromSeconds(value, round: true);
|
||||||
final pos = Duration(milliseconds: (value * 1000).round());
|
if (positionMs != null) {
|
||||||
_positionMs = pos.inMilliseconds;
|
final pos = Duration(milliseconds: positionMs);
|
||||||
// Only allocate Duration + copyWith + emit at ~4Hz (250ms).
|
_positionMs = positionMs;
|
||||||
// Raw int is stored every tick so synchronous reads via _positionMs stay current.
|
// Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer
|
||||||
|
// remains current for synchronous position reads on every tick.
|
||||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||||
if (nowMs - _lastEmitMs >= 250) {
|
if (nowMs - _lastEmitMs >= 250) {
|
||||||
_lastEmitMs = nowMs;
|
_lastEmitMs = nowMs;
|
||||||
@@ -211,8 +257,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'duration':
|
case 'duration':
|
||||||
if (value is num) {
|
final durationMs = _millisecondsFromSeconds(value);
|
||||||
final duration = _timelineDuration ?? Duration(milliseconds: (value * 1000).toInt());
|
if (durationMs != null) {
|
||||||
|
final duration = _timelineDuration ?? Duration(milliseconds: durationMs);
|
||||||
_state = _state.copyWith(duration: duration);
|
_state = _state.copyWith(duration: duration);
|
||||||
durationController.add(duration);
|
durationController.add(duration);
|
||||||
}
|
}
|
||||||
@@ -225,11 +272,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'demuxer-cache-time':
|
case 'demuxer-cache-time':
|
||||||
if (value is num) {
|
final bufferMs = _millisecondsFromSeconds(value);
|
||||||
|
if (bufferMs != null) {
|
||||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||||
if (nowMs - _lastCacheStateMs < 250) break;
|
if (nowMs - _lastCacheStateMs < 250) break;
|
||||||
_lastCacheStateMs = nowMs;
|
_lastCacheStateMs = nowMs;
|
||||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
final buffer = Duration(milliseconds: bufferMs);
|
||||||
_state = _state.copyWith(buffer: buffer);
|
_state = _state.copyWith(buffer: buffer);
|
||||||
bufferController.add(buffer);
|
bufferController.add(buffer);
|
||||||
// Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
|
// Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
|
||||||
@@ -245,14 +293,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'volume':
|
case 'volume':
|
||||||
if (value is num) {
|
final volume = _finiteDouble(value);
|
||||||
setVolumeState(value.toDouble());
|
if (volume != null) {
|
||||||
|
setVolumeState(volume);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'speed':
|
case 'speed':
|
||||||
if (value is num) {
|
final rate = _finiteDouble(value);
|
||||||
final rate = value.toDouble();
|
if (rate != null) {
|
||||||
_state = _state.copyWith(rate: rate);
|
_state = _state.copyWith(rate: rate);
|
||||||
rateController.add(rate);
|
rateController.add(rate);
|
||||||
}
|
}
|
||||||
@@ -295,10 +344,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
case 'audio-device-list':
|
case 'audio-device-list':
|
||||||
final deviceList = MpvNodeDecoder.decodeList(value);
|
final deviceList = MpvNodeDecoder.decodeList(value);
|
||||||
if (deviceList != null) {
|
if (deviceList != null) {
|
||||||
final devices = deviceList
|
final devices = <AudioDevice>[];
|
||||||
.whereType<Map>()
|
for (final entry in deviceList) {
|
||||||
.map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? ''))
|
if (entry is! Map) continue;
|
||||||
.toList();
|
final name = entry['name'];
|
||||||
|
final description = entry['description'];
|
||||||
|
if (name is! String) continue;
|
||||||
|
devices.add(AudioDevice(name: name, description: description is String ? description : ''));
|
||||||
|
}
|
||||||
_state = _state.copyWith(audioDevices: devices);
|
_state = _state.copyWith(audioDevices: devices);
|
||||||
audioDevicesController.add(devices);
|
audioDevicesController.add(devices);
|
||||||
}
|
}
|
||||||
@@ -326,9 +379,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
if (cacheState == null) return;
|
if (cacheState == null) return;
|
||||||
|
|
||||||
// Extract cache-end for the single buffer duration (replaces demuxer-cache-time)
|
// Extract cache-end for the single buffer duration (replaces demuxer-cache-time)
|
||||||
final cacheEnd = cacheState['cache-end'] as num?;
|
final cacheEndMs = _millisecondsFromSeconds(cacheState['cache-end']);
|
||||||
if (cacheEnd != null) {
|
if (cacheEndMs != null) {
|
||||||
final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt());
|
final buffer = Duration(milliseconds: cacheEndMs);
|
||||||
_state = _state.copyWith(buffer: buffer);
|
_state = _state.copyWith(buffer: buffer);
|
||||||
bufferController.add(buffer);
|
bufferController.add(buffer);
|
||||||
}
|
}
|
||||||
@@ -338,17 +391,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
if (seekableRanges is List) {
|
if (seekableRanges is List) {
|
||||||
final ranges = <BufferRange>[];
|
final ranges = <BufferRange>[];
|
||||||
for (final range in seekableRanges) {
|
for (final range in seekableRanges) {
|
||||||
if (range is Map) {
|
if (range is! Map) continue;
|
||||||
final start = range['start'] as num?;
|
final startMs = _millisecondsFromSeconds(range['start']);
|
||||||
final end = range['end'] as num?;
|
final endMs = _millisecondsFromSeconds(range['end']);
|
||||||
if (start != null && end != null) {
|
if (startMs != null && endMs != null) {
|
||||||
ranges.add(
|
ranges.add(
|
||||||
BufferRange(
|
BufferRange(
|
||||||
start: Duration(milliseconds: (start * 1000).toInt()),
|
start: Duration(milliseconds: startMs),
|
||||||
end: Duration(milliseconds: (end * 1000).toInt()),
|
end: Duration(milliseconds: endMs),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_state = _state.copyWith(bufferRanges: ranges);
|
_state = _state.copyWith(bufferRanges: ranges);
|
||||||
@@ -383,8 +435,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
completedController.add(true);
|
completedController.add(true);
|
||||||
} else if (reason == 'error') {
|
} else if (reason == 'error') {
|
||||||
fileLoadFailedController.add(null);
|
fileLoadFailedController.add(null);
|
||||||
|
final rawMessage = data?['message'];
|
||||||
|
final rawCause = data?['cause'];
|
||||||
errorController.add(
|
errorController.add(
|
||||||
PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
|
PlayerError(
|
||||||
|
rawMessage is String ? rawMessage : 'Playback error',
|
||||||
|
cause: rawCause is String ? rawCause : null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -400,10 +457,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'log-message':
|
case 'log-message':
|
||||||
final prefix = data?['prefix'] as String? ?? '';
|
final rawPrefix = data?['prefix'];
|
||||||
final levelStr = data?['level'] as String? ?? 'info';
|
final rawLevel = data?['level'];
|
||||||
final text = data?['text'] as String? ?? '';
|
final rawText = data?['text'];
|
||||||
final level = parseLogLevel(levelStr);
|
final prefix = rawPrefix is String ? rawPrefix : '';
|
||||||
|
final level = parseLogLevel(rawLevel is String ? rawLevel : 'info');
|
||||||
|
final text = rawText is String ? rawText : '';
|
||||||
logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -444,37 +503,44 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
for (final track in trackList) {
|
for (final track in trackList) {
|
||||||
if (track is! Map) continue;
|
if (track is! Map) continue;
|
||||||
|
|
||||||
final type = track['type'] as String?;
|
final rawType = track['type'];
|
||||||
final id = track['id']?.toString() ?? '';
|
if (rawType is! String) continue;
|
||||||
final selected = track['selected'] as bool? ?? false;
|
final type = rawType;
|
||||||
|
final rawId = track['id'];
|
||||||
|
final id = rawId is String || rawId is num ? rawId.toString() : '';
|
||||||
|
final selected = track['selected'] == true;
|
||||||
|
|
||||||
if (type == 'audio') {
|
if (type == 'audio') {
|
||||||
if (selected) selectedAudioId = id;
|
if (selected) selectedAudioId = id;
|
||||||
audioTracks.add(
|
audioTracks.add(
|
||||||
AudioTrack(
|
AudioTrack(
|
||||||
id: id,
|
id: id,
|
||||||
title: cleanTrackMetadataValue(track['title'] as String?),
|
title: cleanTrackMetadataValue(track['title'] is String ? track['title'] as String : null),
|
||||||
language: cleanTrackMetadataValue(track['lang'] as String?),
|
language: cleanTrackMetadataValue(track['lang'] is String ? track['lang'] as String : null),
|
||||||
codec: track['codec'] as String?,
|
codec: track['codec'] is String ? track['codec'] as String : null,
|
||||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
channels: _finiteInt(track['demux-channel-count']),
|
||||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
sampleRate: _finiteInt(track['demux-samplerate']),
|
||||||
isDefault: track['default'] as bool? ?? false,
|
isDefault: track['default'] == true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (type == 'sub') {
|
} else if (type == 'sub') {
|
||||||
if (selected) selectedSubtitleId = id;
|
if (selected) selectedSubtitleId = id;
|
||||||
final codec = track['codec'] as String?;
|
final rawCodec = track['codec'];
|
||||||
final externalFilename = track['external-filename'] as String?;
|
final codec = rawCodec is String ? rawCodec : null;
|
||||||
|
final rawExternalFilename = track['external-filename'];
|
||||||
|
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
|
||||||
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
|
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
|
||||||
|
final rawTitle = track['title'];
|
||||||
|
final rawLanguage = track['lang'];
|
||||||
subtitleTracks.add(
|
subtitleTracks.add(
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
id: id,
|
id: id,
|
||||||
title: externalMetadata?.title ?? cleanSubtitleTitle(track['title'] as String?, codec: codec),
|
title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
|
||||||
language: externalMetadata?.language ?? cleanTrackMetadataValue(track['lang'] as String?),
|
language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
|
||||||
codec: externalMetadata?.codec ?? codec,
|
codec: externalMetadata?.codec ?? codec,
|
||||||
isDefault: externalMetadata?.isDefault ?? (track['default'] as bool? ?? false),
|
isDefault: externalMetadata?.isDefault ?? (track['default'] == true),
|
||||||
isForced: externalMetadata?.isForced ?? (track['forced'] as bool? ?? false),
|
isForced: externalMetadata?.isForced ?? (track['forced'] == true),
|
||||||
isExternal: track['external'] as bool? ?? false,
|
isExternal: track['external'] == true,
|
||||||
uri: externalFilename,
|
uri: externalFilename,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -490,13 +556,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
|
|
||||||
void updateSelectedAudioTrack(dynamic trackId) {
|
void updateSelectedAudioTrack(dynamic trackId) {
|
||||||
final id = trackId?.toString();
|
final id = trackId?.toString();
|
||||||
AudioTrack? selectedTrack;
|
final selectedTrack = (id == null || id == 'no')
|
||||||
|
? null
|
||||||
|
: _state.tracks.audio.firstWhereOrNull((track) => track.id == id);
|
||||||
|
if (id != null && id != 'no' && selectedTrack == null) return;
|
||||||
|
|
||||||
if (id != null && id != 'no') {
|
|
||||||
selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTrack == null) return;
|
|
||||||
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
||||||
trackController.add(_state.track);
|
trackController.add(_state.track);
|
||||||
}
|
}
|
||||||
@@ -505,7 +569,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
final id = trackId?.toString();
|
final id = trackId?.toString();
|
||||||
final selectedTrack = (id == null || id == 'no')
|
final selectedTrack = (id == null || id == 'no')
|
||||||
? SubtitleTrack.off
|
? SubtitleTrack.off
|
||||||
: _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);
|
: _state.tracks.subtitle.firstWhereOrNull((track) => track.id == id);
|
||||||
|
|
||||||
if (selectedTrack == null) return;
|
if (selectedTrack == null) return;
|
||||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||||
@@ -584,6 +648,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
|
|
||||||
@protected
|
@protected
|
||||||
Future<T?> invoke<T>(String method, [dynamic args]) async {
|
Future<T?> invoke<T>(String method, [dynamic args]) async {
|
||||||
|
if (_disposed) return null;
|
||||||
|
if (_nativeOwnershipReady case final ready?) {
|
||||||
|
try {
|
||||||
|
await ready.timeout(debugNativeOwnershipDisposeTimeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (_disposed) return null;
|
if (_disposed) return null;
|
||||||
return methodChannel.invokeMethod<T>(method, args);
|
return methodChannel.invokeMethod<T>(method, args);
|
||||||
}
|
}
|
||||||
@@ -800,13 +872,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForNativeOwnershipForDispose() async {
|
||||||
|
final ready = _nativeOwnershipReady;
|
||||||
|
if (ready == null) return true;
|
||||||
|
try {
|
||||||
|
await ready.timeout(debugNativeOwnershipDisposeTimeout);
|
||||||
|
return true;
|
||||||
|
} on TimeoutException catch (error, stackTrace) {
|
||||||
|
appLogger.w(
|
||||||
|
'Timed out waiting for the previous player to release the native channel; skipping native dispose',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
if (!_nativeRelease.isCompleted) _nativeRelease.complete(ready);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
_textureId.value = null;
|
||||||
|
|
||||||
if (identical(_eventChannelOwners[eventChannel.name], this)) {
|
final channelName = eventChannel.name;
|
||||||
_eventChannelOwners.remove(eventChannel.name);
|
if (identical(_eventChannelOwners[channelName], this)) {
|
||||||
|
// Keep this owner registered while its native release is pending so a
|
||||||
|
// player created during disposal inherits the complete release chain.
|
||||||
|
// The newer listen cannot interleave before cancel() is invoked on this
|
||||||
|
// isolate; after the first await, ownership is checked again at removal.
|
||||||
try {
|
try {
|
||||||
await _eventSubscription?.cancel();
|
await _eventSubscription?.cancel();
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
@@ -822,15 +916,33 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
}
|
}
|
||||||
_eventSubscription = null;
|
_eventSubscription = null;
|
||||||
await _logSubscription?.cancel();
|
await _logSubscription?.cancel();
|
||||||
|
final ownsNativeChannel = await _waitForNativeOwnershipForDispose();
|
||||||
try {
|
try {
|
||||||
await methodChannel.invokeMethod('dispose', {
|
if (ownsNativeChannel) {
|
||||||
'preserveDisplayMode': preserveDisplayMode,
|
await methodChannel.invokeMethod('dispose', {
|
||||||
}); // Direct call — already guarded by _disposed check above
|
'preserveDisplayMode': preserveDisplayMode,
|
||||||
|
}); // Direct call — invoke() is disabled once _disposed is set.
|
||||||
|
}
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st);
|
appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st);
|
||||||
} on MissingPluginException catch (e, st) {
|
} on MissingPluginException catch (e, st) {
|
||||||
appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st);
|
appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st);
|
||||||
|
} finally {
|
||||||
|
if (ownsNativeChannel && !_nativeRelease.isCompleted) _nativeRelease.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timed-out predecessor is still represented by this release future.
|
||||||
|
// Do not expose an empty ownership slot until that chained release settles.
|
||||||
|
if (_nativeRelease.isCompleted) {
|
||||||
|
unawaited(
|
||||||
|
_nativeRelease.future.whenComplete(() {
|
||||||
|
if (identical(_eventChannelOwners[channelName], this)) {
|
||||||
|
_eventChannelOwners.remove(channelName);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await closeStreamControllers();
|
await closeStreamControllers();
|
||||||
|
_textureId.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import '../../utils/app_logger.dart';
|
|||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import 'player_base.dart';
|
import 'player_base.dart';
|
||||||
|
|
||||||
|
typedef _AudioStateRequest = ({
|
||||||
|
bool passthrough,
|
||||||
|
bool normalization,
|
||||||
|
bool downmix,
|
||||||
|
int downmixCenterBoostDb,
|
||||||
|
bool downmixNormalize,
|
||||||
|
double rate,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef _AudioStateGenerations = ({int passthrough, int normalization, int downmix, int rate});
|
||||||
|
|
||||||
/// MPV-backed player for platforms where AetherEngine is not the native route.
|
/// MPV-backed player for platforms where AetherEngine is not the native route.
|
||||||
class PlayerNative extends PlayerBase {
|
class PlayerNative extends PlayerBase {
|
||||||
/// Video player on the default mpv channels/core.
|
/// Video player on the default mpv channels/core.
|
||||||
@@ -27,7 +38,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'),
|
eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'),
|
||||||
audioOnly = true;
|
audioOnly = true;
|
||||||
|
|
||||||
int? _textureIdValue;
|
|
||||||
String _dvConversionMode = 'auto';
|
String _dvConversionMode = 'auto';
|
||||||
String _dvConversionLog = 'no';
|
String _dvConversionLog = 'no';
|
||||||
|
|
||||||
@@ -45,13 +55,14 @@ class PlayerNative extends PlayerBase {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static bool debugForceContentFdConversion = false;
|
static bool debugForceContentFdConversion = false;
|
||||||
|
|
||||||
|
/// Overrides the Linux-only video readiness handshake in host tests.
|
||||||
|
@visibleForTesting
|
||||||
|
static bool? debugUseLinuxVideoBootstrap;
|
||||||
|
|
||||||
// Set by open() and consumed by that load's file-loaded event, so it is
|
// Set by open() and consumed by that load's file-loaded event, so it is
|
||||||
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
|
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
|
||||||
bool _expectOpenFileLoad = false;
|
bool _expectOpenFileLoad = false;
|
||||||
|
|
||||||
@override
|
|
||||||
int? get textureId => _textureIdValue;
|
|
||||||
|
|
||||||
/// Whether this instance drives the audio-only core.
|
/// Whether this instance drives the audio-only core.
|
||||||
final bool audioOnly;
|
final bool audioOnly;
|
||||||
|
|
||||||
@@ -168,13 +179,29 @@ class PlayerNative extends PlayerBase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the UI must mount the provisional texture before initialization
|
||||||
|
/// can complete its first render/bootstrap handshake.
|
||||||
|
bool get requiresProvisionalTextureSurface => !audioOnly && (debugUseLinuxVideoBootstrap ?? Platform.isLinux);
|
||||||
|
|
||||||
// Memoizes the in-flight init Future so concurrent callers (e.g. the
|
// Memoizes the in-flight init Future so concurrent callers (e.g. the
|
||||||
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
|
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
|
||||||
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
|
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
|
||||||
// Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize
|
// Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize
|
||||||
// to dispose-and-recreate the in-flight core, hanging playback (#930).
|
// to dispose-and-recreate the in-flight core, hanging playback (#930).
|
||||||
Future<void>? _initFuture;
|
Future<void>? _initFuture;
|
||||||
Future<void> _rateChangeTail = Future<void>.value();
|
Future<void> _audioStateTail = Future<void>.value();
|
||||||
|
Future<void>? _disposeFuture;
|
||||||
|
bool _disposing = false;
|
||||||
|
|
||||||
|
bool get _nativeCoreUnavailable => disposed || _disposing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> invoke<T>(String method, [dynamic args]) {
|
||||||
|
if (_nativeCoreUnavailable) return Future<T?>.value();
|
||||||
|
return super.invoke<T>(method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _requestedRate = 1.0;
|
||||||
|
|
||||||
Future<void> _ensureInitialized() async {
|
Future<void> _ensureInitialized() async {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
@@ -186,8 +213,13 @@ class PlayerNative extends PlayerBase {
|
|||||||
final result = await invoke<Object>('initialize');
|
final result = await invoke<Object>('initialize');
|
||||||
final bool ok;
|
final bool ok;
|
||||||
if (result is int) {
|
if (result is int) {
|
||||||
// Linux: initialize returns the texture ID
|
// Linux publishes a provisional texture so Flutter can invoke
|
||||||
_textureIdValue = result;
|
// FlTextureGL::populate. Playback stays gated until native GPU
|
||||||
|
// bootstrap reports that the texture is usable.
|
||||||
|
setTextureId(result);
|
||||||
|
if (debugUseLinuxVideoBootstrap ?? Platform.isLinux) {
|
||||||
|
await invoke('waitForVideoReady');
|
||||||
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
} else {
|
} else {
|
||||||
ok = result == true;
|
ok = result == true;
|
||||||
@@ -195,6 +227,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw Exception('Failed to initialize player');
|
throw Exception('Failed to initialize player');
|
||||||
}
|
}
|
||||||
|
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
|
||||||
|
|
||||||
// Subscribe to MPV properties before flipping `initialized` so partial
|
// Subscribe to MPV properties before flipping `initialized` so partial
|
||||||
// failures don't leave us in a half-initialized state that the memoized
|
// failures don't leave us in a half-initialized state that the memoized
|
||||||
@@ -217,10 +250,14 @@ class PlayerNative extends PlayerBase {
|
|||||||
await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'});
|
await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setTextureId(null);
|
||||||
_initFuture = null;
|
_initFuture = null;
|
||||||
errorController.add(PlayerError('Initialization failed: $e'));
|
if (!_nativeCoreUnavailable) {
|
||||||
|
errorController.add(PlayerError('Initialization failed: $e'));
|
||||||
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,9 +272,13 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
/// Closes a detached content fd that mpv will never consume. Fire-and-forget
|
/// Closes a detached content fd that mpv will never consume. Fire-and-forget
|
||||||
/// safe: a failure only leaks one fd.
|
/// safe: a failure only leaks one fd.
|
||||||
Future<void> _closeContentFd(int fd) async {
|
Future<void> _closeContentFd(int fd, {bool duringDispose = false}) async {
|
||||||
try {
|
try {
|
||||||
await invoke('closeContentFd', {'fd': fd});
|
if (duringDispose) {
|
||||||
|
await super.invoke('closeContentFd', {'fd': fd});
|
||||||
|
} else {
|
||||||
|
await invoke('closeContentFd', {'fd': fd});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e);
|
appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e);
|
||||||
}
|
}
|
||||||
@@ -269,8 +310,9 @@ class PlayerNative extends PlayerBase {
|
|||||||
List<SubtitleTrack>? externalSubtitles,
|
List<SubtitleTrack>? externalSubtitles,
|
||||||
Duration? timelineDuration,
|
Duration? timelineDuration,
|
||||||
}) async {
|
}) async {
|
||||||
if (disposed) return;
|
if (_nativeCoreUnavailable) return;
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
// `loadfile replace` (below) clears the native playlist, dropping any
|
// `loadfile replace` (below) clears the native playlist, dropping any
|
||||||
// gapless entry armed via setNext — settle its content-fd claim first.
|
// gapless entry armed via setNext — settle its content-fd claim first.
|
||||||
// No transition is surfaced: the caller is replacing playback anyway.
|
// No transition is surfaced: the caller is replacing playback anyway.
|
||||||
@@ -338,16 +380,19 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('pause', 'no');
|
await setProperty('pause', 'no');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('pause', 'yes');
|
await setProperty('pause', 'yes');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
// `stop` tears down the playlist without mpv opening the armed entry —
|
// `stop` tears down the playlist without mpv opening the armed entry —
|
||||||
// settle its content-fd claim first. No transition: playback is ending.
|
// settle its content-fd claim first. No transition: playback is ending.
|
||||||
await _clearArmedNext(adoptIfRolledIn: false);
|
await _clearArmedNext(adoptIfRolledIn: false);
|
||||||
@@ -358,12 +403,13 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setNext(Media? media) async {
|
Future<void> setNext(Media? media) async {
|
||||||
if (!audioOnly || disposed || !initialized) return;
|
if (_nativeCoreUnavailable || !audioOnly || !initialized) return;
|
||||||
|
|
||||||
await _clearArmedNext();
|
await _clearArmedNext();
|
||||||
if (media == null) return;
|
if (media == null) return;
|
||||||
@@ -409,7 +455,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
/// exactly at the gapless boundary desyncs the music service from the
|
/// exactly at the gapless boundary desyncs the music service from the
|
||||||
/// audio for the whole next track. Callers that replace or stop playback
|
/// audio for the whole next track. Callers that replace or stop playback
|
||||||
/// pass false: no one is listening for that entry anymore.
|
/// pass false: no one is listening for that entry anymore.
|
||||||
Future<void> _clearArmedNext({bool adoptIfRolledIn = true}) async {
|
Future<void> _clearArmedNext({bool adoptIfRolledIn = true, bool duringDispose = false}) async {
|
||||||
if (!_hasArmedNext) return;
|
if (!_hasArmedNext) return;
|
||||||
final uri = _armedNextUri;
|
final uri = _armedNextUri;
|
||||||
final fd = _armedNextFd;
|
final fd = _armedNextFd;
|
||||||
@@ -419,7 +465,9 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
String? pos;
|
String? pos;
|
||||||
try {
|
try {
|
||||||
pos = await getProperty('playlist-pos');
|
pos = duringDispose
|
||||||
|
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
|
||||||
|
: await getProperty('playlist-pos');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Unknown state — fall through to the remove, never close the fd.
|
// Unknown state — fall through to the remove, never close the fd.
|
||||||
}
|
}
|
||||||
@@ -431,7 +479,13 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
|
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
|
||||||
try {
|
try {
|
||||||
await command(['playlist-remove', '1']);
|
if (duringDispose) {
|
||||||
|
await super.invoke('command', {
|
||||||
|
'args': ['playlist-remove', '1'],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await command(['playlist-remove', '1']);
|
||||||
|
}
|
||||||
} on PlatformException {
|
} on PlatformException {
|
||||||
// Entry 1 vanished in the arm/advance race — mpv rolled into it and
|
// Entry 1 vanished in the arm/advance race — mpv rolled into it and
|
||||||
// the file-loaded handler already rebased. The fd (if any) is mpv's.
|
// the file-loaded handler already rebased. The fd (if any) is mpv's.
|
||||||
@@ -440,10 +494,12 @@ class PlayerNative extends PlayerBase {
|
|||||||
if (fd == null) return;
|
if (fd == null) return;
|
||||||
String? postPos;
|
String? postPos;
|
||||||
try {
|
try {
|
||||||
postPos = await getProperty('playlist-pos');
|
postPos = duringDispose
|
||||||
|
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
|
||||||
|
: await getProperty('playlist-pos');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (pos == '0' && postPos == '0') {
|
if (pos == '0' && postPos == '0') {
|
||||||
unawaited(_closeContentFd(fd));
|
unawaited(_closeContentFd(fd, duringDispose: duringDispose));
|
||||||
}
|
}
|
||||||
// Any other combination is ambiguous (mpv advanced mid-clear, idle
|
// Any other combination is ambiguous (mpv advanced mid-clear, idle
|
||||||
// playlist, property error): leak on doubt.
|
// playlist, property error): leak on doubt.
|
||||||
@@ -516,38 +572,52 @@ class PlayerNative extends PlayerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
Future<void> dispose({bool preserveDisplayMode = false}) {
|
||||||
|
final existing = _disposeFuture;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
_disposing = true;
|
||||||
|
final disposal = _disposeNative(preserveDisplayMode: preserveDisplayMode);
|
||||||
|
_disposeFuture = disposal;
|
||||||
|
return disposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeNative({required bool preserveDisplayMode}) async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
// Settle an armed-but-unconsumed content fd before the base teardown
|
// Settle an armed-but-unconsumed content fd before the base teardown
|
||||||
// disables invoke() — the playlist is torn down without mpv ever opening
|
// disables invoke() — the playlist is torn down without mpv ever opening
|
||||||
// the entry.
|
// the entry.
|
||||||
if (_hasArmedNext) {
|
if (_hasArmedNext) {
|
||||||
try {
|
try {
|
||||||
await _clearArmedNext(adoptIfRolledIn: false);
|
await _clearArmedNext(adoptIfRolledIn: false, duringDispose: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Leak on doubt.
|
// Leak on doubt.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await _audioStateTail;
|
||||||
await super.dispose(preserveDisplayMode: preserveDisplayMode);
|
await super.dispose(preserveDisplayMode: preserveDisplayMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('aid', track.id);
|
await setProperty('aid', track.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('sid', track.id);
|
await setProperty('sid', track.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
|
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('secondary-sid', track.id);
|
await setProperty('secondary-sid', track.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
final args = ['sub-add', uri, select ? 'select' : 'auto'];
|
final args = ['sub-add', uri, select ? 'select' : 'auto'];
|
||||||
if (title != null) args.add('title=$title');
|
if (title != null) args.add('title=$title');
|
||||||
if (language != null) args.add('lang=$language');
|
if (language != null) args.add('lang=$language');
|
||||||
@@ -556,53 +626,64 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setVolume(double volume) async {
|
Future<void> setVolume(double volume) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('volume', volume.toString());
|
await setProperty('volume', volume.toString());
|
||||||
if (!disposed) setVolumeState(volume);
|
if (!_nativeCoreUnavailable) setVolumeState(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setRate(double rate) {
|
Future<void> setRate(double rate) {
|
||||||
_currentRate = rate;
|
if (_nativeCoreUnavailable) return Future<void>.value();
|
||||||
final operation = _rateChangeTail.then((_) => _applyRateChange(rate));
|
_requestedRate = rate;
|
||||||
_rateChangeTail = operation.catchError((Object _, StackTrace _) {});
|
return _enqueueAudioStateReconciliation(_rateAudioField);
|
||||||
return operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _applyRateChange(double rate) async {
|
|
||||||
// mpv cannot scaletempo compressed (spdif) audio and silently keeps
|
|
||||||
// playing at 1x, so serialize passthrough and speed transitions.
|
|
||||||
if (_passthroughActive && rate != 1.0) {
|
|
||||||
await _applyPassthrough(false);
|
|
||||||
}
|
|
||||||
await setProperty('speed', rate.toString());
|
|
||||||
if (_passthroughRequested && !_passthroughActive && rate == 1.0 && !_downmixEnabled) {
|
|
||||||
await _applyPassthrough(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setAudioDevice(AudioDevice device) async {
|
Future<void> setAudioDevice(AudioDevice device) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
await setProperty('audio-device', device.name);
|
await setProperty('audio-device', device.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setProperty(String name, String value) async {
|
Future<void> setProperty(String name, String value) => _setProperty(name, value, synchronizeRate: true);
|
||||||
if (disposed) return;
|
|
||||||
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
|
Future<void> _setProperty(String name, String value, {required bool synchronizeRate}) async {
|
||||||
value = _normalizeDvConversionMode(value);
|
if (_nativeCoreUnavailable) return;
|
||||||
_dvConversionMode = value;
|
final updatesDvMode = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode';
|
||||||
}
|
final updatesDvLog = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log';
|
||||||
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log') {
|
if (updatesDvMode) value = _normalizeDvConversionMode(value);
|
||||||
value = _normalizeBoolProperty(value);
|
if (updatesDvLog) value = _normalizeBoolProperty(value);
|
||||||
_dvConversionLog = value;
|
|
||||||
}
|
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
await invoke('setProperty', {'name': name, 'value': value});
|
await invoke('setProperty', {'name': name, 'value': value});
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
|
if (updatesDvMode) _dvConversionMode = value;
|
||||||
|
if (updatesDvLog) _dvConversionLog = value;
|
||||||
|
if (synchronizeRate && name == 'speed') {
|
||||||
|
final rate = double.tryParse(value);
|
||||||
|
if (rate != null && rate.isFinite) {
|
||||||
|
_currentRate = rate;
|
||||||
|
_requestedRate = rate;
|
||||||
|
final accepted = _acceptedAudioState;
|
||||||
|
_acceptedAudioState = (
|
||||||
|
passthrough: accepted.passthrough,
|
||||||
|
normalization: accepted.normalization,
|
||||||
|
downmix: accepted.downmix,
|
||||||
|
downmixCenterBoostDb: accepted.downmixCenterBoostDb,
|
||||||
|
downmixNormalize: accepted.downmixNormalize,
|
||||||
|
rate: rate,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The native bridge may accept custom mpv speed syntax. Its numeric
|
||||||
|
// value is unknown, so the next typed setRate must write explicitly.
|
||||||
|
_currentRate = double.nan;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> getProperty(String name) async {
|
Future<String?> getProperty(String name) async {
|
||||||
if (disposed) return null;
|
if (_nativeCoreUnavailable) return null;
|
||||||
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
|
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
|
||||||
return _dvConversionMode;
|
return _dvConversionMode;
|
||||||
}
|
}
|
||||||
@@ -615,7 +696,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> getStats() async {
|
Future<Map<String, dynamic>> getStats() async {
|
||||||
if (disposed || !Platform.isAndroid) return super.getStats();
|
if (_nativeCoreUnavailable || !Platform.isAndroid) return super.getStats();
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
final result = await invoke<Map>('getStats');
|
final result = await invoke<Map>('getStats');
|
||||||
return Map<String, dynamic>.from(result ?? const {});
|
return Map<String, dynamic>.from(result ?? const {});
|
||||||
@@ -623,7 +704,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> command(List<String> args) async {
|
Future<void> command(List<String> args) async {
|
||||||
if (disposed) return;
|
if (_nativeCoreUnavailable) return;
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
await invoke('command', {'args': args});
|
await invoke('command', {'args': args});
|
||||||
}
|
}
|
||||||
@@ -633,7 +714,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
|
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
|
||||||
if (disposed || audioOnly || !Platform.isIOS) return;
|
if (_nativeCoreUnavailable || audioOnly || !Platform.isIOS) return;
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
await invoke('setDisplayCriteria', {
|
await invoke('setDisplayCriteria', {
|
||||||
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
|
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
|
||||||
@@ -643,16 +724,46 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setLogLevel(String level) async {
|
Future<void> setLogLevel(String level) async {
|
||||||
if (disposed) return;
|
if (_nativeCoreUnavailable) return;
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
await invoke('setLogLevel', {'level': level});
|
await invoke('setLogLevel', {'level': level});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
|
||||||
|
if (_nativeCoreUnavailable) return false;
|
||||||
|
final changed = await super.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible);
|
||||||
|
return changed && !_nativeCoreUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const int _passthroughAudioField = 1 << 0;
|
||||||
|
static const int _normalizationAudioField = 1 << 1;
|
||||||
|
static const int _downmixAudioField = 1 << 2;
|
||||||
|
static const int _rateAudioField = 1 << 3;
|
||||||
|
|
||||||
bool _passthroughRequested = false;
|
bool _passthroughRequested = false;
|
||||||
bool _passthroughActive = false;
|
bool _passthroughActive = false;
|
||||||
bool _normalizationRequested = false;
|
bool _normalizationRequested = false;
|
||||||
bool _downmixEnabled = false;
|
bool _normalizationActive = false;
|
||||||
|
bool _downmixRequested = false;
|
||||||
|
bool _downmixActive = false;
|
||||||
|
int _downmixCenterBoostDb = 0;
|
||||||
|
int _activeDownmixCenterBoostDb = 0;
|
||||||
|
bool _downmixNormalize = false;
|
||||||
|
bool _activeDownmixNormalize = false;
|
||||||
double _currentRate = 1.0;
|
double _currentRate = 1.0;
|
||||||
|
int _passthroughGeneration = 0;
|
||||||
|
int _normalizationGeneration = 0;
|
||||||
|
int _downmixGeneration = 0;
|
||||||
|
int _rateGeneration = 0;
|
||||||
|
_AudioStateRequest _acceptedAudioState = const (
|
||||||
|
passthrough: false,
|
||||||
|
normalization: false,
|
||||||
|
downmix: false,
|
||||||
|
downmixCenterBoostDb: 0,
|
||||||
|
downmixNormalize: false,
|
||||||
|
rate: 1.0,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get audioPassthroughActive => _passthroughActive;
|
bool get audioPassthroughActive => _passthroughActive;
|
||||||
@@ -662,58 +773,177 @@ class PlayerNative extends PlayerBase {
|
|||||||
/// Digital (Plus); desktop does real device passthrough for the full list.
|
/// Digital (Plus); desktop does real device passthrough for the full list.
|
||||||
static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd';
|
static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd';
|
||||||
|
|
||||||
@override
|
_AudioStateRequest get _requestedAudioState => (
|
||||||
Future<void> setAudioPassthrough(bool enabled) async {
|
passthrough: _passthroughRequested,
|
||||||
_passthroughRequested = enabled;
|
normalization: _normalizationRequested,
|
||||||
// Deferred until the rate returns to 1.0 (see setRate) and the stereo
|
downmix: _downmixRequested,
|
||||||
// downmix ends (see setAudioDownmix).
|
downmixCenterBoostDb: _downmixCenterBoostDb,
|
||||||
if (enabled && (_currentRate != 1.0 || _downmixEnabled)) return;
|
downmixNormalize: _downmixNormalize,
|
||||||
await _applyPassthrough(enabled);
|
rate: _requestedRate,
|
||||||
}
|
);
|
||||||
|
|
||||||
Future<void> _applyPassthrough(bool enabled) async {
|
_AudioStateRequest _rebaseAudioState(_AudioStateRequest accepted, _AudioStateRequest requested, int fields) => (
|
||||||
_passthroughActive = enabled;
|
passthrough: fields & _passthroughAudioField != 0 ? requested.passthrough : accepted.passthrough,
|
||||||
// loudnorm decodes to PCM, which defeats bitstream passthrough; the
|
normalization: fields & _normalizationAudioField != 0 ? requested.normalization : accepted.normalization,
|
||||||
// filter yields while passthrough is active and returns when it ends.
|
downmix: fields & _downmixAudioField != 0 ? requested.downmix : accepted.downmix,
|
||||||
if (enabled && _normalizationRequested) {
|
downmixCenterBoostDb: fields & _downmixAudioField != 0
|
||||||
await super.setAudioNormalization(false);
|
? requested.downmixCenterBoostDb
|
||||||
|
: accepted.downmixCenterBoostDb,
|
||||||
|
downmixNormalize: fields & _downmixAudioField != 0 ? requested.downmixNormalize : accepted.downmixNormalize,
|
||||||
|
rate: fields & _rateAudioField != 0 ? requested.rate : accepted.rate,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _restoreFailedRequestedFields(_AudioStateRequest previous, int fields, _AudioStateGenerations generations) {
|
||||||
|
if (fields & _passthroughAudioField != 0 && generations.passthrough == _passthroughGeneration) {
|
||||||
|
_passthroughRequested = previous.passthrough;
|
||||||
}
|
}
|
||||||
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
|
if (fields & _normalizationAudioField != 0 && generations.normalization == _normalizationGeneration) {
|
||||||
// audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS
|
_normalizationRequested = previous.normalization;
|
||||||
// (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at
|
|
||||||
// playback start and must not be clobbered here.
|
|
||||||
if (!Platform.isIOS) {
|
|
||||||
await setProperty('audio-exclusive', enabled ? 'yes' : 'no');
|
|
||||||
}
|
}
|
||||||
if (!enabled && _normalizationRequested) {
|
if (fields & _downmixAudioField != 0 && generations.downmix == _downmixGeneration) {
|
||||||
await super.setAudioNormalization(true);
|
_downmixRequested = previous.downmix;
|
||||||
|
_downmixCenterBoostDb = previous.downmixCenterBoostDb;
|
||||||
|
_downmixNormalize = previous.downmixNormalize;
|
||||||
|
}
|
||||||
|
if (fields & _rateAudioField != 0 && generations.rate == _rateGeneration) {
|
||||||
|
_requestedRate = previous.rate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _enqueueAudioStateReconciliation(int fields) {
|
||||||
Future<void> setAudioNormalization(bool enabled) async {
|
final requested = _requestedAudioState;
|
||||||
_normalizationRequested = enabled;
|
if (fields & _passthroughAudioField != 0) ++_passthroughGeneration;
|
||||||
if (enabled && _passthroughActive) return; // deferred until passthrough ends
|
if (fields & _normalizationAudioField != 0) ++_normalizationGeneration;
|
||||||
await super.setAudioNormalization(enabled);
|
if (fields & _downmixAudioField != 0) ++_downmixGeneration;
|
||||||
|
if (fields & _rateAudioField != 0) ++_rateGeneration;
|
||||||
|
final generations = (
|
||||||
|
passthrough: _passthroughGeneration,
|
||||||
|
normalization: _normalizationGeneration,
|
||||||
|
downmix: _downmixGeneration,
|
||||||
|
rate: _rateGeneration,
|
||||||
|
);
|
||||||
|
final operation = _audioStateTail.then((_) => _reconcileAudioState(requested, fields, generations));
|
||||||
|
_audioStateTail = operation.catchError((Object _, StackTrace _) {});
|
||||||
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _reconcileAudioState(
|
||||||
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {
|
_AudioStateRequest requested,
|
||||||
_downmixEnabled = enabled;
|
int fields,
|
||||||
// spdif bypasses the filter chain entirely; passthrough yields while a
|
_AudioStateGenerations generations,
|
||||||
// stereo downmix is forced and returns when it is disabled.
|
) async {
|
||||||
if (enabled && _passthroughActive) {
|
if (_nativeCoreUnavailable) return;
|
||||||
|
final previous = _acceptedAudioState;
|
||||||
|
final target = _rebaseAudioState(previous, requested, fields);
|
||||||
|
try {
|
||||||
|
await _applyAudioState(target);
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
|
_acceptedAudioState = target;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
try {
|
||||||
|
await _applyAudioState(
|
||||||
|
previous,
|
||||||
|
forceDownmix: fields & _downmixAudioField != 0,
|
||||||
|
forceNormalization: fields & _downmixAudioField != 0,
|
||||||
|
);
|
||||||
|
} catch (rollbackError, rollbackStackTrace) {
|
||||||
|
appLogger.e(
|
||||||
|
'MPV: failed to restore accepted audio state',
|
||||||
|
error: rollbackError,
|
||||||
|
stackTrace: rollbackStackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_restoreFailedRequestedFields(previous, fields, generations);
|
||||||
|
Error.throwWithStackTrace(error, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyAudioState(
|
||||||
|
_AudioStateRequest target, {
|
||||||
|
bool forceDownmix = false,
|
||||||
|
bool forceNormalization = false,
|
||||||
|
}) async {
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
|
final passthroughShouldBeActive = target.passthrough && target.rate == 1.0 && !target.downmix;
|
||||||
|
|
||||||
|
// mpv cannot scaletempo compressed audio and filters cannot process a
|
||||||
|
// bitstream. Always leave passthrough before applying either state.
|
||||||
|
if (_passthroughActive && !passthroughShouldBeActive) {
|
||||||
await _applyPassthrough(false);
|
await _applyPassthrough(false);
|
||||||
}
|
}
|
||||||
await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize);
|
if (_currentRate != target.rate) {
|
||||||
if (!enabled && _passthroughRequested && !_passthroughActive && _currentRate == 1.0) {
|
await _setProperty('speed', target.rate.toString(), synchronizeRate: false);
|
||||||
|
_currentRate = target.rate;
|
||||||
|
}
|
||||||
|
if (forceDownmix ||
|
||||||
|
_downmixActive != target.downmix ||
|
||||||
|
(target.downmix &&
|
||||||
|
(_activeDownmixCenterBoostDb != target.downmixCenterBoostDb ||
|
||||||
|
_activeDownmixNormalize != target.downmixNormalize))) {
|
||||||
|
await super.setAudioDownmix(
|
||||||
|
enabled: target.downmix,
|
||||||
|
centerBoostDb: target.downmixCenterBoostDb,
|
||||||
|
normalize: target.downmixNormalize,
|
||||||
|
);
|
||||||
|
_downmixActive = target.downmix;
|
||||||
|
_activeDownmixCenterBoostDb = target.downmixCenterBoostDb;
|
||||||
|
_activeDownmixNormalize = target.downmixNormalize;
|
||||||
|
}
|
||||||
|
final normalizationShouldBeActive = target.normalization && !passthroughShouldBeActive;
|
||||||
|
if (forceNormalization || _normalizationActive != normalizationShouldBeActive) {
|
||||||
|
await super.setAudioNormalization(normalizationShouldBeActive);
|
||||||
|
_normalizationActive = normalizationShouldBeActive;
|
||||||
|
}
|
||||||
|
if (passthroughShouldBeActive && !_passthroughActive) {
|
||||||
await _applyPassthrough(true);
|
await _applyPassthrough(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setAudioPassthrough(bool enabled) {
|
||||||
|
if (_nativeCoreUnavailable) return Future<void>.value();
|
||||||
|
_passthroughRequested = enabled;
|
||||||
|
return _enqueueAudioStateReconciliation(_passthroughAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyPassthrough(bool enabled) async {
|
||||||
|
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
|
||||||
|
if (_nativeCoreUnavailable) return;
|
||||||
|
|
||||||
|
// audio-spdif is the authoritative transition. Publish only after mpv
|
||||||
|
// accepts it; audio-exclusive below is an independent device-mode hint.
|
||||||
|
_passthroughActive = enabled;
|
||||||
|
// audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS
|
||||||
|
// (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at
|
||||||
|
// playback start and must not be clobbered here.
|
||||||
|
if (!Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await setProperty('audio-exclusive', enabled ? 'yes' : 'no');
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.w('MPV: failed to update exclusive-audio hint', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setAudioNormalization(bool enabled) {
|
||||||
|
if (_nativeCoreUnavailable) return Future<void>.value();
|
||||||
|
_normalizationRequested = enabled;
|
||||||
|
return _enqueueAudioStateReconciliation(_normalizationAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) {
|
||||||
|
if (_nativeCoreUnavailable) return Future<void>.value();
|
||||||
|
_downmixRequested = enabled;
|
||||||
|
_downmixCenterBoostDb = centerBoostDb;
|
||||||
|
_downmixNormalize = normalize;
|
||||||
|
return _enqueueAudioStateReconciliation(_downmixAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateFrame() async {
|
Future<void> updateFrame() async {
|
||||||
if (disposed || !initialized) return;
|
if (_nativeCoreUnavailable || !initialized) return;
|
||||||
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
|
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
|
||||||
await invoke('updateFrame');
|
await invoke('updateFrame');
|
||||||
}
|
}
|
||||||
@@ -727,7 +957,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
int videoWidth = 0,
|
int videoWidth = 0,
|
||||||
int videoHeight = 0,
|
int videoHeight = 0,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isAndroid || disposed || !initialized) return false;
|
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return false;
|
||||||
final result = await invoke<bool>('setVideoFrameRate', {
|
final result = await invoke<bool>('setVideoFrameRate', {
|
||||||
'fps': fps,
|
'fps': fps,
|
||||||
'duration': durationMs,
|
'duration': durationMs,
|
||||||
@@ -740,13 +970,13 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearVideoFrameRate() async {
|
Future<void> clearVideoFrameRate() async {
|
||||||
if (!Platform.isAndroid || disposed || !initialized) return;
|
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
|
||||||
await invoke('clearVideoFrameRate');
|
await invoke('clearVideoFrameRate');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requestAudioFocus() async {
|
Future<bool> requestAudioFocus() async {
|
||||||
if (disposed) return false;
|
if (_nativeCoreUnavailable) return false;
|
||||||
if (!Platform.isAndroid) return true;
|
if (!Platform.isAndroid) return true;
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
return await invoke<bool>('requestAudioFocus') ?? false;
|
return await invoke<bool>('requestAudioFocus') ?? false;
|
||||||
@@ -754,7 +984,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> abandonAudioFocus() async {
|
Future<void> abandonAudioFocus() async {
|
||||||
if (!Platform.isAndroid || disposed || !initialized) return;
|
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
|
||||||
await invoke('abandonAudioFocus');
|
await invoke('abandonAudioFocus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -108,7 +108,17 @@ class _VideoState extends State<Video> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVideoSurface() {
|
Widget _buildVideoSurface() {
|
||||||
final textureId = widget.player.textureId;
|
final player = widget.player;
|
||||||
|
if (player is PlayerBase) {
|
||||||
|
return ValueListenableBuilder<int?>(
|
||||||
|
valueListenable: player.textureIdListenable,
|
||||||
|
builder: (context, textureId, _) => _buildVideoSurfaceForId(textureId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildVideoSurfaceForId(player.textureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoSurfaceForId(int? textureId) {
|
||||||
if (textureId != null) {
|
if (textureId != null) {
|
||||||
return Texture(textureId: textureId);
|
return Texture(textureId: textureId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPlayerInitializationSurface() {
|
||||||
|
final bootstrapPlayer = _bootstrapPlayer;
|
||||||
|
if (bootstrapPlayer == null) return _buildLoadingSpinner();
|
||||||
|
|
||||||
|
// Linux creates the texture before its EGL/mpv render bootstrap can be
|
||||||
|
// proven. Mount the provisional surface so Flutter drives one texture
|
||||||
|
// copy, while retaining the black loading cover until playback itself
|
||||||
|
// reports its first frame.
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
|
||||||
|
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInitializationError(String message) {
|
Widget _buildInitializationError(String message) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
|
|
||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
import '../mpv/player/platform/player_android.dart';
|
import '../mpv/player/platform/player_android.dart';
|
||||||
|
import '../mpv/player/player_native.dart';
|
||||||
|
|
||||||
import '../services/scrub_preview_source.dart';
|
import '../services/scrub_preview_source.dart';
|
||||||
import '../media/media_backend.dart';
|
import '../media/media_backend.dart';
|
||||||
@@ -122,6 +123,16 @@ part 'video_player/parts/watch_together.dart';
|
|||||||
|
|
||||||
final WakelockController _wakelockController = WakelockController();
|
final WakelockController _wakelockController = WakelockController();
|
||||||
|
|
||||||
|
/// Whether an in-place source reload may start the replacement media.
|
||||||
|
///
|
||||||
|
/// Reloading a paused player must not manufacture a new play intent. Watch
|
||||||
|
/// Together and explicit paused starts keep owning the eventual resume.
|
||||||
|
bool shouldAutoStartReloadedMedia({
|
||||||
|
required bool wasPlayingBeforeReload,
|
||||||
|
required bool watchTogetherOwnsStart,
|
||||||
|
required bool startPaused,
|
||||||
|
}) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused;
|
||||||
|
|
||||||
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
||||||
/// They are mutually exclusive by construction — entry points bail while a
|
/// They are mutually exclusive by construction — entry points bail while a
|
||||||
/// transition is in flight.
|
/// transition is in flight.
|
||||||
@@ -279,6 +290,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
|
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
|
||||||
|
|
||||||
Player? player;
|
Player? player;
|
||||||
|
Player? _bootstrapPlayer;
|
||||||
VideoVolumeController? _volumeController;
|
VideoVolumeController? _volumeController;
|
||||||
bool _isPlayerInitialized = false;
|
bool _isPlayerInitialized = false;
|
||||||
String? _playerInitializationError;
|
String? _playerInitializationError;
|
||||||
@@ -884,6 +896,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (identical(player, attemptPlayer)) {
|
if (identical(player, attemptPlayer)) {
|
||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
|
if (identical(_bootstrapPlayer, attemptPlayer)) {
|
||||||
|
_bootstrapPlayer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await _tearDownFailedPlayerAttempt(attemptPlayer);
|
await _tearDownFailedPlayerAttempt(attemptPlayer);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -955,6 +970,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final currentPlayer = Player(useExoPlayer: useExoPlayer);
|
final currentPlayer = Player(useExoPlayer: useExoPlayer);
|
||||||
attemptPlayer = currentPlayer;
|
attemptPlayer = currentPlayer;
|
||||||
if (!mounted || generation != _playerInitializationGeneration) return;
|
if (!mounted || generation != _playerInitializationGeneration) return;
|
||||||
|
if (currentPlayer is PlayerNative && currentPlayer.requiresProvisionalTextureSurface) {
|
||||||
|
setState(() => _bootstrapPlayer = currentPlayer);
|
||||||
|
}
|
||||||
if (Platform.isAndroid && useExoPlayer) {
|
if (Platform.isAndroid && useExoPlayer) {
|
||||||
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
||||||
if (!mounted || generation != _playerInitializationGeneration) return;
|
if (!mounted || generation != _playerInitializationGeneration) return;
|
||||||
@@ -1194,6 +1212,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPlayerInitialized = true;
|
_isPlayerInitialized = true;
|
||||||
|
_bootstrapPlayer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restart sleep timer if we're starting a new playback session
|
// Restart sleep timer if we're starting a new playback session
|
||||||
@@ -1548,8 +1567,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final volumeController = _volumeController;
|
final volumeController = _volumeController;
|
||||||
_volumeController = null;
|
_volumeController = null;
|
||||||
volumeController?.dispose();
|
volumeController?.dispose();
|
||||||
final playerToDispose = player;
|
final playerToDispose = player ?? _bootstrapPlayer;
|
||||||
player = null;
|
player = null;
|
||||||
|
_bootstrapPlayer = null;
|
||||||
if (playerToDispose != null) {
|
if (playerToDispose != null) {
|
||||||
// Keep the native display mode (tvOS HDMI criteria) across a
|
// Keep the native display mode (tvOS HDMI criteria) across a
|
||||||
// player→player handoff; the replacement screen primes its own.
|
// player→player handoff; the replacement screen primes its own.
|
||||||
@@ -1866,7 +1886,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
? _buildVideoPlayer(sheetContext)
|
? _buildVideoPlayer(sheetContext)
|
||||||
: (_playerInitializationError != null
|
: (_playerInitializationError != null
|
||||||
? _buildInitializationError(_playerInitializationError!)
|
? _buildInitializationError(_playerInitializationError!)
|
||||||
: _buildLoadingSpinner()),
|
: _buildPlayerInitializationSurface()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
|
|||||||
/// 3. Optionally override onInit() for post-initialization setup
|
/// 3. Optionally override onInit() for post-initialization setup
|
||||||
abstract class BaseSharedPreferencesService {
|
abstract class BaseSharedPreferencesService {
|
||||||
static final Map<Type, BaseSharedPreferencesService> _instances = {};
|
static final Map<Type, BaseSharedPreferencesService> _instances = {};
|
||||||
|
static final Map<Type, Future<BaseSharedPreferencesService>> _initializations = {};
|
||||||
|
static int _resetGeneration = 0;
|
||||||
// Single shared cache across all subclasses so writes from one service are
|
// Single shared cache across all subclasses so writes from one service are
|
||||||
// visible to reads from another without per-instance cache divergence.
|
// visible to reads from another without per-instance cache divergence.
|
||||||
static Future<SharedPreferencesWithCache>? _cacheFuture;
|
static Future<SharedPreferencesWithCache>? _cacheFuture;
|
||||||
@@ -29,14 +31,30 @@ abstract class BaseSharedPreferencesService {
|
|||||||
/// - One-time migration from the legacy SharedPreferences API to the
|
/// - One-time migration from the legacy SharedPreferences API to the
|
||||||
/// SharedPreferencesAsync-backed cache (idempotent across launches)
|
/// SharedPreferencesAsync-backed cache (idempotent across launches)
|
||||||
/// - Calling onInit() hook for subclass-specific setup
|
/// - Calling onInit() hook for subclass-specific setup
|
||||||
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) async {
|
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) {
|
||||||
if (_instances[T] == null) {
|
final initialized = _instances[T];
|
||||||
|
if (initialized != null) return Future<T>.value(initialized as T);
|
||||||
|
|
||||||
|
final inFlight = _initializations[T];
|
||||||
|
if (inFlight != null) return inFlight.then((instance) => instance as T);
|
||||||
|
|
||||||
|
final generation = _resetGeneration;
|
||||||
|
final initialization = () async {
|
||||||
final instance = constructor();
|
final instance = constructor();
|
||||||
_instances[T] = instance;
|
|
||||||
instance._cache = await sharedCache();
|
instance._cache = await sharedCache();
|
||||||
await instance.onInit();
|
await instance.onInit();
|
||||||
}
|
if (generation != _resetGeneration) {
|
||||||
return _instances[T] as T;
|
return initializeInstance<T>(constructor);
|
||||||
|
}
|
||||||
|
_instances[T] = instance;
|
||||||
|
return instance;
|
||||||
|
}();
|
||||||
|
_initializations[T] = initialization;
|
||||||
|
return initialization.whenComplete(() {
|
||||||
|
if (identical(_initializations[T], initialization)) {
|
||||||
|
_initializations.remove(T);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared preferences cache used app-wide. Runs the legacy → async
|
/// Shared preferences cache used app-wide. Runs the legacy → async
|
||||||
@@ -59,6 +77,8 @@ abstract class BaseSharedPreferencesService {
|
|||||||
/// `SharedPreferences.setMockInitialValues(...)`. Test-only.
|
/// `SharedPreferences.setMockInitialValues(...)`. Test-only.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void resetForTesting() {
|
static void resetForTesting() {
|
||||||
|
_resetGeneration++;
|
||||||
|
_initializations.clear();
|
||||||
_instances.clear();
|
_instances.clear();
|
||||||
_cacheFuture = null;
|
_cacheFuture = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class DevicePerformance {
|
|||||||
DevicePerformance._();
|
DevicePerformance._();
|
||||||
|
|
||||||
static DevicePerformance? _instance;
|
static DevicePerformance? _instance;
|
||||||
|
static Future<void>? _initialization;
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<void>? debugDetectionGate;
|
||||||
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
||||||
|
|
||||||
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
|
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
|
||||||
@@ -37,15 +40,31 @@ class DevicePerformance {
|
|||||||
/// Get the singleton, detecting hardware signals on first call.
|
/// Get the singleton, detecting hardware signals on first call.
|
||||||
/// [override] is the persisted SettingsService.visualEffects value.
|
/// [override] is the persisted SettingsService.visualEffects value.
|
||||||
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
|
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
|
||||||
if (_instance == null) {
|
final existing = _instance;
|
||||||
_instance = DevicePerformance._();
|
if (existing != null) {
|
||||||
_instance!._override = override;
|
final initialization = _initialization;
|
||||||
await _instance!._detect();
|
if (initialization != null) await initialization;
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
return _instance!;
|
|
||||||
|
final instance = DevicePerformance._().._override = override;
|
||||||
|
_instance = instance;
|
||||||
|
final initialization = instance._detect();
|
||||||
|
_initialization = initialization;
|
||||||
|
try {
|
||||||
|
await initialization;
|
||||||
|
} catch (_) {
|
||||||
|
if (identical(_instance, instance)) _instance = null;
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
if (identical(_initialization, initialization)) _initialization = null;
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _detect() async {
|
Future<void> _detect() async {
|
||||||
|
final gate = debugDetectionGate;
|
||||||
|
if (gate != null) await gate;
|
||||||
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
||||||
try {
|
try {
|
||||||
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
||||||
@@ -139,6 +158,8 @@ class DevicePerformance {
|
|||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
||||||
|
_initialization = null;
|
||||||
|
debugDetectionGate = null;
|
||||||
if (autoReduced == null && override == null) {
|
if (autoReduced == null && override == null) {
|
||||||
_instance = null;
|
_instance = null;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import 'settings_service.dart';
|
|||||||
/// Orchestrates Windows display mode matching (refresh rate, HDR) during video playback.
|
/// Orchestrates Windows display mode matching (refresh rate, HDR) during video playback.
|
||||||
/// Uses the same platform channel as the mpv player (com.plezy/mpv_player).
|
/// Uses the same platform channel as the mpv player (com.plezy/mpv_player).
|
||||||
class DisplayModeService {
|
class DisplayModeService {
|
||||||
static const _channel = MethodChannel('com.plezy/mpv_player');
|
static const _defaultChannel = MethodChannel('com.plezy/mpv_player');
|
||||||
|
|
||||||
final SettingsService _settings;
|
final SettingsService _settings;
|
||||||
final FullscreenStateManager _fullscreen;
|
final FullscreenStateManager _fullscreen;
|
||||||
|
final MethodChannel _channel;
|
||||||
|
final bool? _isWindowsOverride;
|
||||||
|
|
||||||
bool _displayModeChanged = false;
|
bool _displayModeChanged = false;
|
||||||
bool _hdrStateChanged = false;
|
bool _hdrStateChanged = false;
|
||||||
@@ -21,7 +23,18 @@ class DisplayModeService {
|
|||||||
bool get hdrStateChanged => _hdrStateChanged;
|
bool get hdrStateChanged => _hdrStateChanged;
|
||||||
bool get anyChangeApplied => _displayModeChanged || _hdrStateChanged;
|
bool get anyChangeApplied => _displayModeChanged || _hdrStateChanged;
|
||||||
|
|
||||||
DisplayModeService(this._settings, this._fullscreen);
|
DisplayModeService(this._settings, this._fullscreen) : _channel = _defaultChannel, _isWindowsOverride = null;
|
||||||
|
|
||||||
|
factory DisplayModeService.forTesting(
|
||||||
|
SettingsService settings,
|
||||||
|
FullscreenStateManager fullscreen, {
|
||||||
|
required MethodChannel channel,
|
||||||
|
bool isWindows = true,
|
||||||
|
}) => DisplayModeService._(settings, fullscreen, channel, isWindows);
|
||||||
|
|
||||||
|
DisplayModeService._(this._settings, this._fullscreen, this._channel, this._isWindowsOverride);
|
||||||
|
|
||||||
|
bool get _isWindows => _isWindowsOverride ?? Platform.isWindows;
|
||||||
|
|
||||||
/// Apply display matching based on video properties. Returns the delay
|
/// Apply display matching based on video properties. Returns the delay
|
||||||
/// duration to wait before starting playback.
|
/// duration to wait before starting playback.
|
||||||
@@ -30,7 +43,7 @@ class DisplayModeService {
|
|||||||
required double? fallbackFps,
|
required double? fallbackFps,
|
||||||
required double? fallbackSigPeak,
|
required double? fallbackSigPeak,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isWindows) return Duration.zero;
|
if (!_isWindows) return Duration.zero;
|
||||||
if (!_fullscreen.isFullscreen) {
|
if (!_fullscreen.isFullscreen) {
|
||||||
appLogger.d('Display matching skipped: not in fullscreen');
|
appLogger.d('Display matching skipped: not in fullscreen');
|
||||||
return Duration.zero;
|
return Duration.zero;
|
||||||
@@ -68,13 +81,17 @@ class DisplayModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> restoreAll() async {
|
Future<void> restoreAll() async {
|
||||||
if (!Platform.isWindows) return;
|
if (!_isWindows) return;
|
||||||
|
|
||||||
if (_hdrStateChanged) {
|
if (_hdrStateChanged) {
|
||||||
try {
|
try {
|
||||||
await _channel.invokeMethod('restoreSystemHDR');
|
final restored = await _channel.invokeMethod<bool>('restoreSystemHDR');
|
||||||
_hdrStateChanged = false;
|
if (restored == true) {
|
||||||
appLogger.d('Restored system HDR state');
|
_hdrStateChanged = false;
|
||||||
|
appLogger.d('Restored system HDR state');
|
||||||
|
} else {
|
||||||
|
appLogger.w('Native system HDR restore was not accepted; retaining retry state');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to restore system HDR', error: e);
|
appLogger.w('Failed to restore system HDR', error: e);
|
||||||
}
|
}
|
||||||
@@ -82,9 +99,13 @@ class DisplayModeService {
|
|||||||
|
|
||||||
if (_displayModeChanged) {
|
if (_displayModeChanged) {
|
||||||
try {
|
try {
|
||||||
await _channel.invokeMethod('restoreDisplayMode');
|
final restored = await _channel.invokeMethod<bool>('restoreDisplayMode');
|
||||||
_displayModeChanged = false;
|
if (restored == true) {
|
||||||
appLogger.d('Restored display mode');
|
_displayModeChanged = false;
|
||||||
|
appLogger.d('Restored display mode');
|
||||||
|
} else {
|
||||||
|
appLogger.w('Native display mode restore was not accepted; retaining retry state');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to restore display mode', error: e);
|
appLogger.w('Failed to restore display mode', error: e);
|
||||||
}
|
}
|
||||||
@@ -177,7 +198,7 @@ class DisplayModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> syncWithNative() async {
|
Future<void> syncWithNative() async {
|
||||||
if (!Platform.isWindows) return;
|
if (!_isWindows) return;
|
||||||
try {
|
try {
|
||||||
final modeChanged = await _channel.invokeMethod<bool>('isModeChanged');
|
final modeChanged = await _channel.invokeMethod<bool>('isModeChanged');
|
||||||
_displayModeChanged = modeChanged ?? false;
|
_displayModeChanged = modeChanged ?? false;
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
|
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
|
||||||
|
|
||||||
static KeyboardShortcutsService? _instance;
|
static KeyboardShortcutsService? _instance;
|
||||||
|
static Future<void>? _initialization;
|
||||||
late final SettingsBindingOwner _settingsBinding;
|
late final SettingsBindingOwner _settingsBinding;
|
||||||
Map<String, HotKey?> _hotkeys = {};
|
Map<String, HotKey?> _hotkeys = {};
|
||||||
Future<void> _shortcutMutationTail = Future.value();
|
Future<void> _shortcutMutationTail = Future.value();
|
||||||
int _seekTimeSmall = 10; // Default, loaded from settings
|
int _seekTimeSmall = 10; // Default, loaded from settings
|
||||||
int _seekTimeLarge = 30; // Default, loaded from settings
|
int _seekTimeLarge = 30; // Default, loaded from settings
|
||||||
|
bool _disposed = false;
|
||||||
bool _settingsInitialized = false;
|
bool _settingsInitialized = false;
|
||||||
|
|
||||||
KeyboardShortcutsService._() {
|
KeyboardShortcutsService._() {
|
||||||
@@ -32,11 +34,31 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
SettingsService get _settingsService => _settingsBinding.settings!;
|
SettingsService get _settingsService => _settingsBinding.settings!;
|
||||||
|
|
||||||
static Future<KeyboardShortcutsService> getInstance() async {
|
static Future<KeyboardShortcutsService> getInstance() async {
|
||||||
if (_instance == null) {
|
var instance = _instance;
|
||||||
_instance = KeyboardShortcutsService._();
|
if (instance == null) {
|
||||||
await _instance!._init();
|
instance = KeyboardShortcutsService._();
|
||||||
|
_instance = instance;
|
||||||
|
final initialization = instance._init();
|
||||||
|
_initialization = initialization;
|
||||||
}
|
}
|
||||||
return _instance!;
|
|
||||||
|
final initialization = _initialization;
|
||||||
|
if (initialization != null) {
|
||||||
|
try {
|
||||||
|
await initialization;
|
||||||
|
} catch (_) {
|
||||||
|
if (identical(_instance, instance)) {
|
||||||
|
instance._settingsBinding.dispose();
|
||||||
|
instance._disposed = true;
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
if (identical(_initialization, initialization)) _initialization = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (instance._disposed) throw StateError('KeyboardShortcutsService was disposed during initialization');
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keyboard shortcut customization is only supported on desktop platforms.
|
/// Keyboard shortcut customization is only supported on desktop platforms.
|
||||||
@@ -112,8 +134,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
_settingsBinding.dispose();
|
_settingsBinding.dispose();
|
||||||
if (identical(_instance, this)) _instance = null;
|
if (identical(_instance, this)) {
|
||||||
|
_instance = null;
|
||||||
|
_initialization = null;
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ class SystemShelfService {
|
|||||||
final result = await _enqueueMutation<bool>(() async {
|
final result = await _enqueueMutation<bool>(() async {
|
||||||
if (!_owns(profileId, generation)) return false;
|
if (!_owns(profileId, generation)) return false;
|
||||||
try {
|
try {
|
||||||
if (!_owns(profileId, generation)) return false;
|
|
||||||
return await channel.invokeMethod<bool>('sync', {
|
return await channel.invokeMethod<bool>('sync', {
|
||||||
'schemaVersion': schemaVersion,
|
'schemaVersion': schemaVersion,
|
||||||
'ownerId': profileId,
|
'ownerId': profileId,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> fea
|
|||||||
/// Service for detecting if the app is running on Android TV or Apple TV.
|
/// Service for detecting if the app is running on Android TV or Apple TV.
|
||||||
class TvDetectionService {
|
class TvDetectionService {
|
||||||
static TvDetectionService? _instance;
|
static TvDetectionService? _instance;
|
||||||
|
static Future<void>? _initialization;
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<void>? debugDetectionGate;
|
||||||
static bool? _debugAppleTVOverride;
|
static bool? _debugAppleTVOverride;
|
||||||
bool _detected = false;
|
bool _detected = false;
|
||||||
bool _forceTv = false;
|
bool _forceTv = false;
|
||||||
@@ -44,17 +47,34 @@ class TvDetectionService {
|
|||||||
/// Get the singleton instance, initializing if needed.
|
/// Get the singleton instance, initializing if needed.
|
||||||
/// Pass [forceTv] to combine a user override with the system-feature check.
|
/// Pass [forceTv] to combine a user override with the system-feature check.
|
||||||
static Future<TvDetectionService> getInstance({bool forceTv = false}) async {
|
static Future<TvDetectionService> getInstance({bool forceTv = false}) async {
|
||||||
if (_instance == null) {
|
final existing = _instance;
|
||||||
_instance = TvDetectionService._();
|
if (existing != null) {
|
||||||
await _instance!._detect(forceTv);
|
final initialization = _initialization;
|
||||||
|
if (initialization != null) await initialization;
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
return _instance!;
|
|
||||||
|
final instance = TvDetectionService._();
|
||||||
|
_instance = instance;
|
||||||
|
final initialization = instance._detect(forceTv);
|
||||||
|
_initialization = initialization;
|
||||||
|
try {
|
||||||
|
await initialization;
|
||||||
|
} catch (_) {
|
||||||
|
if (identical(_instance, instance)) _instance = null;
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
if (identical(_initialization, initialization)) _initialization = null;
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
||||||
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
||||||
|
|
||||||
Future<void> _detect(bool forceTv) async {
|
Future<void> _detect(bool forceTv) async {
|
||||||
|
final gate = debugDetectionGate;
|
||||||
|
if (gate != null) await gate;
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
@@ -147,6 +167,14 @@ class TvDetectionService {
|
|||||||
_debugAppleTVOverride = value;
|
_debugAppleTVOverride = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void debugReset() {
|
||||||
|
_instance = null;
|
||||||
|
_initialization = null;
|
||||||
|
debugDetectionGate = null;
|
||||||
|
_debugAppleTVOverride = null;
|
||||||
|
}
|
||||||
|
|
||||||
static List<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
|
static List<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
|
||||||
|
|
||||||
/// Convenience setter that forwards to the singleton if available.
|
/// Convenience setter that forwards to the singleton if available.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
cmake_minimum_required(VERSION 3.13)
|
cmake_minimum_required(VERSION 3.13)
|
||||||
project(runner LANGUAGES CXX)
|
project(runner LANGUAGES CXX)
|
||||||
|
|
||||||
|
# Testing must be enabled at the project root so CTest can discover tests
|
||||||
|
# registered by the runner subdirectory.
|
||||||
|
include(CTest)
|
||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
# The name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
set(BINARY_NAME "plezy")
|
set(BINARY_NAME "plezy")
|
||||||
@@ -34,6 +38,19 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
|||||||
"Debug" "Profile" "Release")
|
"Debug" "Profile" "Release")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Direct CMake configurations (used by the native reliability tests) do not
|
||||||
|
# receive Flutter's target-platform argument. Derive the two supported Linux
|
||||||
|
# target names so flutter_assemble still receives both required arguments.
|
||||||
|
if(NOT FLUTTER_TARGET_PLATFORM)
|
||||||
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$")
|
||||||
|
set(FLUTTER_TARGET_PLATFORM "linux-arm64")
|
||||||
|
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")
|
||||||
|
set(FLUTTER_TARGET_PLATFORM "linux-x64")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Unsupported Linux architecture: ${CMAKE_SYSTEM_PROCESSOR}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# Compilation settings that should be applied to most targets.
|
# Compilation settings that should be applied to most targets.
|
||||||
#
|
#
|
||||||
# Be cautious about adding new options here, as plugins use this function by
|
# Be cautious about adding new options here, as plugins use this function by
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ add_executable(${BINARY_NAME}
|
|||||||
"main.cc"
|
"main.cc"
|
||||||
"my_application.cc"
|
"my_application.cc"
|
||||||
"mpv/mpv_player.cc"
|
"mpv/mpv_player.cc"
|
||||||
|
"mpv/mpv_gpu_bootstrap.cc"
|
||||||
"mpv/mpv_plugin.cc"
|
"mpv/mpv_plugin.cc"
|
||||||
"mpv/mpv_texture.cc"
|
"mpv/mpv_texture.cc"
|
||||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||||
@@ -31,7 +32,7 @@ pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
|
|||||||
# Build simdutf as a static library from the single-header amalgamation.
|
# Build simdutf as a static library from the single-header amalgamation.
|
||||||
add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp")
|
add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp")
|
||||||
target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}")
|
target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}")
|
||||||
target_compile_features(simdutf PUBLIC cxx_std_17)
|
target_compile_features(simdutf PUBLIC cxx_std_14)
|
||||||
# Suppress warnings in third-party code
|
# Suppress warnings in third-party code
|
||||||
target_compile_options(simdutf PRIVATE -w)
|
target_compile_options(simdutf PRIVATE -w)
|
||||||
|
|
||||||
@@ -45,17 +46,30 @@ target_link_libraries(${BINARY_NAME} PRIVATE simdutf)
|
|||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
|
||||||
|
|
||||||
|
function(check_mpv_sanitizer_support SANITIZER_FLAG RESULT_VARIABLE)
|
||||||
|
include(CheckCXXSourceCompiles)
|
||||||
|
# Force an executable try-compile: sanitizer availability depends on the
|
||||||
|
# runtime being linkable, not just on the compiler accepting the flag.
|
||||||
|
set(CMAKE_TRY_COMPILE_TARGET_TYPE EXECUTABLE)
|
||||||
|
set(CMAKE_REQUIRED_FLAGS "${SANITIZER_FLAG}")
|
||||||
|
set(CMAKE_REQUIRED_LIBRARIES "${SANITIZER_FLAG}")
|
||||||
|
check_cxx_source_compiles("int main() { return 0; }" ${RESULT_VARIABLE})
|
||||||
|
set(${RESULT_VARIABLE} "${${RESULT_VARIABLE}}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
option(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS
|
option(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS
|
||||||
"Build the focused Linux mpv callback lifecycle test" OFF)
|
"Build the focused Linux mpv callback lifecycle test" OFF)
|
||||||
if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
|
if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
|
||||||
enable_testing()
|
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
add_executable(mpv_player_lifecycle_test
|
add_executable(mpv_player_lifecycle_test
|
||||||
"mpv/mpv_player.cc"
|
"mpv/mpv_player.cc"
|
||||||
|
"mpv/mpv_gpu_bootstrap.cc"
|
||||||
|
"mpv/mpv_texture.cc"
|
||||||
"mpv/mpv_player_lifecycle_test.cc"
|
"mpv/mpv_player_lifecycle_test.cc"
|
||||||
)
|
)
|
||||||
apply_standard_settings(mpv_player_lifecycle_test)
|
apply_standard_settings(mpv_player_lifecycle_test)
|
||||||
|
target_compile_definitions(mpv_player_lifecycle_test PRIVATE PLEZY_MPV_PLAYER_LIFECYCLE_TEST=1)
|
||||||
target_link_libraries(mpv_player_lifecycle_test PRIVATE flutter)
|
target_link_libraries(mpv_player_lifecycle_test PRIVATE flutter)
|
||||||
target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::GTK)
|
target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::GTK)
|
||||||
target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::MPV)
|
target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::MPV)
|
||||||
@@ -68,8 +82,8 @@ if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
|
|||||||
option(PLEZY_MPV_LIFECYCLE_SANITIZERS
|
option(PLEZY_MPV_LIFECYCLE_SANITIZERS
|
||||||
"Enable ASan and UBSan for the focused mpv lifecycle test" ON)
|
"Enable ASan and UBSan for the focused mpv lifecycle test" ON)
|
||||||
if(PLEZY_MPV_LIFECYCLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
if(PLEZY_MPV_LIFECYCLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
||||||
include(CheckCXXCompilerFlag)
|
check_mpv_sanitizer_support(
|
||||||
check_cxx_compiler_flag("-fsanitize=address,undefined" MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
|
"-fsanitize=address,undefined" MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
|
||||||
if(MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
|
if(MPV_LIFECYCLE_SANITIZERS_SUPPORTED)
|
||||||
target_compile_options(mpv_player_lifecycle_test PRIVATE -fno-omit-frame-pointer -fsanitize=address,undefined)
|
target_compile_options(mpv_player_lifecycle_test PRIVATE -fno-omit-frame-pointer -fsanitize=address,undefined)
|
||||||
target_link_options(mpv_player_lifecycle_test PRIVATE -fsanitize=address,undefined)
|
target_link_options(mpv_player_lifecycle_test PRIVATE -fsanitize=address,undefined)
|
||||||
@@ -77,20 +91,63 @@ if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_test(NAME mpv_player_lifecycle_test COMMAND mpv_player_lifecycle_test)
|
add_test(NAME mpv_player_lifecycle_test COMMAND mpv_player_lifecycle_test)
|
||||||
|
set_tests_properties(mpv_player_lifecycle_test PROPERTIES TIMEOUT 30)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
option(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS
|
option(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS
|
||||||
"Build the focused desktop mpv property-result contract test" OFF)
|
"Build the focused desktop mpv property-result contract test" OFF)
|
||||||
if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS)
|
option(PLEZY_BUILD_MPV_RELIABILITY_TESTS
|
||||||
enable_testing()
|
"Build focused Linux mpv registry and GPU bootstrap tests" OFF)
|
||||||
find_package(Threads REQUIRED)
|
set(PLEZY_MPV_RELIABILITY_SANITIZER "none" CACHE STRING
|
||||||
|
"Sanitizer for focused mpv reliability tests: none, address, or thread")
|
||||||
|
set_property(CACHE PLEZY_MPV_RELIABILITY_SANITIZER PROPERTY STRINGS none address thread)
|
||||||
|
|
||||||
|
function(apply_mpv_reliability_sanitizer TARGET)
|
||||||
|
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU" OR
|
||||||
|
PLEZY_MPV_RELIABILITY_SANITIZER STREQUAL "none")
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
if(PLEZY_MPV_RELIABILITY_SANITIZER STREQUAL "address")
|
||||||
|
set(SANITIZER_FLAG "-fsanitize=address,undefined")
|
||||||
|
set(SANITIZER_SUPPORT_VARIABLE MPV_RELIABILITY_ADDRESS_SANITIZER_SUPPORTED)
|
||||||
|
elseif(PLEZY_MPV_RELIABILITY_SANITIZER STREQUAL "thread")
|
||||||
|
set(SANITIZER_FLAG "-fsanitize=thread")
|
||||||
|
set(SANITIZER_SUPPORT_VARIABLE MPV_RELIABILITY_THREAD_SANITIZER_SUPPORTED)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Unknown PLEZY_MPV_RELIABILITY_SANITIZER value")
|
||||||
|
endif()
|
||||||
|
check_mpv_sanitizer_support("${SANITIZER_FLAG}" ${SANITIZER_SUPPORT_VARIABLE})
|
||||||
|
if(NOT ${SANITIZER_SUPPORT_VARIABLE})
|
||||||
|
message(WARNING
|
||||||
|
"${PLEZY_MPV_RELIABILITY_SANITIZER} sanitizer is unavailable; focused tests will be unsanitized")
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
target_compile_options(${TARGET} PRIVATE -fno-omit-frame-pointer ${SANITIZER_FLAG})
|
||||||
|
target_link_options(${TARGET} PRIVATE ${SANITIZER_FLAG})
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS OR PLEZY_BUILD_MPV_RELIABILITY_TESTS)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
add_executable(mpv_property_result_contract_test
|
add_executable(mpv_property_result_contract_test
|
||||||
"../../shared/mpv/mpv_player_common_test.cpp"
|
"../../shared/mpv/mpv_player_common_test.cpp"
|
||||||
)
|
)
|
||||||
apply_standard_settings(mpv_property_result_contract_test)
|
apply_standard_settings(mpv_property_result_contract_test)
|
||||||
|
target_compile_features(mpv_property_result_contract_test PRIVATE cxx_std_14)
|
||||||
target_link_libraries(mpv_property_result_contract_test PRIVATE PkgConfig::MPV Threads::Threads)
|
target_link_libraries(mpv_property_result_contract_test PRIVATE PkgConfig::MPV Threads::Threads)
|
||||||
target_include_directories(mpv_property_result_contract_test PRIVATE "../../shared/mpv")
|
target_include_directories(mpv_property_result_contract_test PRIVATE "../../shared/mpv")
|
||||||
|
apply_mpv_reliability_sanitizer(mpv_property_result_contract_test)
|
||||||
add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test)
|
add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(PLEZY_BUILD_MPV_RELIABILITY_TESTS)
|
||||||
|
add_executable(mpv_gpu_bootstrap_test
|
||||||
|
"mpv/mpv_gpu_bootstrap.cc"
|
||||||
|
"mpv/mpv_gpu_bootstrap_test.cc"
|
||||||
|
)
|
||||||
|
apply_standard_settings(mpv_gpu_bootstrap_test)
|
||||||
|
target_compile_features(mpv_gpu_bootstrap_test PRIVATE cxx_std_14)
|
||||||
|
target_link_libraries(mpv_gpu_bootstrap_test PRIVATE PkgConfig::EPOXY)
|
||||||
|
target_include_directories(mpv_gpu_bootstrap_test PRIVATE "mpv")
|
||||||
|
apply_mpv_reliability_sanitizer(mpv_gpu_bootstrap_test)
|
||||||
|
add_test(NAME mpv_gpu_bootstrap_test COMMAND mpv_gpu_bootstrap_test)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#include "mpv_gpu_bootstrap.h"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace mpv {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool HasExtension(const char* extensions, const char* requested) {
|
||||||
|
if (!extensions || !requested || requested[0] == '\0' || std::strchr(requested, ' ')) return false;
|
||||||
|
const size_t requested_length = std::strlen(requested);
|
||||||
|
const char* current = extensions;
|
||||||
|
while ((current = std::strstr(current, requested)) != nullptr) {
|
||||||
|
const bool starts_token = current == extensions || current[-1] == ' ';
|
||||||
|
const char following = current[requested_length];
|
||||||
|
if (starts_token && (following == '\0' || following == ' ')) return true;
|
||||||
|
current += requested_length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ParseEglVersion(const char* version, int* major, int* minor) {
|
||||||
|
if (!version || !major || !minor) return false;
|
||||||
|
char* end = nullptr;
|
||||||
|
const long parsed_major = std::strtol(version, &end, 10);
|
||||||
|
if (end == version || *end != '.') return false;
|
||||||
|
const char* minor_start = end + 1;
|
||||||
|
const long parsed_minor = std::strtol(minor_start, &end, 10);
|
||||||
|
if (end == minor_start || parsed_major < 0 || parsed_minor < 0) return false;
|
||||||
|
*major = static_cast<int>(parsed_major);
|
||||||
|
*minor = static_cast<int>(parsed_minor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AtLeastEgl15(const GpuBootstrapProbe& probe) {
|
||||||
|
return probe.egl_major > 1 || (probe.egl_major == 1 && probe.egl_minor >= 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fail(std::string* error, const char* message) {
|
||||||
|
if (error) *error = message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
EGLImageKHR GpuImageDispatch::Create(EGLDisplay display, EGLContext context, EGLClientBuffer buffer) const {
|
||||||
|
if (uses_core) {
|
||||||
|
if (!create_image_core) return EGL_NO_IMAGE_KHR;
|
||||||
|
const EGLAttrib attributes[] = {EGL_NONE};
|
||||||
|
return reinterpret_cast<EGLImageKHR>(
|
||||||
|
create_image_core(display, context, EGL_GL_TEXTURE_2D_KHR, buffer, attributes));
|
||||||
|
}
|
||||||
|
if (!create_image_khr) return EGL_NO_IMAGE_KHR;
|
||||||
|
const EGLint attributes[] = {EGL_NONE};
|
||||||
|
return create_image_khr(display, context, EGL_GL_TEXTURE_2D_KHR, buffer, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GpuImageDispatch::Destroy(EGLDisplay display, EGLImageKHR image) const {
|
||||||
|
if (image == EGL_NO_IMAGE_KHR) return true;
|
||||||
|
if (uses_core) {
|
||||||
|
return destroy_image_core && destroy_image_core(display, reinterpret_cast<EGLImage>(image)) == EGL_TRUE;
|
||||||
|
}
|
||||||
|
return destroy_image_khr && destroy_image_khr(display, image) == EGL_TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
GpuImageDispatch::operator bool() const {
|
||||||
|
const bool image_functions =
|
||||||
|
uses_core ? create_image_core && destroy_image_core : create_image_khr && destroy_image_khr;
|
||||||
|
return image_functions && image_target_texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ValidateGpuBootstrapProbe(const GpuBootstrapProbe& probe, std::string* error) {
|
||||||
|
const bool egl15 = AtLeastEgl15(probe);
|
||||||
|
if (!egl15 && !HasExtension(probe.egl_extensions, "EGL_KHR_surfaceless_context")) {
|
||||||
|
return Fail(error, "EGL surfaceless contexts are unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool core_images = egl15 && probe.create_image_core && probe.destroy_image_core;
|
||||||
|
const bool has_khr_image_extension =
|
||||||
|
HasExtension(probe.egl_extensions, "EGL_KHR_image") || HasExtension(probe.egl_extensions, "EGL_KHR_image_base");
|
||||||
|
const bool khr_images = has_khr_image_extension && probe.create_image_khr && probe.destroy_image_khr;
|
||||||
|
if (!core_images && !khr_images) {
|
||||||
|
return Fail(error, "EGL image creation is unavailable");
|
||||||
|
}
|
||||||
|
if (!HasExtension(probe.gl_extensions, "GL_OES_EGL_image")) {
|
||||||
|
return Fail(error, "OpenGL EGL image binding is unavailable");
|
||||||
|
}
|
||||||
|
if (!probe.image_target_texture) {
|
||||||
|
return Fail(error, "OpenGL EGL image entry point is unavailable");
|
||||||
|
}
|
||||||
|
if (error) error->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ResolveGpuImageDispatch(EGLDisplay display, GpuImageDispatch* dispatch, std::string* error) {
|
||||||
|
if (!dispatch || display == EGL_NO_DISPLAY || eglGetCurrentContext() == EGL_NO_CONTEXT) {
|
||||||
|
return Fail(error, "No current EGL context is available");
|
||||||
|
}
|
||||||
|
|
||||||
|
GpuBootstrapProbe probe;
|
||||||
|
if (!ParseEglVersion(eglQueryString(display, EGL_VERSION), &probe.egl_major, &probe.egl_minor)) {
|
||||||
|
return Fail(error, "EGL version is unavailable");
|
||||||
|
}
|
||||||
|
probe.egl_extensions = eglQueryString(display, EGL_EXTENSIONS);
|
||||||
|
probe.gl_extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
|
||||||
|
probe.create_image_core = reinterpret_cast<void*>(eglGetProcAddress("eglCreateImage"));
|
||||||
|
probe.destroy_image_core = reinterpret_cast<void*>(eglGetProcAddress("eglDestroyImage"));
|
||||||
|
probe.create_image_khr = reinterpret_cast<void*>(eglGetProcAddress("eglCreateImageKHR"));
|
||||||
|
probe.destroy_image_khr = reinterpret_cast<void*>(eglGetProcAddress("eglDestroyImageKHR"));
|
||||||
|
probe.image_target_texture = reinterpret_cast<void*>(eglGetProcAddress("glEGLImageTargetTexture2DOES"));
|
||||||
|
if (!ValidateGpuBootstrapProbe(probe, error)) return false;
|
||||||
|
|
||||||
|
GpuImageDispatch resolved;
|
||||||
|
const bool egl15 = AtLeastEgl15(probe);
|
||||||
|
if (egl15 && probe.create_image_core && probe.destroy_image_core) {
|
||||||
|
resolved.uses_core = true;
|
||||||
|
resolved.create_image_core = reinterpret_cast<EglCreateImageCoreProc>(probe.create_image_core);
|
||||||
|
resolved.destroy_image_core = reinterpret_cast<EglDestroyImageCoreProc>(probe.destroy_image_core);
|
||||||
|
} else {
|
||||||
|
resolved.create_image_khr = reinterpret_cast<EglCreateImageKhrProc>(probe.create_image_khr);
|
||||||
|
resolved.destroy_image_khr = reinterpret_cast<EglDestroyImageKhrProc>(probe.destroy_image_khr);
|
||||||
|
}
|
||||||
|
resolved.image_target_texture = reinterpret_cast<GlImageTargetTextureProc>(probe.image_target_texture);
|
||||||
|
if (!resolved) return Fail(error, "GPU image dispatch is incomplete");
|
||||||
|
*dispatch = resolved;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mpv
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#ifndef MPV_GPU_BOOTSTRAP_H_
|
||||||
|
#define MPV_GPU_BOOTSTRAP_H_
|
||||||
|
|
||||||
|
#include <epoxy/egl.h>
|
||||||
|
#include <epoxy/gl.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace mpv {
|
||||||
|
|
||||||
|
using EglCreateImageCoreProc = EGLImage (*)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLAttrib*);
|
||||||
|
using EglDestroyImageCoreProc = EGLBoolean (*)(EGLDisplay, EGLImage);
|
||||||
|
using EglCreateImageKhrProc = EGLImageKHR (*)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*);
|
||||||
|
using EglDestroyImageKhrProc = EGLBoolean (*)(EGLDisplay, EGLImageKHR);
|
||||||
|
using GlImageTargetTextureProc = void (*)(GLenum, GLeglImageOES);
|
||||||
|
|
||||||
|
struct GpuBootstrapProbe {
|
||||||
|
int egl_major = 0;
|
||||||
|
int egl_minor = 0;
|
||||||
|
const char* egl_extensions = nullptr;
|
||||||
|
const char* gl_extensions = nullptr;
|
||||||
|
void* create_image_core = nullptr;
|
||||||
|
void* destroy_image_core = nullptr;
|
||||||
|
void* create_image_khr = nullptr;
|
||||||
|
void* destroy_image_khr = nullptr;
|
||||||
|
void* image_target_texture = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GpuImageDispatch {
|
||||||
|
bool uses_core = false;
|
||||||
|
EglCreateImageCoreProc create_image_core = nullptr;
|
||||||
|
EglDestroyImageCoreProc destroy_image_core = nullptr;
|
||||||
|
EglCreateImageKhrProc create_image_khr = nullptr;
|
||||||
|
EglDestroyImageKhrProc destroy_image_khr = nullptr;
|
||||||
|
GlImageTargetTextureProc image_target_texture = nullptr;
|
||||||
|
|
||||||
|
EGLImageKHR Create(EGLDisplay display, EGLContext context, EGLClientBuffer buffer) const;
|
||||||
|
bool Destroy(EGLDisplay display, EGLImageKHR image) const;
|
||||||
|
explicit operator bool() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ValidateGpuBootstrapProbe(const GpuBootstrapProbe& probe, std::string* error);
|
||||||
|
bool ResolveGpuImageDispatch(EGLDisplay display, GpuImageDispatch* dispatch, std::string* error);
|
||||||
|
|
||||||
|
} // namespace mpv
|
||||||
|
|
||||||
|
#endif // MPV_GPU_BOOTSTRAP_H_
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#include "mpv_gpu_bootstrap.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int create_calls = 0;
|
||||||
|
int destroy_calls = 0;
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Expect(bool condition, const char* expression, int line) {
|
||||||
|
if (condition) return;
|
||||||
|
std::cerr << "line " << line << ": check failed: " << expression << '\n';
|
||||||
|
++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define EXPECT(condition) Expect(static_cast<bool>(condition), #condition, __LINE__)
|
||||||
|
|
||||||
|
EGLImageKHR CreateImageKhr(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*) {
|
||||||
|
++create_calls;
|
||||||
|
return reinterpret_cast<EGLImageKHR>(0x1234);
|
||||||
|
}
|
||||||
|
|
||||||
|
EGLBoolean DestroyImageKhr(EGLDisplay, EGLImageKHR image) {
|
||||||
|
EXPECT(image == reinterpret_cast<EGLImageKHR>(0x1234));
|
||||||
|
++destroy_calls;
|
||||||
|
return EGL_TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BindImage(GLenum, GLeglImageOES) {}
|
||||||
|
|
||||||
|
template <typename Function>
|
||||||
|
void* Address(Function function) {
|
||||||
|
return reinterpret_cast<void*>(function);
|
||||||
|
}
|
||||||
|
|
||||||
|
mpv::GpuBootstrapProbe SupportedKhrProbe() {
|
||||||
|
mpv::GpuBootstrapProbe probe;
|
||||||
|
probe.egl_major = 1;
|
||||||
|
probe.egl_minor = 4;
|
||||||
|
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image_base";
|
||||||
|
probe.gl_extensions = "GL_EXT_texture GL_OES_EGL_image";
|
||||||
|
probe.create_image_khr = Address(CreateImageKhr);
|
||||||
|
probe.destroy_image_khr = Address(DestroyImageKhr);
|
||||||
|
probe.image_target_texture = Address(BindImage);
|
||||||
|
return probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestKhrCapabilitiesFailClosed() {
|
||||||
|
std::string error;
|
||||||
|
auto probe = SupportedKhrProbe();
|
||||||
|
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
EXPECT(error.empty());
|
||||||
|
|
||||||
|
probe.egl_extensions = "EGL_KHR_image_base";
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image";
|
||||||
|
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.egl_extensions = "EGL_KHR_surfaceless_context EGL_KHR_image_suffix";
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.egl_extensions = "EGL_KHR_surfaceless_context";
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.gl_extensions = "GL_OES_EGL_image_external";
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.create_image_khr = nullptr;
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.destroy_image_khr = nullptr;
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.image_target_texture = nullptr;
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestCoreCapabilities() {
|
||||||
|
std::string error;
|
||||||
|
auto probe = SupportedKhrProbe();
|
||||||
|
probe.egl_major = 1;
|
||||||
|
probe.egl_minor = 5;
|
||||||
|
probe.egl_extensions = "";
|
||||||
|
probe.create_image_khr = nullptr;
|
||||||
|
probe.destroy_image_khr = nullptr;
|
||||||
|
probe.create_image_core = Address(CreateImageKhr);
|
||||||
|
probe.destroy_image_core = Address(DestroyImageKhr);
|
||||||
|
EXPECT(mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
|
||||||
|
probe.create_image_core = nullptr;
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
probe = SupportedKhrProbe();
|
||||||
|
probe.egl_major = 0;
|
||||||
|
probe.egl_minor = 0;
|
||||||
|
probe.egl_extensions = "";
|
||||||
|
EXPECT(!mpv::ValidateGpuBootstrapProbe(probe, &error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestDispatchChecksBeforeCalls() {
|
||||||
|
mpv::GpuImageDispatch dispatch;
|
||||||
|
EXPECT(!dispatch);
|
||||||
|
EXPECT(dispatch.Create(EGL_NO_DISPLAY, EGL_NO_CONTEXT, nullptr) == EGL_NO_IMAGE_KHR);
|
||||||
|
EXPECT(!dispatch.Destroy(EGL_NO_DISPLAY, reinterpret_cast<EGLImageKHR>(0x1234)));
|
||||||
|
EXPECT(create_calls == 0);
|
||||||
|
EXPECT(destroy_calls == 0);
|
||||||
|
|
||||||
|
dispatch.create_image_khr = CreateImageKhr;
|
||||||
|
dispatch.destroy_image_khr = DestroyImageKhr;
|
||||||
|
dispatch.image_target_texture = BindImage;
|
||||||
|
EXPECT(dispatch);
|
||||||
|
const auto image = dispatch.Create(EGL_NO_DISPLAY, EGL_NO_CONTEXT, nullptr);
|
||||||
|
EXPECT(image == reinterpret_cast<EGLImageKHR>(0x1234));
|
||||||
|
EXPECT(dispatch.Destroy(EGL_NO_DISPLAY, image));
|
||||||
|
EXPECT(create_calls == 1);
|
||||||
|
EXPECT(destroy_calls == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
TestKhrCapabilitiesFailClosed();
|
||||||
|
TestCoreCapabilities();
|
||||||
|
TestDispatchChecksBeforeCalls();
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
+463
-152
@@ -10,10 +10,25 @@
|
|||||||
#ifdef GDK_WINDOWING_WAYLAND
|
#ifdef GDK_WINDOWING_WAYLAND
|
||||||
#include <gdk/gdkwayland.h>
|
#include <gdk/gdkwayland.h>
|
||||||
#endif
|
#endif
|
||||||
#include <clocale>
|
#include <locale.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
#include "sanitize_utf8.h"
|
#include "sanitize_utf8.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool EnsureProcessNumericLocale() {
|
||||||
|
// libmpv parses numeric options on worker threads, so a thread-local locale
|
||||||
|
// is insufficient. This process-wide setting intentionally remains in force
|
||||||
|
// for the rest of the process after the first player starts.
|
||||||
|
static const bool configured = setlocale(LC_NUMERIC, "C") != nullptr;
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
|
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
|
||||||
static void* get_opengl_proc_address(void* ctx, const char* name) {
|
static void* get_opengl_proc_address(void* ctx, const char* name) {
|
||||||
(void)ctx;
|
(void)ctx;
|
||||||
@@ -21,6 +36,171 @@ static void* get_opengl_proc_address(void* ctx, const char* name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
NativeRenderTeardownOperations ProductionTeardownOperations() {
|
||||||
|
return {
|
||||||
|
[](EGLDisplay display, EGLContext context) {
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)) {
|
||||||
|
g_warning("MPV: Failed to activate EGL context for teardown: 0x%x", eglGetError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[](EGLDisplay display) {
|
||||||
|
if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
||||||
|
g_warning("MPV: Failed to release EGL context during teardown: 0x%x", eglGetError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[](EGLDisplay display, EGLContext context) {
|
||||||
|
if (!eglDestroyContext(display, context)) {
|
||||||
|
g_warning("MPV: Failed to destroy EGL context during teardown: 0x%x", eglGetError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[](mpv_render_context* render) { mpv_render_context_free(render); },
|
||||||
|
[](mpv_handle* handle) { mpv_terminate_destroy(handle); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
||||||
|
NativeRenderTeardownOperations*& TestTeardownOperationsOverride() {
|
||||||
|
static NativeRenderTeardownOperations* operations = nullptr;
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class NativeRenderTeardownQueue {
|
||||||
|
public:
|
||||||
|
static NativeRenderTeardownQueue& Instance() {
|
||||||
|
// Native driver/libmpv teardown can block indefinitely. Keep both the
|
||||||
|
// queue and its worker state alive until the OS ends the process so static
|
||||||
|
// destruction never joins the worker or invalidates state it may access.
|
||||||
|
static NativeRenderTeardownQueue* const queue = new NativeRenderTeardownQueue();
|
||||||
|
return *queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Enqueue(NativeRenderTeardownBatch batch) {
|
||||||
|
if (batch.resources.empty() && !batch.handle) return;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
batches_.push_back(std::move(batch));
|
||||||
|
++generation_;
|
||||||
|
}
|
||||||
|
condition_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Retry() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
++generation_;
|
||||||
|
}
|
||||||
|
condition_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
NativeRenderTeardownQueue() : worker_([this]() { Run(); }) {}
|
||||||
|
|
||||||
|
void Run() {
|
||||||
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
||||||
|
const NativeRenderTeardownOperations operations =
|
||||||
|
TestTeardownOperationsOverride() ? *TestTeardownOperationsOverride() : ProductionTeardownOperations();
|
||||||
|
#else
|
||||||
|
const NativeRenderTeardownOperations operations = ProductionTeardownOperations();
|
||||||
|
#endif
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
for (;;) {
|
||||||
|
condition_.wait(lock, [this]() { return !batches_.empty(); });
|
||||||
|
const uint64_t observed_generation = generation_;
|
||||||
|
std::vector<NativeRenderTeardownBatch> work = std::move(batches_);
|
||||||
|
batches_.clear();
|
||||||
|
|
||||||
|
// EGL activation and mpv shutdown can block in a driver. Keep queue
|
||||||
|
// admission independent so replacement initialization and disposal only
|
||||||
|
// pay the short ownership-transfer critical section.
|
||||||
|
lock.unlock();
|
||||||
|
std::vector<NativeRenderTeardownBatch> retry;
|
||||||
|
for (auto& batch : work) {
|
||||||
|
if (!TryReleaseNativeRenderTeardown(batch, operations)) retry.push_back(std::move(batch));
|
||||||
|
}
|
||||||
|
lock.lock();
|
||||||
|
for (auto& batch : retry) batches_.push_back(std::move(batch));
|
||||||
|
|
||||||
|
if (batches_.empty()) continue;
|
||||||
|
|
||||||
|
condition_.wait_for(lock, std::chrono::milliseconds(100), [this, observed_generation]() {
|
||||||
|
return generation_ != observed_generation;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::condition_variable condition_;
|
||||||
|
std::vector<NativeRenderTeardownBatch> batches_;
|
||||||
|
uint64_t generation_ = 0;
|
||||||
|
std::thread worker_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
||||||
|
void ConfigureNativeRenderTeardownQueueForTesting(NativeRenderTeardownOperations operations) {
|
||||||
|
auto*& configured = TestTeardownOperationsOverride();
|
||||||
|
if (configured) {
|
||||||
|
*configured = std::move(operations);
|
||||||
|
} else {
|
||||||
|
configured = new NativeRenderTeardownOperations(std::move(operations));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnqueueNativeRenderTeardownForTesting(NativeRenderTeardownBatch batch) {
|
||||||
|
NativeRenderTeardownQueue::Instance().Enqueue(std::move(batch));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
bool TryReleaseNativeRenderTeardown(
|
||||||
|
NativeRenderTeardownBatch& batch, const NativeRenderTeardownOperations& operations) {
|
||||||
|
for (auto it = batch.resources.begin(); it != batch.resources.end();) {
|
||||||
|
if (it->context == EGL_NO_CONTEXT || !operations.make_current(it->display, it->context)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it->render) {
|
||||||
|
operations.free_render(it->render);
|
||||||
|
it->render = nullptr;
|
||||||
|
}
|
||||||
|
if (!operations.release_current(it->display)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!operations.destroy_context(it->display, it->context)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
it = batch.resources.erase(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch.resources.empty()) return false;
|
||||||
|
if (batch.handle) {
|
||||||
|
operations.terminate_handle(batch.handle);
|
||||||
|
batch.handle = nullptr;
|
||||||
|
}
|
||||||
|
batch.callback_keep_alive.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryReleaseRetainedNativeRenderContexts(
|
||||||
|
std::vector<NativeRenderTeardownResource>& resources, const NativeRenderTeardownOperations& operations) {
|
||||||
|
NativeRenderTeardownBatch batch;
|
||||||
|
batch.resources = std::move(resources);
|
||||||
|
const bool complete = TryReleaseNativeRenderTeardown(batch, operations);
|
||||||
|
resources = std::move(batch.resources);
|
||||||
|
return complete;
|
||||||
|
}
|
||||||
|
|
||||||
MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player)
|
MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player)
|
||||||
: context_(context), player_(player) {}
|
: context_(context), player_(player) {}
|
||||||
@@ -62,9 +242,14 @@ MpvPlayer::CallbackContext::Lease MpvPlayer::CallbackContext::Acquire() {
|
|||||||
return Lease(this, player_);
|
return Lease(this, player_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MpvPlayer::CallbackContext::WaitUntilDetached() {
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
quiescent_.wait(lock, [this]() { return player_ == nullptr; });
|
||||||
|
}
|
||||||
void MpvPlayer::CallbackContext::DetachAndWait() {
|
void MpvPlayer::CallbackContext::DetachAndWait() {
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
player_ = nullptr;
|
player_ = nullptr;
|
||||||
|
quiescent_.notify_all();
|
||||||
quiescent_.wait(lock, [this]() { return in_flight_ == 0; });
|
quiescent_.wait(lock, [this]() { return in_flight_ == 0; });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +272,45 @@ MpvPlayer::MpvPlayer(bool audio_only)
|
|||||||
|
|
||||||
MpvPlayer::~MpvPlayer() { Dispose(); }
|
MpvPlayer::~MpvPlayer() { Dispose(); }
|
||||||
|
|
||||||
|
bool MpvPlayer::HasRenderContext() const {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
return mpv_gl_ != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
EGLDisplay MpvPlayer::GetEglDisplay() const {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
return egl_display_;
|
||||||
|
}
|
||||||
|
|
||||||
|
EGLContext MpvPlayer::GetEglContext() const {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
return egl_context_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MpvPlayer::IsInitialized() const {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MpvPlayer::HasMpvHandle() const {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
return mpv_ != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool MpvPlayer::Initialize() {
|
bool MpvPlayer::Initialize() {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
if (disposed_) {
|
||||||
|
g_warning("MPV: initialization requested after disposal");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (mpv_) {
|
if (mpv_) {
|
||||||
return true; // Already initialized.
|
return true; // Already initialized.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MPV requires C locale for numeric formatting
|
if (!EnsureProcessNumericLocale()) {
|
||||||
std::setlocale(LC_NUMERIC, "C");
|
g_warning("MPV: Failed to establish the process-wide C numeric locale");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Create mpv instance.
|
// Create mpv instance.
|
||||||
mpv_ = mpv_create();
|
mpv_ = mpv_create();
|
||||||
@@ -151,84 +368,115 @@ bool MpvPlayer::Initialize() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MpvPlayer::RetryPendingNativeTeardown() { NativeRenderTeardownQueue::Instance().Retry(); }
|
||||||
|
|
||||||
bool MpvPlayer::InitRenderContext() {
|
bool MpvPlayer::InitRenderContext() {
|
||||||
if (audio_only_) {
|
RetryPendingNativeTeardown();
|
||||||
g_warning("MPV: InitRenderContext called on an audio-only player");
|
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
|
if (audio_only_ || disposed_) {
|
||||||
|
g_warning("MPV: Render context requested for an unavailable player");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (mpv_gl_) return true;
|
||||||
if (mpv_gl_) {
|
|
||||||
return true; // Already created.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mpv_) {
|
if (!mpv_) {
|
||||||
g_warning("MPV: Cannot create render context - mpv not initialized");
|
g_warning("MPV: Cannot create render context - mpv not initialized");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture Flutter's EGL display and create an isolated EGL context.
|
const EGLDisplay flutter_display = eglGetCurrentDisplay();
|
||||||
// Flutter on Linux uses EGL for both X11 and Wayland. Running mpv in
|
const EGLContext flutter_context = eglGetCurrentContext();
|
||||||
// an isolated context prevents OpenGL state pollution between mpv and
|
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
||||||
// Flutter, which caused corrupted/blank video on some drivers.
|
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
||||||
EGLDisplay flutter_display = eglGetCurrentDisplay();
|
const EGLenum previous_api = eglQueryAPI();
|
||||||
EGLContext flutter_context = eglGetCurrentContext();
|
if (flutter_display == EGL_NO_DISPLAY || flutter_context == EGL_NO_CONTEXT || previous_api == EGL_NONE) {
|
||||||
|
|
||||||
if (flutter_display == EGL_NO_DISPLAY || flutter_context == EGL_NO_CONTEXT) {
|
|
||||||
g_warning("MPV: No EGL context available");
|
g_warning("MPV: No EGL context available");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
egl_display_ = flutter_display;
|
auto restore_flutter = [&]() {
|
||||||
|
const EGLBoolean api_restored = previous_api == EGL_NONE ? EGL_TRUE : eglBindAPI(previous_api);
|
||||||
|
const EGLBoolean restored = api_restored == EGL_TRUE
|
||||||
|
? eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context)
|
||||||
|
: EGL_FALSE;
|
||||||
|
return restored == EGL_TRUE && api_restored == EGL_TRUE;
|
||||||
|
};
|
||||||
|
if (!retained_render_contexts_.empty()) {
|
||||||
|
const bool released =
|
||||||
|
TryReleaseRetainedNativeRenderContexts(retained_render_contexts_, ProductionTeardownOperations());
|
||||||
|
const bool flutter_restored = restore_flutter();
|
||||||
|
if (!released) {
|
||||||
|
g_warning("MPV: Retained render context still requires a later EGL teardown retry");
|
||||||
|
}
|
||||||
|
if (!flutter_restored) {
|
||||||
|
g_warning("MPV: Failed to restore Flutter EGL state after retained teardown: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
if (!released || !flutter_restored) return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Query Flutter's EGL config and reuse it for compatibility
|
|
||||||
EGLConfig config = nullptr;
|
|
||||||
EGLint config_id = 0;
|
EGLint config_id = 0;
|
||||||
|
if (!eglQueryContext(flutter_display, flutter_context, EGL_CONFIG_ID, &config_id)) {
|
||||||
if (!eglQueryContext(egl_display_, flutter_context, EGL_CONFIG_ID, &config_id)) {
|
g_warning("MPV: Failed to query Flutter EGL config: 0x%x", eglGetError());
|
||||||
g_warning("MPV: Failed to query Flutter's EGL config ID");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
EGLConfig config = nullptr;
|
||||||
EGLint num_configs = 0;
|
EGLint num_configs = 0;
|
||||||
EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE};
|
const EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE};
|
||||||
if (!eglChooseConfig(egl_display_, config_attribs, &config, 1, &num_configs) || num_configs == 0) {
|
if (!eglChooseConfig(flutter_display, config_attribs, &config, 1, &num_configs) || num_configs != 1) {
|
||||||
g_warning("MPV: Failed to get Flutter's EGL config");
|
g_warning("MPV: Failed to select Flutter EGL config: 0x%x", eglGetError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
|
||||||
|
g_warning("MPV: Failed to bind OpenGL ES API: 0x%x", eglGetError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create isolated EGL context (NOT shared with Flutter) to prevent
|
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
||||||
// GL state pollution
|
EGLContext candidate_context = eglCreateContext(flutter_display, config, EGL_NO_CONTEXT, context_attribs);
|
||||||
eglBindAPI(EGL_OPENGL_ES_API);
|
if (candidate_context == EGL_NO_CONTEXT) {
|
||||||
EGLint context_attribs[] = {
|
|
||||||
EGL_CONTEXT_CLIENT_VERSION,
|
|
||||||
2,
|
|
||||||
EGL_NONE,
|
|
||||||
};
|
|
||||||
egl_context_ = eglCreateContext(egl_display_, config, EGL_NO_CONTEXT, context_attribs);
|
|
||||||
if (egl_context_ == EGL_NO_CONTEXT) {
|
|
||||||
g_warning("MPV: Failed to create isolated EGL context: 0x%x", eglGetError());
|
g_warning("MPV: Failed to create isolated EGL context: 0x%x", eglGetError());
|
||||||
|
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
|
||||||
|
g_warning("MPV: Failed to restore EGL client API: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the isolated context current for mpv render context creation
|
auto destroy_candidate_context = [&]() {
|
||||||
EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
const EGLenum api_before_cleanup = eglQueryAPI();
|
||||||
EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
if (eglGetCurrentContext() == candidate_context) {
|
||||||
eglMakeCurrent(egl_display_, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context_);
|
if (!eglBindAPI(EGL_OPENGL_ES_API) ||
|
||||||
|
!eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
||||||
// Set up OpenGL parameters for mpv.
|
g_warning("MPV: Failed to release rejected EGL context: 0x%x", eglGetError());
|
||||||
mpv_opengl_init_params gl_init_params{
|
return;
|
||||||
.get_proc_address = get_opengl_proc_address,
|
}
|
||||||
.get_proc_address_ctx = nullptr,
|
}
|
||||||
|
if (!eglDestroyContext(flutter_display, candidate_context)) {
|
||||||
|
g_warning("MPV: Failed to destroy rejected EGL context: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
if (api_before_cleanup != EGL_NONE && !eglBindAPI(api_before_cleanup)) {
|
||||||
|
g_warning("MPV: Failed to restore EGL API after context cleanup: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, candidate_context)) {
|
||||||
|
g_warning("MPV: Failed to activate isolated EGL context: 0x%x", eglGetError());
|
||||||
|
destroy_candidate_context();
|
||||||
|
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
|
||||||
|
g_warning("MPV: Failed to restore EGL client API: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mpv_opengl_init_params gl_init_params{};
|
||||||
|
gl_init_params.get_proc_address = get_opengl_proc_address;
|
||||||
|
gl_init_params.get_proc_address_ctx = nullptr;
|
||||||
mpv_render_param params[] = {
|
mpv_render_param params[] = {
|
||||||
{MPV_RENDER_PARAM_API_TYPE, const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
{MPV_RENDER_PARAM_API_TYPE, const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||||
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params},
|
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params},
|
||||||
{MPV_RENDER_PARAM_INVALID, nullptr}, // slot for X11/Wayland display
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pass X11/Wayland display for VAAPI hardware acceleration
|
|
||||||
GdkDisplay* gdk_display = gdk_display_get_default();
|
GdkDisplay* gdk_display = gdk_display_get_default();
|
||||||
#ifdef GDK_WINDOWING_WAYLAND
|
#ifdef GDK_WINDOWING_WAYLAND
|
||||||
if (GDK_IS_WAYLAND_DISPLAY(gdk_display)) {
|
if (GDK_IS_WAYLAND_DISPLAY(gdk_display)) {
|
||||||
@@ -243,21 +491,38 @@ bool MpvPlayer::InitRenderContext() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
int err = mpv_render_context_create(&mpv_gl_, mpv_, params);
|
mpv_render_context* candidate_gl = nullptr;
|
||||||
|
const int error = mpv_render_context_create(&candidate_gl, mpv_, params);
|
||||||
// Restore Flutter's context
|
const bool restored = restore_flutter();
|
||||||
eglMakeCurrent(egl_display_, flutter_draw, flutter_read, flutter_context);
|
if (error < 0 || candidate_gl == nullptr || !restored) {
|
||||||
|
if (error < 0) {
|
||||||
if (err < 0) {
|
g_warning("MPV: mpv_render_context_create() failed: %s", mpv_error_string(error));
|
||||||
g_warning("MPV: mpv_render_context_create() failed: %s", mpv_error_string(err));
|
} else if (!restored) {
|
||||||
eglDestroyContext(egl_display_, egl_context_);
|
g_warning("MPV: Failed to restore Flutter EGL state: 0x%x", eglGetError());
|
||||||
egl_context_ = EGL_NO_CONTEXT;
|
} else {
|
||||||
|
g_warning("MPV: mpv returned a null render context");
|
||||||
|
}
|
||||||
|
bool retained_candidate = false;
|
||||||
|
if (candidate_gl) {
|
||||||
|
if (eglMakeCurrent(flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, candidate_context)) {
|
||||||
|
mpv_render_context_free(candidate_gl);
|
||||||
|
} else {
|
||||||
|
g_warning("MPV: Failed to reactivate rejected EGL context: 0x%x; retaining it for teardown", eglGetError());
|
||||||
|
retained_render_contexts_.push_back({candidate_gl, flutter_display, candidate_context});
|
||||||
|
retained_candidate = true;
|
||||||
|
}
|
||||||
|
if (!restore_flutter()) {
|
||||||
|
g_warning("MPV: Failed final Flutter EGL restoration: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!retained_candidate) destroy_candidate_context();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up render update callback.
|
egl_display_ = flutter_display;
|
||||||
|
egl_context_ = candidate_context;
|
||||||
|
mpv_gl_ = candidate_gl;
|
||||||
mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get());
|
mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get());
|
||||||
|
|
||||||
g_message("MPV: Render context created with isolated EGL context");
|
g_message("MPV: Render context created with isolated EGL context");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -269,11 +534,21 @@ void MpvPlayer::Dispose() {
|
|||||||
|
|
||||||
// Stop native producers before revoking access to the player. A callback
|
// Stop native producers before revoking access to the player. A callback
|
||||||
// already entered on an mpv thread owns a lease and is allowed to finish.
|
// already entered on an mpv thread owns a lease and is allowed to finish.
|
||||||
if (mpv_gl_) {
|
{
|
||||||
mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr);
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
}
|
if (mpv_) {
|
||||||
if (mpv_) {
|
const char* stop_command[] = {"stop", nullptr};
|
||||||
mpv_set_wakeup_callback(mpv_, nullptr, nullptr);
|
const int stop_result = mpv_command_async(mpv_, 0, stop_command);
|
||||||
|
if (stop_result < 0) {
|
||||||
|
g_warning("MPV: Failed to enqueue stop during disposal: %s", mpv_error_string(stop_result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mpv_gl_) {
|
||||||
|
mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
if (mpv_) {
|
||||||
|
mpv_set_wakeup_callback(mpv_, nullptr, nullptr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
callback_context_->DetachAndWait();
|
callback_context_->DetachAndWait();
|
||||||
|
|
||||||
@@ -293,59 +568,44 @@ void MpvPlayer::Dispose() {
|
|||||||
|
|
||||||
RemoveTrackedSources();
|
RemoveTrackedSources();
|
||||||
|
|
||||||
// Native destruction remains off the main thread. Keeping the detached
|
// Transfer every render/context pair and the shared mpv handle to the
|
||||||
// callback context alive until both mpv objects are gone makes even a late
|
// managed teardown thread. A failed EGL bind leaves the complete pair in
|
||||||
// invocation through mpv's old context pointer harmless.
|
// the queue; the handle cannot be terminated until every pair is gone.
|
||||||
auto* gl = mpv_gl_;
|
NativeRenderTeardownBatch teardown;
|
||||||
auto* handle = mpv_;
|
{
|
||||||
auto egl_display = egl_display_;
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
auto egl_context = egl_context_;
|
teardown.resources = std::move(retained_render_contexts_);
|
||||||
auto callback_context = callback_context_;
|
if (mpv_gl_ || egl_context_ != EGL_NO_CONTEXT) {
|
||||||
mpv_gl_ = nullptr;
|
teardown.resources.push_back({mpv_gl_, egl_display_, egl_context_});
|
||||||
mpv_ = nullptr;
|
}
|
||||||
egl_display_ = EGL_NO_DISPLAY;
|
teardown.handle = mpv_;
|
||||||
egl_context_ = EGL_NO_CONTEXT;
|
teardown.callback_keep_alive = callback_context_;
|
||||||
|
mpv_gl_ = nullptr;
|
||||||
if (gl || handle || egl_context != EGL_NO_CONTEXT) {
|
mpv_ = nullptr;
|
||||||
std::thread([gl, handle, egl_display, egl_context, callback_context]() {
|
egl_display_ = EGL_NO_DISPLAY;
|
||||||
(void)callback_context;
|
egl_context_ = EGL_NO_CONTEXT;
|
||||||
if (gl) {
|
|
||||||
if (egl_context != EGL_NO_CONTEXT) {
|
|
||||||
eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context);
|
|
||||||
}
|
|
||||||
mpv_render_context_free(gl);
|
|
||||||
}
|
|
||||||
if (handle) {
|
|
||||||
mpv_terminate_destroy(handle);
|
|
||||||
}
|
|
||||||
if (egl_context != EGL_NO_CONTEXT) {
|
|
||||||
eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
|
||||||
eglDestroyContext(egl_display, egl_context);
|
|
||||||
}
|
|
||||||
}).detach();
|
|
||||||
}
|
}
|
||||||
|
NativeRenderTeardownQueue::Instance().Enqueue(std::move(teardown));
|
||||||
|
|
||||||
observed_properties_.Clear();
|
observed_properties_.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::Render(int width, int height, int fbo) {
|
void MpvPlayer::Render(int width, int height, int fbo) {
|
||||||
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
||||||
if (disposed_ || !mpv_gl_) return;
|
if (disposed_ || !mpv_gl_) return;
|
||||||
|
|
||||||
mpv_opengl_fbo mpv_fbo{
|
mpv_opengl_fbo mpv_fbo{};
|
||||||
.fbo = fbo,
|
mpv_fbo.fbo = fbo;
|
||||||
.w = width,
|
mpv_fbo.w = width;
|
||||||
.h = height,
|
mpv_fbo.h = height;
|
||||||
.internal_format = 0,
|
mpv_fbo.internal_format = 0;
|
||||||
};
|
|
||||||
|
|
||||||
int flip_y = 0;
|
int flip_y = 0;
|
||||||
|
|
||||||
mpv_render_param params[] = {
|
mpv_render_param params[] = {
|
||||||
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
|
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
|
||||||
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
|
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
|
||||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||||
};
|
};
|
||||||
|
|
||||||
mpv_render_context_render(mpv_gl_, params);
|
mpv_render_context_render(mpv_gl_, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +613,7 @@ void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(arg
|
|||||||
|
|
||||||
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
|
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
|
||||||
if (disposed_ || !mpv_) {
|
if (disposed_ || !mpv_) {
|
||||||
if (callback) callback(0);
|
if (callback) callback(MPV_ERROR_UNINITIALIZED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +648,16 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name == "pause" && !plezy::mpv_common::ParseEnabledFlag(value)) {
|
||||||
|
auto completion = std::move(callback);
|
||||||
|
callback = [this, completion = std::move(completion)](int error) {
|
||||||
|
if (error >= 0 && !disposed_) {
|
||||||
|
audio_recovery_.RequestResume();
|
||||||
|
EnsureAudioRecoveryTimer();
|
||||||
|
}
|
||||||
|
if (completion) completion(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
|
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
|
||||||
|
|
||||||
char* property_value = const_cast<char*>(value.c_str());
|
char* property_value = const_cast<char*>(value.c_str());
|
||||||
@@ -400,7 +670,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
|
|||||||
|
|
||||||
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
|
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
|
||||||
if (disposed_ || !mpv_) {
|
if (disposed_ || !mpv_) {
|
||||||
if (callback) callback(-1, "");
|
if (callback) callback(MPV_ERROR_UNINITIALIZED, "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,12 +887,16 @@ void MpvPlayer::LogRecovery(const std::string& text) {
|
|||||||
fl_value_unref(data);
|
fl_value_unref(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
|
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
|
||||||
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
||||||
const std::string reason_copy = reason;
|
const std::string reason_copy = reason;
|
||||||
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
|
auto callback_context = callback_context_;
|
||||||
audio_recovery_.CompleteReload();
|
CommandAsync({"ao-reload"}, [callback_context, reason_copy, attempt, request_generation](int error) {
|
||||||
LogRecovery(
|
auto lease = callback_context->Acquire();
|
||||||
|
if (!lease) return;
|
||||||
|
MpvPlayer* player = lease.player();
|
||||||
|
player->audio_recovery_.CompleteReload(request_generation);
|
||||||
|
player->LogRecovery(
|
||||||
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
||||||
", error=" + std::to_string(error) + ")");
|
", error=" + std::to_string(error) + ")");
|
||||||
});
|
});
|
||||||
@@ -634,7 +908,7 @@ void MpvPlayer::MaybeRunAudioRecovery() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
||||||
TryAudioReload(reason, action.attempt);
|
TryAudioReload(reason, action.attempt, action.request_generation);
|
||||||
if (action.exhausted) {
|
if (action.exhausted) {
|
||||||
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
||||||
}
|
}
|
||||||
@@ -652,15 +926,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
uint64_t request_id = event->reply_userdata;
|
uint64_t request_id = event->reply_userdata;
|
||||||
StatusCallback callback = pending_requests_.TakeStatus(request_id);
|
StatusCallback callback = pending_requests_.TakeStatus(request_id);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
int error = event->error;
|
callback(event->error);
|
||||||
g_idle_add(
|
|
||||||
[](gpointer data) -> gboolean {
|
|
||||||
auto* pair = static_cast<std::pair<CommandCallback, int>*>(data);
|
|
||||||
if (pair->first) pair->first(pair->second);
|
|
||||||
delete pair;
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
},
|
|
||||||
new std::pair<CommandCallback, int>(std::move(callback), error));
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -677,20 +943,13 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
if (c_value) value = SanitizeUtf8(c_value);
|
if (c_value) value = SanitizeUtf8(c_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g_idle_add(
|
callback(error, value);
|
||||||
[](gpointer data) -> gboolean {
|
|
||||||
auto* tuple = static_cast<std::tuple<GetPropertyCallback, int, std::string>*>(data);
|
|
||||||
const auto& callback = std::get<0>(*tuple);
|
|
||||||
if (callback) callback(std::get<1>(*tuple), std::get<2>(*tuple));
|
|
||||||
delete tuple;
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
},
|
|
||||||
new std::tuple<GetPropertyCallback, int, std::string>(std::move(callback), error, std::move(value)));
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MPV_EVENT_LOG_MESSAGE: {
|
case MPV_EVENT_LOG_MESSAGE: {
|
||||||
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
||||||
|
if (!msg) break;
|
||||||
g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
|
g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
|
||||||
|
|
||||||
FlValue* data = fl_value_new_map();
|
FlValue* data = fl_value_new_map();
|
||||||
@@ -703,6 +962,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
}
|
}
|
||||||
case MPV_EVENT_PROPERTY_CHANGE: {
|
case MPV_EVENT_PROPERTY_CHANGE: {
|
||||||
auto* prop = static_cast<mpv_event_property*>(event->data);
|
auto* prop = static_cast<mpv_event_property*>(event->data);
|
||||||
|
if (!prop || !prop->name) break;
|
||||||
mpv_node node;
|
mpv_node node;
|
||||||
node.format = prop->format;
|
node.format = prop->format;
|
||||||
|
|
||||||
@@ -722,6 +982,8 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
case MPV_FORMAT_NODE:
|
case MPV_FORMAT_NODE:
|
||||||
if (prop->data) {
|
if (prop->data) {
|
||||||
node = *static_cast<mpv_node*>(prop->data);
|
node = *static_cast<mpv_node*>(prop->data);
|
||||||
|
} else {
|
||||||
|
node.format = MPV_FORMAT_NONE;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -756,6 +1018,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
case MPV_EVENT_END_FILE: {
|
case MPV_EVENT_END_FILE: {
|
||||||
audio_recovery_.SetFileLoaded(false);
|
audio_recovery_.SetFileLoaded(false);
|
||||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||||
|
if (!end) break;
|
||||||
FlValue* data = fl_value_new_map();
|
FlValue* data = fl_value_new_map();
|
||||||
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
|
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
|
||||||
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
||||||
@@ -773,6 +1036,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
}
|
}
|
||||||
case MPV_EVENT_FILE_LOADED: {
|
case MPV_EVENT_FILE_LOADED: {
|
||||||
audio_recovery_.SetFileLoaded(true);
|
audio_recovery_.SetFileLoaded(true);
|
||||||
|
EnsureAudioRecoveryTimer();
|
||||||
SendEvent("file-loaded");
|
SendEvent("file-loaded");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -784,13 +1048,37 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) {
|
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) {
|
||||||
if (!node) return fl_value_new_null();
|
NodeConversionBudget budget{
|
||||||
|
/*remaining_entries=*/16384,
|
||||||
|
/*remaining_bytes=*/16 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
return NodeToFlValue(node, 0, &budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MpvPlayer::ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result) {
|
||||||
|
if (!input || !budget || !result) return false;
|
||||||
|
const size_t length = strnlen(input, budget->remaining_bytes + 1);
|
||||||
|
if (length > budget->remaining_bytes) return false;
|
||||||
|
budget->remaining_bytes -= length;
|
||||||
|
*result = SanitizeUtf8(input, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget) {
|
||||||
|
constexpr size_t kMaxNodeDepth = 32;
|
||||||
|
constexpr int kMaxNodeEntries = 16384;
|
||||||
|
if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) {
|
||||||
|
return fl_value_new_null();
|
||||||
|
}
|
||||||
|
--budget->remaining_entries;
|
||||||
|
|
||||||
switch (node->format) {
|
switch (node->format) {
|
||||||
case MPV_FORMAT_STRING:
|
case MPV_FORMAT_STRING: {
|
||||||
return fl_value_new_string(SanitizeUtf8(node->u.string).c_str());
|
std::string value;
|
||||||
|
if (!ConvertNodeString(node->u.string, budget, &value)) return fl_value_new_null();
|
||||||
|
return fl_value_new_string(value.c_str());
|
||||||
|
}
|
||||||
case MPV_FORMAT_FLAG:
|
case MPV_FORMAT_FLAG:
|
||||||
return fl_value_new_bool(node->u.flag != 0);
|
return fl_value_new_bool(node->u.flag != 0);
|
||||||
case MPV_FORMAT_INT64:
|
case MPV_FORMAT_INT64:
|
||||||
@@ -798,18 +1086,35 @@ FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) {
|
|||||||
case MPV_FORMAT_DOUBLE:
|
case MPV_FORMAT_DOUBLE:
|
||||||
return fl_value_new_float(node->u.double_);
|
return fl_value_new_float(node->u.double_);
|
||||||
case MPV_FORMAT_NODE_ARRAY: {
|
case MPV_FORMAT_NODE_ARRAY: {
|
||||||
FlValue* list = fl_value_new_list();
|
const mpv_node_list* list = node->u.list;
|
||||||
for (int i = 0; i < node->u.list->num; i++) {
|
if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) {
|
||||||
fl_value_append_take(list, NodeToFlValue(&node->u.list->values[i]));
|
return fl_value_new_null();
|
||||||
}
|
}
|
||||||
return list;
|
FlValue* result = fl_value_new_list();
|
||||||
|
for (int i = 0; i < list->num; i++) {
|
||||||
|
fl_value_append_take(result, NodeToFlValue(&list->values[i], depth + 1, budget));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
case MPV_FORMAT_NODE_MAP: {
|
case MPV_FORMAT_NODE_MAP: {
|
||||||
FlValue* map = fl_value_new_map();
|
const mpv_node_list* map = node->u.list;
|
||||||
for (int i = 0; i < node->u.list->num; i++) {
|
if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) {
|
||||||
fl_value_set_string_take(map, node->u.list->keys[i], NodeToFlValue(&node->u.list->values[i]));
|
return fl_value_new_null();
|
||||||
}
|
}
|
||||||
return map;
|
FlValue* result = fl_value_new_map();
|
||||||
|
for (int i = 0; i < map->num; i++) {
|
||||||
|
if (!map->keys[i]) {
|
||||||
|
fl_value_unref(result);
|
||||||
|
return fl_value_new_null();
|
||||||
|
}
|
||||||
|
std::string key;
|
||||||
|
if (!ConvertNodeString(map->keys[i], budget, &key)) {
|
||||||
|
fl_value_unref(result);
|
||||||
|
return fl_value_new_null();
|
||||||
|
}
|
||||||
|
fl_value_set_string_take(result, key.c_str(), NodeToFlValue(&map->values[i], depth + 1, budget));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fl_value_new_null();
|
return fl_value_new_null();
|
||||||
@@ -830,10 +1135,12 @@ void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
|||||||
fl_value_append_take(list, fl_value_new_null());
|
fl_value_append_take(list, fl_value_new_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(callback_mutex_);
|
EventCallback callback;
|
||||||
if (event_callback_) {
|
{
|
||||||
event_callback_(list);
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
||||||
|
callback = event_callback_;
|
||||||
}
|
}
|
||||||
|
if (callback) callback(list);
|
||||||
fl_value_unref(list);
|
fl_value_unref(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,19 +1152,23 @@ void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
|
|||||||
fl_value_set_string_take(event_map, "data", fl_value_ref(data));
|
fl_value_set_string_take(event_map, "data", fl_value_ref(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(callback_mutex_);
|
EventCallback callback;
|
||||||
if (event_callback_) {
|
{
|
||||||
event_callback_(event_map);
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
||||||
|
callback = event_callback_;
|
||||||
}
|
}
|
||||||
|
if (callback) callback(event_map);
|
||||||
fl_value_unref(event_map);
|
fl_value_unref(event_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
|
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
|
||||||
hdr_enabled_ = enabled;
|
SetPropertyAsync(
|
||||||
if (!mpv_) {
|
"target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled),
|
||||||
if (callback) callback(0);
|
[this, enabled, callback = std::move(callback)](int error) mutable {
|
||||||
return;
|
if (plezy::mpv_common::SetPropertyStatusSucceeded(error) && !disposed_) {
|
||||||
}
|
hdr_enabled_ = enabled;
|
||||||
SetPropertyAsync("target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled), std::move(callback));
|
}
|
||||||
|
if (callback) callback(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|||||||
@@ -32,6 +32,45 @@ using EventCallback = std::function<void(::_FlValue*)>;
|
|||||||
/// Callback for requesting a redraw (called from mpv render update thread).
|
/// Callback for requesting a redraw (called from mpv render update thread).
|
||||||
using RedrawCallback = std::function<void()>;
|
using RedrawCallback = std::function<void()>;
|
||||||
|
|
||||||
|
// Linux-runner-internal teardown boundary. A render context may only be
|
||||||
|
// released while its EGL context is current; the batch retains the shared mpv
|
||||||
|
// handle until every render/context pair has been safely released.
|
||||||
|
struct NativeRenderTeardownResource {
|
||||||
|
mpv_render_context* render = nullptr;
|
||||||
|
EGLDisplay display = EGL_NO_DISPLAY;
|
||||||
|
EGLContext context = EGL_NO_CONTEXT;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NativeRenderTeardownBatch {
|
||||||
|
std::vector<NativeRenderTeardownResource> resources;
|
||||||
|
mpv_handle* handle = nullptr;
|
||||||
|
std::shared_ptr<void> callback_keep_alive;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NativeRenderTeardownOperations {
|
||||||
|
std::function<bool(EGLDisplay, EGLContext)> make_current;
|
||||||
|
std::function<bool(EGLDisplay)> release_current;
|
||||||
|
std::function<bool(EGLDisplay, EGLContext)> destroy_context;
|
||||||
|
std::function<void(mpv_render_context*)> free_render;
|
||||||
|
std::function<void(mpv_handle*)> terminate_handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempts one teardown pass. Failed resources remain owned by |batch| for a
|
||||||
|
// later retry, and |handle| is never terminated while any resource remains.
|
||||||
|
bool TryReleaseNativeRenderTeardown(NativeRenderTeardownBatch& batch, const NativeRenderTeardownOperations& operations);
|
||||||
|
|
||||||
|
// Releases render contexts retained by a failed initialization attempt. A
|
||||||
|
// false result must block another render-context creation on the same core.
|
||||||
|
bool TryReleaseRetainedNativeRenderContexts(
|
||||||
|
std::vector<NativeRenderTeardownResource>& resources, const NativeRenderTeardownOperations& operations);
|
||||||
|
|
||||||
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
||||||
|
// Focused-test boundary for exercising the process-lifetime teardown queue
|
||||||
|
// without invoking real EGL or libmpv resources.
|
||||||
|
void ConfigureNativeRenderTeardownQueueForTesting(NativeRenderTeardownOperations operations);
|
||||||
|
void EnqueueNativeRenderTeardownForTesting(NativeRenderTeardownBatch batch);
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Wrapper for libmpv that handles initialization, OpenGL rendering,
|
/// Wrapper for libmpv that handles initialization, OpenGL rendering,
|
||||||
/// commands, properties, and event dispatching.
|
/// commands, properties, and event dispatching.
|
||||||
class MpvPlayer {
|
class MpvPlayer {
|
||||||
@@ -55,26 +94,26 @@ class MpvPlayer {
|
|||||||
bool InitRenderContext();
|
bool InitRenderContext();
|
||||||
|
|
||||||
/// Returns true if the render context has been created.
|
/// Returns true if the render context has been created.
|
||||||
bool HasRenderContext() const { return mpv_gl_ != nullptr; }
|
bool HasRenderContext() const;
|
||||||
|
|
||||||
/// Returns the isolated EGL display used for mpv rendering.
|
/// Returns the isolated EGL display used for mpv rendering.
|
||||||
EGLDisplay GetEglDisplay() const { return egl_display_; }
|
EGLDisplay GetEglDisplay() const;
|
||||||
|
|
||||||
/// Returns the isolated EGL context used for mpv rendering.
|
/// Returns the isolated EGL context used for mpv rendering.
|
||||||
EGLContext GetEglContext() const { return egl_context_; }
|
EGLContext GetEglContext() const;
|
||||||
|
|
||||||
/// Disposes mpv and releases resources.
|
/// Disposes mpv and releases resources.
|
||||||
void Dispose();
|
void Dispose();
|
||||||
|
|
||||||
/// Returns true if mpv is initialized (has both mpv handle and render
|
/// Returns true if mpv is initialized (has both mpv handle and render
|
||||||
/// context; audio-only players never have a render context).
|
/// context; audio-only players never have a render context).
|
||||||
bool IsInitialized() const { return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr); }
|
bool IsInitialized() const;
|
||||||
|
|
||||||
/// Returns true if this player has been disposed.
|
/// Returns true if this player has been disposed.
|
||||||
bool IsDisposed() const { return disposed_.load(); }
|
bool IsDisposed() const { return disposed_.load(); }
|
||||||
|
|
||||||
/// Returns true if mpv handle exists (even without render context).
|
/// Returns true if mpv handle exists (even without render context).
|
||||||
bool HasMpvHandle() const { return mpv_ != nullptr; }
|
bool HasMpvHandle() const;
|
||||||
|
|
||||||
/// Queues an mpv command without waiting for completion.
|
/// Queues an mpv command without waiting for completion.
|
||||||
void Command(const std::vector<std::string>& args);
|
void Command(const std::vector<std::string>& args);
|
||||||
@@ -120,6 +159,10 @@ class MpvPlayer {
|
|||||||
/// Sets the MPV log message level (e.g., "warn", "v", "debug").
|
/// Sets the MPV log message level (e.g., "warn", "v", "debug").
|
||||||
void SetLogLevel(const std::string& level);
|
void SetLogLevel(const std::string& level);
|
||||||
|
|
||||||
|
/// Retries process-owned native teardown work on the managed EGL teardown
|
||||||
|
/// thread. Primarily useful before creating another render context.
|
||||||
|
static void RetryPendingNativeTeardown();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
class CallbackContext {
|
class CallbackContext {
|
||||||
public:
|
public:
|
||||||
@@ -149,6 +192,7 @@ class MpvPlayer {
|
|||||||
|
|
||||||
Lease Acquire();
|
Lease Acquire();
|
||||||
void DetachAndWait();
|
void DetachAndWait();
|
||||||
|
void WaitUntilDetached();
|
||||||
GMainContext* main_context() const { return main_context_; }
|
GMainContext* main_context() const { return main_context_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -193,13 +237,20 @@ class MpvPlayer {
|
|||||||
/// Sends an event notification.
|
/// Sends an event notification.
|
||||||
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
|
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
|
||||||
void MaybeRunAudioRecovery();
|
void MaybeRunAudioRecovery();
|
||||||
void TryAudioReload(const char* reason, int attempt);
|
void TryAudioReload(const char* reason, int attempt, uint64_t request_generation);
|
||||||
void EnsureAudioRecoveryTimer();
|
void EnsureAudioRecoveryTimer();
|
||||||
void LogRecovery(const std::string& text);
|
void LogRecovery(const std::string& text);
|
||||||
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
|
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
|
||||||
|
|
||||||
|
struct NodeConversionBudget {
|
||||||
|
size_t remaining_entries;
|
||||||
|
size_t remaining_bytes;
|
||||||
|
};
|
||||||
|
|
||||||
/// Helper to convert mpv_node to FlValue.
|
/// Helper to convert mpv_node to FlValue.
|
||||||
::_FlValue* NodeToFlValue(mpv_node* node);
|
::_FlValue* NodeToFlValue(mpv_node* node);
|
||||||
|
::_FlValue* NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget);
|
||||||
|
bool ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result);
|
||||||
|
|
||||||
const bool audio_only_;
|
const bool audio_only_;
|
||||||
mpv_handle* mpv_ = nullptr;
|
mpv_handle* mpv_ = nullptr;
|
||||||
@@ -208,6 +259,8 @@ class MpvPlayer {
|
|||||||
// Isolated EGL context for mpv rendering (not shared with Flutter)
|
// Isolated EGL context for mpv rendering (not shared with Flutter)
|
||||||
EGLDisplay egl_display_ = EGL_NO_DISPLAY;
|
EGLDisplay egl_display_ = EGL_NO_DISPLAY;
|
||||||
EGLContext egl_context_ = EGL_NO_CONTEXT;
|
EGLContext egl_context_ = EGL_NO_CONTEXT;
|
||||||
|
std::vector<NativeRenderTeardownResource> retained_render_contexts_;
|
||||||
|
mutable std::mutex native_mutex_;
|
||||||
|
|
||||||
std::atomic<bool> needs_redraw_{false};
|
std::atomic<bool> needs_redraw_{false};
|
||||||
std::atomic<bool> disposed_{false};
|
std::atomic<bool> disposed_{false};
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <cerrno>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdlib>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -10,6 +18,56 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "mpv_player.h"
|
#include "mpv_player.h"
|
||||||
|
#include "mpv_texture.h"
|
||||||
|
|
||||||
|
struct LifetimeTextureRegistrar {
|
||||||
|
GObject parent_instance;
|
||||||
|
FlTexture* texture;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LifetimeTextureRegistrarClass {
|
||||||
|
GObjectClass parent_class;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void LifetimeTextureRegistrarInterfaceInit(FlTextureRegistrarInterface* interface);
|
||||||
|
static void LifetimeTextureRegistrarDispose(GObject* object);
|
||||||
|
static void lifetime_texture_registrar_class_init(LifetimeTextureRegistrarClass* klass);
|
||||||
|
static void lifetime_texture_registrar_init(LifetimeTextureRegistrar* self);
|
||||||
|
|
||||||
|
G_DEFINE_TYPE_WITH_CODE(
|
||||||
|
LifetimeTextureRegistrar, lifetime_texture_registrar, G_TYPE_OBJECT,
|
||||||
|
G_IMPLEMENT_INTERFACE(fl_texture_registrar_get_type(), LifetimeTextureRegistrarInterfaceInit))
|
||||||
|
|
||||||
|
static gboolean LifetimeTextureRegistrarRegister(FlTextureRegistrar* registrar, FlTexture* texture) {
|
||||||
|
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(registrar);
|
||||||
|
if (self->texture) return FALSE;
|
||||||
|
self->texture = FL_TEXTURE(g_object_ref(texture));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean LifetimeTextureRegistrarUnregister(FlTextureRegistrar* registrar, FlTexture* texture) {
|
||||||
|
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(registrar);
|
||||||
|
if (self->texture != texture) return FALSE;
|
||||||
|
g_clear_object(&self->texture);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LifetimeTextureRegistrarInterfaceInit(FlTextureRegistrarInterface* interface) {
|
||||||
|
interface->register_texture = LifetimeTextureRegistrarRegister;
|
||||||
|
interface->unregister_texture = LifetimeTextureRegistrarUnregister;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LifetimeTextureRegistrarDispose(GObject* object) {
|
||||||
|
auto* self = reinterpret_cast<LifetimeTextureRegistrar*>(object);
|
||||||
|
g_clear_object(&self->texture);
|
||||||
|
G_OBJECT_CLASS(lifetime_texture_registrar_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void lifetime_texture_registrar_class_init(LifetimeTextureRegistrarClass* klass) {
|
||||||
|
G_OBJECT_CLASS(klass)->dispose = LifetimeTextureRegistrarDispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void lifetime_texture_registrar_init(LifetimeTextureRegistrar* self) { self->texture = nullptr; }
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
@@ -27,6 +85,9 @@ class MpvPlayerLifecycleTestPeer {
|
|||||||
MpvPlayer::OnMpvRenderUpdate(context.get());
|
MpvPlayer::OnMpvRenderUpdate(context.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void WaitUntilDetached(const std::shared_ptr<MpvPlayer::CallbackContext>& context) {
|
||||||
|
context->WaitUntilDetached();
|
||||||
|
}
|
||||||
static void ScheduleRecovery(MpvPlayer& player) { player.ScheduleRecoverySource(); }
|
static void ScheduleRecovery(MpvPlayer& player) { player.ScheduleRecoverySource(); }
|
||||||
|
|
||||||
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
|
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
|
||||||
@@ -38,6 +99,16 @@ class MpvPlayerLifecycleTestPeer {
|
|||||||
return (player.wakeup_source_id_ != 0 ? 1 : 0) + (player.redraw_source_id_ != 0 ? 1 : 0) +
|
return (player.wakeup_source_id_ != 0 ? 1 : 0) + (player.redraw_source_id_ != 0 ? 1 : 0) +
|
||||||
(player.recovery_source_id_ != 0 ? 1 : 0);
|
(player.recovery_source_id_ != 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
static FlValue* ConvertNode(MpvPlayer& player, mpv_node* node) { return player.NodeToFlValue(node); }
|
||||||
|
static FlValue* ConvertNodeWithBudget(
|
||||||
|
MpvPlayer& player, mpv_node* node, size_t remaining_entries, size_t remaining_bytes) {
|
||||||
|
MpvPlayer::NodeConversionBudget budget{remaining_entries, remaining_bytes};
|
||||||
|
return player.NodeToFlValue(node, 0, &budget);
|
||||||
|
}
|
||||||
|
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 HoldLease(
|
static void HoldLease(
|
||||||
const std::shared_ptr<MpvPlayer::CallbackContext>& context, std::mutex& mutex, std::condition_variable& condition,
|
const std::shared_ptr<MpvPlayer::CallbackContext>& context, std::mutex& mutex, std::condition_variable& condition,
|
||||||
@@ -65,6 +136,291 @@ void Drain(GMainContext* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool WriteByte(int descriptor, char value) {
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t written = write(descriptor, &value, 1);
|
||||||
|
if (written == 1) return true;
|
||||||
|
if (written < 0 && errno == EINTR) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadByte(int descriptor, char expected) {
|
||||||
|
char value = '\0';
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t received = read(descriptor, &value, 1);
|
||||||
|
if (received == 1) return value == expected;
|
||||||
|
if (received < 0 && errno == EINTR) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[noreturn]] void ExitBlockedTeardownChild(int status) { _exit(status); }
|
||||||
|
|
||||||
|
int RunBlockedTeardownShutdownChild(int progress_read, int progress_write, int release_read) {
|
||||||
|
auto* const completed_handle = reinterpret_cast<mpv_handle*>(0x11);
|
||||||
|
auto* const blocked_render = reinterpret_cast<mpv_render_context*>(0x12);
|
||||||
|
auto const blocked_display = reinterpret_cast<EGLDisplay>(0x13);
|
||||||
|
auto const blocked_context = reinterpret_cast<EGLContext>(0x14);
|
||||||
|
|
||||||
|
NativeRenderTeardownOperations operations{
|
||||||
|
[](EGLDisplay, EGLContext) { return true; },
|
||||||
|
[](EGLDisplay) { return true; },
|
||||||
|
[](EGLDisplay, EGLContext) { return true; },
|
||||||
|
[progress_write, release_read, blocked_render](mpv_render_context* render) {
|
||||||
|
if (render != blocked_render || !WriteByte(progress_write, 'B')) ExitBlockedTeardownChild(121);
|
||||||
|
char release = '\0';
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t received = read(release_read, &release, 1);
|
||||||
|
if (received == 1) break;
|
||||||
|
if (received < 0 && errno == EINTR) continue;
|
||||||
|
ExitBlockedTeardownChild(122);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[progress_write, completed_handle](mpv_handle* handle) {
|
||||||
|
if (handle != completed_handle || !WriteByte(progress_write, 'R')) ExitBlockedTeardownChild(123);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ConfigureNativeRenderTeardownQueueForTesting(std::move(operations));
|
||||||
|
|
||||||
|
NativeRenderTeardownBatch completed_batch;
|
||||||
|
completed_batch.handle = completed_handle;
|
||||||
|
EnqueueNativeRenderTeardownForTesting(std::move(completed_batch));
|
||||||
|
if (!ReadByte(progress_read, 'R')) return 124;
|
||||||
|
|
||||||
|
NativeRenderTeardownBatch blocked_batch;
|
||||||
|
blocked_batch.resources.push_back({blocked_render, blocked_display, blocked_context});
|
||||||
|
EnqueueNativeRenderTeardownForTesting(std::move(blocked_batch));
|
||||||
|
if (!ReadByte(progress_read, 'B')) return 125;
|
||||||
|
|
||||||
|
// Returning through std::exit below deliberately begins normal static
|
||||||
|
// shutdown while the queue worker remains blocked in free_render.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProcessShutdownDoesNotJoinBlockedNativeTeardown() {
|
||||||
|
int progress_pipe[2] = {-1, -1};
|
||||||
|
int release_pipe[2] = {-1, -1};
|
||||||
|
Check(pipe(progress_pipe) == 0, "could not create teardown progress barrier");
|
||||||
|
if (pipe(release_pipe) != 0) {
|
||||||
|
close(progress_pipe[0]);
|
||||||
|
close(progress_pipe[1]);
|
||||||
|
Check(false, "could not create teardown release barrier");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid_t child = fork();
|
||||||
|
if (child == 0) {
|
||||||
|
close(release_pipe[1]);
|
||||||
|
const int status = RunBlockedTeardownShutdownChild(progress_pipe[0], progress_pipe[1], release_pipe[0]);
|
||||||
|
std::exit(status);
|
||||||
|
}
|
||||||
|
if (child < 0) {
|
||||||
|
close(progress_pipe[0]);
|
||||||
|
close(progress_pipe[1]);
|
||||||
|
close(release_pipe[0]);
|
||||||
|
close(release_pipe[1]);
|
||||||
|
Check(false, "could not create teardown shutdown subprocess");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(progress_pipe[0]);
|
||||||
|
close(progress_pipe[1]);
|
||||||
|
close(release_pipe[0]);
|
||||||
|
|
||||||
|
std::mutex wait_mutex;
|
||||||
|
std::condition_variable wait_condition;
|
||||||
|
bool wait_finished = false;
|
||||||
|
pid_t wait_result = -1;
|
||||||
|
int child_status = 0;
|
||||||
|
std::thread waiter([&]() {
|
||||||
|
pid_t result;
|
||||||
|
do {
|
||||||
|
result = waitpid(child, &child_status, 0);
|
||||||
|
} while (result < 0 && errno == EINTR);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(wait_mutex);
|
||||||
|
wait_result = result;
|
||||||
|
wait_finished = true;
|
||||||
|
}
|
||||||
|
wait_condition.notify_one();
|
||||||
|
});
|
||||||
|
|
||||||
|
bool exited_before_deadline = false;
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(wait_mutex);
|
||||||
|
exited_before_deadline = wait_condition.wait_for(lock, std::chrono::seconds(2), [&]() { return wait_finished; });
|
||||||
|
}
|
||||||
|
if (!exited_before_deadline) kill(child, SIGKILL);
|
||||||
|
close(release_pipe[1]);
|
||||||
|
waiter.join();
|
||||||
|
|
||||||
|
Check(exited_before_deadline, "normal process shutdown joined a deliberately blocked native teardown");
|
||||||
|
Check(wait_result == child, "could not collect teardown shutdown subprocess");
|
||||||
|
Check(WIFEXITED(child_status), "teardown shutdown subprocess terminated abnormally");
|
||||||
|
Check(WEXITSTATUS(child_status) == 0, "teardown shutdown subprocess did not reach normal static shutdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TextureLifetimeState {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::condition_variable condition;
|
||||||
|
bool callback_entered = false;
|
||||||
|
bool release_callback = false;
|
||||||
|
std::atomic<bool> callback_finalized{false};
|
||||||
|
bool finalized_during_callback = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
void BlockingTextureReadyCallback(gboolean, const gchar*, gpointer user_data) {
|
||||||
|
auto* state = static_cast<TextureLifetimeState*>(user_data);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(state->mutex);
|
||||||
|
state->callback_entered = true;
|
||||||
|
}
|
||||||
|
state->condition.notify_all();
|
||||||
|
|
||||||
|
std::unique_lock<std::mutex> lock(state->mutex);
|
||||||
|
state->condition.wait(lock, [state]() { return state->release_callback; });
|
||||||
|
state->finalized_during_callback = state->callback_finalized.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextureReadyCallbackFinalized(gpointer user_data) {
|
||||||
|
static_cast<TextureLifetimeState*>(user_data)->callback_finalized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPopulateRetainsTextureWhileBootstrapCallbackRuns() {
|
||||||
|
auto* registrar = FL_TEXTURE_REGISTRAR(g_object_new(lifetime_texture_registrar_get_type(), nullptr));
|
||||||
|
TextureLifetimeState state;
|
||||||
|
MpvTexture* texture = mpv_texture_new(nullptr, registrar, nullptr);
|
||||||
|
mpv_texture_set_ready_callback(texture, BlockingTextureReadyCallback, &state, TextureReadyCallbackFinalized);
|
||||||
|
Check(
|
||||||
|
fl_texture_registrar_register_texture(registrar, FL_TEXTURE(texture)),
|
||||||
|
"the lifetime fixture must retain the registered texture");
|
||||||
|
|
||||||
|
gboolean populate_result = TRUE;
|
||||||
|
GError* populate_error = nullptr;
|
||||||
|
std::thread raster_thread([&]() {
|
||||||
|
uint32_t target = 0;
|
||||||
|
uint32_t name = 0;
|
||||||
|
uint32_t width = 0;
|
||||||
|
uint32_t height = 0;
|
||||||
|
auto* texture_class = FL_TEXTURE_GL_GET_CLASS(texture);
|
||||||
|
populate_result = texture_class->populate(FL_TEXTURE_GL(texture), &target, &name, &width, &height, &populate_error);
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(state.mutex);
|
||||||
|
state.condition.wait(lock, [&state]() { return state.callback_entered; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match plugin teardown while populate is between releasing its mutex and
|
||||||
|
// returning from the ready callback. Unregister drops the registrar's
|
||||||
|
// reference before dispose drops the plugin's reference.
|
||||||
|
Check(
|
||||||
|
fl_texture_registrar_unregister_texture(registrar, FL_TEXTURE(texture)),
|
||||||
|
"the lifetime fixture must unregister the texture");
|
||||||
|
mpv_texture_dispose(texture);
|
||||||
|
g_object_unref(texture);
|
||||||
|
const bool finalized_before_populate_released = state.callback_finalized.load();
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(state.mutex);
|
||||||
|
state.release_callback = true;
|
||||||
|
}
|
||||||
|
state.condition.notify_all();
|
||||||
|
raster_thread.join();
|
||||||
|
|
||||||
|
Check(
|
||||||
|
!finalized_before_populate_released,
|
||||||
|
"platform disposal finalized the texture while its populate callback was still running");
|
||||||
|
Check(
|
||||||
|
!state.finalized_during_callback,
|
||||||
|
"the ready callback was finalized before populate released its retained texture reference");
|
||||||
|
Check(state.callback_finalized.load(), "the texture callback was not finalized after populate returned");
|
||||||
|
Check(!populate_result, "a populate without a player must fail");
|
||||||
|
Check(populate_error != nullptr, "failed populate must report an error");
|
||||||
|
g_clear_error(&populate_error);
|
||||||
|
g_object_unref(registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestNodeConversionRejectsMalformedPayloads() {
|
||||||
|
MpvPlayer player;
|
||||||
|
|
||||||
|
mpv_node missing_list{};
|
||||||
|
missing_list.format = MPV_FORMAT_NODE_ARRAY;
|
||||||
|
missing_list.u.list = nullptr;
|
||||||
|
FlValue* result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &missing_list);
|
||||||
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node array without storage must decode as null");
|
||||||
|
fl_value_unref(result);
|
||||||
|
|
||||||
|
mpv_node value{};
|
||||||
|
value.format = MPV_FORMAT_INT64;
|
||||||
|
value.u.int64 = 1;
|
||||||
|
char* missing_key = nullptr;
|
||||||
|
mpv_node_list malformed_map{1, &value, &missing_key};
|
||||||
|
mpv_node map{};
|
||||||
|
map.format = MPV_FORMAT_NODE_MAP;
|
||||||
|
map.u.list = &malformed_map;
|
||||||
|
result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &map);
|
||||||
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node map with a null key must decode as null");
|
||||||
|
fl_value_unref(result);
|
||||||
|
|
||||||
|
char invalid_utf8[] = {'a', static_cast<char>(0xFF), 'b', '\0'};
|
||||||
|
mpv_node text{};
|
||||||
|
text.format = MPV_FORMAT_STRING;
|
||||||
|
text.u.string = invalid_utf8;
|
||||||
|
result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &text);
|
||||||
|
Check(
|
||||||
|
std::string(fl_value_get_string(result)) ==
|
||||||
|
"a\xEF\xBF\xBD"
|
||||||
|
"b",
|
||||||
|
"invalid UTF-8 must be replaced before entering the Flutter codec");
|
||||||
|
fl_value_unref(result);
|
||||||
|
|
||||||
|
char oversized_text[] = "bounded";
|
||||||
|
text.u.string = oversized_text;
|
||||||
|
result =
|
||||||
|
MpvPlayerLifecycleTestPeer::ConvertNodeWithBudget(player, &text, /*remaining_entries=*/1, /*remaining_bytes=*/6);
|
||||||
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node string beyond the byte budget must decode as null");
|
||||||
|
fl_value_unref(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestNullNodePropertyPayloadDecodesAsNull() {
|
||||||
|
MpvPlayer player;
|
||||||
|
MpvPlayerLifecycleTestPeer::RegisterObservedNode(player, "track-list", 42);
|
||||||
|
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_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");
|
||||||
|
delivered = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
mpv_event_property property{};
|
||||||
|
property.name = "track-list";
|
||||||
|
property.format = MPV_FORMAT_NODE;
|
||||||
|
property.data = nullptr;
|
||||||
|
mpv_event event{};
|
||||||
|
event.event_id = MPV_EVENT_PROPERTY_CHANGE;
|
||||||
|
event.data = &property;
|
||||||
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &event);
|
||||||
|
Check(delivered, "null node property event was not delivered");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestUnavailableCommandFails() {
|
||||||
|
MpvPlayer player;
|
||||||
|
int callback_count = 0;
|
||||||
|
int status = MPV_ERROR_SUCCESS;
|
||||||
|
|
||||||
|
player.CommandAsync({"stop"}, [&](int error) {
|
||||||
|
++callback_count;
|
||||||
|
status = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
Check(callback_count == 1, "a command without an mpv handle must complete exactly once");
|
||||||
|
Check(status == MPV_ERROR_UNINITIALIZED, "a command without an mpv handle must fail as uninitialized");
|
||||||
|
}
|
||||||
|
|
||||||
void TestUnavailablePropertyWriteFails() {
|
void TestUnavailablePropertyWriteFails() {
|
||||||
MpvPlayer player;
|
MpvPlayer player;
|
||||||
int callback_count = 0;
|
int callback_count = 0;
|
||||||
@@ -113,7 +469,6 @@ void TestQueuedSourcesAreRetired(GMainContext* context) {
|
|||||||
|
|
||||||
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
||||||
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(125));
|
|
||||||
Drain(context);
|
Drain(context);
|
||||||
Check(redraws == 0, "detached callbacks must not publish redraws");
|
Check(redraws == 0, "detached callbacks must not publish redraws");
|
||||||
}
|
}
|
||||||
@@ -138,7 +493,7 @@ void TestNativeLeaseBlocksDispose() {
|
|||||||
player->Dispose();
|
player->Dispose();
|
||||||
disposed = true;
|
disposed = true;
|
||||||
});
|
});
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
MpvPlayerLifecycleTestPeer::WaitUntilDetached(callback_context);
|
||||||
Check(!disposed.load(), "dispose returned while a native callback lease was active");
|
Check(!disposed.load(), "dispose returned while a native callback lease was active");
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -206,6 +561,131 @@ void TestRapidReplacementCannotReceiveOldCallbacks(GMainContext* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestRenderTeardownRetainsOwnershipUntilContextIsCurrent() {
|
||||||
|
NativeRenderTeardownBatch batch;
|
||||||
|
auto* render = reinterpret_cast<mpv_render_context*>(1);
|
||||||
|
auto* handle = reinterpret_cast<mpv_handle*>(2);
|
||||||
|
auto display = reinterpret_cast<EGLDisplay>(3);
|
||||||
|
auto context = reinterpret_cast<EGLContext>(4);
|
||||||
|
batch.resources.push_back({render, display, context});
|
||||||
|
batch.handle = handle;
|
||||||
|
|
||||||
|
bool allow_make_current = false;
|
||||||
|
bool allow_release = true;
|
||||||
|
int make_current_calls = 0;
|
||||||
|
int release_calls = 0;
|
||||||
|
int free_calls = 0;
|
||||||
|
int destroy_calls = 0;
|
||||||
|
int terminate_calls = 0;
|
||||||
|
NativeRenderTeardownOperations operations{
|
||||||
|
[&](EGLDisplay actual_display, EGLContext actual_context) {
|
||||||
|
Check(actual_display == display && actual_context == context, "teardown must bind the retained EGL context");
|
||||||
|
++make_current_calls;
|
||||||
|
return allow_make_current;
|
||||||
|
},
|
||||||
|
[&](EGLDisplay actual_display) {
|
||||||
|
Check(actual_display == display, "teardown must release the retained EGL display");
|
||||||
|
++release_calls;
|
||||||
|
return allow_release;
|
||||||
|
},
|
||||||
|
[&](EGLDisplay actual_display, EGLContext actual_context) {
|
||||||
|
Check(actual_display == display && actual_context == context, "teardown destroyed the wrong EGL context");
|
||||||
|
++destroy_calls;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[&](mpv_render_context* actual_render) {
|
||||||
|
Check(actual_render == render, "teardown freed the wrong render context");
|
||||||
|
++free_calls;
|
||||||
|
},
|
||||||
|
[&](mpv_handle* actual_handle) {
|
||||||
|
Check(actual_handle == handle, "teardown terminated the wrong mpv handle");
|
||||||
|
++terminate_calls;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a failed EGL bind must retain the native teardown batch");
|
||||||
|
Check(make_current_calls == 1, "teardown must attempt to bind the required EGL context");
|
||||||
|
Check(
|
||||||
|
free_calls == 0 && release_calls == 0 && destroy_calls == 0 && terminate_calls == 0,
|
||||||
|
"a failed EGL bind must not free, destroy, or terminate dependent native objects");
|
||||||
|
Check(
|
||||||
|
batch.resources.size() == 1 && batch.resources.front().render == render && batch.handle == handle,
|
||||||
|
"a failed EGL bind must preserve complete ownership for retry");
|
||||||
|
|
||||||
|
allow_make_current = true;
|
||||||
|
Check(TryReleaseNativeRenderTeardown(batch, operations), "a later valid EGL bind must complete retained teardown");
|
||||||
|
Check(batch.resources.empty() && batch.handle == nullptr, "successful retry must consume the teardown batch");
|
||||||
|
Check(
|
||||||
|
free_calls == 1 && release_calls == 1 && destroy_calls == 1 && terminate_calls == 1,
|
||||||
|
"successful retry must release the render, EGL context, and then the shared handle exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestRenderTeardownDoesNotDestroyAStillCurrentContext() {
|
||||||
|
NativeRenderTeardownBatch batch;
|
||||||
|
auto* render = reinterpret_cast<mpv_render_context*>(5);
|
||||||
|
auto* handle = reinterpret_cast<mpv_handle*>(6);
|
||||||
|
auto display = reinterpret_cast<EGLDisplay>(7);
|
||||||
|
auto context = reinterpret_cast<EGLContext>(8);
|
||||||
|
batch.resources.push_back({render, display, context});
|
||||||
|
batch.handle = handle;
|
||||||
|
|
||||||
|
bool allow_release = false;
|
||||||
|
int free_calls = 0;
|
||||||
|
int destroy_calls = 0;
|
||||||
|
int terminate_calls = 0;
|
||||||
|
NativeRenderTeardownOperations operations{
|
||||||
|
[](EGLDisplay, EGLContext) { return true; },
|
||||||
|
[&](EGLDisplay) { return allow_release; },
|
||||||
|
[&](EGLDisplay, EGLContext) {
|
||||||
|
++destroy_calls;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[&](mpv_render_context*) { ++free_calls; },
|
||||||
|
[&](mpv_handle*) { ++terminate_calls; },
|
||||||
|
};
|
||||||
|
|
||||||
|
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a context that cannot be released must remain queued");
|
||||||
|
Check(free_calls == 1, "the render context may be freed only after its EGL context became current");
|
||||||
|
Check(
|
||||||
|
destroy_calls == 0 && terminate_calls == 0 && batch.resources.front().render == nullptr,
|
||||||
|
"failed EGL release must retain the context and handle without double-freeing the render");
|
||||||
|
|
||||||
|
allow_release = true;
|
||||||
|
Check(TryReleaseNativeRenderTeardown(batch, operations), "a later EGL release must finish teardown");
|
||||||
|
Check(
|
||||||
|
free_calls == 1 && destroy_calls == 1 && terminate_calls == 1,
|
||||||
|
"retry must not repeat render-context destruction");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestRetainedRenderBlocksAnotherCreationUntilReleased() {
|
||||||
|
std::vector<NativeRenderTeardownResource> retained{
|
||||||
|
{reinterpret_cast<mpv_render_context*>(9), reinterpret_cast<EGLDisplay>(10), reinterpret_cast<EGLContext>(11)}};
|
||||||
|
bool allow_make_current = false;
|
||||||
|
int free_calls = 0;
|
||||||
|
int destroy_calls = 0;
|
||||||
|
int render_creations = 0;
|
||||||
|
NativeRenderTeardownOperations operations{
|
||||||
|
[&](EGLDisplay, EGLContext) { return allow_make_current; },
|
||||||
|
[](EGLDisplay) { return true; },
|
||||||
|
[&](EGLDisplay, EGLContext) {
|
||||||
|
++destroy_calls;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[&](mpv_render_context*) { ++free_calls; },
|
||||||
|
[](mpv_handle*) { Check(false, "retained initialization cleanup must not terminate the shared core"); },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (TryReleaseRetainedNativeRenderContexts(retained, operations)) ++render_creations;
|
||||||
|
Check(render_creations == 0, "a retained render context must block another creation on the same core");
|
||||||
|
Check(retained.size() == 1, "failed retained cleanup must preserve ownership for another GL-thread retry");
|
||||||
|
|
||||||
|
allow_make_current = true;
|
||||||
|
if (TryReleaseRetainedNativeRenderContexts(retained, operations)) ++render_creations;
|
||||||
|
Check(render_creations == 1, "render creation may resume after retained teardown completes");
|
||||||
|
Check(retained.empty(), "successful retained teardown must consume the old render context");
|
||||||
|
Check(free_calls == 1 && destroy_calls == 1, "retained teardown must release each native object exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|
||||||
@@ -214,12 +694,20 @@ int main() {
|
|||||||
g_main_context_push_thread_default(context);
|
g_main_context_push_thread_default(context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
mpv::TestProcessShutdownDoesNotJoinBlockedNativeTeardown();
|
||||||
|
mpv::TestPopulateRetainsTextureWhileBootstrapCallbackRuns();
|
||||||
mpv::TestUnavailablePropertyWriteFails();
|
mpv::TestUnavailablePropertyWriteFails();
|
||||||
|
mpv::TestNodeConversionRejectsMalformedPayloads();
|
||||||
|
mpv::TestUnavailableCommandFails();
|
||||||
mpv::TestPendingPropertyWriteFailsOnDispose();
|
mpv::TestPendingPropertyWriteFailsOnDispose();
|
||||||
mpv::TestQueuedSourcesAreRetired(context);
|
mpv::TestQueuedSourcesAreRetired(context);
|
||||||
mpv::TestNativeLeaseBlocksDispose();
|
mpv::TestNativeLeaseBlocksDispose();
|
||||||
mpv::TestWakeupAndRedrawCoalesce(context);
|
mpv::TestWakeupAndRedrawCoalesce(context);
|
||||||
mpv::TestRapidReplacementCannotReceiveOldCallbacks(context);
|
mpv::TestRapidReplacementCannotReceiveOldCallbacks(context);
|
||||||
|
mpv::TestRenderTeardownRetainsOwnershipUntilContextIsCurrent();
|
||||||
|
mpv::TestRenderTeardownDoesNotDestroyAStillCurrentContext();
|
||||||
|
mpv::TestNullNodePropertyPayloadDecodesAsNull();
|
||||||
|
mpv::TestRetainedRenderBlocksAnotherCreationUntilReleased();
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
g_main_context_pop_thread_default(context);
|
g_main_context_pop_thread_default(context);
|
||||||
g_main_context_unref(context);
|
g_main_context_unref(context);
|
||||||
|
|||||||
+193
-56
@@ -1,9 +1,13 @@
|
|||||||
#include "mpv_plugin.h"
|
#include "mpv_plugin.h"
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
#include "mpv_texture.h"
|
#include "mpv_texture.h"
|
||||||
|
|
||||||
|
enum class VideoBootstrapState { kIdle, kPending, kReady, kFailed };
|
||||||
|
using PlayerPtr = std::unique_ptr<mpv::MpvPlayer>;
|
||||||
|
|
||||||
struct _MpvPlugin {
|
struct _MpvPlugin {
|
||||||
GObject parent_instance;
|
GObject parent_instance;
|
||||||
|
|
||||||
@@ -12,11 +16,17 @@ struct _MpvPlugin {
|
|||||||
FlEventChannel* event_channel;
|
FlEventChannel* event_channel;
|
||||||
FlTextureRegistrar* texture_registrar;
|
FlTextureRegistrar* texture_registrar;
|
||||||
|
|
||||||
std::unique_ptr<mpv::MpvPlayer> player;
|
PlayerPtr player;
|
||||||
MpvTexture* texture; // owned via GObject ref
|
MpvTexture* texture; // owned via GObject ref
|
||||||
|
gboolean texture_registered;
|
||||||
gboolean visible;
|
gboolean visible;
|
||||||
gboolean initialized;
|
gboolean initialized;
|
||||||
gboolean audio_only;
|
gboolean audio_only;
|
||||||
|
VideoBootstrapState bootstrap_state;
|
||||||
|
gchar* bootstrap_error;
|
||||||
|
FlMethodCall* ready_call;
|
||||||
|
guint64 generation;
|
||||||
|
guint ready_timeout_source_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT)
|
G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT)
|
||||||
@@ -27,48 +37,163 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall
|
|||||||
static void send_event(MpvPlugin* self, FlValue* event) {
|
static void send_event(MpvPlugin* self, FlValue* event) {
|
||||||
if (self->event_channel) {
|
if (self->event_channel) {
|
||||||
g_autoptr(GError) error = nullptr;
|
g_autoptr(GError) error = nullptr;
|
||||||
if (!fl_event_channel_send(self->event_channel, event, nullptr, &error)) {
|
if (!fl_event_channel_send(self->event_channel, event, nullptr, &error) && error != nullptr) {
|
||||||
if (error != nullptr) {
|
g_warning("Failed to send event: %s", error->message);
|
||||||
g_warning("Failed to send event: %s", error->message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void mpv_plugin_dispose(GObject* object) {
|
static gboolean handle_ready_timeout(gpointer user_data);
|
||||||
MpvPlugin* self = MPV_PLUGIN(object);
|
|
||||||
|
|
||||||
// Texture must be disposed BEFORE player — mpv_texture_dispose needs
|
static void complete_ready_call(MpvPlugin* self, gboolean success, const char* message) {
|
||||||
// the player's EGL context to clean up GL resources.
|
if (self->ready_timeout_source_id != 0) {
|
||||||
|
g_source_remove(self->ready_timeout_source_id);
|
||||||
|
self->ready_timeout_source_id = 0;
|
||||||
|
}
|
||||||
|
if (!self->ready_call) return;
|
||||||
|
g_autoptr(FlMethodResponse) response =
|
||||||
|
success ? FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr))
|
||||||
|
: FL_METHOD_RESPONSE(fl_method_error_response_new(
|
||||||
|
"INIT_FAILED", message ? message : "Video initialization failed", nullptr));
|
||||||
|
fl_method_call_respond(self->ready_call, response, nullptr);
|
||||||
|
g_object_unref(self->ready_call);
|
||||||
|
self->ready_call = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void release_video_resources(MpvPlugin* self) {
|
||||||
|
++self->generation;
|
||||||
|
if (self->player) {
|
||||||
|
// The texture is a raw callback target. Revoke both callback paths before
|
||||||
|
// unregistering or unreferencing it; Dispose then drains any callback
|
||||||
|
// already holding a native lease.
|
||||||
|
self->player->SetRedrawCallback(nullptr);
|
||||||
|
self->player->SetEventCallback(nullptr);
|
||||||
|
}
|
||||||
if (self->texture) {
|
if (self->texture) {
|
||||||
mpv_texture_dispose(self->texture);
|
if (self->texture_registered && self->texture_registrar) {
|
||||||
if (self->texture_registrar) {
|
|
||||||
fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture));
|
fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture));
|
||||||
|
self->texture_registered = FALSE;
|
||||||
}
|
}
|
||||||
|
mpv_texture_dispose(self->texture);
|
||||||
g_object_unref(self->texture);
|
g_object_unref(self->texture);
|
||||||
self->texture = nullptr;
|
self->texture = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self->player) {
|
if (self->player) {
|
||||||
self->player->Dispose();
|
self->player->Dispose();
|
||||||
self->player.reset();
|
self->player.reset();
|
||||||
}
|
}
|
||||||
|
self->initialized = FALSE;
|
||||||
|
self->visible = FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TextureReadyContext {
|
||||||
|
MpvPlugin* plugin;
|
||||||
|
guint64 generation;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TextureReadyResult {
|
||||||
|
MpvPlugin* plugin;
|
||||||
|
guint64 generation;
|
||||||
|
gboolean success;
|
||||||
|
gchar* message;
|
||||||
|
};
|
||||||
|
|
||||||
|
static gboolean handle_texture_ready_result(gpointer data) {
|
||||||
|
auto* result = static_cast<TextureReadyResult*>(data);
|
||||||
|
MpvPlugin* self = result->plugin;
|
||||||
|
if (result->generation != self->generation || self->bootstrap_state != VideoBootstrapState::kPending) {
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result->success) {
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kReady;
|
||||||
|
self->initialized = TRUE;
|
||||||
|
complete_ready_call(self, TRUE, nullptr);
|
||||||
|
} else {
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kFailed;
|
||||||
|
g_free(self->bootstrap_error);
|
||||||
|
self->bootstrap_error = g_strdup(result->message ? result->message : "Video initialization failed");
|
||||||
|
complete_ready_call(self, FALSE, self->bootstrap_error);
|
||||||
|
release_video_resources(self);
|
||||||
|
}
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void destroy_texture_ready_result(gpointer data) {
|
||||||
|
auto* result = static_cast<TextureReadyResult*>(data);
|
||||||
|
g_object_unref(result->plugin);
|
||||||
|
g_free(result->message);
|
||||||
|
delete result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_texture_ready(gboolean success, const gchar* message, gpointer user_data) {
|
||||||
|
auto* context = static_cast<TextureReadyContext*>(user_data);
|
||||||
|
auto* result = new TextureReadyResult{
|
||||||
|
MPV_PLUGIN(g_object_ref(context->plugin)),
|
||||||
|
context->generation,
|
||||||
|
success,
|
||||||
|
g_strdup(message),
|
||||||
|
};
|
||||||
|
g_main_context_invoke_full(
|
||||||
|
nullptr, G_PRIORITY_DEFAULT, handle_texture_ready_result, result, destroy_texture_ready_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void destroy_texture_ready_context(gpointer data) {
|
||||||
|
auto* context = static_cast<TextureReadyContext*>(data);
|
||||||
|
g_object_unref(context->plugin);
|
||||||
|
delete context;
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean handle_ready_timeout(gpointer user_data) {
|
||||||
|
MpvPlugin* self = MPV_PLUGIN(user_data);
|
||||||
|
self->ready_timeout_source_id = 0;
|
||||||
|
if (self->bootstrap_state != VideoBootstrapState::kPending || !self->ready_call) {
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kFailed;
|
||||||
|
g_free(self->bootstrap_error);
|
||||||
|
self->bootstrap_error = g_strdup("Video texture did not become ready before the initialization deadline");
|
||||||
|
complete_ready_call(self, FALSE, self->bootstrap_error);
|
||||||
|
release_video_resources(self);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void mpv_plugin_dispose(GObject* object) {
|
||||||
|
MpvPlugin* self = MPV_PLUGIN(object);
|
||||||
|
complete_ready_call(self, FALSE, "Video initialization was cancelled");
|
||||||
|
release_video_resources(self);
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kIdle;
|
||||||
|
g_clear_pointer(&self->bootstrap_error, g_free);
|
||||||
g_clear_object(&self->method_channel);
|
g_clear_object(&self->method_channel);
|
||||||
g_clear_object(&self->event_channel);
|
g_clear_object(&self->event_channel);
|
||||||
g_clear_object(&self->registrar);
|
g_clear_object(&self->registrar);
|
||||||
|
|
||||||
G_OBJECT_CLASS(mpv_plugin_parent_class)->dispose(object);
|
G_OBJECT_CLASS(mpv_plugin_parent_class)->dispose(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void mpv_plugin_class_init(MpvPluginClass* klass) { G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose; }
|
static void mpv_plugin_finalize(GObject* object) {
|
||||||
|
MpvPlugin* self = MPV_PLUGIN(object);
|
||||||
|
self->player.~PlayerPtr();
|
||||||
|
G_OBJECT_CLASS(mpv_plugin_parent_class)->finalize(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void mpv_plugin_class_init(MpvPluginClass* klass) {
|
||||||
|
G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose;
|
||||||
|
G_OBJECT_CLASS(klass)->finalize = mpv_plugin_finalize;
|
||||||
|
}
|
||||||
|
|
||||||
static void mpv_plugin_init(MpvPlugin* self) {
|
static void mpv_plugin_init(MpvPlugin* self) {
|
||||||
|
new (&self->player) PlayerPtr();
|
||||||
self->visible = FALSE;
|
self->visible = FALSE;
|
||||||
self->initialized = FALSE;
|
self->initialized = FALSE;
|
||||||
self->texture = nullptr;
|
self->texture = nullptr;
|
||||||
|
self->texture_registered = FALSE;
|
||||||
self->texture_registrar = nullptr;
|
self->texture_registrar = nullptr;
|
||||||
self->audio_only = FALSE;
|
self->audio_only = FALSE;
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kIdle;
|
||||||
|
self->bootstrap_error = nullptr;
|
||||||
|
self->ready_call = nullptr;
|
||||||
|
self->generation = 0;
|
||||||
|
self->ready_timeout_source_id = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, const gchar* channel_name, gboolean audio_only) {
|
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, const gchar* channel_name, gboolean audio_only) {
|
||||||
@@ -135,61 +260,73 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall
|
|||||||
response =
|
response =
|
||||||
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
|
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
|
||||||
}
|
}
|
||||||
} else if (self->initialized && self->texture) {
|
} else if (
|
||||||
// Already initialized — return existing texture ID
|
self->texture && (self->bootstrap_state == VideoBootstrapState::kPending ||
|
||||||
|
self->bootstrap_state == VideoBootstrapState::kReady)) {
|
||||||
response =
|
response =
|
||||||
FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture))));
|
FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture))));
|
||||||
} else {
|
} else {
|
||||||
// Create player if it was disposed or doesn't exist
|
g_clear_pointer(&self->bootstrap_error, g_free);
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kIdle;
|
||||||
if (!self->player || self->player->IsDisposed()) {
|
if (!self->player || self->player->IsDisposed()) {
|
||||||
self->player = std::make_unique<mpv::MpvPlayer>();
|
self->player = std::make_unique<mpv::MpvPlayer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self->player->Initialize()) {
|
if (!self->player->Initialize()) {
|
||||||
// Create the FlTextureGL and register it
|
release_video_resources(self);
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kFailed;
|
||||||
|
self->bootstrap_error = g_strdup("Failed to initialize MPV player");
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", self->bootstrap_error, nullptr));
|
||||||
|
} else {
|
||||||
FlView* view = fl_plugin_registrar_get_view(self->registrar);
|
FlView* view = fl_plugin_registrar_get_view(self->registrar);
|
||||||
self->texture = mpv_texture_new(self->player.get(), self->texture_registrar, view);
|
self->texture = mpv_texture_new(self->player.get(), self->texture_registrar, view);
|
||||||
|
++self->generation;
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kPending;
|
||||||
|
auto* ready_context = new TextureReadyContext{MPV_PLUGIN(g_object_ref(self)), self->generation};
|
||||||
|
mpv_texture_set_ready_callback(self->texture, on_texture_ready, ready_context, destroy_texture_ready_context);
|
||||||
|
|
||||||
fl_texture_registrar_register_texture(self->texture_registrar, FL_TEXTURE(self->texture));
|
if (!fl_texture_registrar_register_texture(self->texture_registrar, FL_TEXTURE(self->texture))) {
|
||||||
|
self->bootstrap_state = VideoBootstrapState::kFailed;
|
||||||
// Create the render context eagerly — mpv needs it BEFORE any
|
self->bootstrap_error = g_strdup("Failed to register video texture");
|
||||||
// file is loaded, otherwise VO init fails with "No render context
|
release_video_resources(self);
|
||||||
// set" and the video track is dropped entirely.
|
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", self->bootstrap_error, nullptr));
|
||||||
self->player->InitRenderContext();
|
} else {
|
||||||
|
self->texture_registered = TRUE;
|
||||||
// Set redraw callback: when mpv has a frame, mark texture available
|
MpvTexture* texture = self->texture;
|
||||||
MpvTexture* tex = self->texture;
|
self->player->SetRedrawCallback([texture]() { mpv_texture_mark_frame_available(texture); });
|
||||||
self->player->SetRedrawCallback([tex]() { mpv_texture_mark_frame_available(tex); });
|
self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); });
|
||||||
|
mpv_texture_mark_frame_available(self->texture);
|
||||||
self->initialized = TRUE;
|
response =
|
||||||
|
FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture))));
|
||||||
// Set up event callback
|
}
|
||||||
self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); });
|
|
||||||
|
|
||||||
// Return the texture ID for the Dart Texture widget
|
|
||||||
response =
|
|
||||||
FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture))));
|
|
||||||
} else {
|
|
||||||
response =
|
|
||||||
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (strcmp(method, "waitForVideoReady") == 0) {
|
||||||
|
if (self->audio_only) {
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("INIT_FAILED", "Audio players have no video readiness state", nullptr));
|
||||||
|
} else if (self->bootstrap_state == VideoBootstrapState::kReady && self->initialized) {
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else if (self->bootstrap_state == VideoBootstrapState::kFailed) {
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_error_response_new(
|
||||||
|
"INIT_FAILED", self->bootstrap_error ? self->bootstrap_error : "Video initialization failed", nullptr));
|
||||||
|
} else if (self->bootstrap_state != VideoBootstrapState::kPending || !self->texture) {
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("INIT_FAILED", "Video initialization is not pending", nullptr));
|
||||||
|
} else if (self->ready_call) {
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("INIT_IN_PROGRESS", "Video readiness is already being awaited", nullptr));
|
||||||
|
} else {
|
||||||
|
self->ready_call = FL_METHOD_CALL(g_object_ref(method_call));
|
||||||
|
self->ready_timeout_source_id =
|
||||||
|
g_timeout_add_seconds_full(G_PRIORITY_DEFAULT, 5, handle_ready_timeout, g_object_ref(self), g_object_unref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (strcmp(method, "dispose") == 0) {
|
} else if (strcmp(method, "dispose") == 0) {
|
||||||
// Disconnect and unregister texture FIRST — this stops Flutter from
|
complete_ready_call(self, FALSE, "Video initialization was cancelled");
|
||||||
// calling populate(), preventing concurrent mpv_render_context_render()
|
release_video_resources(self);
|
||||||
// during player disposal.
|
self->bootstrap_state = VideoBootstrapState::kIdle;
|
||||||
if (self->texture) {
|
g_clear_pointer(&self->bootstrap_error, g_free);
|
||||||
mpv_texture_dispose(self->texture);
|
|
||||||
fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture));
|
|
||||||
g_object_unref(self->texture);
|
|
||||||
self->texture = nullptr;
|
|
||||||
}
|
|
||||||
if (self->player) {
|
|
||||||
self->player->Dispose();
|
|
||||||
self->player.reset();
|
|
||||||
}
|
|
||||||
self->initialized = FALSE;
|
|
||||||
self->visible = FALSE;
|
|
||||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
} else if (strcmp(method, "command") == 0) {
|
} else if (strcmp(method, "command") == 0) {
|
||||||
if (!self->player || !self->initialized) {
|
if (!self->player || !self->initialized) {
|
||||||
|
|||||||
+465
-186
@@ -3,199 +3,467 @@
|
|||||||
#include <epoxy/egl.h>
|
#include <epoxy/egl.h>
|
||||||
#include <epoxy/gl.h>
|
#include <epoxy/gl.h>
|
||||||
|
|
||||||
// EGLImage extension function pointers
|
#include <algorithm>
|
||||||
typedef EGLImageKHR (*PFNEGLCREATEIMAGEKHRPROC)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*);
|
#include <cstdint>
|
||||||
typedef EGLBoolean (*PFNEGLDESTROYIMAGEKHRPROC)(EGLDisplay, EGLImageKHR);
|
#include <string>
|
||||||
typedef void (*PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)(GLenum, GLeglImageOES);
|
#include <vector>
|
||||||
|
|
||||||
static PFNEGLCREATEIMAGEKHRPROC _eglCreateImageKHR = nullptr;
|
#include "mpv_gpu_bootstrap.h"
|
||||||
static PFNEGLDESTROYIMAGEKHRPROC _eglDestroyImageKHR = nullptr;
|
|
||||||
static PFNGLEGLIMAGETARGETTEXTURE2DOESPROC _glEGLImageTargetTexture2DOES = nullptr;
|
|
||||||
|
|
||||||
static void init_egl_image_extensions() {
|
namespace {
|
||||||
static bool initialized = false;
|
|
||||||
if (!initialized) {
|
GQuark TextureErrorDomain() { return g_quark_from_static_string("plezy-mpv-texture"); }
|
||||||
_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
|
|
||||||
_eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
|
struct TextureResources {
|
||||||
_glEGLImageTargetTexture2DOES =
|
GLuint mpv_fbo = 0;
|
||||||
(PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
|
GLuint mpv_texture = 0;
|
||||||
initialized = true;
|
GLuint flutter_texture = 0;
|
||||||
|
EGLImageKHR egl_image = EGL_NO_IMAGE_KHR;
|
||||||
|
int32_t width = 0;
|
||||||
|
int32_t height = 0;
|
||||||
|
|
||||||
|
bool complete() const {
|
||||||
|
return mpv_fbo != 0 && mpv_texture != 0 && flutter_texture != 0 && egl_image != EGL_NO_IMAGE_KHR;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool SetError(GError** error, const char* message) {
|
||||||
|
g_set_error_literal(error, TextureErrorDomain(), 1, message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClearGlErrors() {
|
||||||
|
while (glGetError() != GL_NO_ERROR) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
struct _MpvTexture {
|
struct _MpvTexture {
|
||||||
FlTextureGL parent_instance;
|
FlTextureGL parent_instance;
|
||||||
|
|
||||||
mpv::MpvPlayer* player; // not owned
|
mpv::MpvPlayer* player;
|
||||||
FlTextureRegistrar* registrar; // not owned
|
FlTextureRegistrar* registrar;
|
||||||
FlView* view; // not owned, for querying allocation size
|
FlView* view;
|
||||||
|
|
||||||
// mpv's FBO and texture (owned by mpv's isolated EGL context)
|
GMutex mutex;
|
||||||
GLuint mpv_fbo;
|
bool disposed;
|
||||||
GLuint mpv_texture;
|
TextureResources* active;
|
||||||
|
std::vector<TextureResources>* retired;
|
||||||
|
mpv::GpuImageDispatch* image_dispatch;
|
||||||
|
EGLDisplay flutter_display;
|
||||||
|
EGLContext flutter_share_context;
|
||||||
|
EGLContext flutter_cleanup_context;
|
||||||
|
|
||||||
// Flutter's texture (owned by Flutter's EGL context)
|
GMutex bootstrap_mutex;
|
||||||
GLuint flutter_texture;
|
gint bootstrap_state;
|
||||||
|
gchar* bootstrap_error;
|
||||||
// EGLImage bridging the two contexts
|
MpvTextureReadyCallback ready_callback;
|
||||||
EGLImageKHR egl_image;
|
gpointer ready_user_data;
|
||||||
|
GDestroyNotify ready_destroy_notify;
|
||||||
int32_t width;
|
|
||||||
int32_t height;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
G_DEFINE_TYPE(MpvTexture, mpv_texture, fl_texture_gl_get_type())
|
G_DEFINE_TYPE(MpvTexture, mpv_texture, fl_texture_gl_get_type())
|
||||||
|
|
||||||
// Create/resize the FBO in mpv's context and the shared EGLImage + Flutter texture.
|
namespace {
|
||||||
static void ensure_textures(MpvTexture* self, int32_t w, int32_t h) {
|
|
||||||
if (self->mpv_fbo != 0 && self->width == w && self->height == h) {
|
void SignalBootstrap(MpvTexture* self, gboolean success, const char* message) {
|
||||||
return;
|
MpvTextureReadyCallback callback = nullptr;
|
||||||
|
gpointer user_data = nullptr;
|
||||||
|
g_mutex_lock(&self->bootstrap_mutex);
|
||||||
|
if (self->bootstrap_state == 0) {
|
||||||
|
self->bootstrap_state = success ? 1 : 2;
|
||||||
|
if (!success) self->bootstrap_error = g_strdup(message ? message : "Video initialization failed");
|
||||||
|
callback = self->ready_callback;
|
||||||
|
user_data = self->ready_user_data;
|
||||||
}
|
}
|
||||||
|
g_mutex_unlock(&self->bootstrap_mutex);
|
||||||
EGLDisplay egl_display = self->player->GetEglDisplay();
|
if (callback) callback(success, message, user_data);
|
||||||
EGLContext egl_context = self->player->GetEglContext();
|
|
||||||
|
|
||||||
// Save Flutter's current EGL state
|
|
||||||
EGLDisplay flutter_display = eglGetCurrentDisplay();
|
|
||||||
EGLContext flutter_context = eglGetCurrentContext();
|
|
||||||
EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
|
||||||
EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
|
||||||
|
|
||||||
// --- Switch to mpv's isolated context ---
|
|
||||||
eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context);
|
|
||||||
|
|
||||||
// Clean up previous mpv resources
|
|
||||||
if (self->mpv_texture != 0) {
|
|
||||||
glDeleteTextures(1, &self->mpv_texture);
|
|
||||||
}
|
|
||||||
if (self->mpv_fbo != 0) {
|
|
||||||
glDeleteFramebuffers(1, &self->mpv_fbo);
|
|
||||||
}
|
|
||||||
if (self->egl_image != EGL_NO_IMAGE_KHR) {
|
|
||||||
_eglDestroyImageKHR(egl_display, self->egl_image);
|
|
||||||
}
|
|
||||||
|
|
||||||
self->width = w;
|
|
||||||
self->height = h;
|
|
||||||
|
|
||||||
// Create mpv's texture and FBO
|
|
||||||
glGenTextures(1, &self->mpv_texture);
|
|
||||||
glBindTexture(GL_TEXTURE_2D, self->mpv_texture);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
|
||||||
|
|
||||||
glGenFramebuffers(1, &self->mpv_fbo);
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, self->mpv_fbo);
|
|
||||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, self->mpv_texture, 0);
|
|
||||||
|
|
||||||
// Create EGLImage from mpv's texture for cross-context sharing
|
|
||||||
EGLint image_attribs[] = {EGL_NONE};
|
|
||||||
self->egl_image = _eglCreateImageKHR(
|
|
||||||
egl_display, egl_context, EGL_GL_TEXTURE_2D_KHR, (EGLClientBuffer)(uintptr_t)self->mpv_texture, image_attribs);
|
|
||||||
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
||||||
glBindTexture(GL_TEXTURE_2D, 0);
|
|
||||||
glFlush();
|
|
||||||
|
|
||||||
// --- Switch back to Flutter's context ---
|
|
||||||
eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context);
|
|
||||||
|
|
||||||
// Clean up previous Flutter texture
|
|
||||||
if (self->flutter_texture != 0) {
|
|
||||||
glDeleteTextures(1, &self->flutter_texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Flutter's texture backed by the EGLImage
|
|
||||||
glGenTextures(1, &self->flutter_texture);
|
|
||||||
glBindTexture(GL_TEXTURE_2D, self->flutter_texture);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
||||||
_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, self->egl_image);
|
|
||||||
glBindTexture(GL_TEXTURE_2D, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static gboolean mpv_texture_populate(
|
bool RestoreContext(
|
||||||
FlTextureGL* gl_texture, uint32_t* target, uint32_t* name, uint32_t* width, uint32_t* height, GError** error) {
|
EGLDisplay display, EGLSurface draw, EGLSurface read, EGLContext context, EGLenum api, GError** error) {
|
||||||
MpvTexture* self = MPV_TEXTURE(gl_texture);
|
if (api != EGL_NONE && !eglBindAPI(api)) {
|
||||||
|
g_warning("MPV texture: failed to restore Flutter EGL API: 0x%x", eglGetError());
|
||||||
|
return SetError(error, "Failed to restore Flutter EGL API");
|
||||||
|
}
|
||||||
|
if (eglMakeCurrent(display, draw, read, context)) return true;
|
||||||
|
g_warning("MPV texture: failed to restore EGL context: 0x%x", eglGetError());
|
||||||
|
return SetError(error, "Failed to restore Flutter EGL context");
|
||||||
|
}
|
||||||
|
|
||||||
if (!self->player) {
|
void RestoreOrReleaseContext(
|
||||||
return FALSE;
|
EGLDisplay flutter_display, EGLSurface flutter_draw, EGLSurface flutter_read, EGLContext flutter_context,
|
||||||
|
EGLenum flutter_api, EGLDisplay mpv_display) {
|
||||||
|
if (flutter_display != EGL_NO_DISPLAY && flutter_context != EGL_NO_CONTEXT) {
|
||||||
|
const bool api_restored = flutter_api == EGL_NONE || eglBindAPI(flutter_api);
|
||||||
|
if (api_restored && eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context)) return;
|
||||||
|
g_warning("MPV texture: failed to restore EGL state during cleanup: 0x%x", eglGetError());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily create the mpv render context on first populate() call,
|
if (mpv_display != EGL_NO_DISPLAY) {
|
||||||
// since Flutter's GL context is current here.
|
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
|
||||||
if (!self->player->HasRenderContext()) {
|
g_warning("MPV texture: failed to bind OpenGL while releasing cleanup context: 0x%x", eglGetError());
|
||||||
if (!self->player->InitRenderContext()) {
|
} else if (!eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
||||||
g_set_error(error, g_quark_from_static_string("mpv"), 0, "Failed to create mpv render context");
|
g_warning("MPV texture: failed to release EGL context during cleanup: 0x%x", eglGetError());
|
||||||
return FALSE;
|
}
|
||||||
|
}
|
||||||
|
if (flutter_api != EGL_NONE && !eglBindAPI(flutter_api)) {
|
||||||
|
g_warning("MPV texture: failed to restore Flutter EGL API after cleanup: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ResourceSetEmpty(const TextureResources& resources) {
|
||||||
|
return resources.mpv_fbo == 0 && resources.mpv_texture == 0 && resources.flutter_texture == 0 &&
|
||||||
|
resources.egl_image == EGL_NO_IMAGE_KHR;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EnsureFlutterCleanupContext(
|
||||||
|
MpvTexture* self, EGLDisplay flutter_display, EGLContext flutter_context, EGLenum flutter_api, GError** error) {
|
||||||
|
if (self->flutter_cleanup_context != EGL_NO_CONTEXT) {
|
||||||
|
if (self->flutter_display == flutter_display && self->flutter_share_context == flutter_context) return true;
|
||||||
|
return SetError(error, "Flutter EGL context changed while video textures were active");
|
||||||
|
}
|
||||||
|
if (flutter_api != EGL_OPENGL_ES_API) {
|
||||||
|
return SetError(error, "Flutter is not using an OpenGL ES context");
|
||||||
|
}
|
||||||
|
|
||||||
|
EGLint config_id = 0;
|
||||||
|
EGLint client_version = 0;
|
||||||
|
if (!eglQueryContext(flutter_display, flutter_context, EGL_CONFIG_ID, &config_id) ||
|
||||||
|
!eglQueryContext(flutter_display, flutter_context, EGL_CONTEXT_CLIENT_VERSION, &client_version)) {
|
||||||
|
g_warning("MPV texture: failed to query Flutter EGL context: 0x%x", eglGetError());
|
||||||
|
return SetError(error, "Failed to query Flutter EGL context");
|
||||||
|
}
|
||||||
|
EGLConfig config = nullptr;
|
||||||
|
EGLint num_configs = 0;
|
||||||
|
const EGLint config_attribs[] = {EGL_CONFIG_ID, config_id, EGL_NONE};
|
||||||
|
if (!eglChooseConfig(flutter_display, config_attribs, &config, 1, &num_configs) || num_configs != 1) {
|
||||||
|
g_warning("MPV texture: failed to select Flutter EGL config: 0x%x", eglGetError());
|
||||||
|
return SetError(error, "Failed to select Flutter EGL config");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
|
||||||
|
g_warning("MPV texture: failed to bind OpenGL ES for cleanup context creation: 0x%x", eglGetError());
|
||||||
|
return SetError(error, "Failed to bind OpenGL ES for video cleanup");
|
||||||
|
}
|
||||||
|
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE};
|
||||||
|
const EGLContext cleanup_context = eglCreateContext(flutter_display, config, flutter_context, context_attribs);
|
||||||
|
const bool api_restored = eglBindAPI(flutter_api) == EGL_TRUE;
|
||||||
|
if (cleanup_context == EGL_NO_CONTEXT || !api_restored) {
|
||||||
|
if (cleanup_context != EGL_NO_CONTEXT && !eglDestroyContext(flutter_display, cleanup_context)) {
|
||||||
|
g_warning("MPV texture: failed to destroy rejected cleanup context: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
if (!api_restored) {
|
||||||
|
g_warning("MPV texture: failed to restore Flutter EGL API after cleanup context creation: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
return SetError(error, "Failed to create video cleanup context");
|
||||||
|
}
|
||||||
|
|
||||||
|
self->flutter_display = flutter_display;
|
||||||
|
self->flutter_share_context = flutter_context;
|
||||||
|
self->flutter_cleanup_context = cleanup_context;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DestroyFlutterCleanupContext(MpvTexture* self) {
|
||||||
|
if (self->flutter_cleanup_context == EGL_NO_CONTEXT || self->flutter_display == EGL_NO_DISPLAY) return;
|
||||||
|
|
||||||
|
const EGLenum previous_api = eglQueryAPI();
|
||||||
|
if (eglGetCurrentContext() == self->flutter_cleanup_context) {
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API) ||
|
||||||
|
!eglMakeCurrent(self->flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
||||||
|
g_warning("MPV texture: failed to release Flutter cleanup context: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!eglDestroyContext(self->flutter_display, self->flutter_cleanup_context)) {
|
||||||
|
g_warning("MPV texture: failed to destroy Flutter cleanup context: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
if (previous_api != EGL_NONE && !eglBindAPI(previous_api)) {
|
||||||
|
g_warning("MPV texture: failed to restore EGL API after cleanup context destruction: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
self->flutter_cleanup_context = EGL_NO_CONTEXT;
|
||||||
|
self->flutter_share_context = EGL_NO_CONTEXT;
|
||||||
|
self->flutter_display = EGL_NO_DISPLAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RetireIncompleteCandidate(MpvTexture* self, const TextureResources& candidate) {
|
||||||
|
if (!ResourceSetEmpty(candidate)) self->retired->push_back(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CleanupResourceSet(
|
||||||
|
MpvTexture* self, TextureResources* resources, EGLDisplay flutter_display, EGLSurface flutter_draw,
|
||||||
|
EGLSurface flutter_read, EGLContext flutter_context, EGLenum flutter_api) {
|
||||||
|
const EGLDisplay mpv_display = self->player ? self->player->GetEglDisplay() : EGL_NO_DISPLAY;
|
||||||
|
const EGLContext mpv_context = self->player ? self->player->GetEglContext() : EGL_NO_CONTEXT;
|
||||||
|
|
||||||
|
if (resources->egl_image != EGL_NO_IMAGE_KHR && mpv_display != EGL_NO_DISPLAY && self->image_dispatch &&
|
||||||
|
*self->image_dispatch) {
|
||||||
|
if (self->image_dispatch->Destroy(mpv_display, resources->egl_image)) {
|
||||||
|
resources->egl_image = EGL_NO_IMAGE_KHR;
|
||||||
|
} else {
|
||||||
|
g_warning("MPV texture: failed to destroy EGL image: 0x%x", eglGetError());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine target size from the FlView widget allocation.
|
if (resources->flutter_texture) {
|
||||||
GtkAllocation alloc;
|
const bool flutter_current = flutter_display == self->flutter_display &&
|
||||||
gtk_widget_get_allocation(GTK_WIDGET(self->view), &alloc);
|
flutter_context == self->flutter_share_context && flutter_context != EGL_NO_CONTEXT &&
|
||||||
int scale = gtk_widget_get_scale_factor(GTK_WIDGET(self->view));
|
eglGetCurrentContext() == self->flutter_share_context;
|
||||||
int32_t w = alloc.width * scale;
|
const bool cleanup_current =
|
||||||
int32_t h = alloc.height * scale;
|
!flutter_current && self->flutter_display != EGL_NO_DISPLAY &&
|
||||||
|
self->flutter_cleanup_context != EGL_NO_CONTEXT && eglBindAPI(EGL_OPENGL_ES_API) &&
|
||||||
|
eglMakeCurrent(self->flutter_display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->flutter_cleanup_context);
|
||||||
|
if (flutter_current || cleanup_current) {
|
||||||
|
glDeleteTextures(1, &resources->flutter_texture);
|
||||||
|
resources->flutter_texture = 0;
|
||||||
|
} else {
|
||||||
|
g_warning("MPV texture: failed to activate Flutter cleanup context: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
if (cleanup_current) {
|
||||||
|
RestoreOrReleaseContext(
|
||||||
|
flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, self->flutter_display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (w <= 0 || h <= 0) {
|
if (resources->mpv_fbo || resources->mpv_texture) {
|
||||||
|
const bool mpv_current = mpv_display != EGL_NO_DISPLAY && mpv_context != EGL_NO_CONTEXT &&
|
||||||
|
eglBindAPI(EGL_OPENGL_ES_API) &&
|
||||||
|
eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context);
|
||||||
|
if (mpv_current) {
|
||||||
|
if (resources->mpv_fbo) glDeleteFramebuffers(1, &resources->mpv_fbo);
|
||||||
|
if (resources->mpv_texture) glDeleteTextures(1, &resources->mpv_texture);
|
||||||
|
resources->mpv_fbo = 0;
|
||||||
|
resources->mpv_texture = 0;
|
||||||
|
} else {
|
||||||
|
g_warning("MPV texture: failed to activate EGL context during cleanup: 0x%x", eglGetError());
|
||||||
|
}
|
||||||
|
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CleanupRetired(MpvTexture* self) {
|
||||||
|
if (!self->retired || self->retired->empty() || !self->player) return;
|
||||||
|
const EGLDisplay flutter_display = eglGetCurrentDisplay();
|
||||||
|
const EGLContext flutter_context = eglGetCurrentContext();
|
||||||
|
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
||||||
|
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
||||||
|
const EGLenum flutter_api = eglQueryAPI();
|
||||||
|
for (auto& resources : *self->retired) {
|
||||||
|
CleanupResourceSet(self, &resources, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
|
}
|
||||||
|
auto& retired = *self->retired;
|
||||||
|
retired.erase(
|
||||||
|
std::remove_if(
|
||||||
|
retired.begin(), retired.end(),
|
||||||
|
[](const TextureResources& resources) { return ResourceSetEmpty(resources); }),
|
||||||
|
retired.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EnsureTextures(MpvTexture* self, int32_t width, int32_t height, GError** error) {
|
||||||
|
if (self->active->complete() && self->active->width == width && self->active->height == height) return true;
|
||||||
|
|
||||||
|
const EGLDisplay flutter_display = eglGetCurrentDisplay();
|
||||||
|
const EGLContext flutter_context = eglGetCurrentContext();
|
||||||
|
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
||||||
|
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
||||||
|
const EGLenum flutter_api = eglQueryAPI();
|
||||||
|
const EGLDisplay mpv_display = self->player->GetEglDisplay();
|
||||||
|
const EGLContext mpv_context = self->player->GetEglContext();
|
||||||
|
if (flutter_display == EGL_NO_DISPLAY || flutter_context == EGL_NO_CONTEXT || mpv_display == EGL_NO_DISPLAY ||
|
||||||
|
mpv_context == EGL_NO_CONTEXT) {
|
||||||
|
return SetError(error, "Video EGL contexts are unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EnsureFlutterCleanupContext(self, flutter_display, flutter_context, flutter_api, error)) return false;
|
||||||
|
|
||||||
|
if (!*self->image_dispatch) {
|
||||||
|
std::string dispatch_error;
|
||||||
|
if (!mpv::ResolveGpuImageDispatch(flutter_display, self->image_dispatch, &dispatch_error)) {
|
||||||
|
g_warning("MPV texture: GPU bootstrap rejected: %s", dispatch_error.c_str());
|
||||||
|
return SetError(error, dispatch_error.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextureResources candidate;
|
||||||
|
candidate.width = width;
|
||||||
|
candidate.height = height;
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context)) {
|
||||||
|
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
|
||||||
|
return SetError(error, "Failed to activate video EGL context");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearGlErrors();
|
||||||
|
glGenTextures(1, &candidate.mpv_texture);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, candidate.mpv_texture);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||||
|
glGenFramebuffers(1, &candidate.mpv_fbo);
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, candidate.mpv_fbo);
|
||||||
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, candidate.mpv_texture, 0);
|
||||||
|
bool framebuffer_complete = candidate.mpv_texture != 0 && candidate.mpv_fbo != 0 &&
|
||||||
|
glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
|
||||||
|
glGetError() == GL_NO_ERROR;
|
||||||
|
if (framebuffer_complete) {
|
||||||
|
candidate.egl_image = self->image_dispatch->Create(
|
||||||
|
mpv_display, mpv_context, reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(candidate.mpv_texture)));
|
||||||
|
}
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, 0);
|
||||||
|
glFlush();
|
||||||
|
framebuffer_complete = framebuffer_complete && glGetError() == GL_NO_ERROR;
|
||||||
|
|
||||||
|
if (!RestoreContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, error)) {
|
||||||
|
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
|
RetireIncompleteCandidate(self, candidate);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!framebuffer_complete || candidate.egl_image == EGL_NO_IMAGE_KHR) {
|
||||||
|
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
|
RetireIncompleteCandidate(self, candidate);
|
||||||
|
return SetError(error, "Failed to create a complete video framebuffer");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearGlErrors();
|
||||||
|
glGenTextures(1, &candidate.flutter_texture);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, candidate.flutter_texture);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||||
|
self->image_dispatch->image_target_texture(GL_TEXTURE_2D, reinterpret_cast<GLeglImageOES>(candidate.egl_image));
|
||||||
|
const bool flutter_texture_complete = candidate.flutter_texture != 0 && glGetError() == GL_NO_ERROR;
|
||||||
|
glBindTexture(GL_TEXTURE_2D, 0);
|
||||||
|
if (!flutter_texture_complete) {
|
||||||
|
CleanupResourceSet(self, &candidate, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
|
RetireIncompleteCandidate(self, candidate);
|
||||||
|
return SetError(error, "Failed to bind the shared video image");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->active->complete()) self->retired->push_back(*self->active);
|
||||||
|
*self->active = candidate;
|
||||||
|
CleanupRetired(self);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean MpvTexturePopulate(
|
||||||
|
FlTextureGL* texture, uint32_t* target, uint32_t* name, uint32_t* width, uint32_t* height, GError** error) {
|
||||||
|
MpvTexture* self = MPV_TEXTURE(texture);
|
||||||
|
g_mutex_lock(&self->mutex);
|
||||||
|
if (self->disposed || !self->player) {
|
||||||
|
g_object_ref(self);
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
SignalBootstrap(self, FALSE, "Video texture was disposed");
|
||||||
|
g_object_unref(self);
|
||||||
|
return SetError(error, "Video texture was disposed");
|
||||||
|
}
|
||||||
|
if (!self->player->HasRenderContext() && !self->player->InitRenderContext()) {
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return SetError(error, "Failed to create video render context");
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkAllocation allocation;
|
||||||
|
gtk_widget_get_allocation(GTK_WIDGET(self->view), &allocation);
|
||||||
|
const int scale = gtk_widget_get_scale_factor(GTK_WIDGET(self->view));
|
||||||
|
const int32_t requested_width = allocation.width * scale;
|
||||||
|
const int32_t requested_height = allocation.height * scale;
|
||||||
|
if (requested_width <= 0 || requested_height <= 0) {
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return SetError(error, "Video surface has no drawable size");
|
||||||
|
}
|
||||||
|
// GL/EGL failures during the first populate are not terminal. Flutter may
|
||||||
|
// call populate again while waitForVideoReady owns the bounded deadline.
|
||||||
|
if (!EnsureTextures(self, requested_width, requested_height, error)) {
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_textures(self, w, h);
|
const EGLDisplay flutter_display = eglGetCurrentDisplay();
|
||||||
|
const EGLContext flutter_context = eglGetCurrentContext();
|
||||||
|
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
||||||
|
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
||||||
|
const EGLenum flutter_api = eglQueryAPI();
|
||||||
|
const EGLDisplay mpv_display = self->player->GetEglDisplay();
|
||||||
|
const EGLContext mpv_context = self->player->GetEglContext();
|
||||||
|
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(mpv_display, EGL_NO_SURFACE, EGL_NO_SURFACE, mpv_context)) {
|
||||||
|
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return SetError(error, "Failed to activate video EGL context");
|
||||||
|
}
|
||||||
|
|
||||||
// Save Flutter's current EGL state
|
ClearGlErrors();
|
||||||
EGLDisplay flutter_display = eglGetCurrentDisplay();
|
glBindFramebuffer(GL_FRAMEBUFFER, self->active->mpv_fbo);
|
||||||
EGLContext flutter_context = eglGetCurrentContext();
|
|
||||||
EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
|
||||||
EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
|
||||||
|
|
||||||
// Switch to mpv's isolated context for rendering
|
|
||||||
EGLDisplay egl_display = self->player->GetEglDisplay();
|
|
||||||
EGLContext egl_context = self->player->GetEglContext();
|
|
||||||
eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context);
|
|
||||||
|
|
||||||
// Render mpv into its FBO
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, self->mpv_fbo);
|
|
||||||
self->player->ClearRedrawFlag();
|
self->player->ClearRedrawFlag();
|
||||||
self->player->Render(w, h, static_cast<int>(self->mpv_fbo));
|
self->player->Render(requested_width, requested_height, static_cast<int>(self->active->mpv_fbo));
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
glFlush();
|
glFlush();
|
||||||
|
const bool render_succeeded = glGetError() == GL_NO_ERROR;
|
||||||
// Restore Flutter's context
|
if (!RestoreContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, error)) {
|
||||||
eglMakeCurrent(flutter_display, flutter_draw, flutter_read, flutter_context);
|
RestoreOrReleaseContext(flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api, mpv_display);
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
if (!render_succeeded) {
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return SetError(error, "Video render operation failed");
|
||||||
|
}
|
||||||
|
|
||||||
*target = GL_TEXTURE_2D;
|
*target = GL_TEXTURE_2D;
|
||||||
*name = self->flutter_texture;
|
*name = self->active->flutter_texture;
|
||||||
*width = static_cast<uint32_t>(w);
|
*width = static_cast<uint32_t>(requested_width);
|
||||||
*height = static_cast<uint32_t>(h);
|
*height = static_cast<uint32_t>(requested_height);
|
||||||
|
g_object_ref(self);
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
SignalBootstrap(self, TRUE, nullptr);
|
||||||
|
g_object_unref(self);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void MpvTextureFinalize(GObject* object) {
|
||||||
|
MpvTexture* self = MPV_TEXTURE(object);
|
||||||
|
if (self->ready_destroy_notify && self->ready_user_data) {
|
||||||
|
self->ready_destroy_notify(self->ready_user_data);
|
||||||
|
}
|
||||||
|
g_free(self->bootstrap_error);
|
||||||
|
delete self->active;
|
||||||
|
delete self->retired;
|
||||||
|
delete self->image_dispatch;
|
||||||
|
g_mutex_clear(&self->bootstrap_mutex);
|
||||||
|
g_mutex_clear(&self->mutex);
|
||||||
|
G_OBJECT_CLASS(mpv_texture_parent_class)->finalize(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
static void mpv_texture_class_init(MpvTextureClass* klass) {
|
static void mpv_texture_class_init(MpvTextureClass* klass) {
|
||||||
FL_TEXTURE_GL_CLASS(klass)->populate = mpv_texture_populate;
|
FL_TEXTURE_GL_CLASS(klass)->populate = MpvTexturePopulate;
|
||||||
|
G_OBJECT_CLASS(klass)->finalize = MpvTextureFinalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void mpv_texture_init(MpvTexture* self) {
|
static void mpv_texture_init(MpvTexture* self) {
|
||||||
self->player = nullptr;
|
self->player = nullptr;
|
||||||
self->registrar = nullptr;
|
self->registrar = nullptr;
|
||||||
self->view = nullptr;
|
self->view = nullptr;
|
||||||
self->mpv_fbo = 0;
|
g_mutex_init(&self->mutex);
|
||||||
self->mpv_texture = 0;
|
self->disposed = false;
|
||||||
self->flutter_texture = 0;
|
self->active = new TextureResources();
|
||||||
self->egl_image = EGL_NO_IMAGE_KHR;
|
self->retired = new std::vector<TextureResources>();
|
||||||
self->width = 0;
|
self->image_dispatch = new mpv::GpuImageDispatch();
|
||||||
self->height = 0;
|
self->flutter_display = EGL_NO_DISPLAY;
|
||||||
|
self->flutter_share_context = EGL_NO_CONTEXT;
|
||||||
|
self->flutter_cleanup_context = EGL_NO_CONTEXT;
|
||||||
|
g_mutex_init(&self->bootstrap_mutex);
|
||||||
|
self->bootstrap_state = 0;
|
||||||
|
self->bootstrap_error = nullptr;
|
||||||
|
self->ready_callback = nullptr;
|
||||||
|
self->ready_user_data = nullptr;
|
||||||
|
self->ready_destroy_notify = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view) {
|
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view) {
|
||||||
init_egl_image_extensions();
|
|
||||||
MpvTexture* self = MPV_TEXTURE(g_object_new(MPV_TEXTURE_TYPE, nullptr));
|
MpvTexture* self = MPV_TEXTURE(g_object_new(MPV_TEXTURE_TYPE, nullptr));
|
||||||
self->player = player;
|
self->player = player;
|
||||||
self->registrar = registrar;
|
self->registrar = registrar;
|
||||||
@@ -203,59 +471,70 @@ MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registra
|
|||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void mpv_texture_set_ready_callback(
|
||||||
|
MpvTexture* self, MpvTextureReadyCallback callback, gpointer user_data, GDestroyNotify destroy_notify) {
|
||||||
|
gboolean success = FALSE;
|
||||||
|
const gchar* message = nullptr;
|
||||||
|
bool complete = false;
|
||||||
|
g_mutex_lock(&self->bootstrap_mutex);
|
||||||
|
self->ready_callback = callback;
|
||||||
|
self->ready_user_data = user_data;
|
||||||
|
self->ready_destroy_notify = destroy_notify;
|
||||||
|
if (self->bootstrap_state != 0) {
|
||||||
|
complete = true;
|
||||||
|
success = self->bootstrap_state == 1;
|
||||||
|
message = self->bootstrap_error;
|
||||||
|
}
|
||||||
|
g_mutex_unlock(&self->bootstrap_mutex);
|
||||||
|
if (complete && callback) callback(success, message, user_data);
|
||||||
|
}
|
||||||
|
|
||||||
void mpv_texture_mark_frame_available(MpvTexture* self) {
|
void mpv_texture_mark_frame_available(MpvTexture* self) {
|
||||||
if (self && self->registrar) {
|
if (!self) return;
|
||||||
fl_texture_registrar_mark_texture_frame_available(self->registrar, FL_TEXTURE(self));
|
g_mutex_lock(&self->mutex);
|
||||||
|
FlTextureRegistrar* registrar = self->disposed ? nullptr : self->registrar;
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
|
if (registrar) {
|
||||||
|
fl_texture_registrar_mark_texture_frame_available(registrar, FL_TEXTURE(self));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void mpv_texture_dispose(MpvTexture* self) {
|
void mpv_texture_dispose(MpvTexture* self) {
|
||||||
if (!self) return;
|
if (!self) return;
|
||||||
|
g_mutex_lock(&self->mutex);
|
||||||
EGLDisplay egl_display = EGL_NO_DISPLAY;
|
if (self->disposed) {
|
||||||
EGLContext egl_context = EGL_NO_CONTEXT;
|
g_mutex_unlock(&self->mutex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->disposed = true;
|
||||||
|
SignalBootstrap(self, FALSE, "Video initialization was cancelled");
|
||||||
|
|
||||||
if (self->player) {
|
if (self->player) {
|
||||||
egl_display = self->player->GetEglDisplay();
|
const EGLDisplay flutter_display = eglGetCurrentDisplay();
|
||||||
egl_context = self->player->GetEglContext();
|
const EGLContext flutter_context = eglGetCurrentContext();
|
||||||
}
|
const EGLSurface flutter_draw = eglGetCurrentSurface(EGL_DRAW);
|
||||||
|
const EGLSurface flutter_read = eglGetCurrentSurface(EGL_READ);
|
||||||
// Clean up Flutter's texture (in Flutter's current context)
|
const EGLenum flutter_api = eglQueryAPI();
|
||||||
if (self->flutter_texture != 0) {
|
CleanupResourceSet(self, self->active, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
glDeleteTextures(1, &self->flutter_texture);
|
for (auto& resources : *self->retired) {
|
||||||
self->flutter_texture = 0;
|
CleanupResourceSet(self, &resources, flutter_display, flutter_draw, flutter_read, flutter_context, flutter_api);
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up EGLImage
|
|
||||||
if (self->egl_image != EGL_NO_IMAGE_KHR && egl_display != EGL_NO_DISPLAY) {
|
|
||||||
_eglDestroyImageKHR(egl_display, self->egl_image);
|
|
||||||
self->egl_image = EGL_NO_IMAGE_KHR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up mpv's GL resources in mpv's context
|
|
||||||
if (egl_context != EGL_NO_CONTEXT) {
|
|
||||||
EGLDisplay cur_display = eglGetCurrentDisplay();
|
|
||||||
EGLContext cur_context = eglGetCurrentContext();
|
|
||||||
EGLSurface cur_draw = eglGetCurrentSurface(EGL_DRAW);
|
|
||||||
EGLSurface cur_read = eglGetCurrentSurface(EGL_READ);
|
|
||||||
|
|
||||||
eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context);
|
|
||||||
|
|
||||||
if (self->mpv_texture != 0) {
|
|
||||||
glDeleteTextures(1, &self->mpv_texture);
|
|
||||||
self->mpv_texture = 0;
|
|
||||||
}
|
}
|
||||||
if (self->mpv_fbo != 0) {
|
DestroyFlutterCleanupContext(self);
|
||||||
glDeleteFramebuffers(1, &self->mpv_fbo);
|
const auto leaked_sets =
|
||||||
self->mpv_fbo = 0;
|
static_cast<size_t>(!ResourceSetEmpty(*self->active)) +
|
||||||
|
static_cast<size_t>(std::count_if(self->retired->begin(), self->retired->end(), [](const auto& resources) {
|
||||||
|
return !ResourceSetEmpty(resources);
|
||||||
|
}));
|
||||||
|
if (leaked_sets != 0) {
|
||||||
|
g_warning("MPV texture: %zu resource set(s) could not be released before disposal", leaked_sets);
|
||||||
}
|
}
|
||||||
|
|
||||||
eglMakeCurrent(cur_display, cur_draw, cur_read, cur_context);
|
|
||||||
}
|
}
|
||||||
|
*self->active = TextureResources{};
|
||||||
|
self->retired->clear();
|
||||||
self->player = nullptr;
|
self->player = nullptr;
|
||||||
self->registrar = nullptr;
|
self->registrar = nullptr;
|
||||||
self->view = nullptr;
|
self->view = nullptr;
|
||||||
|
g_mutex_unlock(&self->mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
int64_t mpv_texture_get_id(MpvTexture* self) { return fl_texture_get_id(FL_TEXTURE(self)); }
|
int64_t mpv_texture_get_id(MpvTexture* self) { return fl_texture_get_id(FL_TEXTURE(self)); }
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ G_DECLARE_FINAL_TYPE(MpvTexture, mpv_texture, MPV, TEXTURE, FlTextureGL)
|
|||||||
/// Creates a new MpvTexture that renders mpv video to an offscreen FBO.
|
/// Creates a new MpvTexture that renders mpv video to an offscreen FBO.
|
||||||
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view);
|
MpvTexture* mpv_texture_new(mpv::MpvPlayer* player, FlTextureRegistrar* registrar, FlView* view);
|
||||||
|
|
||||||
|
typedef void (*MpvTextureReadyCallback)(gboolean success, const gchar* error_message, gpointer user_data);
|
||||||
|
|
||||||
|
/// Installs the one-shot video bootstrap result callback.
|
||||||
|
void mpv_texture_set_ready_callback(
|
||||||
|
MpvTexture* self, MpvTextureReadyCallback callback, gpointer user_data, GDestroyNotify destroy_notify);
|
||||||
|
|
||||||
/// Notifies Flutter that a new frame is available.
|
/// Notifies Flutter that a new frame is available.
|
||||||
void mpv_texture_mark_frame_available(MpvTexture* self);
|
void mpv_texture_mark_frame_available(MpvTexture* self);
|
||||||
|
|
||||||
|
|||||||
@@ -540,7 +540,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Plezy";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -555,7 +555,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Plezy";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -570,7 +570,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Plezy";
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Libmpv
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ final class RecordingMpvPlugin: MpvPluginShared {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class RecordingLifecycleDelegate: MpvPlayerDelegate {
|
||||||
|
private(set) var events: [String] = []
|
||||||
|
private(set) var properties: [String] = []
|
||||||
|
|
||||||
|
func onPropertyChange(name: String, value: Any?) { properties.append(name) }
|
||||||
|
func onEvent(name: String, data: [String: Any]?) { events.append(name) }
|
||||||
|
}
|
||||||
|
|
||||||
final class MpvPlayerContractTests: XCTestCase {
|
final class MpvPlayerContractTests: XCTestCase {
|
||||||
private let failure = NSError(
|
private let failure = NSError(
|
||||||
domain: "MpvPlayerContractTests",
|
domain: "MpvPlayerContractTests",
|
||||||
@@ -108,6 +117,35 @@ final class MpvPlayerContractTests: XCTestCase {
|
|||||||
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() {
|
||||||
|
let core = MpvAudioPlayerCore()
|
||||||
|
XCTAssertTrue(core.initialize())
|
||||||
|
defer {
|
||||||
|
core.dispose()
|
||||||
|
core.queue.sync {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let queueEntered = expectation(description: "mpv queue blocked")
|
||||||
|
let releaseQueue = DispatchSemaphore(value: 0)
|
||||||
|
core.queue.async {
|
||||||
|
queueEntered.fulfill()
|
||||||
|
releaseQueue.wait()
|
||||||
|
}
|
||||||
|
wait(for: [queueEntered], timeout: 2)
|
||||||
|
|
||||||
|
let completion = expectation(description: "pause write completed")
|
||||||
|
core.setPropertyAsync("pause", value: "no") { result in
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
XCTFail("Pause write failed: \(error)")
|
||||||
|
}
|
||||||
|
completion.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes")
|
||||||
|
releaseQueue.signal()
|
||||||
|
wait(for: [completion], timeout: 2)
|
||||||
|
}
|
||||||
|
|
||||||
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
||||||
let core = MpvAudioPlayerCore()
|
let core = MpvAudioPlayerCore()
|
||||||
XCTAssertTrue(core.initialize())
|
XCTAssertTrue(core.initialize())
|
||||||
@@ -152,6 +190,94 @@ final class MpvPlayerContractTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() {
|
||||||
|
let core = MpvPlayerCoreBase()
|
||||||
|
let delegate = RecordingLifecycleDelegate()
|
||||||
|
core.delegate = delegate
|
||||||
|
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
|
||||||
|
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
|
||||||
|
XCTAssertTrue(core.beginDisposal())
|
||||||
|
|
||||||
|
let drained = expectation(description: "main delivery drained")
|
||||||
|
DispatchQueue.main.async { drained.fulfill() }
|
||||||
|
wait(for: [drained], timeout: 2)
|
||||||
|
XCTAssertTrue(delegate.events.isEmpty)
|
||||||
|
XCTAssertTrue(delegate.properties.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() {
|
||||||
|
let core = MpvAudioPlayerCore()
|
||||||
|
XCTAssertTrue(core.initialize())
|
||||||
|
core.dispose()
|
||||||
|
core.queue.sync {}
|
||||||
|
|
||||||
|
let completed = expectation(description: "unavailable property completed")
|
||||||
|
completed.assertForOverFulfill = true
|
||||||
|
var completionCount = 0
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
core.getPropertyAsync("volume") { result in
|
||||||
|
XCTAssertTrue(Thread.isMainThread)
|
||||||
|
if case .success = result { XCTFail("Expected unavailable property failure") }
|
||||||
|
completionCount += 1
|
||||||
|
completed.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait(for: [completed], timeout: 2)
|
||||||
|
XCTAssertEqual(completionCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNormalizedPlaybackDelayStringsPassThroughUnchanged() {
|
||||||
|
let core = ControllablePropertyCore()
|
||||||
|
let plugin = RecordingMpvPlugin(core: core)
|
||||||
|
let values = ["0.25", "-0.5", "0", "0.25"]
|
||||||
|
|
||||||
|
for value in values {
|
||||||
|
core.nextResult = .success(())
|
||||||
|
let result = invokeSetProperty(plugin, name: "audio-delay", value: value)
|
||||||
|
XCTAssertEqual(result.count, 1)
|
||||||
|
XCTAssertNil(result[0])
|
||||||
|
}
|
||||||
|
XCTAssertEqual(core.propertyCalls.map(\.1), values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNodeConversionBoundsAndDiscardsMalformedSiblings() {
|
||||||
|
let core = MpvPlayerCoreBase()
|
||||||
|
var valid = mpv_node()
|
||||||
|
valid.format = MPV_FORMAT_INT64
|
||||||
|
valid.u.int64 = 7
|
||||||
|
var malformed = mpv_node()
|
||||||
|
malformed.format = MPV_FORMAT_NONE
|
||||||
|
var values = [valid, malformed, valid]
|
||||||
|
var decoded: Any?
|
||||||
|
|
||||||
|
let valueCount = values.count
|
||||||
|
values.withUnsafeMutableBufferPointer { valuesPointer in
|
||||||
|
var list = mpv_node_list()
|
||||||
|
list.num = Int32(valueCount)
|
||||||
|
list.values = valuesPointer.baseAddress
|
||||||
|
withUnsafeMutablePointer(to: &list) { listPointer in
|
||||||
|
var root = mpv_node()
|
||||||
|
root.format = MPV_FORMAT_NODE_ARRAY
|
||||||
|
root.u.list = listPointer
|
||||||
|
decoded = core.convertNode(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded as? [Int64], [7, 7])
|
||||||
|
|
||||||
|
var oversizedBytes = mpv_byte_array()
|
||||||
|
oversizedBytes.size = 16 * 1_024 * 1_024 + 1
|
||||||
|
withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in
|
||||||
|
var root = mpv_node()
|
||||||
|
root.format = MPV_FORMAT_BYTE_ARRAY
|
||||||
|
root.u.ba = bytePointer
|
||||||
|
XCTAssertNil(core.convertNode(root))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160))
|
||||||
|
XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
|
||||||
|
}
|
||||||
|
|
||||||
private func invokeSetProperty(
|
private func invokeSetProperty(
|
||||||
_ plugin: RecordingMpvPlugin,
|
_ plugin: RecordingMpvPlugin,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
+10
-1
@@ -49,7 +49,16 @@ internal object PersistedPermissionResolver {
|
|||||||
}
|
}
|
||||||
if (flags == 0) return
|
if (flags == 0) return
|
||||||
|
|
||||||
contentResolver.releasePersistableUriPermission(permission.uri, flags)
|
try {
|
||||||
|
contentResolver.releasePersistableUriPermission(permission.uri, flags)
|
||||||
|
} catch (failure: SecurityException) {
|
||||||
|
val remainingPermission = resolve(contentResolver, requestedUri) ?: return
|
||||||
|
val requestedModeRemains =
|
||||||
|
(read && remainingPermission.isReadPermission) ||
|
||||||
|
(write && remainingPermission.isWritePermission)
|
||||||
|
if (!requestedModeRemains) return
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveUri(
|
fun resolveUri(
|
||||||
|
|||||||
+52
@@ -7,6 +7,10 @@ import android.content.UriPermission
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@@ -18,6 +22,7 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
import org.mockito.ArgumentMatchers.anyInt
|
import org.mockito.ArgumentMatchers.anyInt
|
||||||
|
import org.mockito.Mockito.doAnswer
|
||||||
import org.mockito.Mockito.doThrow
|
import org.mockito.Mockito.doThrow
|
||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
import org.mockito.Mockito.never
|
import org.mockito.Mockito.never
|
||||||
@@ -209,6 +214,53 @@ internal class SafUtilPersistedPermissionTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrentReleaseRaceIsIdempotent() {
|
||||||
|
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
|
||||||
|
val requested = DocumentFile.fromTreeUri(context, treeUri)!!.uri
|
||||||
|
val permission = permission(treeUri, read = true, write = false)
|
||||||
|
val released = AtomicBoolean(false)
|
||||||
|
val concurrentPlatformCalls = CountDownLatch(2)
|
||||||
|
val resolver = mock(ContentResolver::class.java)
|
||||||
|
`when`(resolver.persistedUriPermissions).thenAnswer {
|
||||||
|
if (released.get()) emptyList<UriPermission>() else listOf(permission)
|
||||||
|
}
|
||||||
|
doAnswer {
|
||||||
|
concurrentPlatformCalls.countDown()
|
||||||
|
assertTrue(concurrentPlatformCalls.await(5, TimeUnit.SECONDS))
|
||||||
|
if (!released.compareAndSet(false, true)) {
|
||||||
|
throw SecurityException("grant was already released")
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.`when`(resolver).releasePersistableUriPermission(
|
||||||
|
treeUri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
val executor = Executors.newFixedThreadPool(2)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val releases =
|
||||||
|
List(2) {
|
||||||
|
executor.submit {
|
||||||
|
PersistedPermissionResolver.release(
|
||||||
|
resolver,
|
||||||
|
requested,
|
||||||
|
read = true,
|
||||||
|
write = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releases.forEach { it.get(5, TimeUnit.SECONDS) }
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(resolver, times(2)).releasePersistableUriPermission(
|
||||||
|
treeUri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun matchedPlatformReleaseErrorPropagates() {
|
fun matchedPlatformReleaseErrorPropagates() {
|
||||||
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
|
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
|
||||||
|
|||||||
+36
-4
@@ -12,12 +12,13 @@ import io.flutter.plugin.common.MethodCall
|
|||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.PluginRegistry
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
import org.mockito.ArgumentCaptor
|
import org.mockito.ArgumentCaptor
|
||||||
import org.mockito.Mockito.doThrow
|
import org.mockito.Mockito.doThrow
|
||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
@@ -26,7 +27,11 @@ import org.mockito.Mockito.verify
|
|||||||
import org.mockito.Mockito.verifyNoInteractions
|
import org.mockito.Mockito.verifyNoInteractions
|
||||||
import org.mockito.Mockito.verifyNoMoreInteractions
|
import org.mockito.Mockito.verifyNoMoreInteractions
|
||||||
import org.mockito.Mockito.`when`
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
internal class SafUtilPluginTest {
|
internal class SafUtilPluginTest {
|
||||||
@Test
|
@Test
|
||||||
fun onMethodCall_unknownMethod_returnsNotImplemented() {
|
fun onMethodCall_unknownMethod_returnsNotImplemented() {
|
||||||
@@ -120,12 +125,25 @@ internal class SafUtilPluginTest {
|
|||||||
val uri = mock(Uri::class.java)
|
val uri = mock(Uri::class.java)
|
||||||
val frame = mock(Bitmap::class.java)
|
val frame = mock(Bitmap::class.java)
|
||||||
val retriever = mock(MediaMetadataRetriever::class.java)
|
val retriever = mock(MediaMetadataRetriever::class.java)
|
||||||
`when`(retriever.frameAtTime).thenReturn(frame)
|
`when`(
|
||||||
|
retriever.getScaledFrameAtTime(
|
||||||
|
-1,
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
320,
|
||||||
|
180
|
||||||
|
)
|
||||||
|
).thenReturn(frame)
|
||||||
|
|
||||||
val extracted = extractVideoFrame(context, uri, 320, 180, retriever)
|
val extracted = extractVideoFrame(context, uri, 320, 180, retriever)
|
||||||
|
|
||||||
assertEquals(frame, extracted)
|
assertEquals(frame, extracted)
|
||||||
verify(retriever).setDataSource(context, uri)
|
verify(retriever).setDataSource(context, uri)
|
||||||
|
verify(retriever).getScaledFrameAtTime(
|
||||||
|
-1,
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
320,
|
||||||
|
180
|
||||||
|
)
|
||||||
verify(retriever).release()
|
verify(retriever).release()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +170,14 @@ internal class SafUtilPluginTest {
|
|||||||
val uri = mock(Uri::class.java)
|
val uri = mock(Uri::class.java)
|
||||||
val retriever = mock(MediaMetadataRetriever::class.java)
|
val retriever = mock(MediaMetadataRetriever::class.java)
|
||||||
val failure = IllegalStateException("extract failed")
|
val failure = IllegalStateException("extract failed")
|
||||||
`when`(retriever.frameAtTime).thenThrow(failure)
|
`when`(
|
||||||
|
retriever.getScaledFrameAtTime(
|
||||||
|
-1,
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
320,
|
||||||
|
180
|
||||||
|
)
|
||||||
|
).thenThrow(failure)
|
||||||
|
|
||||||
val thrown =
|
val thrown =
|
||||||
assertFailsWith<IllegalStateException> {
|
assertFailsWith<IllegalStateException> {
|
||||||
@@ -168,7 +193,14 @@ internal class SafUtilPluginTest {
|
|||||||
val context = mock(Context::class.java)
|
val context = mock(Context::class.java)
|
||||||
val uri = mock(Uri::class.java)
|
val uri = mock(Uri::class.java)
|
||||||
val retriever = mock(MediaMetadataRetriever::class.java)
|
val retriever = mock(MediaMetadataRetriever::class.java)
|
||||||
`when`(retriever.frameAtTime).thenReturn(null)
|
`when`(
|
||||||
|
retriever.getScaledFrameAtTime(
|
||||||
|
-1,
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
320,
|
||||||
|
180
|
||||||
|
)
|
||||||
|
).thenReturn(null)
|
||||||
|
|
||||||
assertNull(extractVideoFrame(context, uri, 320, 180, retriever))
|
assertNull(extractVideoFrame(context, uri, 320, 180, retriever))
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ var NoSleep = (function () {
|
|||||||
this._nativeRequested = false
|
this._nativeRequested = false
|
||||||
this._wakeLock = null
|
this._wakeLock = null
|
||||||
this._wakeLockRequest = null
|
this._wakeLockRequest = null
|
||||||
|
this._wakeLockRelease = null
|
||||||
|
this._wakeLockReleaseSentinel = null
|
||||||
|
this._wakeLockReplacement = null
|
||||||
var handleVisibilityChange = function handleVisibilityChange() {
|
var handleVisibilityChange = function handleVisibilityChange() {
|
||||||
if (_this._nativeRequested && document.visibilityState === 'visible') {
|
if (_this._nativeRequested && document.visibilityState === 'visible') {
|
||||||
_this._requestNativeWakeLock().catch(function () {})
|
_this._requestNativeWakeLock().catch(function () {})
|
||||||
@@ -115,17 +118,76 @@ var NoSleep = (function () {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '_requestNativeWakeLock',
|
key: '_releaseNativeWakeLock',
|
||||||
value: function _requestNativeWakeLock() {
|
value: function _releaseNativeWakeLock(wakeLock) {
|
||||||
var _this2 = this
|
var _this2 = this
|
||||||
|
|
||||||
|
if (this._wakeLockRelease !== null) {
|
||||||
|
if (this._wakeLockReleaseSentinel === wakeLock) {
|
||||||
|
return this._wakeLockRelease
|
||||||
|
}
|
||||||
|
return this._wakeLockRelease
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () {
|
||||||
|
return _this2._releaseNativeWakeLock(wakeLock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var release = Promise.resolve()
|
||||||
|
.then(function () {
|
||||||
|
return wakeLock.release()
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
if (_this2._wakeLock === wakeLock) {
|
||||||
|
_this2._wakeLock = null
|
||||||
|
_this2.nativeEnabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
if (_this2._wakeLockRelease === release) {
|
||||||
|
_this2._wakeLockRelease = null
|
||||||
|
_this2._wakeLockReleaseSentinel = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this._wakeLockReleaseSentinel = wakeLock
|
||||||
|
this._wakeLockRelease = release
|
||||||
|
return release
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_requestNativeWakeLock',
|
||||||
|
value: function _requestNativeWakeLock(afterRelease) {
|
||||||
|
var _this3 = this
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this._nativeRequested ||
|
!this._nativeRequested ||
|
||||||
document.visibilityState !== 'visible' ||
|
document.visibilityState !== 'visible'
|
||||||
this._wakeLock !== null
|
|
||||||
) {
|
) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
if (!afterRelease && this._wakeLockReplacement !== null) {
|
||||||
|
return this._wakeLockReplacement
|
||||||
|
}
|
||||||
|
if (this._wakeLock !== null) {
|
||||||
|
if (
|
||||||
|
this._wakeLockRelease !== null &&
|
||||||
|
this._wakeLockReleaseSentinel === this._wakeLock
|
||||||
|
) {
|
||||||
|
var replacement
|
||||||
|
replacement = this._wakeLockRelease
|
||||||
|
.then(function () {
|
||||||
|
return _this3._requestNativeWakeLock(true)
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
if (_this3._wakeLockReplacement === replacement) {
|
||||||
|
_this3._wakeLockReplacement = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this._wakeLockReplacement = replacement
|
||||||
|
return replacement
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
if (this._wakeLockRequest !== null) {
|
if (this._wakeLockRequest !== null) {
|
||||||
return this._wakeLockRequest
|
return this._wakeLockRequest
|
||||||
}
|
}
|
||||||
@@ -135,31 +197,33 @@ var NoSleep = (function () {
|
|||||||
.request('screen')
|
.request('screen')
|
||||||
.then(function (wakeLock) {
|
.then(function (wakeLock) {
|
||||||
wakeLock.addEventListener('release', function () {
|
wakeLock.addEventListener('release', function () {
|
||||||
if (_this2._wakeLock === wakeLock) {
|
if (_this3._wakeLock === wakeLock) {
|
||||||
_this2._wakeLock = null
|
_this3._wakeLock = null
|
||||||
_this2.nativeEnabled = false
|
_this3.nativeEnabled = false
|
||||||
if (
|
if (
|
||||||
_this2._nativeRequested &&
|
_this3._nativeRequested &&
|
||||||
document.visibilityState === 'visible'
|
document.visibilityState === 'visible'
|
||||||
) {
|
) {
|
||||||
_this2._requestNativeWakeLock().catch(function () {})
|
_this3._requestNativeWakeLock().catch(function () {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!_this2._nativeRequested || _this2._wakeLock !== null) {
|
if (!_this3._nativeRequested) {
|
||||||
return wakeLock.release()
|
_this3._wakeLock = wakeLock
|
||||||
|
_this3.nativeEnabled = true
|
||||||
|
return _this3._releaseNativeWakeLock(wakeLock)
|
||||||
}
|
}
|
||||||
|
|
||||||
_this2._wakeLock = wakeLock
|
_this3._wakeLock = wakeLock
|
||||||
_this2.nativeEnabled = true
|
_this3.nativeEnabled = true
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
throw err.name + ', ' + err.message
|
throw err.name + ', ' + err.message
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (_this2._wakeLockRequest === acquisition) {
|
if (_this3._wakeLockRequest === acquisition) {
|
||||||
_this2._wakeLockRequest = null
|
_this3._wakeLockRequest = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this._wakeLockRequest = acquisition
|
this._wakeLockRequest = acquisition
|
||||||
@@ -206,11 +270,7 @@ var NoSleep = (function () {
|
|||||||
|
|
||||||
var wakeLock = this._wakeLock
|
var wakeLock = this._wakeLock
|
||||||
if (wakeLock !== null) {
|
if (wakeLock !== null) {
|
||||||
await wakeLock.release()
|
await this._releaseNativeWakeLock(wakeLock)
|
||||||
if (this._wakeLock === wakeLock) {
|
|
||||||
this._wakeLock = null
|
|
||||||
this.nativeEnabled = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._nativeRequested) {
|
if (this._nativeRequested) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class IsEnabledMessage {
|
|||||||
'android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt',
|
'android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@HostApi(dartHostTestHandler: 'TestWakelockPlusApi')
|
@HostApi()
|
||||||
abstract class WakelockPlusApi {
|
abstract class WakelockPlusApi {
|
||||||
void toggle(ToggleMessage msg);
|
void toggle(ToggleMessage msg);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"host-only Pigeon schema with an external Dart-client boundary"
|
"host-only Pigeon schema with an external Dart-client boundary"
|
||||||
],
|
],
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"pigeons/messages.dart": "a562715ba7ce115c6f6096fb0c70e591be24c53c127eb4974bdb05dc25f202d6",
|
"pigeons/messages.dart": "37c48310b98b434673409270dc1272ce01fc1c9655b1eb2f662063529ff54eaf",
|
||||||
"android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt": "7dae2bb2ff5e0c0e5e5c4c3d39500bcfb803e0e34fcaf5ba0dae47db4bd17c67",
|
"android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt": "7dae2bb2ff5e0c0e5e5c4c3d39500bcfb803e0e34fcaf5ba0dae47db4bd17c67",
|
||||||
"ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h": "c658260df4ecebbf28ce32022e17ef620104b0c4ee400684aa6af1af86400d0a",
|
"ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h": "c658260df4ecebbf28ce32022e17ef620104b0c4ee400684aa6af1af86400d0a",
|
||||||
"ios/wakelock_plus/Sources/wakelock_plus/messages.g.m": "0f2f741586d2d1298f1b9ba4c19f02c3fde9bac91bec7c5a71cfc5bccf854d7d"
|
"ios/wakelock_plus/Sources/wakelock_plus/messages.g.m": "0f2f741586d2d1298f1b9ba4c19f02c3fde9bac91bec7c5a71cfc5bccf854d7d"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@TestOn('browser')
|
@TestOn('browser')
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:js_interop';
|
import 'dart:js_interop';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -23,6 +24,8 @@ external void delayNextWakeLockRequest();
|
|||||||
|
|
||||||
@JS('wakeLockTest.resolvePendingRequest')
|
@JS('wakeLockTest.resolvePendingRequest')
|
||||||
external void resolvePendingWakeLockRequest();
|
external void resolvePendingWakeLockRequest();
|
||||||
|
@JS('wakeLockTest.rejectPendingRequest')
|
||||||
|
external void rejectPendingWakeLockRequest();
|
||||||
|
|
||||||
@JS('wakeLockTest.failNextRequest')
|
@JS('wakeLockTest.failNextRequest')
|
||||||
external void failNextWakeLockRequest();
|
external void failNextWakeLockRequest();
|
||||||
@@ -35,6 +38,8 @@ external void resolvePendingWakeLockRelease();
|
|||||||
|
|
||||||
@JS('wakeLockTest.failNextRelease')
|
@JS('wakeLockTest.failNextRelease')
|
||||||
external void failNextWakeLockRelease();
|
external void failNextWakeLockRelease();
|
||||||
|
@JS('wakeLockTest.settleForCleanup')
|
||||||
|
external void settleFakeWakeLockForCleanup();
|
||||||
|
|
||||||
@JS('wakeLockTest.requestCount')
|
@JS('wakeLockTest.requestCount')
|
||||||
external int get wakeLockRequestCount;
|
external int get wakeLockRequestCount;
|
||||||
@@ -54,6 +59,7 @@ void installFakeWakeLock() {
|
|||||||
let rejectRequest = false;
|
let rejectRequest = false;
|
||||||
let rejectRelease = false;
|
let rejectRelease = false;
|
||||||
let pendingRequest = null;
|
let pendingRequest = null;
|
||||||
|
let pendingRequestReject = null;
|
||||||
let pendingRelease = null;
|
let pendingRelease = null;
|
||||||
let sentinels = [];
|
let sentinels = [];
|
||||||
|
|
||||||
@@ -108,11 +114,17 @@ void installFakeWakeLock() {
|
|||||||
return Promise.resolve(sentinel);
|
return Promise.resolve(sentinel);
|
||||||
}
|
}
|
||||||
delayRequest = false;
|
delayRequest = false;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
pendingRequest = () => {
|
pendingRequest = () => {
|
||||||
pendingRequest = null;
|
pendingRequest = null;
|
||||||
|
pendingRequestReject = null;
|
||||||
resolve(sentinel);
|
resolve(sentinel);
|
||||||
};
|
};
|
||||||
|
pendingRequestReject = () => {
|
||||||
|
pendingRequest = null;
|
||||||
|
pendingRequestReject = null;
|
||||||
|
reject(new Error('request failed'));
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -128,6 +140,7 @@ void installFakeWakeLock() {
|
|||||||
rejectRequest = false;
|
rejectRequest = false;
|
||||||
rejectRelease = false;
|
rejectRelease = false;
|
||||||
pendingRequest = null;
|
pendingRequest = null;
|
||||||
|
pendingRequestReject = null;
|
||||||
pendingRelease = null;
|
pendingRelease = null;
|
||||||
sentinels = [];
|
sentinels = [];
|
||||||
},
|
},
|
||||||
@@ -151,6 +164,12 @@ void installFakeWakeLock() {
|
|||||||
}
|
}
|
||||||
pendingRequest();
|
pendingRequest();
|
||||||
},
|
},
|
||||||
|
rejectPendingRequest() {
|
||||||
|
if (pendingRequestReject === null) {
|
||||||
|
throw new Error('no pending wake lock request');
|
||||||
|
}
|
||||||
|
pendingRequestReject();
|
||||||
|
},
|
||||||
failNextRequest() {
|
failNextRequest() {
|
||||||
rejectRequest = true;
|
rejectRequest = true;
|
||||||
},
|
},
|
||||||
@@ -166,6 +185,18 @@ void installFakeWakeLock() {
|
|||||||
failNextRelease() {
|
failNextRelease() {
|
||||||
rejectRelease = true;
|
rejectRelease = true;
|
||||||
},
|
},
|
||||||
|
settleForCleanup() {
|
||||||
|
delayRequest = false;
|
||||||
|
delayRelease = false;
|
||||||
|
rejectRequest = false;
|
||||||
|
rejectRelease = false;
|
||||||
|
if (pendingRequest !== null) {
|
||||||
|
pendingRequest();
|
||||||
|
}
|
||||||
|
if (pendingRelease !== null) {
|
||||||
|
pendingRelease();
|
||||||
|
}
|
||||||
|
},
|
||||||
get requestCount() {
|
get requestCount() {
|
||||||
return requests;
|
return requests;
|
||||||
},
|
},
|
||||||
@@ -183,6 +214,24 @@ Future<void> flushBrowserTasks() async {
|
|||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Completer<void> settlementOf(Future<void> future) {
|
||||||
|
final settlement = Completer<void>();
|
||||||
|
unawaited(
|
||||||
|
future.then<void>((_) => settlement.complete(), onError: (Object _, StackTrace _) => settlement.complete()),
|
||||||
|
);
|
||||||
|
return settlement;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cleanUpFakeWakeLock() async {
|
||||||
|
settleFakeWakeLockForCleanup();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
try {
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
} finally {
|
||||||
|
resetWakeLockTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('$WakelockPlusWebPlugin', () {
|
group('$WakelockPlusWebPlugin', () {
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
@@ -190,10 +239,7 @@ void main() {
|
|||||||
WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin();
|
WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(cleanUpFakeWakeLock);
|
||||||
await WakelockPlus.disable();
|
|
||||||
resetWakeLockTest();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('$WakelockPlusWebPlugin is the platform instance', () {
|
test('$WakelockPlusWebPlugin is the platform instance', () {
|
||||||
expect(WakelockPlusPlatformInterface.instance, isA<WakelockPlusWebPlugin>());
|
expect(WakelockPlusPlatformInterface.instance, isA<WakelockPlusWebPlugin>());
|
||||||
@@ -214,22 +260,93 @@ void main() {
|
|||||||
expect(await WakelockPlus.enabled, isFalse);
|
expect(await WakelockPlus.enabled, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disable then enable reacquires after the release completes', () async {
|
test('concurrent enables share a pending acquisition', () async {
|
||||||
|
delayNextWakeLockRequest();
|
||||||
|
|
||||||
|
final firstEnable = WakelockPlus.enable();
|
||||||
|
final secondEnable = WakelockPlus.enable();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
|
||||||
|
expect(wakeLockRequestCount, 1);
|
||||||
|
resolvePendingWakeLockRequest();
|
||||||
|
await Future.wait([firstEnable, secondEnable]);
|
||||||
|
expect(await WakelockPlus.enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent toggles await one replacement after a delayed release', () async {
|
||||||
await WakelockPlus.enable();
|
await WakelockPlus.enable();
|
||||||
delayNextWakeLockRelease();
|
delayNextWakeLockRelease();
|
||||||
|
delayNextWakeLockRequest();
|
||||||
|
|
||||||
|
final disabling = WakelockPlus.disable();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
final firstEnable = WakelockPlus.enable();
|
||||||
|
final secondEnable = WakelockPlus.enable();
|
||||||
|
final firstSettlement = settlementOf(firstEnable);
|
||||||
|
final secondSettlement = settlementOf(secondEnable);
|
||||||
|
await flushBrowserTasks();
|
||||||
|
|
||||||
|
expect(wakeLockReleaseCount, 1);
|
||||||
|
expect(wakeLockRequestCount, 1);
|
||||||
|
expect(firstSettlement.isCompleted, isFalse);
|
||||||
|
expect(secondSettlement.isCompleted, isFalse);
|
||||||
|
|
||||||
|
resolvePendingWakeLockRelease();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
|
||||||
|
expect(wakeLockRequestCount, 2);
|
||||||
|
expect(firstSettlement.isCompleted, isFalse);
|
||||||
|
expect(secondSettlement.isCompleted, isFalse);
|
||||||
|
|
||||||
|
resolvePendingWakeLockRequest();
|
||||||
|
await Future.wait([disabling, firstEnable, secondEnable]);
|
||||||
|
|
||||||
|
expect(wakeLockRequestCount, 2);
|
||||||
|
expect(firstSettlement.isCompleted, isTrue);
|
||||||
|
expect(secondSettlement.isCompleted, isTrue);
|
||||||
|
expect(await WakelockPlus.enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replacement rejection reaches enable and leaves wake lock disabled', () async {
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
delayNextWakeLockRelease();
|
||||||
|
delayNextWakeLockRequest();
|
||||||
|
|
||||||
final disabling = WakelockPlus.disable();
|
final disabling = WakelockPlus.disable();
|
||||||
await flushBrowserTasks();
|
await flushBrowserTasks();
|
||||||
final enabling = WakelockPlus.enable();
|
final enabling = WakelockPlus.enable();
|
||||||
|
final enableSettlement = settlementOf(enabling);
|
||||||
await flushBrowserTasks();
|
await flushBrowserTasks();
|
||||||
expect(wakeLockRequestCount, 1);
|
|
||||||
|
|
||||||
|
expect(enableSettlement.isCompleted, isFalse);
|
||||||
resolvePendingWakeLockRelease();
|
resolvePendingWakeLockRelease();
|
||||||
await Future.wait([disabling, enabling]);
|
|
||||||
await flushBrowserTasks();
|
await flushBrowserTasks();
|
||||||
|
|
||||||
expect(wakeLockRequestCount, 2);
|
expect(wakeLockRequestCount, 2);
|
||||||
expect(await WakelockPlus.enabled, isTrue);
|
expect(enableSettlement.isCompleted, isFalse);
|
||||||
|
final failedDisable = expectLater(disabling, throwsA(contains('request failed')));
|
||||||
|
final failedEnable = expectLater(enabling, throwsA(contains('request failed')));
|
||||||
|
|
||||||
|
rejectPendingWakeLockRequest();
|
||||||
|
await Future.wait([failedDisable, failedEnable]);
|
||||||
|
|
||||||
|
expect(enableSettlement.isCompleted, isTrue);
|
||||||
|
expect(wakeLockRequestCount, 2);
|
||||||
|
expect(await WakelockPlus.enabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent disables share a pending release', () async {
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
delayNextWakeLockRelease();
|
||||||
|
|
||||||
|
final firstDisable = WakelockPlus.disable();
|
||||||
|
final secondDisable = WakelockPlus.disable();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
|
||||||
|
expect(wakeLockReleaseCount, 1);
|
||||||
|
resolvePendingWakeLockRelease();
|
||||||
|
await Future.wait([firstDisable, secondDisable]);
|
||||||
|
expect(await WakelockPlus.enabled, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('acquisition failure remains retryable', () async {
|
test('acquisition failure remains retryable', () async {
|
||||||
@@ -255,6 +372,21 @@ void main() {
|
|||||||
expect(await WakelockPlus.enabled, isFalse);
|
expect(await WakelockPlus.enabled, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('failed acquisition rollback retains the sentinel for retry', () async {
|
||||||
|
delayNextWakeLockRequest();
|
||||||
|
final enabling = WakelockPlus.enable();
|
||||||
|
await flushBrowserTasks();
|
||||||
|
final disabling = WakelockPlus.disable();
|
||||||
|
failNextWakeLockRelease();
|
||||||
|
|
||||||
|
final failedEnable = expectLater(enabling, throwsA(anything));
|
||||||
|
resolvePendingWakeLockRequest();
|
||||||
|
await Future.wait([failedEnable, disabling]);
|
||||||
|
|
||||||
|
expect(wakeLockReleaseCount, 2);
|
||||||
|
expect(await WakelockPlus.enabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('reacquires after a browser release when still requested', () async {
|
test('reacquires after a browser release when still requested', () async {
|
||||||
await WakelockPlus.enable();
|
await WakelockPlus.enable();
|
||||||
expect(wakeLockRequestCount, 1);
|
expect(wakeLockRequestCount, 1);
|
||||||
|
|||||||
@@ -140,8 +140,15 @@ public class AtmosProbePlugin: NSObject, FlutterPlugin {
|
|||||||
}.joined(separator: ", ")
|
}.joined(separator: ", ")
|
||||||
}
|
}
|
||||||
if let loader = loader {
|
if let loader = loader {
|
||||||
out["fedBytes"] = loader.bytesReceived
|
let snapshot = loader.statusSnapshot()
|
||||||
out["loaderRequests"] = loader.requestLog
|
out["fedBytes"] = snapshot.bytesReceived
|
||||||
|
out["loaderRequests"] = snapshot.requestLog
|
||||||
|
out["loaderRetainedBytes"] = snapshot.retainedBytes
|
||||||
|
out["loaderPendingRequests"] = snapshot.pendingRequestCount
|
||||||
|
out["loaderMaximumBytes"] = snapshot.maximumBufferedBytes
|
||||||
|
if let errorCode = snapshot.errorCode {
|
||||||
|
out["loaderError"] = errorCode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -157,59 +164,205 @@ public class AtmosProbePlugin: NSObject, FlutterPlugin {
|
|||||||
|
|
||||||
/// Streams an HTTP source into memory and serves it to AVPlayer through an
|
/// Streams an HTTP source into memory and serves it to AVPlayer through an
|
||||||
/// AVAssetResourceLoader on a custom scheme, mirroring the mpv sink's model.
|
/// AVAssetResourceLoader on a custom scheme, mirroring the mpv sink's model.
|
||||||
private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate {
|
final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate {
|
||||||
|
static let defaultMaximumBufferedBytes = 64 * 1_024 * 1_024
|
||||||
|
private static let maximumPendingRequests = 256
|
||||||
|
|
||||||
|
struct StatusSnapshot {
|
||||||
|
let bytesReceived: Int
|
||||||
|
let requestLog: String
|
||||||
|
let retainedBytes: Int
|
||||||
|
let pendingRequestCount: Int
|
||||||
|
let maximumBufferedBytes: Int
|
||||||
|
let errorCode: String?
|
||||||
|
let isFinished: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
case active
|
||||||
|
case finished
|
||||||
|
case failed(code: String, error: Error)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
var terminalError: Error? {
|
||||||
|
switch self {
|
||||||
|
case .failed(_, let error):
|
||||||
|
return error
|
||||||
|
case .cancelled:
|
||||||
|
return URLError(.cancelled)
|
||||||
|
case .active, .finished:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorCode: String? {
|
||||||
|
if case .failed(let code, _) = self { return code }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFinished: Bool {
|
||||||
|
if case .finished = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let asset: AVURLAsset
|
let asset: AVURLAsset
|
||||||
|
let maximumBufferedBytes: Int
|
||||||
private let source: URL
|
private let source: URL
|
||||||
private let finiteLength: Bool
|
private let finiteLength: Bool
|
||||||
|
private let sessionConfiguration: URLSessionConfiguration
|
||||||
private let queue = DispatchQueue(label: "plezy.atmos.probe.loader")
|
private let queue = DispatchQueue(label: "plezy.atmos.probe.loader")
|
||||||
private var session: URLSession!
|
private var terminalHandlerForTesting: (() -> Void)?
|
||||||
|
private let queueKey = DispatchSpecificKey<Void>()
|
||||||
|
private var session: URLSession?
|
||||||
private var buffer = Data()
|
private var buffer = Data()
|
||||||
private var contentLength: Int64 = -1
|
private var contentLength: Int64 = -1
|
||||||
private var finished = false
|
private var state: State = .active
|
||||||
private var pending: [AVAssetResourceLoadingRequest] = []
|
private var pending: [AVAssetResourceLoadingRequest] = []
|
||||||
private(set) var bytesReceived: Int = 0
|
private var bytesReceived = 0
|
||||||
private(set) var requestLog: String = ""
|
private var requestLog = ""
|
||||||
|
private var hasBegun = false
|
||||||
|
|
||||||
init(source: URL, finiteLength: Bool) {
|
init(
|
||||||
|
source: URL,
|
||||||
|
finiteLength: Bool,
|
||||||
|
maximumBufferedBytes: Int = RawEc3Loader.defaultMaximumBufferedBytes,
|
||||||
|
sessionConfiguration: URLSessionConfiguration = .default,
|
||||||
|
terminalHandlerForTesting: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
precondition(maximumBufferedBytes > 0)
|
||||||
self.source = source
|
self.source = source
|
||||||
self.finiteLength = finiteLength
|
self.finiteLength = finiteLength
|
||||||
|
self.maximumBufferedBytes = maximumBufferedBytes
|
||||||
|
self.sessionConfiguration = sessionConfiguration
|
||||||
|
self.terminalHandlerForTesting = terminalHandlerForTesting
|
||||||
self.asset = AVURLAsset(url: URL(string: "plezy-ec3-probe://stream/audio.ec3")!)
|
self.asset = AVURLAsset(url: URL(string: "plezy-ec3-probe://stream/audio.ec3")!)
|
||||||
super.init()
|
super.init()
|
||||||
|
queue.setSpecific(key: queueKey, value: ())
|
||||||
asset.resourceLoader.setDelegate(self, queue: queue)
|
asset.resourceLoader.setDelegate(self, queue: queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func begin() {
|
func begin() {
|
||||||
let config = URLSessionConfiguration.default
|
queue.async { [weak self] in
|
||||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
guard let self, !self.hasBegun else { return }
|
||||||
session.dataTask(with: source).resume()
|
guard case .active = self.state else { return }
|
||||||
|
self.hasBegun = true
|
||||||
|
let session = URLSession(
|
||||||
|
configuration: self.sessionConfiguration,
|
||||||
|
delegate: self,
|
||||||
|
delegateQueue: nil
|
||||||
|
)
|
||||||
|
self.session = session
|
||||||
|
session.dataTask(with: self.source).resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
session?.invalidateAndCancel()
|
queue.async { [weak self] in
|
||||||
queue.async {
|
self?.cancelOnQueue()
|
||||||
for request in self.pending where !request.isFinished {
|
|
||||||
request.finishLoading(with: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
self.pending.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called only from the method-channel/main path, never from the loader queue.
|
||||||
|
func statusSnapshot() -> StatusSnapshot {
|
||||||
|
syncOnQueue {
|
||||||
|
StatusSnapshot(
|
||||||
|
bytesReceived: bytesReceived,
|
||||||
|
requestLog: requestLog,
|
||||||
|
retainedBytes: buffer.count,
|
||||||
|
pendingRequestCount: pending.count,
|
||||||
|
maximumBufferedBytes: maximumBufferedBytes,
|
||||||
|
errorCode: state.errorCode,
|
||||||
|
isFinished: state.isFinished
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncOnQueue<T>(_ body: () -> T) -> T {
|
||||||
|
if DispatchQueue.getSpecific(key: queueKey) != nil {
|
||||||
|
return body()
|
||||||
|
}
|
||||||
|
return queue.sync(execute: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyTerminalForTesting() {
|
||||||
|
let handler = terminalHandlerForTesting
|
||||||
|
terminalHandlerForTesting = nil
|
||||||
|
handler?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelOnQueue() {
|
||||||
|
guard case .active = state else {
|
||||||
|
if case .finished = state {
|
||||||
|
state = .cancelled
|
||||||
|
finishPending(with: URLError(.cancelled))
|
||||||
|
releaseRetainedBytes()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .cancelled
|
||||||
|
session?.invalidateAndCancel()
|
||||||
|
session = nil
|
||||||
|
finishPending(with: URLError(.cancelled))
|
||||||
|
releaseRetainedBytes()
|
||||||
|
notifyTerminalForTesting()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failOnQueue(code: String, error: Error) {
|
||||||
|
guard case .active = state else { return }
|
||||||
|
state = .failed(code: code, error: error)
|
||||||
|
session?.invalidateAndCancel()
|
||||||
|
session = nil
|
||||||
|
finishPending(with: error)
|
||||||
|
releaseRetainedBytes()
|
||||||
|
notifyTerminalForTesting()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishPending(with error: Error) {
|
||||||
|
let requests = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
for request in requests where !request.isFinished {
|
||||||
|
request.finishLoading(with: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseRetainedBytes() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: URLSessionDataDelegate (background queue -> hop to `queue`)
|
// MARK: URLSessionDataDelegate (background queue -> hop to `queue`)
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession, dataTask: URLSessionDataTask,
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
didReceive response: URLResponse,
|
didReceive response: URLResponse,
|
||||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||||
) {
|
) {
|
||||||
queue.async {
|
queue.async { [weak self] in
|
||||||
|
guard let self, case .active = self.state else { return }
|
||||||
self.contentLength = response.expectedContentLength
|
self.contentLength = response.expectedContentLength
|
||||||
self.serve()
|
if response.expectedContentLength > Int64(self.maximumBufferedBytes) {
|
||||||
|
self.failOnQueue(
|
||||||
|
code: "response_too_large",
|
||||||
|
error: URLError(.dataLengthExceedsMaximum)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
self.serve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
completionHandler(.allow)
|
completionHandler(.allow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||||
queue.async {
|
queue.async { [weak self] in
|
||||||
|
guard let self, case .active = self.state else { return }
|
||||||
|
guard data.count <= self.maximumBufferedBytes - self.buffer.count else {
|
||||||
|
self.failOnQueue(
|
||||||
|
code: "response_too_large",
|
||||||
|
error: URLError(.dataLengthExceedsMaximum)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.buffer.append(data)
|
self.buffer.append(data)
|
||||||
self.bytesReceived += data.count
|
self.bytesReceived += data.count
|
||||||
self.serve()
|
self.serve()
|
||||||
@@ -217,9 +370,19 @@ private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
queue.async {
|
queue.async { [weak self] in
|
||||||
self.finished = true
|
guard let self, case .active = self.state else { return }
|
||||||
self.serve()
|
self.session = nil
|
||||||
|
if let error {
|
||||||
|
self.state = .failed(code: "network_error", error: error)
|
||||||
|
self.finishPending(with: error)
|
||||||
|
self.releaseRetainedBytes()
|
||||||
|
} else {
|
||||||
|
self.state = .finished
|
||||||
|
self.serve()
|
||||||
|
}
|
||||||
|
self.notifyTerminalForTesting()
|
||||||
|
session.finishTasksAndInvalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +392,14 @@ private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSe
|
|||||||
_ resourceLoader: AVAssetResourceLoader,
|
_ resourceLoader: AVAssetResourceLoader,
|
||||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
if let terminalError = state.terminalError {
|
||||||
|
loadingRequest.finishLoading(with: terminalError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard pending.count < Self.maximumPendingRequests else {
|
||||||
|
loadingRequest.finishLoading(with: URLError(.resourceUnavailable))
|
||||||
|
return true
|
||||||
|
}
|
||||||
if let dataRequest = loadingRequest.dataRequest {
|
if let dataRequest = loadingRequest.dataRequest {
|
||||||
requestLog += "[\(dataRequest.requestedOffset)+\(dataRequest.requestedLength)]"
|
requestLog += "[\(dataRequest.requestedOffset)+\(dataRequest.requestedLength)]"
|
||||||
if requestLog.count > 300 { requestLog = String(requestLog.suffix(300)) }
|
if requestLog.count > 300 { requestLog = String(requestLog.suffix(300)) }
|
||||||
@@ -246,11 +417,21 @@ private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func serve() {
|
private func serve() {
|
||||||
// content info: unbounded mirrors the mpv sink; finite passes the real
|
let isFinished: Bool
|
||||||
// length through once the HTTP response reveals it
|
switch state {
|
||||||
|
case .active:
|
||||||
|
isFinished = false
|
||||||
|
case .finished:
|
||||||
|
isFinished = true
|
||||||
|
case .failed, .cancelled:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logical 1-TiB length remains the raw probe contract. Retained bytes
|
||||||
|
// are independently bounded by maximumBufferedBytes.
|
||||||
let knownLength: Int64? =
|
let knownLength: Int64? =
|
||||||
finiteLength
|
finiteLength
|
||||||
? (contentLength >= 0 ? contentLength : (finished ? Int64(buffer.count) : nil))
|
? (contentLength >= 0 ? contentLength : (isFinished ? Int64(buffer.count) : nil))
|
||||||
: Int64(1) << 40
|
: Int64(1) << 40
|
||||||
|
|
||||||
var index = 0
|
var index = 0
|
||||||
@@ -259,7 +440,7 @@ private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSe
|
|||||||
if let info = request.contentInformationRequest {
|
if let info = request.contentInformationRequest {
|
||||||
guard let length = knownLength else {
|
guard let length = knownLength else {
|
||||||
index += 1
|
index += 1
|
||||||
continue // wait for the HTTP response before answering
|
continue
|
||||||
}
|
}
|
||||||
info.contentType = "public.enhanced-ac3-audio"
|
info.contentType = "public.enhanced-ac3-audio"
|
||||||
info.contentLength = length
|
info.contentLength = length
|
||||||
@@ -274,13 +455,33 @@ private final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSe
|
|||||||
index += 1
|
index += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let offset = dataRequest.currentOffset
|
|
||||||
let end = dataRequest.requestedOffset + Int64(dataRequest.requestedLength)
|
let requestedOffset = dataRequest.requestedOffset
|
||||||
if offset < Int64(buffer.count) {
|
let currentOffset = dataRequest.currentOffset
|
||||||
let chunkEnd = min(Int64(buffer.count), end)
|
let requestedLength = Int64(dataRequest.requestedLength)
|
||||||
dataRequest.respond(with: buffer.subdata(in: Int(offset)..<Int(chunkEnd)))
|
let (end, overflow) = requestedOffset.addingReportingOverflow(requestedLength)
|
||||||
|
guard requestedOffset >= 0, currentOffset >= requestedOffset, requestedLength >= 0, !overflow else {
|
||||||
|
request.finishLoading(with: URLError(.badServerResponse))
|
||||||
|
pending.remove(at: index)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if dataRequest.currentOffset >= end || (finished && dataRequest.currentOffset >= Int64(buffer.count)) {
|
|
||||||
|
let bufferedCount = Int64(buffer.count)
|
||||||
|
if currentOffset < bufferedCount {
|
||||||
|
let chunkEnd = min(bufferedCount, end)
|
||||||
|
guard currentOffset <= chunkEnd,
|
||||||
|
let start = Int(exactly: currentOffset),
|
||||||
|
let finish = Int(exactly: chunkEnd)
|
||||||
|
else {
|
||||||
|
request.finishLoading(with: URLError(.badServerResponse))
|
||||||
|
pending.remove(at: index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataRequest.respond(with: buffer.subdata(in: start..<finish))
|
||||||
|
}
|
||||||
|
if dataRequest.currentOffset >= end
|
||||||
|
|| (isFinished && dataRequest.currentOffset >= bufferedCount)
|
||||||
|
{
|
||||||
request.finishLoading()
|
request.finishLoading()
|
||||||
pending.remove(at: index)
|
pending.remove(at: index)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -80,6 +80,27 @@ struct ServerDisplayCriteria {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class MpvWakeupCallbackContext {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private weak var core: MpvPlayerCoreBase?
|
||||||
|
|
||||||
|
init(core: MpvPlayerCoreBase) {
|
||||||
|
self.core = core
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatchWakeup() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
core?.readEventsFromCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
func detach() {
|
||||||
|
lock.lock()
|
||||||
|
core = nil
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MpvPlayerCoreBase: NSObject {
|
class MpvPlayerCoreBase: NSObject {
|
||||||
weak var delegate: MpvPlayerDelegate?
|
weak var delegate: MpvPlayerDelegate?
|
||||||
|
|
||||||
@@ -179,6 +200,11 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
private let cacheLock = NSLock()
|
private let cacheLock = NSLock()
|
||||||
private var cachedPaused = true
|
private var cachedPaused = true
|
||||||
|
private var confirmedPaused = true
|
||||||
|
private var resolvedPauseGeneration: UInt64 = 0
|
||||||
|
private var pauseIntentGeneration: UInt64 = 0
|
||||||
|
private var pauseObservationRevision: UInt64 = 0
|
||||||
|
private var pendingPauseIntents: [UInt64: Bool] = [:]
|
||||||
private var cachedDuration = 0.0
|
private var cachedDuration = 0.0
|
||||||
private var cachedTimePos = 0.0
|
private var cachedTimePos = 0.0
|
||||||
private var cachedWidth = 0.0
|
private var cachedWidth = 0.0
|
||||||
@@ -449,15 +475,18 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// mpv stores this context without retaining it. Retain manually so the
|
// mpv stores this context without retaining it. The core owns the context,
|
||||||
// Swift core cannot deallocate while mpv can still fire wakeup callbacks.
|
// while the context keeps only a weak callback target; disposal atomically
|
||||||
let wakeupContext = Unmanaged.passRetained(self).toOpaque()
|
// detaches it before removing the C callback. A late callback can therefore
|
||||||
|
// neither retain/dereference a dead core nor enqueue work against a replacement.
|
||||||
|
let callbackOwner = MpvWakeupCallbackContext(core: self)
|
||||||
|
let wakeupContext = Unmanaged.passRetained(callbackOwner).toOpaque()
|
||||||
|
|
||||||
lifecycleLock.lock()
|
lifecycleLock.lock()
|
||||||
guard !lifecycleState.isTerminal, lifecycleState.mpv == nil else {
|
guard !lifecycleState.isTerminal, lifecycleState.mpv == nil else {
|
||||||
lifecycleLock.unlock()
|
lifecycleLock.unlock()
|
||||||
mpv_terminate_destroy(mpv)
|
mpv_terminate_destroy(mpv)
|
||||||
Unmanaged<MpvPlayerCoreBase>.fromOpaque(wakeupContext).release()
|
Unmanaged<MpvWakeupCallbackContext>.fromOpaque(wakeupContext).release()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lifecycleState.mpv = mpv
|
lifecycleState.mpv = mpv
|
||||||
@@ -466,8 +495,9 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
mpv,
|
mpv,
|
||||||
{ context in
|
{ context in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
let core = Unmanaged<MpvPlayerCoreBase>.fromOpaque(context).takeUnretainedValue()
|
Unmanaged<MpvWakeupCallbackContext>.fromOpaque(context)
|
||||||
core.readEvents()
|
.takeUnretainedValue()
|
||||||
|
.dispatchWakeup()
|
||||||
},
|
},
|
||||||
wakeupContext
|
wakeupContext
|
||||||
)
|
)
|
||||||
@@ -490,6 +520,10 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
value: String,
|
value: String,
|
||||||
completion: @escaping (Result<Void, Error>) -> Void
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
) {
|
) {
|
||||||
|
guard isLifecycleActive else {
|
||||||
|
completeOnMain { completion(.failure(self.lifecycleUnavailableError())) }
|
||||||
|
return
|
||||||
|
}
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
if name == "hwdec" {
|
if name == "hwdec" {
|
||||||
if value != "no" {
|
if value != "no" {
|
||||||
@@ -502,7 +536,7 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
if isManagedRendererProperty(name) {
|
if isManagedRendererProperty(name) {
|
||||||
print("[MpvPlayerCore] Ignoring managed renderer property: \(name)=\(value)")
|
print("[MpvPlayerCore] Ignoring managed renderer property: \(name)=\(value)")
|
||||||
completion(.success(()))
|
completeOnMain { completion(.success(())) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,10 +544,9 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
if name == "pause" {
|
if name == "pause" {
|
||||||
let paused = parseBoolProperty(value)
|
let paused = parseBoolProperty(value)
|
||||||
|
let intent = beginCachedPauseIntent(paused)
|
||||||
setRawStringPropertyAsync(name, value: value) { [weak self] result in
|
setRawStringPropertyAsync(name, value: value) { [weak self] result in
|
||||||
if case .success = result {
|
self?.finishCachedPauseIntent(intent, result: result)
|
||||||
self?.setCachedPaused(paused)
|
|
||||||
}
|
|
||||||
completion(result)
|
completion(result)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -527,13 +560,13 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
if name == "dv-conversion-mode" {
|
if name == "dv-conversion-mode" {
|
||||||
setDvConversionMode(value)
|
setDvConversionMode(value)
|
||||||
completion(.success(()))
|
completeOnMain { completion(.success(())) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if name == "dv-conversion-log" {
|
if name == "dv-conversion-log" {
|
||||||
setDvConversionLogEnabled(parseBoolProperty(value))
|
setDvConversionLogEnabled(parseBoolProperty(value))
|
||||||
completion(.success(()))
|
completeOnMain { completion(.success(())) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,22 +647,12 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
value: Int64,
|
value: Int64,
|
||||||
completion: @escaping (Result<Void, Error>) -> Void
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
) {
|
) {
|
||||||
var requestId: UInt64?
|
|
||||||
var propertyValue = value
|
var propertyValue = value
|
||||||
guard
|
submitAsyncRequest(.void(completion)) { mpv, requestId in
|
||||||
let status = withActiveMpv({ mpv in
|
name.withCString { namePointer in
|
||||||
let id = registerRequest(.void(completion))
|
mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_INT64, &propertyValue)
|
||||||
requestId = id
|
}
|
||||||
return name.withCString { namePointer in
|
|
||||||
mpv_set_property_async(mpv, id, namePointer, MPV_FORMAT_INT64, &propertyValue)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
let requestId
|
|
||||||
else {
|
|
||||||
completion(.failure(lifecycleUnavailableError()))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
completeRequestIfSubmissionFailed(requestId: requestId, status: status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setHDREnabled(_ enabled: Bool, completion: ((Result<Void, Error>) -> Void)? = nil) {
|
func setHDREnabled(_ enabled: Bool, completion: ((Result<Void, Error>) -> Void)? = nil) {
|
||||||
@@ -677,31 +700,25 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getPropertyAsync(_ name: String, completion: @escaping (Result<String?, Error>) -> Void) {
|
func getPropertyAsync(_ name: String, completion: @escaping (Result<String?, Error>) -> Void) {
|
||||||
|
guard isLifecycleActive else {
|
||||||
|
completeOnMain { completion(.failure(self.lifecycleUnavailableError())) }
|
||||||
|
return
|
||||||
|
}
|
||||||
if name == "dv-conversion-mode" {
|
if name == "dv-conversion-mode" {
|
||||||
completion(.success(getDvConversionMode()))
|
completeOnMain { completion(.success(self.getDvConversionMode())) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if name == "dv-conversion-log" {
|
if name == "dv-conversion-log" {
|
||||||
completion(.success(getDvConversionLogEnabled() ? "yes" : "no"))
|
completeOnMain { completion(.success(self.getDvConversionLogEnabled() ? "yes" : "no")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestId: UInt64?
|
submitAsyncRequest(.getProperty(completion)) { mpv, requestId in
|
||||||
guard
|
name.withCString { namePointer in
|
||||||
let status = withActiveMpv({ mpv in
|
mpv_get_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING)
|
||||||
let id = registerRequest(.getProperty(completion))
|
}
|
||||||
requestId = id
|
|
||||||
return name.withCString { namePointer in
|
|
||||||
mpv_get_property_async(mpv, id, namePointer, MPV_FORMAT_STRING)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
let requestId
|
|
||||||
else {
|
|
||||||
completion(.failure(lifecycleUnavailableError()))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
completeRequestIfSubmissionFailed(requestId: requestId, status: status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func observeProperty(_ name: String, format: String) {
|
func observeProperty(_ name: String, format: String) {
|
||||||
@@ -730,32 +747,23 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
func commandAsync(_ args: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
func commandAsync(_ args: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
guard !args.isEmpty else {
|
guard !args.isEmpty else {
|
||||||
completion(.success(()))
|
completeOnMain { completion(.success(())) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var cargs: [UnsafeMutablePointer<CChar>?] = args.map { strdup($0) }
|
var cargs: [UnsafeMutablePointer<CChar>?] = args.map { strdup($0) }
|
||||||
cargs.append(nil)
|
cargs.append(nil)
|
||||||
|
|
||||||
var requestId: UInt64?
|
submitAsyncRequest(.void(completion)) { mpv, requestId in
|
||||||
let status = withActiveMpv { mpv in
|
cargs.withUnsafeBufferPointer { buffer in
|
||||||
let id = registerRequest(.void(completion))
|
|
||||||
requestId = id
|
|
||||||
return cargs.withUnsafeBufferPointer { buffer in
|
|
||||||
var constPointers = buffer.map { UnsafePointer($0) }
|
var constPointers = buffer.map { UnsafePointer($0) }
|
||||||
return mpv_command_async(mpv, id, &constPointers)
|
return mpv_command_async(mpv, requestId, &constPointers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for pointer in cargs {
|
for pointer in cargs {
|
||||||
free(pointer)
|
free(pointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let status, let requestId else {
|
|
||||||
completion(.failure(lifecycleUnavailableError()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completeRequestIfSubmissionFailed(requestId: requestId, status: status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setRawStringPropertyAsync(
|
private func setRawStringPropertyAsync(
|
||||||
@@ -763,24 +771,14 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
value: String,
|
value: String,
|
||||||
completion: @escaping (Result<Void, Error>) -> Void
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
) {
|
) {
|
||||||
var requestId: UInt64?
|
submitAsyncRequest(.void(completion)) { mpv, requestId in
|
||||||
guard
|
name.withCString { namePointer in
|
||||||
let status = withActiveMpv({ mpv in
|
value.withCString { valuePointer in
|
||||||
let id = registerRequest(.void(completion))
|
var propertyValue: UnsafePointer<CChar>? = valuePointer
|
||||||
requestId = id
|
return mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING, &propertyValue)
|
||||||
return name.withCString { namePointer in
|
|
||||||
value.withCString { valuePointer in
|
|
||||||
var propertyValue: UnsafePointer<CChar>? = valuePointer
|
|
||||||
return mpv_set_property_async(mpv, id, namePointer, MPV_FORMAT_STRING, &propertyValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
let requestId
|
|
||||||
else {
|
|
||||||
completion(.failure(lifecycleUnavailableError()))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
completeRequestIfSubmissionFailed(requestId: requestId, status: status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
var isPaused: Bool {
|
||||||
@@ -829,6 +827,11 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
lifecycleState.mpv = nil
|
lifecycleState.mpv = nil
|
||||||
lifecycleState.wakeupCallbackContext = nil
|
lifecycleState.wakeupCallbackContext = nil
|
||||||
lifecycleLock.unlock()
|
lifecycleLock.unlock()
|
||||||
|
if let callbackContext {
|
||||||
|
Unmanaged<MpvWakeupCallbackContext>.fromOpaque(callbackContext)
|
||||||
|
.takeUnretainedValue()
|
||||||
|
.detach()
|
||||||
|
}
|
||||||
|
|
||||||
let destroy = {
|
let destroy = {
|
||||||
if let mpvHandle {
|
if let mpvHandle {
|
||||||
@@ -836,7 +839,7 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
mpv_terminate_destroy(mpvHandle)
|
mpv_terminate_destroy(mpvHandle)
|
||||||
}
|
}
|
||||||
if let callbackContext {
|
if let callbackContext {
|
||||||
Unmanaged<MpvPlayerCoreBase>.fromOpaque(callbackContext).release()
|
Unmanaged<MpvWakeupCallbackContext>.fromOpaque(callbackContext).release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +930,14 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func completeOnMain(_ completion: @escaping () -> Void) {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
completion()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async(execute: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func cancelPendingRequests() {
|
private func cancelPendingRequests() {
|
||||||
pendingRequestsLock.lock()
|
pendingRequestsLock.lock()
|
||||||
let pending = pendingRequests
|
let pending = pendingRequests
|
||||||
@@ -982,6 +993,33 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func submitAsyncRequest(
|
||||||
|
_ request: PendingRequest,
|
||||||
|
submission: (OpaquePointer, UInt64) -> CInt
|
||||||
|
) {
|
||||||
|
var requestId: UInt64?
|
||||||
|
guard
|
||||||
|
let status = withActiveMpv({ mpv in
|
||||||
|
let id = registerRequest(request)
|
||||||
|
requestId = id
|
||||||
|
return submission(mpv, id)
|
||||||
|
}),
|
||||||
|
let requestId
|
||||||
|
else {
|
||||||
|
let error = lifecycleUnavailableError()
|
||||||
|
completeOnMain {
|
||||||
|
switch request {
|
||||||
|
case .void(let completion):
|
||||||
|
completion(.failure(error))
|
||||||
|
case .getProperty(let completion):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completeRequestIfSubmissionFailed(requestId: requestId, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
private func completeRequestIfSubmissionFailed(requestId: UInt64, status: CInt) {
|
private func completeRequestIfSubmissionFailed(requestId: UInt64, status: CInt) {
|
||||||
guard status < 0, let request = takeRequest(requestId) else { return }
|
guard status < 0, let request = takeRequest(requestId) else { return }
|
||||||
let error = mpvError(status)
|
let error = mpvError(status)
|
||||||
@@ -996,29 +1034,27 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func completeVoidRequest(requestId: UInt64, error status: CInt) {
|
private func completeVoidRequest(requestId: UInt64, error status: CInt) {
|
||||||
guard let request = takeRequest(requestId) else { return }
|
guard case .void(let completion) = takeRequest(requestId) else { return }
|
||||||
|
let result: Result<Void, Error> =
|
||||||
|
status < 0 ? .failure(mpvError(status)) : .success(())
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
switch request {
|
completion(result)
|
||||||
case .void(let completion):
|
|
||||||
if status < 0 {
|
|
||||||
completion(.failure(self.mpvError(status)))
|
|
||||||
} else {
|
|
||||||
completion(.success(()))
|
|
||||||
}
|
|
||||||
case .getProperty:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeGetPropertyRequest(_ event: mpv_event) {
|
private func completeGetPropertyRequest(_ event: mpv_event) {
|
||||||
guard let request = takeRequest(event.reply_userdata) else { return }
|
guard case .getProperty(let completion) = takeRequest(event.reply_userdata) else { return }
|
||||||
guard case .getProperty(let completion) = request else { return }
|
|
||||||
|
if event.error < 0 {
|
||||||
|
let error = mpvError(event.error)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var value: String?
|
var value: String?
|
||||||
if event.error >= 0,
|
if let propertyPointer = event.data?.assumingMemoryBound(to: mpv_event_property.self) {
|
||||||
let propertyPointer = event.data?.assumingMemoryBound(to: mpv_event_property.self)
|
|
||||||
{
|
|
||||||
let property = propertyPointer.pointee
|
let property = propertyPointer.pointee
|
||||||
if property.format == MPV_FORMAT_STRING, let data = property.data {
|
if property.format == MPV_FORMAT_STRING, let data = property.data {
|
||||||
let cstring = data.assumingMemoryBound(to: UnsafePointer<CChar>?.self).pointee
|
let cstring = data.assumingMemoryBound(to: UnsafePointer<CChar>?.self).pointee
|
||||||
@@ -1031,7 +1067,7 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readEvents() {
|
fileprivate func readEventsFromCallback() {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
@@ -1048,14 +1084,14 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dispatchDelegateEvent(name: String, data: [String: Any]?) {
|
func dispatchDelegateEvent(name: String, data: [String: Any]?) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, self.isLifecycleActive else { return }
|
guard let self, self.isLifecycleActive else { return }
|
||||||
self.delegate?.onEvent(name: name, data: data)
|
self.delegate?.onEvent(name: name, data: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dispatchDelegateProperty(name: String, value: Any?) {
|
func dispatchDelegateProperty(name: String, value: Any?) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, self.isLifecycleActive else { return }
|
guard let self, self.isLifecycleActive else { return }
|
||||||
self.delegate?.onPropertyChange(name: name, value: value)
|
self.delegate?.onPropertyChange(name: name, value: value)
|
||||||
@@ -1219,7 +1255,11 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
case "pause":
|
case "pause":
|
||||||
if let paused = value as? Bool { cachedPaused = paused }
|
if let paused = value as? Bool {
|
||||||
|
pauseObservationRevision &+= 1
|
||||||
|
confirmedPaused = paused
|
||||||
|
if pendingPauseIntents.isEmpty { cachedPaused = paused }
|
||||||
|
}
|
||||||
case "duration":
|
case "duration":
|
||||||
if let duration = value as? Double { cachedDuration = duration }
|
if let duration = value as? Double { cachedDuration = duration }
|
||||||
case "time-pos":
|
case "time-pos":
|
||||||
@@ -1233,16 +1273,149 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setCachedPaused(_ paused: Bool) {
|
#if DEBUG
|
||||||
|
func observeCachedPauseForTesting(_ paused: Bool) {
|
||||||
|
updateCachedProperty(name: "pause", value: paused)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func retirePauseIntents(through generation: UInt64) {
|
||||||
|
pendingPauseIntents = pendingPauseIntents.filter { $0.key > generation }
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginCachedPauseIntent(
|
||||||
|
_ paused: Bool
|
||||||
|
) -> (generation: UInt64, observationRevision: UInt64, paused: Bool) {
|
||||||
cacheLock.lock()
|
cacheLock.lock()
|
||||||
|
pauseIntentGeneration &+= 1
|
||||||
|
let intent = (
|
||||||
|
generation: pauseIntentGeneration,
|
||||||
|
observationRevision: pauseObservationRevision,
|
||||||
|
paused: paused
|
||||||
|
)
|
||||||
|
pendingPauseIntents[intent.generation] = paused
|
||||||
cachedPaused = paused
|
cachedPaused = paused
|
||||||
cacheLock.unlock()
|
cacheLock.unlock()
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishCachedPauseIntent(
|
||||||
|
_ intent: (generation: UInt64, observationRevision: UInt64, paused: Bool),
|
||||||
|
result: Result<Void, Error>
|
||||||
|
) {
|
||||||
|
cacheLock.lock()
|
||||||
|
pendingPauseIntents.removeValue(forKey: intent.generation)
|
||||||
|
if intent.generation >= resolvedPauseGeneration {
|
||||||
|
resolvedPauseGeneration = intent.generation
|
||||||
|
if case .success = result,
|
||||||
|
intent.observationRevision == pauseObservationRevision
|
||||||
|
{
|
||||||
|
confirmedPaused = intent.paused
|
||||||
|
}
|
||||||
|
retirePauseIntents(through: intent.generation)
|
||||||
|
}
|
||||||
|
if let latest = pendingPauseIntents.lazy
|
||||||
|
.filter({ $0.key > self.resolvedPauseGeneration })
|
||||||
|
.max(by: { $0.key < $1.key })
|
||||||
|
{
|
||||||
|
cachedPaused = latest.value
|
||||||
|
} else {
|
||||||
|
cachedPaused = confirmedPaused
|
||||||
|
}
|
||||||
|
cacheLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func convertNode(_ node: mpv_node) -> Any? {
|
private static let maximumNodeDepth = 32
|
||||||
|
private static let maximumNodeEntries = 4_096
|
||||||
|
private static let maximumNodeByteCount = 16 * 1_024 * 1_024
|
||||||
|
private static let maximumSideDataDimension: Int64 = 16_384
|
||||||
|
private static let maximumSideDataPixels: Int64 = 64 * 1_024 * 1_024
|
||||||
|
|
||||||
|
private struct NodeConversionBudget {
|
||||||
|
var remainingEntries = MpvPlayerCoreBase.maximumNodeEntries
|
||||||
|
var remainingBytes = MpvPlayerCoreBase.maximumNodeByteCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertNode(_ node: mpv_node) -> Any? {
|
||||||
|
var budget = NodeConversionBudget()
|
||||||
|
return convertNode(node, depth: 0, budget: &budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSideDataDimensions(width: Int64, height: Int64) -> Bool {
|
||||||
|
guard width > 0, height > 0,
|
||||||
|
width <= Self.maximumSideDataDimension,
|
||||||
|
height <= Self.maximumSideDataDimension
|
||||||
|
else { return false }
|
||||||
|
let (pixels, overflow) = width.multipliedReportingOverflow(by: height)
|
||||||
|
return !overflow && pixels <= Self.maximumSideDataPixels
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dimensionValue(_ node: mpv_node) -> Int64? {
|
||||||
|
switch node.format {
|
||||||
|
case MPV_FORMAT_INT64:
|
||||||
|
return node.u.int64
|
||||||
|
case MPV_FORMAT_DOUBLE:
|
||||||
|
let value = node.u.double_
|
||||||
|
guard value.isFinite, value.rounded() == value,
|
||||||
|
value >= Double(Int64.min), value <= Double(Int64.max)
|
||||||
|
else { return nil }
|
||||||
|
return Int64(value)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertNodeString(
|
||||||
|
_ pointer: UnsafePointer<CChar>,
|
||||||
|
budget: inout NodeConversionBudget
|
||||||
|
) -> String? {
|
||||||
|
let length = strnlen(pointer, budget.remainingBytes + 1)
|
||||||
|
guard length <= budget.remainingBytes else { return nil }
|
||||||
|
budget.remainingBytes -= length
|
||||||
|
if let string = String(validatingUTF8: pointer) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
let bytes = UnsafeBufferPointer(
|
||||||
|
start: UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self),
|
||||||
|
count: length
|
||||||
|
)
|
||||||
|
return String(bytes.map { Character(Unicode.Scalar($0)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasValidSideDataDimensions(_ list: mpv_node_list, count: Int) -> Bool {
|
||||||
|
var hasByteArray = false
|
||||||
|
var widthNode: mpv_node?
|
||||||
|
var heightNode: mpv_node?
|
||||||
|
for index in 0..<count {
|
||||||
|
let value = list.values[index]
|
||||||
|
hasByteArray = hasByteArray || value.format == MPV_FORMAT_BYTE_ARRAY
|
||||||
|
guard let keyPointer = list.keys[index] else { continue }
|
||||||
|
if strcmp(keyPointer, "width") == 0 || strcmp(keyPointer, "w") == 0 {
|
||||||
|
widthNode = value
|
||||||
|
} else if strcmp(keyPointer, "height") == 0 || strcmp(keyPointer, "h") == 0 {
|
||||||
|
heightNode = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard hasByteArray, widthNode != nil || heightNode != nil else { return true }
|
||||||
|
guard let widthNode, let heightNode,
|
||||||
|
let width = dimensionValue(widthNode),
|
||||||
|
let height = dimensionValue(heightNode)
|
||||||
|
else { return false }
|
||||||
|
return validateSideDataDimensions(width: width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertNode(
|
||||||
|
_ node: mpv_node,
|
||||||
|
depth: Int,
|
||||||
|
budget: inout NodeConversionBudget
|
||||||
|
) -> Any? {
|
||||||
|
guard depth <= Self.maximumNodeDepth, budget.remainingEntries > 0 else { return nil }
|
||||||
|
budget.remainingEntries -= 1
|
||||||
|
|
||||||
switch node.format {
|
switch node.format {
|
||||||
case MPV_FORMAT_STRING:
|
case MPV_FORMAT_STRING:
|
||||||
return node.u.string.map { safeString($0) }
|
guard let string = node.u.string else { return nil }
|
||||||
|
return convertNodeString(string, budget: &budget)
|
||||||
|
|
||||||
case MPV_FORMAT_FLAG:
|
case MPV_FORMAT_FLAG:
|
||||||
return node.u.flag != 0
|
return node.u.flag != 0
|
||||||
@@ -1253,11 +1426,24 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
case MPV_FORMAT_DOUBLE:
|
case MPV_FORMAT_DOUBLE:
|
||||||
return node.u.double_
|
return node.u.double_
|
||||||
|
|
||||||
|
case MPV_FORMAT_BYTE_ARRAY:
|
||||||
|
guard let byteArray = node.u.ba?.pointee else { return nil }
|
||||||
|
let byteCount = byteArray.size
|
||||||
|
guard byteCount <= budget.remainingBytes else { return nil }
|
||||||
|
guard byteCount == 0 || byteArray.data != nil else { return nil }
|
||||||
|
budget.remainingBytes -= byteCount
|
||||||
|
guard byteCount > 0, let data = byteArray.data else { return Data() }
|
||||||
|
return Data(bytes: data, count: byteCount)
|
||||||
|
|
||||||
case MPV_FORMAT_NODE_ARRAY:
|
case MPV_FORMAT_NODE_ARRAY:
|
||||||
guard let list = node.u.list?.pointee else { return nil }
|
guard let list = node.u.list?.pointee else { return nil }
|
||||||
|
let count = Int(list.num)
|
||||||
|
guard list.num >= 0, count <= budget.remainingEntries else { return nil }
|
||||||
|
guard count == 0 || list.values != nil else { return nil }
|
||||||
var array = [Any]()
|
var array = [Any]()
|
||||||
for index in 0..<Int(list.num) {
|
array.reserveCapacity(count)
|
||||||
if let item = convertNode(list.values[index]) {
|
for index in 0..<count {
|
||||||
|
if let item = convertNode(list.values[index], depth: depth + 1, budget: &budget) {
|
||||||
array.append(item)
|
array.append(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1265,10 +1451,16 @@ class MpvPlayerCoreBase: NSObject {
|
|||||||
|
|
||||||
case MPV_FORMAT_NODE_MAP:
|
case MPV_FORMAT_NODE_MAP:
|
||||||
guard let list = node.u.list?.pointee else { return nil }
|
guard let list = node.u.list?.pointee else { return nil }
|
||||||
|
let count = Int(list.num)
|
||||||
|
guard list.num >= 0, count <= budget.remainingEntries else { return nil }
|
||||||
|
guard count == 0 || (list.values != nil && list.keys != nil) else { return nil }
|
||||||
|
guard hasValidSideDataDimensions(list, count: count) else { return nil }
|
||||||
var dictionary = [String: Any]()
|
var dictionary = [String: Any]()
|
||||||
for index in 0..<Int(list.num) {
|
dictionary.reserveCapacity(count)
|
||||||
if let key = list.keys?[index].map({ safeString($0) }),
|
for index in 0..<count {
|
||||||
let value = convertNode(list.values[index])
|
if let keyPointer = list.keys[index],
|
||||||
|
let key = convertNodeString(keyPointer, budget: &budget),
|
||||||
|
let value = convertNode(list.values[index], depth: depth + 1, budget: &budget)
|
||||||
{
|
{
|
||||||
dictionary[key] = value
|
dictionary[key] = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,8 @@
|
|||||||
// mpv does not guarantee UTF-8 for log messages, error strings, or
|
// mpv does not guarantee UTF-8 for log messages, error strings, or
|
||||||
// system-encoded paths — sending these unsanitized through Flutter's
|
// system-encoded paths — sending these unsanitized through Flutter's
|
||||||
// StandardMessageCodec causes FormatException crashes.
|
// StandardMessageCodec causes FormatException crashes.
|
||||||
static inline std::string SanitizeUtf8(const char* input) {
|
static inline std::string SanitizeUtf8(const char* input, size_t len) {
|
||||||
if (!input) return std::string();
|
if (!input || len == 0) return std::string();
|
||||||
size_t len = strlen(input);
|
|
||||||
if (len == 0) return std::string();
|
|
||||||
|
|
||||||
// Fast path: SIMD-accelerated validation — almost all strings pass this
|
// Fast path: SIMD-accelerated validation — almost all strings pass this
|
||||||
if (simdutf::validate_utf8(input, len)) {
|
if (simdutf::validate_utf8(input, len)) {
|
||||||
@@ -46,4 +44,8 @@ static inline std::string SanitizeUtf8(const char* input) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static inline std::string SanitizeUtf8(const char* input) {
|
||||||
|
return input ? SanitizeUtf8(input, strlen(input)) : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
#endif // SANITIZE_UTF8_H_
|
#endif // SANITIZE_UTF8_H_
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ namespace mpv_common {
|
|||||||
using StatusCallback = std::function<void(int error)>;
|
using StatusCallback = std::function<void(int error)>;
|
||||||
using GetPropertyCallback = std::function<void(int error, const std::string& value)>;
|
using GetPropertyCallback = std::function<void(int error, const std::string& value)>;
|
||||||
|
|
||||||
inline constexpr char kSetPropertyFailedCode[] = "SET_PROPERTY_FAILED";
|
static constexpr char kSetPropertyFailedCode[] = "SET_PROPERTY_FAILED";
|
||||||
inline constexpr char kSetPropertyNotInitializedCode[] = "NOT_INITIALIZED";
|
static constexpr char kSetPropertyNotInitializedCode[] = "NOT_INITIALIZED";
|
||||||
inline constexpr size_t kSetPropertyErrorDescriptionLimit = 160;
|
static constexpr size_t kSetPropertyErrorDescriptionLimit = 160;
|
||||||
|
|
||||||
inline bool SetPropertyStatusSucceeded(int status) { return status >= 0; }
|
inline bool SetPropertyStatusSucceeded(int status) { return status >= 0; }
|
||||||
|
|
||||||
@@ -123,16 +123,19 @@ struct ObservationRequest {
|
|||||||
class PropertyObservationRegistry {
|
class PropertyObservationRegistry {
|
||||||
public:
|
public:
|
||||||
ObservationRequest Register(const std::string& name, const std::string& format, int id) {
|
ObservationRequest Register(const std::string& name, const std::string& format, int id) {
|
||||||
|
const mpv_format parsed_format = ParsePropertyFormat(format);
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (userdata_by_name_.find(name) != userdata_by_name_.end()) {
|
if (userdata_by_name_.find(name) != userdata_by_name_.end()) {
|
||||||
return {false, 0, MPV_FORMAT_NONE};
|
return {false, 0, MPV_FORMAT_NONE};
|
||||||
}
|
}
|
||||||
const uint64_t userdata = next_userdata_++;
|
const uint64_t userdata = next_userdata_++;
|
||||||
userdata_by_name_[name] = userdata;
|
userdata_by_name_[name] = userdata;
|
||||||
id_by_name_[name] = id;
|
id_by_name_[name] = id;
|
||||||
return {true, userdata, ParsePropertyFormat(format)};
|
return {true, userdata, parsed_format};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LookupId(const std::string& name, int* id) const {
|
bool LookupId(const std::string& name, int* id) const {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
const auto it = id_by_name_.find(name);
|
const auto it = id_by_name_.find(name);
|
||||||
if (it == id_by_name_.end()) return false;
|
if (it == id_by_name_.end()) return false;
|
||||||
*id = it->second;
|
*id = it->second;
|
||||||
@@ -140,6 +143,7 @@ class PropertyObservationRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Clear() {
|
void Clear() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
userdata_by_name_.clear();
|
userdata_by_name_.clear();
|
||||||
id_by_name_.clear();
|
id_by_name_.clear();
|
||||||
}
|
}
|
||||||
@@ -148,6 +152,7 @@ class PropertyObservationRegistry {
|
|||||||
uint64_t next_userdata_ = 1;
|
uint64_t next_userdata_ = 1;
|
||||||
std::map<std::string, uint64_t> userdata_by_name_;
|
std::map<std::string, uint64_t> userdata_by_name_;
|
||||||
std::map<std::string, int> id_by_name_;
|
std::map<std::string, int> id_by_name_;
|
||||||
|
mutable std::mutex mutex_;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline bool ParseEnabledFlag(const std::string& value) { return value == "yes" || value == "true" || value == "1"; }
|
inline bool ParseEnabledFlag(const std::string& value) { return value == "yes" || value == "true" || value == "1"; }
|
||||||
@@ -160,6 +165,7 @@ struct AudioReloadAction {
|
|||||||
AudioReloadReason reason = AudioReloadReason::kNone;
|
AudioReloadReason reason = AudioReloadReason::kNone;
|
||||||
int attempt = 0;
|
int attempt = 0;
|
||||||
bool exhausted = false;
|
bool exhausted = false;
|
||||||
|
uint64_t request_generation = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class AudioOutputTransition { kNone, kFellBackToNull, kRecovered };
|
enum class AudioOutputTransition { kNone, kFellBackToNull, kRecovered };
|
||||||
@@ -168,23 +174,45 @@ class AudioRecoveryState {
|
|||||||
public:
|
public:
|
||||||
using Clock = std::chrono::steady_clock;
|
using Clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
void SetFileLoaded(bool loaded) {
|
void SetFileLoaded(bool loaded, Clock::time_point now = Clock::now()) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
const bool was_loaded = file_loaded_;
|
||||||
file_loaded_ = loaded;
|
file_loaded_ = loaded;
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
|
resume_requested_ = false;
|
||||||
resume_attempts_left_ = 0;
|
resume_attempts_left_ = 0;
|
||||||
null_attempts_left_ = 0;
|
null_attempts_left_ = 0;
|
||||||
|
reload_pending_ = false;
|
||||||
|
pending_request_generation_ = 0;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (!was_loaded && current_ao_is_null_) {
|
||||||
|
|
||||||
void RequestResume() { resume_requested_.store(true); }
|
|
||||||
|
|
||||||
AudioOutputTransition SetCurrentAudioOutputNull(bool is_null, Clock::time_point now) {
|
|
||||||
if (is_null == current_ao_is_null_) return AudioOutputTransition::kNone;
|
|
||||||
current_ao_is_null_ = is_null;
|
|
||||||
if (is_null) {
|
|
||||||
null_attempts_left_ = kNullRetryBudget;
|
null_attempts_left_ = kNullRetryBudget;
|
||||||
null_backoff_ = NullFirstDelay();
|
null_backoff_ = NullFirstDelay();
|
||||||
null_next_attempt_ = now + NullFirstDelay();
|
null_next_attempt_ = now + NullFirstDelay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RequestResume() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!file_loaded_) {
|
||||||
|
resume_requested_ = false;
|
||||||
|
resume_attempts_left_ = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resume_requested_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioOutputTransition SetCurrentAudioOutputNull(bool is_null, Clock::time_point now) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (is_null == current_ao_is_null_) return AudioOutputTransition::kNone;
|
||||||
|
current_ao_is_null_ = is_null;
|
||||||
|
if (is_null) {
|
||||||
|
if (file_loaded_) {
|
||||||
|
null_attempts_left_ = kNullRetryBudget;
|
||||||
|
null_backoff_ = NullFirstDelay();
|
||||||
|
null_next_attempt_ = now + NullFirstDelay();
|
||||||
|
}
|
||||||
return AudioOutputTransition::kFellBackToNull;
|
return AudioOutputTransition::kFellBackToNull;
|
||||||
}
|
}
|
||||||
null_attempts_left_ = 0;
|
null_attempts_left_ = 0;
|
||||||
@@ -192,7 +220,8 @@ class AudioRecoveryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool OnAudioDeviceListChanged(Clock::time_point now) {
|
bool OnAudioDeviceListChanged(Clock::time_point now) {
|
||||||
if (!current_ao_is_null_) return false;
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!file_loaded_ || !current_ao_is_null_) return false;
|
||||||
const auto candidate = now + DeviceListDebounce();
|
const auto candidate = now + DeviceListDebounce();
|
||||||
if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) {
|
if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) {
|
||||||
null_next_attempt_ = candidate;
|
null_next_attempt_ = candidate;
|
||||||
@@ -203,7 +232,9 @@ class AudioRecoveryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AudioReloadAction NextReload(Clock::time_point now) {
|
AudioReloadAction NextReload(Clock::time_point now) {
|
||||||
if (resume_requested_.exchange(false) && file_loaded_) {
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (resume_requested_ && file_loaded_) {
|
||||||
|
resume_requested_ = false;
|
||||||
resume_attempts_left_ = kResumeReloadAttempts;
|
resume_attempts_left_ = kResumeReloadAttempts;
|
||||||
resume_next_attempt_ = now + ResumeFirstDelay();
|
resume_next_attempt_ = now + ResumeFirstDelay();
|
||||||
}
|
}
|
||||||
@@ -214,7 +245,8 @@ class AudioRecoveryState {
|
|||||||
--resume_attempts_left_;
|
--resume_attempts_left_;
|
||||||
resume_next_attempt_ = now + ResumeRetryDelay();
|
resume_next_attempt_ = now + ResumeRetryDelay();
|
||||||
reload_pending_ = true;
|
reload_pending_ = true;
|
||||||
return {AudioReloadReason::kResume, attempt, false};
|
pending_request_generation_ = ++next_request_generation_;
|
||||||
|
return {AudioReloadReason::kResume, attempt, false, pending_request_generation_};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null_attempts_left_ > 0 && now >= null_next_attempt_) {
|
if (null_attempts_left_ > 0 && now >= null_next_attempt_) {
|
||||||
@@ -227,20 +259,27 @@ class AudioRecoveryState {
|
|||||||
null_next_attempt_ = now + null_backoff_;
|
null_next_attempt_ = now + null_backoff_;
|
||||||
null_backoff_ = std::min(null_backoff_ * 2, NullBackoffCap());
|
null_backoff_ = std::min(null_backoff_ * 2, NullBackoffCap());
|
||||||
reload_pending_ = true;
|
reload_pending_ = true;
|
||||||
return {AudioReloadReason::kNullFallback, attempt, null_attempts_left_ == 0};
|
pending_request_generation_ = ++next_request_generation_;
|
||||||
|
return {AudioReloadReason::kNullFallback, attempt, null_attempts_left_ == 0, pending_request_generation_};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
void CompleteReload() { reload_pending_ = false; }
|
bool CompleteReload(uint64_t request_generation) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
bool HasPendingWork() const {
|
if (!reload_pending_ || pending_request_generation_ != request_generation) {
|
||||||
return resume_requested_.load() || resume_attempts_left_ > 0 || null_attempts_left_ > 0 || reload_pending_;
|
return false;
|
||||||
|
}
|
||||||
|
reload_pending_ = false;
|
||||||
|
pending_request_generation_ = 0;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool current_audio_output_is_null() const { return current_ao_is_null_; }
|
bool HasPendingWork() const {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
static int NullRetryBudget() { return kNullRetryBudget; }
|
return file_loaded_ &&
|
||||||
|
(resume_requested_ || resume_attempts_left_ > 0 || null_attempts_left_ > 0 || reload_pending_);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr int kResumeReloadAttempts = 2;
|
static constexpr int kResumeReloadAttempts = 2;
|
||||||
@@ -252,15 +291,18 @@ class AudioRecoveryState {
|
|||||||
static std::chrono::milliseconds NullBackoffCap() { return std::chrono::milliseconds(8000); }
|
static std::chrono::milliseconds NullBackoffCap() { return std::chrono::milliseconds(8000); }
|
||||||
static std::chrono::milliseconds DeviceListDebounce() { return std::chrono::milliseconds(250); }
|
static std::chrono::milliseconds DeviceListDebounce() { return std::chrono::milliseconds(250); }
|
||||||
|
|
||||||
std::atomic<bool> resume_requested_{false};
|
bool resume_requested_ = false;
|
||||||
bool file_loaded_ = false;
|
bool file_loaded_ = false;
|
||||||
bool current_ao_is_null_ = false;
|
bool current_ao_is_null_ = false;
|
||||||
bool reload_pending_ = false;
|
bool reload_pending_ = false;
|
||||||
|
uint64_t next_request_generation_ = 0;
|
||||||
|
uint64_t pending_request_generation_ = 0;
|
||||||
int resume_attempts_left_ = 0;
|
int resume_attempts_left_ = 0;
|
||||||
Clock::time_point resume_next_attempt_{};
|
Clock::time_point resume_next_attempt_{};
|
||||||
int null_attempts_left_ = 0;
|
int null_attempts_left_ = 0;
|
||||||
Clock::time_point null_next_attempt_{};
|
Clock::time_point null_next_attempt_{};
|
||||||
std::chrono::milliseconds null_backoff_{0};
|
std::chrono::milliseconds null_backoff_{0};
|
||||||
|
mutable std::mutex mutex_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mpv_common
|
} // namespace mpv_common
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
#undef NDEBUG
|
#undef NDEBUG
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -41,6 +44,35 @@ void TestRequestRegistry() {
|
|||||||
assert(cancelled.properties.size() == 1);
|
assert(cancelled.properties.size() == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestConcurrentRequestCompletion() {
|
||||||
|
for (int iteration = 0; iteration < 200; ++iteration) {
|
||||||
|
plezy::mpv_common::AsyncRequestRegistry registry;
|
||||||
|
std::atomic<int> completions{0};
|
||||||
|
const auto id = registry.RegisterStatus([&](int) { completions.fetch_add(1); });
|
||||||
|
std::atomic<bool> start{false};
|
||||||
|
|
||||||
|
std::thread taker([&]() {
|
||||||
|
while (!start.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
auto callback = registry.TakeStatus(id);
|
||||||
|
if (callback) callback(0);
|
||||||
|
});
|
||||||
|
std::thread canceller([&]() {
|
||||||
|
while (!start.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
auto cancelled = registry.CancelAll();
|
||||||
|
for (auto& callback : cancelled.status) {
|
||||||
|
callback(MPV_ERROR_UNINITIALIZED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
start.store(true, std::memory_order_release);
|
||||||
|
taker.join();
|
||||||
|
canceller.join();
|
||||||
|
assert(completions.load() == 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void TestSetPropertyResultContract() {
|
void TestSetPropertyResultContract() {
|
||||||
using namespace plezy::mpv_common;
|
using namespace plezy::mpv_common;
|
||||||
|
|
||||||
@@ -84,6 +116,66 @@ void TestPropertyObservationRegistry() {
|
|||||||
assert(!registry.LookupId("pause", &id));
|
assert(!registry.LookupId("pause", &id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestConcurrentPropertyObservationRegistry() {
|
||||||
|
constexpr int kPropertyCount = 512;
|
||||||
|
constexpr int kClearRounds = 32;
|
||||||
|
plezy::mpv_common::PropertyObservationRegistry registry;
|
||||||
|
std::vector<std::string> names;
|
||||||
|
names.reserve(kPropertyCount);
|
||||||
|
for (int i = 0; i < kPropertyCount; ++i) {
|
||||||
|
names.push_back("property-" + std::to_string(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::atomic<bool> start{false};
|
||||||
|
std::atomic<bool> writer_done{false};
|
||||||
|
std::thread writer([&]() {
|
||||||
|
while (!start.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
for (int round = 0; round < kClearRounds; ++round) {
|
||||||
|
for (int i = 0; i < kPropertyCount; ++i) {
|
||||||
|
registry.Register(names[i], "int64", 1000 + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer_done.store(true, std::memory_order_release);
|
||||||
|
});
|
||||||
|
std::thread reader([&]() {
|
||||||
|
while (!start.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
while (!writer_done.load(std::memory_order_acquire)) {
|
||||||
|
for (int i = 0; i < kPropertyCount; ++i) {
|
||||||
|
int id = 0;
|
||||||
|
if (registry.LookupId(names[i], &id)) {
|
||||||
|
assert(id == 1000 + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::thread clearer([&]() {
|
||||||
|
while (!start.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
for (int round = 0; round < kClearRounds; ++round) {
|
||||||
|
registry.Clear();
|
||||||
|
std::this_thread::yield();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
start.store(true, std::memory_order_release);
|
||||||
|
writer.join();
|
||||||
|
reader.join();
|
||||||
|
clearer.join();
|
||||||
|
|
||||||
|
registry.Clear();
|
||||||
|
for (int i = 0; i < kPropertyCount; ++i) {
|
||||||
|
const auto request = registry.Register(names[i], "int64", 1000 + i);
|
||||||
|
assert(request.added);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < kPropertyCount; ++i) {
|
||||||
|
int id = 0;
|
||||||
|
assert(registry.LookupId(names[i], &id));
|
||||||
|
assert(id == 1000 + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void TestResumeRecoverySchedule() {
|
void TestResumeRecoverySchedule() {
|
||||||
AudioRecoveryState state;
|
AudioRecoveryState state;
|
||||||
const auto start = AudioRecoveryState::Clock::time_point{};
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
@@ -98,15 +190,72 @@ void TestResumeRecoverySchedule() {
|
|||||||
assert(first.reason == AudioReloadReason::kResume);
|
assert(first.reason == AudioReloadReason::kResume);
|
||||||
assert(first.attempt == 1);
|
assert(first.attempt == 1);
|
||||||
assert(!first.exhausted);
|
assert(!first.exhausted);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(first.request_generation));
|
||||||
|
|
||||||
const auto second = state.NextReload(start + std::chrono::milliseconds(6000));
|
const auto second = state.NextReload(start + std::chrono::milliseconds(6000));
|
||||||
assert(second.reason == AudioReloadReason::kResume);
|
assert(second.reason == AudioReloadReason::kResume);
|
||||||
assert(second.attempt == 2);
|
assert(second.attempt == 2);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(second.request_generation));
|
||||||
assert(!state.HasPendingWork());
|
assert(!state.HasPendingWork());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestConcurrentAudioRecoveryState() {
|
||||||
|
AudioRecoveryState state;
|
||||||
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
|
state.SetFileLoaded(true);
|
||||||
|
std::atomic<bool> begin{false};
|
||||||
|
|
||||||
|
std::thread resume([&]() {
|
||||||
|
while (!begin.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 1000; ++i) state.RequestResume();
|
||||||
|
});
|
||||||
|
std::thread device([&]() {
|
||||||
|
while (!begin.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 1000; ++i) {
|
||||||
|
state.SetCurrentAudioOutputNull(true, start);
|
||||||
|
state.OnAudioDeviceListChanged(start);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::thread timer([&]() {
|
||||||
|
while (!begin.load(std::memory_order_acquire)) {
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 1000; ++i) {
|
||||||
|
const auto action = state.NextReload(start + std::chrono::hours(1));
|
||||||
|
if (action.reason != AudioReloadReason::kNone) {
|
||||||
|
state.CompleteReload(action.request_generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
begin.store(true, std::memory_order_release);
|
||||||
|
resume.join();
|
||||||
|
device.join();
|
||||||
|
timer.join();
|
||||||
|
state.SetFileLoaded(false);
|
||||||
|
assert(!state.HasPendingWork());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestFileBoundaryRestartsNullRecoveryOnlyAfterLoad() {
|
||||||
|
AudioRecoveryState state;
|
||||||
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
|
state.SetFileLoaded(true, start);
|
||||||
|
assert(state.SetCurrentAudioOutputNull(true, start) == AudioOutputTransition::kFellBackToNull);
|
||||||
|
assert(state.HasPendingWork());
|
||||||
|
|
||||||
|
state.SetFileLoaded(false, start + std::chrono::milliseconds(100));
|
||||||
|
assert(!state.HasPendingWork());
|
||||||
|
assert(!state.OnAudioDeviceListChanged(start + std::chrono::milliseconds(200)));
|
||||||
|
|
||||||
|
state.SetFileLoaded(true, start + std::chrono::milliseconds(300));
|
||||||
|
assert(state.HasPendingWork());
|
||||||
|
assert(state.NextReload(start + std::chrono::milliseconds(799)).reason == AudioReloadReason::kNone);
|
||||||
|
const auto retry = state.NextReload(start + std::chrono::milliseconds(800));
|
||||||
|
assert(retry.reason == AudioReloadReason::kNullFallback);
|
||||||
|
assert(retry.attempt == 1);
|
||||||
|
}
|
||||||
|
|
||||||
void TestNullFallbackRecoverySchedule() {
|
void TestNullFallbackRecoverySchedule() {
|
||||||
AudioRecoveryState state;
|
AudioRecoveryState state;
|
||||||
const auto start = AudioRecoveryState::Clock::time_point{};
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
@@ -116,35 +265,35 @@ void TestNullFallbackRecoverySchedule() {
|
|||||||
auto action = state.NextReload(start + std::chrono::milliseconds(500));
|
auto action = state.NextReload(start + std::chrono::milliseconds(500));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 1);
|
assert(action.attempt == 1);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
|
|
||||||
action = state.NextReload(start + std::chrono::milliseconds(1000));
|
action = state.NextReload(start + std::chrono::milliseconds(1000));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 2);
|
assert(action.attempt == 2);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
|
|
||||||
action = state.NextReload(start + std::chrono::milliseconds(2000));
|
action = state.NextReload(start + std::chrono::milliseconds(2000));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 3);
|
assert(action.attempt == 3);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
|
|
||||||
action = state.NextReload(start + std::chrono::milliseconds(4000));
|
action = state.NextReload(start + std::chrono::milliseconds(4000));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 4);
|
assert(action.attempt == 4);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
|
|
||||||
action = state.NextReload(start + std::chrono::milliseconds(8000));
|
action = state.NextReload(start + std::chrono::milliseconds(8000));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 5);
|
assert(action.attempt == 5);
|
||||||
assert(action.exhausted);
|
assert(action.exhausted);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
assert(!state.HasPendingWork());
|
assert(!state.HasPendingWork());
|
||||||
|
|
||||||
assert(state.OnAudioDeviceListChanged(start + std::chrono::milliseconds(9000)));
|
assert(state.OnAudioDeviceListChanged(start + std::chrono::milliseconds(9000)));
|
||||||
action = state.NextReload(start + std::chrono::milliseconds(9250));
|
action = state.NextReload(start + std::chrono::milliseconds(9250));
|
||||||
assert(action.reason == AudioReloadReason::kNullFallback);
|
assert(action.reason == AudioReloadReason::kNullFallback);
|
||||||
assert(action.attempt == 1);
|
assert(action.attempt == 1);
|
||||||
state.CompleteReload();
|
assert(state.CompleteReload(action.request_generation));
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
state.SetCurrentAudioOutputNull(false, start + std::chrono::milliseconds(9300)) ==
|
state.SetCurrentAudioOutputNull(false, start + std::chrono::milliseconds(9300)) ==
|
||||||
@@ -152,6 +301,37 @@ void TestNullFallbackRecoverySchedule() {
|
|||||||
assert(!state.HasPendingWork());
|
assert(!state.HasPendingWork());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestUnloadedResumeIsConsumed() {
|
||||||
|
AudioRecoveryState state;
|
||||||
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
|
|
||||||
|
state.RequestResume();
|
||||||
|
assert(!state.HasPendingWork());
|
||||||
|
assert(state.NextReload(start + std::chrono::hours(1)).reason == AudioReloadReason::kNone);
|
||||||
|
|
||||||
|
state.SetFileLoaded(true, start);
|
||||||
|
assert(!state.HasPendingWork());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestStaleReloadCompletionCannotClearCurrentRequest() {
|
||||||
|
AudioRecoveryState state;
|
||||||
|
const auto start = AudioRecoveryState::Clock::time_point{};
|
||||||
|
state.SetFileLoaded(true, start);
|
||||||
|
assert(state.SetCurrentAudioOutputNull(true, start) == AudioOutputTransition::kFellBackToNull);
|
||||||
|
const auto old_request = state.NextReload(start + std::chrono::milliseconds(500));
|
||||||
|
assert(old_request.reason == AudioReloadReason::kNullFallback);
|
||||||
|
|
||||||
|
state.SetFileLoaded(false, start + std::chrono::milliseconds(600));
|
||||||
|
state.SetFileLoaded(true, start + std::chrono::milliseconds(700));
|
||||||
|
const auto current_request = state.NextReload(start + std::chrono::milliseconds(1200));
|
||||||
|
assert(current_request.reason == AudioReloadReason::kNullFallback);
|
||||||
|
assert(current_request.request_generation != old_request.request_generation);
|
||||||
|
|
||||||
|
assert(!state.CompleteReload(old_request.request_generation));
|
||||||
|
assert(state.NextReload(start + std::chrono::hours(1)).reason == AudioReloadReason::kNone);
|
||||||
|
assert(state.CompleteReload(current_request.request_generation));
|
||||||
|
}
|
||||||
|
|
||||||
void TestHdrHelpers() {
|
void TestHdrHelpers() {
|
||||||
assert(plezy::mpv_common::ParseEnabledFlag("yes"));
|
assert(plezy::mpv_common::ParseEnabledFlag("yes"));
|
||||||
assert(plezy::mpv_common::ParseEnabledFlag("true"));
|
assert(plezy::mpv_common::ParseEnabledFlag("true"));
|
||||||
@@ -165,10 +345,16 @@ void TestHdrHelpers() {
|
|||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
TestRequestRegistry();
|
TestRequestRegistry();
|
||||||
|
TestConcurrentRequestCompletion();
|
||||||
TestSetPropertyResultContract();
|
TestSetPropertyResultContract();
|
||||||
TestPropertyObservationRegistry();
|
TestPropertyObservationRegistry();
|
||||||
|
TestConcurrentPropertyObservationRegistry();
|
||||||
TestResumeRecoverySchedule();
|
TestResumeRecoverySchedule();
|
||||||
|
TestConcurrentAudioRecoveryState();
|
||||||
TestNullFallbackRecoverySchedule();
|
TestNullFallbackRecoverySchedule();
|
||||||
|
TestFileBoundaryRestartsNullRecoveryOnlyAfterLoad();
|
||||||
|
TestUnloadedResumeIsConsumed();
|
||||||
|
TestStaleReloadCompletionCannotClearCurrentRequest();
|
||||||
TestHdrHelpers();
|
TestHdrHelpers();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,30 @@ void main() {
|
|||||||
expect(MpvNodeDecoder.decodeMap(testCase.input), testCase.map);
|
expect(MpvNodeDecoder.decodeMap(testCase.input), testCase.map);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('rejects hostile structured payloads before traversal', () {
|
||||||
|
Object? acceptedDepth = 'leaf';
|
||||||
|
for (var i = 0; i < 31; i++) {
|
||||||
|
acceptedDepth = [acceptedDepth];
|
||||||
|
}
|
||||||
|
expect(MpvNodeDecoder.decodeList(acceptedDepth), isNotNull);
|
||||||
|
|
||||||
|
Object? excessiveDepth = acceptedDepth;
|
||||||
|
excessiveDepth = [excessiveDepth];
|
||||||
|
expect(MpvNodeDecoder.decodeList(excessiveDepth), isNull);
|
||||||
|
expect(MpvNodeDecoder.decodeList(List<Object?>.filled(16384, null)), isNull);
|
||||||
|
expect(MpvNodeDecoder.decodeList([double.nan]), isNull);
|
||||||
|
expect(MpvNodeDecoder.decodeMap({1: 'non-string key'}), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preflights deeply nested and oversized JSON', () {
|
||||||
|
final deepestAccepted = '${List.filled(31, '[').join()}null${List.filled(31, ']').join()}';
|
||||||
|
final tooDeep = '[$deepestAccepted]';
|
||||||
|
expect(MpvNodeDecoder.decodeList(deepestAccepted), isNotNull);
|
||||||
|
expect(MpvNodeDecoder.decodeList(tooDeep), isNull);
|
||||||
|
|
||||||
|
final tooManyEntries = '[${List.filled(16385, 'null').join(',')}]';
|
||||||
|
expect(MpvNodeDecoder.decodeList(tooManyEntries), isNull);
|
||||||
|
expect(MpvNodeDecoder.decodeList('["brackets [ and braces { stay quoted"]'), isNotNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispose() settles an unconsumed armed fd', () async {
|
test('dispose() raw cleanup settles an unconsumed armed fd after admission closes', () async {
|
||||||
final core = _AudioCoreMock();
|
final core = _AudioCoreMock();
|
||||||
await run(core, (player, transitions) async {
|
await run(core, (player, transitions) async {
|
||||||
await openFirst(player);
|
await openFirst(player);
|
||||||
@@ -247,6 +247,9 @@ void main() {
|
|||||||
await player.dispose();
|
await player.dispose();
|
||||||
|
|
||||||
expect(core.closedFds, [7]);
|
expect(core.closedFds, [7]);
|
||||||
|
expect(core.commands('playlist-remove'), [
|
||||||
|
['playlist-remove', '1'],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import 'dart:async' show Completer;
|
import 'dart:async' show Completer;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/mpv/models.dart';
|
import 'package:plezy/mpv/models.dart';
|
||||||
import 'package:plezy/mpv/player/player_native.dart';
|
import 'package:plezy/mpv/player/player_native.dart';
|
||||||
|
import 'package:plezy/mpv/player/player_base.dart';
|
||||||
|
import 'package:plezy/mpv/video.dart';
|
||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
|
||||||
import '../test_helpers/mock_player_channels.dart';
|
import '../test_helpers/mock_player_channels.dart';
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
final class _InvokingPlayerNative extends PlayerNative {
|
||||||
|
Future<T?> debugInvoke<T>(String method) => invoke<T>(method);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -53,6 +60,431 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('three overlapping players preserve the newest event owner and serialize native release', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
final eventCalls = <MethodCall>[];
|
||||||
|
final firstNativeDisposeStarted = Completer<void>();
|
||||||
|
final releaseFirstNativeDispose = Completer<void>();
|
||||||
|
final secondNativeDisposeStarted = Completer<void>();
|
||||||
|
final releaseSecondNativeDispose = Completer<void>();
|
||||||
|
var nativeDisposeCount = 0;
|
||||||
|
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'dispose') {
|
||||||
|
switch (nativeDisposeCount++) {
|
||||||
|
case 0:
|
||||||
|
firstNativeDisposeStarted.complete();
|
||||||
|
await releaseFirstNativeDispose.future;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
secondNativeDisposeStarted.complete();
|
||||||
|
await releaseSecondNativeDispose.future;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
eventHandler: (call) async {
|
||||||
|
eventCalls.add(call);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final first = PlayerNative();
|
||||||
|
PlayerNative? second;
|
||||||
|
PlayerNative? third;
|
||||||
|
Future<void>? firstDisposal;
|
||||||
|
Future<void>? secondDisposal;
|
||||||
|
try {
|
||||||
|
await first.setLogLevel('warn');
|
||||||
|
second = PlayerNative();
|
||||||
|
third = PlayerNative();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(eventCalls.where((call) => call.method == 'listen'), hasLength(3));
|
||||||
|
|
||||||
|
firstDisposal = first.dispose();
|
||||||
|
secondDisposal = second.dispose();
|
||||||
|
final thirdInitialization = third.setLogLevel('warn');
|
||||||
|
|
||||||
|
await firstNativeDisposeStarted.future;
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(secondNativeDisposeStarted.isCompleted, isFalse);
|
||||||
|
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
|
||||||
|
expect(eventCalls.where((call) => call.method == 'cancel'), isEmpty);
|
||||||
|
|
||||||
|
releaseFirstNativeDispose.complete();
|
||||||
|
await firstDisposal;
|
||||||
|
await secondNativeDisposeStarted.future;
|
||||||
|
|
||||||
|
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
|
||||||
|
expect(eventCalls.where((call) => call.method == 'cancel'), isEmpty);
|
||||||
|
|
||||||
|
releaseSecondNativeDispose.complete();
|
||||||
|
await Future.wait([secondDisposal, thirdInitialization]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calls.where((call) => call.method == 'initialize' || call.method == 'dispose').map((call) => call.method),
|
||||||
|
['initialize', 'dispose', 'dispose', 'initialize'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await third.dispose();
|
||||||
|
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
|
||||||
|
expect(calls.where((call) => call.method == 'dispose'), hasLength(3));
|
||||||
|
} finally {
|
||||||
|
if (!releaseFirstNativeDispose.isCompleted) releaseFirstNativeDispose.complete();
|
||||||
|
if (!releaseSecondNativeDispose.isCompleted) releaseSecondNativeDispose.complete();
|
||||||
|
await firstDisposal;
|
||||||
|
await secondDisposal;
|
||||||
|
await first.dispose();
|
||||||
|
await second?.dispose();
|
||||||
|
await third?.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose does not wait forever for a predecessor that never releases the native channel', () async {
|
||||||
|
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
|
||||||
|
addTearDown(() => PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3));
|
||||||
|
final stalledNativeDispose = Completer<void>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return Future.value(true);
|
||||||
|
if (call.method == 'dispose' && !stalledNativeDispose.isCompleted) return stalledNativeDispose.future;
|
||||||
|
return Future.value(null);
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final first = PlayerNative();
|
||||||
|
final second = PlayerNative();
|
||||||
|
Future<void>? firstDisposal;
|
||||||
|
try {
|
||||||
|
await first.setLogLevel('warn');
|
||||||
|
firstDisposal = first.dispose();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
await second.dispose().timeout(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
|
||||||
|
} finally {
|
||||||
|
if (!stalledNativeDispose.isCompleted) stalledNativeDispose.complete();
|
||||||
|
await firstDisposal;
|
||||||
|
await second.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invoke returns null when a predecessor release remains stalled', () async {
|
||||||
|
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
|
||||||
|
addTearDown(() => PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3));
|
||||||
|
final stalledNativeDispose = Completer<void>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return Future.value(true);
|
||||||
|
if (call.method == 'dispose' && !stalledNativeDispose.isCompleted) return stalledNativeDispose.future;
|
||||||
|
return Future.value(null);
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final first = PlayerNative();
|
||||||
|
final second = PlayerNative();
|
||||||
|
final third = _InvokingPlayerNative();
|
||||||
|
Future<void>? firstDisposal;
|
||||||
|
try {
|
||||||
|
await first.setLogLevel('warn');
|
||||||
|
firstDisposal = first.dispose();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await second.dispose();
|
||||||
|
|
||||||
|
expect(await third.debugInvoke<Object>('probe'), isNull);
|
||||||
|
expect(calls.where((call) => call.method == 'probe'), isEmpty);
|
||||||
|
|
||||||
|
stalledNativeDispose.complete();
|
||||||
|
await firstDisposal;
|
||||||
|
await third.setLogLevel('warn');
|
||||||
|
expect(calls.where((call) => call.method == 'initialize'), hasLength(2));
|
||||||
|
} finally {
|
||||||
|
if (!stalledNativeDispose.isCompleted) stalledNativeDispose.complete();
|
||||||
|
await firstDisposal;
|
||||||
|
await second.dispose();
|
||||||
|
await third.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initialization cannot publish readiness after disposal starts', () async {
|
||||||
|
final initialize = Completer<bool>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return initialize.future;
|
||||||
|
return Future.value(null);
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
final initialization = player.setLogLevel('warn');
|
||||||
|
final initializationFailure = expectLater(initialization, throwsA(isA<StateError>()));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final disposal = player.dispose();
|
||||||
|
initialize.complete(true);
|
||||||
|
await initializationFailure;
|
||||||
|
await disposal;
|
||||||
|
|
||||||
|
expect(calls.where((call) => call.method == 'observeProperty'), isEmpty);
|
||||||
|
expect(calls.where((call) => call.method == 'setLogLevel'), isEmpty);
|
||||||
|
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose synchronously rejects public core traffic while an audio write is blocked', () async {
|
||||||
|
final speedStarted = Completer<void>();
|
||||||
|
final releaseSpeed = Completer<void>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'speed') {
|
||||||
|
speedStarted.complete();
|
||||||
|
await releaseSpeed.future;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = _InvokingPlayerNative();
|
||||||
|
Future<void>? disposal;
|
||||||
|
try {
|
||||||
|
await player.setLogLevel('warn');
|
||||||
|
final rate = player.setRate(1.25);
|
||||||
|
await speedStarted.future;
|
||||||
|
|
||||||
|
disposal = player.dispose();
|
||||||
|
expect(identical(disposal, player.dispose()), isTrue);
|
||||||
|
final callCountAtDisposeEntry = calls.length;
|
||||||
|
|
||||||
|
await Future.wait<void>([
|
||||||
|
player.command(['probe']),
|
||||||
|
player.open(Media('https://example.test/late.mkv')),
|
||||||
|
player.setProperty('pause', 'yes'),
|
||||||
|
player.setLogLevel('debug'),
|
||||||
|
player.setRate(1.5),
|
||||||
|
player.play(),
|
||||||
|
player.pause(),
|
||||||
|
player.stop(),
|
||||||
|
player.seek(const Duration(seconds: 3)),
|
||||||
|
player.setVolume(25),
|
||||||
|
player.setAudioPassthrough(true),
|
||||||
|
player.setAudioNormalization(true),
|
||||||
|
player.setAudioDownmix(enabled: true, centerBoostDb: 3, normalize: true),
|
||||||
|
player.updateFrame(),
|
||||||
|
player.abandonAudioFocus(),
|
||||||
|
]);
|
||||||
|
expect(await player.getProperty('pause'), isNull);
|
||||||
|
expect(await player.requestAudioFocus(), isFalse);
|
||||||
|
expect(await player.setVisible(false), isFalse);
|
||||||
|
expect(await player.debugInvoke<Object>('probe-direct'), isNull);
|
||||||
|
expect(calls, hasLength(callCountAtDisposeEntry));
|
||||||
|
|
||||||
|
releaseSpeed.complete();
|
||||||
|
await rate;
|
||||||
|
await disposal;
|
||||||
|
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
|
||||||
|
} finally {
|
||||||
|
if (!releaseSpeed.isCompleted) releaseSpeed.complete();
|
||||||
|
await disposal;
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux texture bootstrap gates observations and commands until ready', () async {
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||||
|
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
final ready = Completer<void>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return Future.value(73);
|
||||||
|
if (call.method == 'waitForVideoReady') return ready.future;
|
||||||
|
return Future.value(null);
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
final operation = player.setLogLevel('warn');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(player.textureId, 73);
|
||||||
|
expect(player.textureIdListenable.value, 73);
|
||||||
|
expect(calls.any((call) => call.method == 'waitForVideoReady'), isTrue);
|
||||||
|
expect(calls.any((call) => call.method == 'observeProperty'), isFalse);
|
||||||
|
expect(calls.any((call) => call.method == 'setLogLevel'), isFalse);
|
||||||
|
|
||||||
|
ready.complete();
|
||||||
|
await operation;
|
||||||
|
expect(calls.any((call) => call.method == 'observeProperty'), isTrue);
|
||||||
|
expect(calls.where((call) => call.method == 'setLogLevel'), hasLength(1));
|
||||||
|
} finally {
|
||||||
|
if (!ready.isCompleted) ready.complete();
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Linux texture handoff stays black until playback restarts', (tester) async {
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||||
|
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
final ready = Completer<void>();
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return 73;
|
||||||
|
if (call.method == 'waitForVideoReady') {
|
||||||
|
await ready.future;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Video(player: player)));
|
||||||
|
expect(find.byType(Texture), findsNothing);
|
||||||
|
|
||||||
|
final initialization = player.setLogLevel('warn');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(Texture), findsOneWidget);
|
||||||
|
final videoBox = find.descendant(of: find.byType(Video), matching: find.byType(ColoredBox));
|
||||||
|
expect(tester.widget<ColoredBox>(videoBox).color, Colors.black);
|
||||||
|
|
||||||
|
ready.complete();
|
||||||
|
await initialization;
|
||||||
|
player.handlePlayerEvent('playback-restart', null);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.widget<ColoredBox>(videoBox).color, Colors.transparent);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.runAsync(player.dispose);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||||
|
|
||||||
|
test('Linux texture bootstrap failure clears the provisional ID and retries', () async {
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||||
|
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
var initializeCount = 0;
|
||||||
|
var readinessCount = 0;
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return 80 + initializeCount++;
|
||||||
|
if (call.method == 'waitForVideoReady' && readinessCount++ == 0) {
|
||||||
|
throw PlatformException(code: 'INIT_FAILED', message: 'GPU bootstrap failed');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await expectLater(
|
||||||
|
player.setLogLevel('warn'),
|
||||||
|
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'INIT_FAILED')),
|
||||||
|
);
|
||||||
|
expect(player.textureId, isNull);
|
||||||
|
|
||||||
|
await player.setLogLevel('warn');
|
||||||
|
expect(initializeCount, 2);
|
||||||
|
expect(readinessCount, 2);
|
||||||
|
expect(player.textureId, 81);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux disposal clears the published texture ID', () async {
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||||
|
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return 73;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
final textureIds = <int?>[];
|
||||||
|
player.textureIdListenable.addListener(() => textureIds.add(player.textureIdListenable.value));
|
||||||
|
|
||||||
|
await player.setLogLevel('warn');
|
||||||
|
expect(player.textureId, 73);
|
||||||
|
await player.dispose();
|
||||||
|
|
||||||
|
expect(textureIds, [73, null]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-Linux texture initialization skips the Linux readiness handshake', () async {
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = false;
|
||||||
|
addTearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return 91;
|
||||||
|
if (call.method == 'waitForVideoReady') {
|
||||||
|
throw StateError('non-Linux backends must not use Linux readiness');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setLogLevel('warn');
|
||||||
|
expect(player.textureId, 91);
|
||||||
|
expect(calls.any((call) => call.method == 'waitForVideoReady'), isFalse);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('MPV accepts nested node observations and null unsupported values', () async {
|
test('MPV accepts nested node observations and null unsupported values', () async {
|
||||||
final observations = <String, int>{};
|
final observations = <String, int>{};
|
||||||
await withMockPlayerChannels(
|
await withMockPlayerChannels(
|
||||||
@@ -73,17 +505,21 @@ void main() {
|
|||||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
const codec = StandardMethodCodec();
|
const codec = StandardMethodCodec();
|
||||||
|
|
||||||
Future<void> sendObservation(String name, Object? value) async {
|
Future<void> sendEvent(Object? event) async {
|
||||||
final done = Completer<void>();
|
final done = Completer<void>();
|
||||||
await messenger.handlePlatformMessage(
|
await messenger.handlePlatformMessage(
|
||||||
'com.plezy/mpv_player/events',
|
'com.plezy/mpv_player/events',
|
||||||
codec.encodeSuccessEnvelope([observations[name], value]),
|
codec.encodeSuccessEnvelope(event),
|
||||||
(_) => done.complete(),
|
(_) => done.complete(),
|
||||||
);
|
);
|
||||||
await done.future;
|
await done.future;
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> sendObservation(String name, Object? value) async {
|
||||||
|
await sendEvent([observations[name], value]);
|
||||||
|
}
|
||||||
|
|
||||||
await sendObservation('track-list', const [
|
await sendObservation('track-list', const [
|
||||||
{
|
{
|
||||||
'type': 'audio',
|
'type': 'audio',
|
||||||
@@ -124,6 +560,50 @@ void main() {
|
|||||||
expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250));
|
expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250));
|
||||||
expect(player.state.audioDevices.single.name, 'speakers');
|
expect(player.state.audioDevices.single.name, 'speakers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Malformed envelopes and malformed siblings are ignored without
|
||||||
|
// taking down the event subscription or discarding valid siblings.
|
||||||
|
await sendEvent(['not-a-property-id', const {}]);
|
||||||
|
await sendEvent({'type': 'event', 'name': 7, 'data': const {}});
|
||||||
|
await sendEvent({'type': 'event', 'name': 'unknown', 'data': 'not-a-map'});
|
||||||
|
await sendObservation('track-list', const [
|
||||||
|
{'type': 7, 'id': 'bad'},
|
||||||
|
{
|
||||||
|
'type': 'audio',
|
||||||
|
'id': 8,
|
||||||
|
'title': 12,
|
||||||
|
'lang': false,
|
||||||
|
'codec': {'unexpected': true},
|
||||||
|
'demux-channel-count': 'many',
|
||||||
|
'selected': true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await sendObservation('demuxer-cache-state', const {
|
||||||
|
'cache-end': 'not-a-number',
|
||||||
|
'seekable-ranges': [
|
||||||
|
{'start': 'bad', 'end': 3},
|
||||||
|
{'start': 2, 'end': 6},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await sendObservation('audio-device-list', const [
|
||||||
|
{'name': 9, 'description': 'bad'},
|
||||||
|
{'name': 'headphones', 'description': 4},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(player.state.tracks.audio.single.id, '8');
|
||||||
|
expect(player.state.tracks.audio.single.title, isNull);
|
||||||
|
expect(player.state.tracks.audio.single.channels, isNull);
|
||||||
|
expect(player.state.buffer, const Duration(milliseconds: 12500));
|
||||||
|
expect(player.state.bufferRanges.single.start, const Duration(seconds: 2));
|
||||||
|
expect(player.state.bufferRanges.single.end, const Duration(seconds: 6));
|
||||||
|
expect(player.state.audioDevices.single.name, 'headphones');
|
||||||
|
expect(player.state.audioDevices.single.description, isEmpty);
|
||||||
|
|
||||||
|
await sendObservation('track-list', [double.nan]);
|
||||||
|
expect(player.state.tracks.audio.single.id, '8');
|
||||||
|
|
||||||
|
player.handlePropertyChange('aid', 'no');
|
||||||
|
expect(player.state.track.audio, isNull);
|
||||||
} finally {
|
} finally {
|
||||||
await player.dispose();
|
await player.dispose();
|
||||||
}
|
}
|
||||||
@@ -288,6 +768,263 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('typed rate restores native speed after a generic speed property write', () async {
|
||||||
|
final speedValues = <String>[];
|
||||||
|
var nativeRate = 1.0;
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
if (arguments['name'] == 'speed') {
|
||||||
|
final value = arguments['value'] as String;
|
||||||
|
speedValues.add(value);
|
||||||
|
nativeRate = double.parse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setProperty('speed', '2');
|
||||||
|
await player.setRate(1);
|
||||||
|
|
||||||
|
expect(speedValues, ['2', '1.0']);
|
||||||
|
expect(nativeRate, 1);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late downmix failure force-restores the accepted native filter state', () async {
|
||||||
|
final nativeProperties = <String, String>{};
|
||||||
|
final writes = <(String, String)>[];
|
||||||
|
var rejectNextStereo = false;
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
final name = arguments['name'] as String;
|
||||||
|
final value = arguments['value'] as String;
|
||||||
|
writes.add((name, value));
|
||||||
|
if (rejectNextStereo && name == 'audio-channels' && value == 'stereo') {
|
||||||
|
rejectNextStereo = false;
|
||||||
|
throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
nativeProperties[name] = value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setAudioDownmix(enabled: true, centerBoostDb: 2, normalize: false);
|
||||||
|
writes.clear();
|
||||||
|
rejectNextStereo = true;
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
player.setAudioDownmix(enabled: true, centerBoostDb: 9, normalize: true),
|
||||||
|
throwsA(isA<PlatformException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(writes, [
|
||||||
|
('audio-swresample-o', 'center_mix_level=1.9953'),
|
||||||
|
('audio-normalize-downmix', 'yes'),
|
||||||
|
('audio-channels', 'auto-safe'),
|
||||||
|
('audio-channels', 'stereo'),
|
||||||
|
('audio-swresample-o', 'center_mix_level=0.8913'),
|
||||||
|
('audio-normalize-downmix', 'no'),
|
||||||
|
('audio-channels', 'auto-safe'),
|
||||||
|
('audio-channels', 'stereo'),
|
||||||
|
('af', ''),
|
||||||
|
]);
|
||||||
|
expect(nativeProperties['audio-swresample-o'], 'center_mix_level=0.8913');
|
||||||
|
expect(nativeProperties['audio-normalize-downmix'], 'no');
|
||||||
|
expect(nativeProperties['audio-channels'], 'stereo');
|
||||||
|
expect(nativeProperties['af'], '');
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed older audio field is not revived by a queued different-field update', () async {
|
||||||
|
final normalizationStarted = Completer<void>();
|
||||||
|
final releaseNormalization = Completer<void>();
|
||||||
|
final speedValues = <String>[];
|
||||||
|
var normalizationAttempts = 0;
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
final name = arguments['name'] as String;
|
||||||
|
final value = arguments['value'] as String;
|
||||||
|
if (name == 'af' && value.isNotEmpty) {
|
||||||
|
normalizationAttempts++;
|
||||||
|
if (normalizationAttempts == 1) {
|
||||||
|
normalizationStarted.complete();
|
||||||
|
await releaseNormalization.future;
|
||||||
|
throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name == 'speed') speedValues.add(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
final normalization = player.setAudioNormalization(true);
|
||||||
|
await normalizationStarted.future;
|
||||||
|
final rate = player.setRate(1.25);
|
||||||
|
releaseNormalization.complete();
|
||||||
|
|
||||||
|
await expectLater(normalization, throwsA(isA<PlatformException>()));
|
||||||
|
await rate;
|
||||||
|
|
||||||
|
expect(normalizationAttempts, 1);
|
||||||
|
expect(speedValues, ['1.25']);
|
||||||
|
} finally {
|
||||||
|
if (!releaseNormalization.isCompleted) releaseNormalization.complete();
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed passthrough write does not publish speculative active state', () async {
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'audio-spdif') {
|
||||||
|
throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await expectLater(player.setAudioPassthrough(true), throwsA(isA<PlatformException>()));
|
||||||
|
expect(player.audioPassthroughActive, isFalse);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed passthrough restores requested normalization', () async {
|
||||||
|
final propertyWrites = <(String, String)>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
final write = (arguments['name'] as String, arguments['value'] as String);
|
||||||
|
propertyWrites.add(write);
|
||||||
|
if (write.$1 == 'audio-spdif') throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setAudioNormalization(true);
|
||||||
|
await expectLater(player.setAudioPassthrough(true), throwsA(isA<PlatformException>()));
|
||||||
|
|
||||||
|
expect(propertyWrites.where((write) => write.$1 == 'af').map((write) => write.$2), [
|
||||||
|
'loudnorm=I=-14:TP=-3:LRA=4',
|
||||||
|
'',
|
||||||
|
'loudnorm=I=-14:TP=-3:LRA=4',
|
||||||
|
]);
|
||||||
|
expect(player.audioPassthroughActive, isFalse);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusive-audio hint failure does not reject accepted passthrough', () async {
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'audio-exclusive') {
|
||||||
|
throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setAudioPassthrough(true);
|
||||||
|
expect(player.audioPassthroughActive, isTrue);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed rate write restores accepted passthrough state', () async {
|
||||||
|
var rejectSpeed = false;
|
||||||
|
final propertyWrites = <(String, String)>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'setProperty') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
final name = arguments['name'] as String;
|
||||||
|
final value = arguments['value'] as String;
|
||||||
|
propertyWrites.add((name, value));
|
||||||
|
if (name == 'speed' && rejectSpeed) {
|
||||||
|
throw PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerNative();
|
||||||
|
try {
|
||||||
|
await player.setAudioPassthrough(true);
|
||||||
|
expect(player.audioPassthroughActive, isTrue);
|
||||||
|
rejectSpeed = true;
|
||||||
|
|
||||||
|
await expectLater(player.setRate(1.25), throwsA(isA<PlatformException>()));
|
||||||
|
|
||||||
|
expect(player.audioPassthroughActive, isTrue);
|
||||||
|
expect(propertyWrites.where((write) => write.$1 == 'audio-spdif').map((write) => write.$2), [
|
||||||
|
'ac3,eac3,dts,dts-hd,truehd',
|
||||||
|
'',
|
||||||
|
'ac3,eac3,dts,dts-hd,truehd',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
for (final channel in [
|
for (final channel in [
|
||||||
(label: 'video', method: 'com.plezy/mpv_player', events: 'com.plezy/mpv_player/events', audio: false),
|
(label: 'video', method: 'com.plezy/mpv_player', events: 'com.plezy/mpv_player/events', audio: false),
|
||||||
(label: 'audio', method: 'com.plezy/mpv_audio_player', events: 'com.plezy/mpv_audio_player/events', audio: true),
|
(label: 'audio', method: 'com.plezy/mpv_audio_player', events: 'com.plezy/mpv_audio_player/events', audio: true),
|
||||||
|
|||||||
@@ -46,6 +46,91 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ExoPlayer applies audio settings queued before initialization', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/exo_player',
|
||||||
|
eventChannelName: 'com.plezy/exo_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return true;
|
||||||
|
if (call.method == 'requestAudioFocus') return true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerAndroid();
|
||||||
|
try {
|
||||||
|
await player.setAudioNormalization(true);
|
||||||
|
await player.setAudioDownmix(enabled: true, centerBoostDb: 4, normalize: false);
|
||||||
|
|
||||||
|
expect(calls.where((call) => call.method == 'setAudioNormalization'), isEmpty);
|
||||||
|
expect(calls.where((call) => call.method == 'setAudioDownmix'), isEmpty);
|
||||||
|
|
||||||
|
expect(await player.requestAudioFocus(), isTrue);
|
||||||
|
|
||||||
|
final normalization = calls.singleWhere((call) => call.method == 'setAudioNormalization');
|
||||||
|
expect((normalization.arguments as Map)['enabled'], isTrue);
|
||||||
|
final downmix = calls.singleWhere((call) => call.method == 'setAudioDownmix');
|
||||||
|
expect(downmix.arguments, {'enabled': true, 'centerBoostDb': 4, 'normalize': false});
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ExoPlayer retries initialization after a recoverable native failure', () async {
|
||||||
|
var initializeAttempts = 0;
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/exo_player',
|
||||||
|
eventChannelName: 'com.plezy/exo_player/events',
|
||||||
|
methodHandler: (call) async {
|
||||||
|
if (call.method == 'initialize') return ++initializeAttempts > 1;
|
||||||
|
if (call.method == 'requestAudioFocus') return true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerAndroid();
|
||||||
|
try {
|
||||||
|
await expectLater(player.requestAudioFocus(), throwsA(isA<Exception>()));
|
||||||
|
expect(await player.requestAudioFocus(), isTrue);
|
||||||
|
expect(initializeAttempts, 2);
|
||||||
|
} finally {
|
||||||
|
await player.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ExoPlayer initialization cannot commit after disposal starts', () async {
|
||||||
|
final initialize = Completer<bool>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/exo_player',
|
||||||
|
eventChannelName: 'com.plezy/exo_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
if (call.method == 'initialize') return initialize.future;
|
||||||
|
return Future.value(null);
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final player = PlayerAndroid();
|
||||||
|
final initialization = player.requestAudioFocus();
|
||||||
|
final initializationFailure = expectLater(initialization, throwsA(isA<StateError>()));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final disposal = player.dispose();
|
||||||
|
initialize.complete(true);
|
||||||
|
await initializationFailure;
|
||||||
|
await disposal;
|
||||||
|
|
||||||
|
expect(calls.where((call) => call.method == 'observeProperty'), isEmpty);
|
||||||
|
expect(calls.where((call) => call.method == 'requestAudioFocus'), isEmpty);
|
||||||
|
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('ExoPlayer forwards external subtitle metadata at open', () async {
|
test('ExoPlayer forwards external subtitle metadata at open', () async {
|
||||||
final calls = <MethodCall>[];
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/mpv/player/player_native.dart';
|
||||||
|
import 'package:plezy/providers/playback_state_provider.dart';
|
||||||
|
import 'package:plezy/screens/video_player_screen.dart';
|
||||||
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../test_helpers/media_items.dart';
|
||||||
|
import '../../test_helpers/mock_player_channels.dart';
|
||||||
|
import '../../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
PlayerNative.debugUseLinuxVideoBootstrap = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => PlayerNative.debugUseLinuxVideoBootstrap = null);
|
||||||
|
|
||||||
|
testWidgets('Linux mounts its provisional texture while initialization is pending', (tester) async {
|
||||||
|
final ready = Completer<void>();
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
final eventCalls = <MethodCall>[];
|
||||||
|
|
||||||
|
await withMockPlayerChannels(
|
||||||
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
return switch (call.method) {
|
||||||
|
'initialize' => Future<Object?>.value(73),
|
||||||
|
'waitForVideoReady' => ready.future,
|
||||||
|
_ => Future<Object?>.value(null),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
eventHandler: (call) async {
|
||||||
|
eventCalls.add(call);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
testBody: () async {
|
||||||
|
final key = GlobalKey<VideoPlayerScreenState>();
|
||||||
|
await tester.pumpWidget(_screen(key));
|
||||||
|
await _pumpUntil(tester, () => calls.any((call) => call.method == 'waitForVideoReady'));
|
||||||
|
|
||||||
|
expect(tester.widget<Texture>(find.byType(Texture)).textureId, 73);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
expect(key.currentState?.player, isNull);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
|
||||||
|
);
|
||||||
|
ready.complete();
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 10)));
|
||||||
|
await tester.pump();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (_) => PlaybackStateProvider(),
|
||||||
|
child: MaterialApp(
|
||||||
|
home: VideoPlayerScreen(
|
||||||
|
key: key,
|
||||||
|
metadata: testMediaItem(title: 'Linux startup test video'),
|
||||||
|
isOffline: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
|
||||||
|
for (var i = 0; i < 200 && !condition(); i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
|
if (!condition()) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(condition(), isTrue);
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/focus/focusable_button.dart';
|
||||||
import 'package:plezy/providers/playback_state_provider.dart';
|
import 'package:plezy/providers/playback_state_provider.dart';
|
||||||
import 'package:plezy/screens/video_player_screen.dart';
|
import 'package:plezy/screens/video_player_screen.dart';
|
||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
import 'package:plezy/focus/focusable_button.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../test_helpers/media_items.dart';
|
import '../../test_helpers/media_items.dart';
|
||||||
@@ -22,6 +22,25 @@ void main() {
|
|||||||
await SettingsService.getInstance();
|
await SettingsService.getInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('in-place reload preserves the current playback intent', () {
|
||||||
|
expect(
|
||||||
|
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: false, watchTogetherOwnsStart: false, startPaused: false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: false, startPaused: false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: true, startPaused: false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldAutoStartReloadedMedia(wasPlayingBeforeReload: true, watchTogetherOwnsStart: false, startPaused: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
||||||
final failedDispose = Completer<void>();
|
final failedDispose = Completer<void>();
|
||||||
final replacementInitialize = Completer<bool>();
|
final replacementInitialize = Completer<bool>();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/services/device_performance.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
DevicePerformance.debugReset();
|
||||||
|
addTearDown(DevicePerformance.debugReset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent callers wait for hardware detection', () async {
|
||||||
|
final detection = Completer<void>();
|
||||||
|
DevicePerformance.debugDetectionGate = detection.future;
|
||||||
|
|
||||||
|
final first = DevicePerformance.getInstance(override: VisualEffectsSetting.reduced);
|
||||||
|
var secondCompleted = false;
|
||||||
|
final second = DevicePerformance.getInstance();
|
||||||
|
unawaited(second.then((_) => secondCompleted = true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(secondCompleted, isFalse);
|
||||||
|
detection.complete();
|
||||||
|
|
||||||
|
final instances = await Future.wait([first, second]);
|
||||||
|
expect(identical(instances.first, instances.last), isTrue);
|
||||||
|
expect(DevicePerformance.isReduced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed hardware detection can be retried', () async {
|
||||||
|
DevicePerformance.debugDetectionGate = Future<void>.error(StateError('detection failed'));
|
||||||
|
|
||||||
|
await expectLater(DevicePerformance.getInstance(), throwsStateError);
|
||||||
|
|
||||||
|
DevicePerformance.debugDetectionGate = null;
|
||||||
|
final recovered = await DevicePerformance.getInstance();
|
||||||
|
expect(recovered, isNotNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/services/display_mode_service.dart';
|
||||||
|
import 'package:plezy/services/fullscreen_state_manager.dart';
|
||||||
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/utils/app_logger.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('test_display_mode_service');
|
||||||
|
late DisplayModeService service;
|
||||||
|
late List<String> calls;
|
||||||
|
|
||||||
|
void setHandler(Future<dynamic> Function(MethodCall call)? handler) {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seedNativeState({required bool mode, required bool hdr}) async {
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
return switch (call.method) {
|
||||||
|
'isModeChanged' => mode,
|
||||||
|
'isHDRChanged' => hdr,
|
||||||
|
_ => throw StateError('Unexpected method ${call.method}'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await service.syncWithNative();
|
||||||
|
calls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
calls = <String>[];
|
||||||
|
MemoryLogOutput.clearLogs();
|
||||||
|
setLoggerLevel(true);
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
service = DisplayModeService.forTesting(settings, FullscreenStateManager(), channel: channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
setHandler(null);
|
||||||
|
MemoryLogOutput.clearLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false display-mode payload retains state for a later retry', () async {
|
||||||
|
await seedNativeState(mode: true, hdr: false);
|
||||||
|
var accepted = false;
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
expect(call.method, 'restoreDisplayMode');
|
||||||
|
return accepted;
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(service.anyChangeApplied, isTrue);
|
||||||
|
|
||||||
|
accepted = true;
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreDisplayMode', 'restoreDisplayMode']);
|
||||||
|
expect(service.anyChangeApplied, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false HDR payload warns without logging restoration success', () async {
|
||||||
|
await seedNativeState(mode: false, hdr: true);
|
||||||
|
var accepted = false;
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
expect(call.method, 'restoreSystemHDR');
|
||||||
|
return accepted;
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(service.hdrStateChanged, isTrue);
|
||||||
|
var messages = MemoryLogOutput.getLogs().map((entry) => entry.message).join('\n');
|
||||||
|
expect(messages, contains('retaining retry state'));
|
||||||
|
expect(messages, isNot(contains('Restored system HDR state')));
|
||||||
|
|
||||||
|
accepted = true;
|
||||||
|
await service.restoreAll();
|
||||||
|
messages = MemoryLogOutput.getLogs().map((entry) => entry.message).join('\n');
|
||||||
|
expect(calls, ['restoreSystemHDR', 'restoreSystemHDR']);
|
||||||
|
expect(service.hdrStateChanged, isFalse);
|
||||||
|
expect(service.anyChangeApplied, isFalse);
|
||||||
|
expect(messages, contains('Restored system HDR state'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HDR failure does not suppress successful mode restoration', () async {
|
||||||
|
await seedNativeState(mode: true, hdr: true);
|
||||||
|
var hdrAccepted = false;
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
return switch (call.method) {
|
||||||
|
'restoreSystemHDR' => hdrAccepted,
|
||||||
|
'restoreDisplayMode' => true,
|
||||||
|
_ => throw StateError('Unexpected method ${call.method}'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
|
||||||
|
expect(service.hdrStateChanged, isTrue);
|
||||||
|
expect(service.anyChangeApplied, isTrue);
|
||||||
|
|
||||||
|
calls.clear();
|
||||||
|
hdrAccepted = true;
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreSystemHDR']);
|
||||||
|
expect(service.anyChangeApplied, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mode failure does not suppress successful HDR restoration', () async {
|
||||||
|
await seedNativeState(mode: true, hdr: true);
|
||||||
|
var modeAccepted = false;
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
return switch (call.method) {
|
||||||
|
'restoreSystemHDR' => true,
|
||||||
|
'restoreDisplayMode' => modeAccepted,
|
||||||
|
_ => throw StateError('Unexpected method ${call.method}'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
|
||||||
|
expect(service.hdrStateChanged, isFalse);
|
||||||
|
expect(service.anyChangeApplied, isTrue);
|
||||||
|
|
||||||
|
calls.clear();
|
||||||
|
modeAccepted = true;
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreDisplayMode']);
|
||||||
|
expect(service.anyChangeApplied, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a channel exception retains only the throwing restoration', () async {
|
||||||
|
await seedNativeState(mode: true, hdr: true);
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
if (call.method == 'restoreSystemHDR') {
|
||||||
|
throw PlatformException(code: 'RESTORE_FAILED');
|
||||||
|
}
|
||||||
|
if (call.method == 'restoreDisplayMode') return true;
|
||||||
|
throw StateError('Unexpected method ${call.method}');
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, ['restoreSystemHDR', 'restoreDisplayMode']);
|
||||||
|
expect(service.hdrStateChanged, isTrue);
|
||||||
|
expect(service.anyChangeApplied, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-Windows override performs no native work', () async {
|
||||||
|
service = DisplayModeService.forTesting(
|
||||||
|
SettingsService.instance,
|
||||||
|
FullscreenStateManager(),
|
||||||
|
channel: channel,
|
||||||
|
isWindows: false,
|
||||||
|
);
|
||||||
|
setHandler((call) async {
|
||||||
|
calls.add(call.method);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.syncWithNative();
|
||||||
|
await service.restoreAll();
|
||||||
|
expect(calls, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,28 @@ void main() {
|
|||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('concurrent callers wait for settings binding', () async {
|
||||||
|
final preferences = _BlockingReadPreferences(const {});
|
||||||
|
SharedPreferencesAsyncPlatform.instance = preferences;
|
||||||
|
BaseSharedPreferencesService.resetForTesting();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
addTearDown(preferences.release);
|
||||||
|
|
||||||
|
final first = KeyboardShortcutsService.getInstance();
|
||||||
|
await preferences.entered;
|
||||||
|
var secondCompleted = false;
|
||||||
|
final second = KeyboardShortcutsService.getInstance();
|
||||||
|
unawaited(second.then((_) => secondCompleted = true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(secondCompleted, isFalse);
|
||||||
|
preferences.release();
|
||||||
|
|
||||||
|
final instances = await Future.wait([first, second]);
|
||||||
|
expect(identical(instances.first, instances.last), isTrue);
|
||||||
|
addTearDown(instances.first.dispose);
|
||||||
|
});
|
||||||
|
|
||||||
group('HotKey persistence', () {
|
group('HotKey persistence', () {
|
||||||
test('loads shortcuts saved with the shipped pre-HID key format', () async {
|
test('loads shortcuts saved with the shipped pre-HID key format', () async {
|
||||||
resetSharedPreferencesForTest(
|
resetSharedPreferencesForTest(
|
||||||
@@ -694,6 +716,29 @@ class _FakePlayer implements Player {
|
|||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _BlockingReadPreferences extends InMemorySharedPreferencesAsync {
|
||||||
|
_BlockingReadPreferences(super.data) : super.withData();
|
||||||
|
|
||||||
|
final _entered = Completer<void>();
|
||||||
|
final _release = Completer<void>();
|
||||||
|
|
||||||
|
Future<void> get entered => _entered.future;
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
if (!_release.isCompleted) _release.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, Object>> getPreferences(
|
||||||
|
GetPreferencesParameters parameters,
|
||||||
|
SharedPreferencesOptions options,
|
||||||
|
) async {
|
||||||
|
if (!_entered.isCompleted) _entered.complete();
|
||||||
|
await _release.future;
|
||||||
|
return super.getPreferences(parameters, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class _HotkeyPreferences extends InMemorySharedPreferencesAsync {
|
final class _HotkeyPreferences extends InMemorySharedPreferencesAsync {
|
||||||
_HotkeyPreferences(super.data) : super.withData();
|
_HotkeyPreferences(super.data) : super.withData();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
|
|
||||||
@@ -7,6 +8,19 @@ import 'package:plezy/services/storage_service.dart';
|
|||||||
|
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
class _GatedPreferencesService extends BaseSharedPreferencesService {
|
||||||
|
_GatedPreferencesService(this.started, this.release);
|
||||||
|
|
||||||
|
final Completer<void> started;
|
||||||
|
final Future<void> release;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
started.complete();
|
||||||
|
await release;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUp(resetSharedPreferencesForTest);
|
setUp(resetSharedPreferencesForTest);
|
||||||
|
|
||||||
@@ -17,6 +31,33 @@ void main() {
|
|||||||
expect(identical(a, b), isTrue);
|
expect(identical(a, b), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('coalesces callers until asynchronous initialization completes', () async {
|
||||||
|
final started = Completer<void>();
|
||||||
|
final release = Completer<void>();
|
||||||
|
var constructorCalls = 0;
|
||||||
|
|
||||||
|
Future<_GatedPreferencesService> acquire() => BaseSharedPreferencesService.initializeInstance(() {
|
||||||
|
constructorCalls++;
|
||||||
|
return _GatedPreferencesService(started, release.future);
|
||||||
|
});
|
||||||
|
|
||||||
|
final first = acquire();
|
||||||
|
await started.future;
|
||||||
|
var secondCompleted = false;
|
||||||
|
final second = acquire().then((instance) {
|
||||||
|
secondCompleted = true;
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(secondCompleted, isFalse);
|
||||||
|
expect(constructorCalls, 1);
|
||||||
|
|
||||||
|
release.complete();
|
||||||
|
final instances = await Future.wait([first, second]);
|
||||||
|
expect(identical(instances.first, instances.last), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('reset rebuilds against current SharedPreferences', () async {
|
test('reset rebuilds against current SharedPreferences', () async {
|
||||||
final first = await StorageService.getInstance();
|
final first = await StorageService.getInstance();
|
||||||
await first.prefs.setString('plex_token', 'token-1');
|
await first.prefs.setString('plex_token', 'token-1');
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/utils/platform_detector.dart';
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
TvDetectionService.debugReset();
|
||||||
|
addTearDown(TvDetectionService.debugReset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent callers wait for TV detection', () async {
|
||||||
|
final detection = Completer<void>();
|
||||||
|
TvDetectionService.debugDetectionGate = detection.future;
|
||||||
|
|
||||||
|
final first = TvDetectionService.getInstance(forceTv: true);
|
||||||
|
var secondCompleted = false;
|
||||||
|
final second = TvDetectionService.getInstance();
|
||||||
|
unawaited(second.then((_) => secondCompleted = true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(secondCompleted, isFalse);
|
||||||
|
detection.complete();
|
||||||
|
|
||||||
|
final instances = await Future.wait([first, second]);
|
||||||
|
expect(identical(instances.first, instances.last), isTrue);
|
||||||
|
expect(instances.first.isTV, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
group('detectAndroidTvFromSystemFeatures', () {
|
group('detectAndroidTvFromSystemFeatures', () {
|
||||||
test('detects leanback devices', () {
|
test('detects leanback devices', () {
|
||||||
final detection = detectAndroidTvFromSystemFeatures([
|
final detection = detectAndroidTvFromSystemFeatures([
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ target 'Runner' do
|
|||||||
raise "tvos/Podfile: plugin '#{name}' not in .flutter-plugins-dependencies"
|
raise "tvos/Podfile: plugin '#{name}' not in .flutter-plugins-dependencies"
|
||||||
pod name, :path => File.join(base, 'ios')
|
pod name, :path => File.join(base, 'ios')
|
||||||
end
|
end
|
||||||
|
target 'RunnerTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
||||||
|
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
|
||||||
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
||||||
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
|
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */; };
|
||||||
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; };
|
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; };
|
||||||
AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; };
|
AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; };
|
||||||
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
|
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
E3DEAD2AAFC347A2E55AC0F7 /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */; };
|
E3DEAD2AAFC347A2E55AC0F7 /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */; };
|
||||||
E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; };
|
E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; };
|
||||||
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
|
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
|
||||||
|
F9C95D532B6F6B72D187217D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -85,10 +88,12 @@
|
|||||||
0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPlugin.swift; sourceTree = "<group>"; };
|
0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPlugin.swift; sourceTree = "<group>"; };
|
||||||
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; };
|
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; };
|
||||||
34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = "<group>"; };
|
34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
|
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
|
||||||
|
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||||
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
|
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
|
||||||
@@ -101,6 +106,7 @@
|
|||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TopShelfExtension.entitlements; sourceTree = "<group>"; };
|
9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TopShelfExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPluginTests.swift; sourceTree = "<group>"; };
|
||||||
937C0D45D6114EF1E957F5F6 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
937C0D45D6114EF1E957F5F6 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -111,13 +117,16 @@
|
|||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||||
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
|
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||||
|
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
|
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
|
||||||
|
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = "<group>"; };
|
||||||
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
|
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||||
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||||
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
|
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
|
||||||
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
|
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
|
||||||
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
|
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
|
||||||
|
EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
|
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
|
||||||
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
|
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -136,6 +145,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
F9C95D532B6F6B72D187217D /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -198,6 +208,9 @@
|
|||||||
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */,
|
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */,
|
||||||
04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */,
|
04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */,
|
||||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */,
|
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
);
|
);
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -208,6 +221,7 @@
|
|||||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */,
|
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */,
|
||||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */,
|
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */,
|
||||||
6D9369A1A01A73D85CAEC2A6 /* tvOS */,
|
6D9369A1A01A73D85CAEC2A6 /* tvOS */,
|
||||||
|
EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -322,6 +336,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
||||||
|
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
|
||||||
|
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */,
|
||||||
);
|
);
|
||||||
name = RunnerTests;
|
name = RunnerTests;
|
||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
@@ -379,6 +395,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = E0F018DBFAD5E036F8B3DED3 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = E0F018DBFAD5E036F8B3DED3 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
A61FC3E730D67F10B0AAB6F9 /* [CP] Check Pods Manifest.lock */,
|
||||||
877FBDFAE7193C414BFC7581 /* Sources */,
|
877FBDFAE7193C414BFC7581 /* Sources */,
|
||||||
5E27C9DB84FD0E86AD0F4664 /* Frameworks */,
|
5E27C9DB84FD0E86AD0F4664 /* Frameworks */,
|
||||||
BA52311F6E3823811234FBB7 /* Resources */,
|
BA52311F6E3823811234FBB7 /* Resources */,
|
||||||
@@ -527,6 +544,28 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "#/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" build\n";
|
shellScript = "#/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" build\n";
|
||||||
};
|
};
|
||||||
|
A61FC3E730D67F10B0AAB6F9 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
E2B5A6D75C9F4D1B9E8C7A63 /* Sync Version */ = {
|
E2B5A6D75C9F4D1B9E8C7A63 /* Sync Version */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -549,6 +588,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
||||||
|
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
|
||||||
|
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -774,10 +815,12 @@
|
|||||||
};
|
};
|
||||||
4F937E75F619E4ABE22B1F17 /* Release */ = {
|
4F937E75F619E4ABE22B1F17 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -1005,10 +1048,12 @@
|
|||||||
};
|
};
|
||||||
AE855FD4D5DF4B132727705D /* Profile */ = {
|
AE855FD4D5DF4B132727705D /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -1021,10 +1066,12 @@
|
|||||||
};
|
};
|
||||||
CD5AF092D8A44891EB23CECC /* Debug */ = {
|
CD5AF092D8A44891EB23CECC /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -5,14 +5,29 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
private final class ConnectivityListenerEpoch {}
|
||||||
|
|
||||||
public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||||
private let connectivityProvider: ConnectivityProvider
|
private let connectivityProvider: ConnectivityProvider
|
||||||
|
private let notificationCenter: NotificationCenter
|
||||||
|
private let applicationState: () -> UIApplication.State
|
||||||
private var eventSink: FlutterEventSink?
|
private var eventSink: FlutterEventSink?
|
||||||
|
private var activationObserver: NSObjectProtocol?
|
||||||
|
private var pendingConnectivity: [String]?
|
||||||
|
private var lastDeliveredConnectivity: [String]?
|
||||||
|
private var activeListenerEpoch: ConnectivityListenerEpoch?
|
||||||
|
|
||||||
init(connectivityProvider: ConnectivityProvider) {
|
init(
|
||||||
|
connectivityProvider: ConnectivityProvider,
|
||||||
|
notificationCenter: NotificationCenter = .default,
|
||||||
|
applicationState: @escaping () -> UIApplication.State = {
|
||||||
|
UIApplication.shared.applicationState
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.connectivityProvider = connectivityProvider
|
self.connectivityProvider = connectivityProvider
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
self.applicationState = applicationState
|
||||||
super.init()
|
super.init()
|
||||||
self.connectivityProvider.connectivityUpdateHandler = connectivityUpdateHandler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
@@ -33,9 +48,43 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
|||||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func onMain<T>(_ block: () -> T) -> T {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return block()
|
||||||
|
}
|
||||||
|
return DispatchQueue.main.sync(execute: block)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installProviderHandler() -> ConnectivityListenerEpoch {
|
||||||
|
let epoch = ConnectivityListenerEpoch()
|
||||||
|
activeListenerEpoch = epoch
|
||||||
|
connectivityProvider.connectivityUpdateHandler = { [weak self] connectivityTypes in
|
||||||
|
self?.connectivityUpdateHandler(connectivityTypes: connectivityTypes, epoch: epoch)
|
||||||
|
}
|
||||||
|
return epoch
|
||||||
|
}
|
||||||
|
|
||||||
public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
|
public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
|
||||||
eventSink = nil
|
onMain {
|
||||||
connectivityProvider.stop()
|
stopListening(preservePendingConnectivity: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
let observer = activationObserver
|
||||||
|
let provider = connectivityProvider
|
||||||
|
let center = notificationCenter
|
||||||
|
let cleanup = {
|
||||||
|
if let observer {
|
||||||
|
center.removeObserver(observer)
|
||||||
|
}
|
||||||
|
provider.connectivityUpdateHandler = nil
|
||||||
|
}
|
||||||
|
if Thread.isMainThread {
|
||||||
|
cleanup()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync(execute: cleanup)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
@@ -56,12 +105,12 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
|||||||
case .wiredEthernet:
|
case .wiredEthernet:
|
||||||
return "ethernet"
|
return "ethernet"
|
||||||
case .other:
|
case .other:
|
||||||
return "other"
|
return "other"
|
||||||
case .none:
|
case .none:
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusFrom(connectivityTypes: [ConnectivityType]) -> [String] {
|
private func statusFrom(connectivityTypes: [ConnectivityType]) -> [String] {
|
||||||
return connectivityTypes.map {
|
return connectivityTypes.map {
|
||||||
self.statusFrom(connectivityType: $0)
|
self.statusFrom(connectivityType: $0)
|
||||||
@@ -72,26 +121,85 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
|||||||
withArguments _: Any?,
|
withArguments _: Any?,
|
||||||
eventSink events: @escaping FlutterEventSink
|
eventSink events: @escaping FlutterEventSink
|
||||||
) -> FlutterError? {
|
) -> FlutterError? {
|
||||||
eventSink = events
|
onMain {
|
||||||
connectivityProvider.start()
|
eventSink = events
|
||||||
// Update this to handle a list
|
let listenerEpoch = installProviderHandler()
|
||||||
connectivityUpdateHandler(connectivityTypes: connectivityProvider.currentConnectivityTypes)
|
if activationObserver == nil {
|
||||||
return nil
|
activationObserver = notificationCenter.addObserver(
|
||||||
}
|
forName: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
private func connectivityUpdateHandler(connectivityTypes: [ConnectivityType]) {
|
queue: .main
|
||||||
DispatchQueue.main.async { [weak self] in
|
) { [weak self] _ in
|
||||||
guard let self = self, let eventSink = self.eventSink else { return }
|
self?.replayPendingConnectivityIfNeeded()
|
||||||
// NWPathMonitor can emit after the FlutterEngine shell is torn down.
|
}
|
||||||
// Do not call its event sink while tvOS is backgrounded.
|
}
|
||||||
guard UIApplication.shared.applicationState != .background else { return }
|
connectivityProvider.start()
|
||||||
eventSink(self.statusFrom(connectivityTypes: connectivityTypes))
|
guard applicationState() != .background else { return nil }
|
||||||
|
let replayedConnectivity = pendingConnectivity
|
||||||
|
if let replayedConnectivity {
|
||||||
|
pendingConnectivity = nil
|
||||||
|
lastDeliveredConnectivity = replayedConnectivity
|
||||||
|
events(replayedConnectivity)
|
||||||
|
}
|
||||||
|
let currentConnectivity = statusFrom(
|
||||||
|
connectivityTypes: connectivityProvider.currentConnectivityTypes)
|
||||||
|
if currentConnectivity != replayedConnectivity {
|
||||||
|
connectivityUpdateHandler(
|
||||||
|
connectivityTypes: connectivityProvider.currentConnectivityTypes,
|
||||||
|
epoch: listenerEpoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func onCancel(withArguments _: Any?) -> FlutterError? {
|
private func connectivityUpdateHandler(
|
||||||
connectivityProvider.stop()
|
connectivityTypes: [ConnectivityType],
|
||||||
|
epoch: ConnectivityListenerEpoch
|
||||||
|
) {
|
||||||
|
let status = statusFrom(connectivityTypes: connectivityTypes)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.activeListenerEpoch === epoch else { return }
|
||||||
|
guard self.applicationState() != .background, let eventSink = self.eventSink else {
|
||||||
|
// Keep only the newest user-visible state. Connectivity changes are
|
||||||
|
// snapshots, not telemetry; one bounded slot is sufficient.
|
||||||
|
self.pendingConnectivity = status
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pendingConnectivity = nil
|
||||||
|
guard status != self.lastDeliveredConnectivity else { return }
|
||||||
|
self.lastDeliveredConnectivity = status
|
||||||
|
eventSink(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replayPendingConnectivityIfNeeded() {
|
||||||
|
guard applicationState() == .active, let pendingConnectivity, let eventSink else { return }
|
||||||
|
self.pendingConnectivity = nil
|
||||||
|
guard pendingConnectivity != lastDeliveredConnectivity else { return }
|
||||||
|
lastDeliveredConnectivity = pendingConnectivity
|
||||||
|
eventSink(pendingConnectivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopListening(preservePendingConnectivity: Bool) {
|
||||||
eventSink = nil
|
eventSink = nil
|
||||||
return nil
|
activeListenerEpoch = nil
|
||||||
|
connectivityProvider.connectivityUpdateHandler = nil
|
||||||
|
if !preservePendingConnectivity {
|
||||||
|
pendingConnectivity = nil
|
||||||
|
}
|
||||||
|
lastDeliveredConnectivity = nil
|
||||||
|
if let activationObserver {
|
||||||
|
notificationCenter.removeObserver(activationObserver)
|
||||||
|
self.activationObserver = nil
|
||||||
|
}
|
||||||
|
connectivityProvider.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onCancel(withArguments _: Any?) -> FlutterError? {
|
||||||
|
onMain {
|
||||||
|
stopListening(preservePendingConnectivity: true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
|||||||
// Use .utility, as it is intended for tasks that the user does not track actively.
|
// Use .utility, as it is intended for tasks that the user does not track actively.
|
||||||
// See: https://developer.apple.com/documentation/dispatch/dispatchqos
|
// See: https://developer.apple.com/documentation/dispatch/dispatchqos
|
||||||
private let queue = DispatchQueue.global(qos: .utility)
|
private let queue = DispatchQueue.global(qos: .utility)
|
||||||
|
private let handlerLock = NSLock()
|
||||||
|
private var updateHandler: ConnectivityUpdateHandler?
|
||||||
|
|
||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
|
|
||||||
@@ -36,7 +38,18 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
|||||||
return connectivityFrom(path: path)
|
return connectivityFrom(path: path)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var connectivityUpdateHandler: ConnectivityUpdateHandler?
|
public var connectivityUpdateHandler: ConnectivityUpdateHandler? {
|
||||||
|
get {
|
||||||
|
handlerLock.lock()
|
||||||
|
defer { handlerLock.unlock() }
|
||||||
|
return updateHandler
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
handlerLock.lock()
|
||||||
|
updateHandler = newValue
|
||||||
|
handlerLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -64,6 +77,13 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func pathUpdateHandler(path: NWPath) {
|
private func pathUpdateHandler(path: NWPath) {
|
||||||
connectivityUpdateHandler?(connectivityFrom(path: path))
|
deliver(connectivityFrom(path: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliver(_ connectivityTypes: [ConnectivityType]) {
|
||||||
|
handlerLock.lock()
|
||||||
|
let handler = updateHandler
|
||||||
|
handlerLock.unlock()
|
||||||
|
handler?(connectivityTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+876
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
|
private final class FakeConnectivityProvider: NSObject, ConnectivityProvider {
|
||||||
|
var currentConnectivityTypes: [ConnectivityType] = [.none]
|
||||||
|
var connectivityUpdateHandler: ConnectivityUpdateHandler?
|
||||||
|
private(set) var startCount = 0
|
||||||
|
private(set) var stopCount = 0
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
startCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(_ connectivityTypes: [ConnectivityType]) {
|
||||||
|
currentConnectivityTypes = connectivityTypes
|
||||||
|
connectivityUpdateHandler?(connectivityTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ConnectivityPlusPluginTests: XCTestCase {
|
||||||
|
func testBackgroundUpdatesCoalesceAndReplayNewestStateOnceInOrder() {
|
||||||
|
let provider = FakeConnectivityProvider()
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
var applicationState = UIApplication.State.background
|
||||||
|
let plugin = ConnectivityPlusPlugin(
|
||||||
|
connectivityProvider: provider,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
applicationState: { applicationState }
|
||||||
|
)
|
||||||
|
var events: [[String]] = []
|
||||||
|
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { events.append(value) }
|
||||||
|
})
|
||||||
|
provider.emit([.wifi])
|
||||||
|
provider.emit([.wiredEthernet])
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertTrue(events.isEmpty)
|
||||||
|
|
||||||
|
applicationState = .active
|
||||||
|
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(events, [["ethernet"]])
|
||||||
|
|
||||||
|
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(events, [["ethernet"]], "The buffered snapshot must replay only once")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancelAndReattachReplayPendingStateBeforeCurrentWithoutDuplicate() {
|
||||||
|
let provider = FakeConnectivityProvider()
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
var applicationState = UIApplication.State.active
|
||||||
|
let plugin = ConnectivityPlusPlugin(
|
||||||
|
connectivityProvider: provider,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
applicationState: { applicationState }
|
||||||
|
)
|
||||||
|
var firstEvents: [[String]] = []
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { firstEvents.append(value) }
|
||||||
|
})
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(firstEvents, [["none"]])
|
||||||
|
|
||||||
|
applicationState = .background
|
||||||
|
provider.emit([.wifi])
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||||
|
|
||||||
|
applicationState = .active
|
||||||
|
var replacementEvents: [[String]] = []
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { replacementEvents.append(value) }
|
||||||
|
})
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(replacementEvents, [["wifi"]])
|
||||||
|
XCTAssertEqual(provider.startCount, 2)
|
||||||
|
XCTAssertEqual(provider.stopCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackgroundReattachLeavesPendingStateUntilActivation() {
|
||||||
|
let provider = FakeConnectivityProvider()
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
var applicationState = UIApplication.State.active
|
||||||
|
let plugin = ConnectivityPlusPlugin(
|
||||||
|
connectivityProvider: provider,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
applicationState: { applicationState }
|
||||||
|
)
|
||||||
|
XCTAssertNil(plugin.onListen(withArguments: nil) { _ in })
|
||||||
|
drainMainQueue()
|
||||||
|
|
||||||
|
applicationState = .background
|
||||||
|
provider.emit([.wifi])
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||||
|
|
||||||
|
var replacementEvents: [[String]] = []
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { replacementEvents.append(value) }
|
||||||
|
})
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertTrue(replacementEvents.isEmpty)
|
||||||
|
|
||||||
|
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertTrue(replacementEvents.isEmpty, "A premature notification must not bypass background gating")
|
||||||
|
|
||||||
|
applicationState = .active
|
||||||
|
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(replacementEvents, [["wifi"]])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQueuedUpdateFromCancelledListenerIsNotDeliveredToReplacement() {
|
||||||
|
let provider = FakeConnectivityProvider()
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
let plugin = ConnectivityPlusPlugin(
|
||||||
|
connectivityProvider: provider,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
applicationState: { .active }
|
||||||
|
)
|
||||||
|
var firstEvents: [[String]] = []
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { firstEvents.append(value) }
|
||||||
|
})
|
||||||
|
drainMainQueue()
|
||||||
|
XCTAssertEqual(firstEvents, [["none"]])
|
||||||
|
|
||||||
|
provider.emit([.wifi])
|
||||||
|
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||||
|
provider.currentConnectivityTypes = [.wiredEthernet]
|
||||||
|
|
||||||
|
var replacementEvents: [[String]] = []
|
||||||
|
XCTAssertNil(
|
||||||
|
plugin.onListen(withArguments: nil) { value in
|
||||||
|
if let value = value as? [String] { replacementEvents.append(value) }
|
||||||
|
})
|
||||||
|
drainMainQueue()
|
||||||
|
|
||||||
|
XCTAssertEqual(firstEvents, [["none"]])
|
||||||
|
XCTAssertEqual(replacementEvents, [["ethernet"]])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancellationBreaksObserverAndProviderOwnership() {
|
||||||
|
let provider = FakeConnectivityProvider()
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
weak var releasedPlugin: ConnectivityPlusPlugin?
|
||||||
|
|
||||||
|
autoreleasepool {
|
||||||
|
var plugin: ConnectivityPlusPlugin? = ConnectivityPlusPlugin(
|
||||||
|
connectivityProvider: provider,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
applicationState: { .active }
|
||||||
|
)
|
||||||
|
releasedPlugin = plugin
|
||||||
|
XCTAssertNil(plugin?.onListen(withArguments: nil) { _ in })
|
||||||
|
XCTAssertNil(plugin?.onCancel(withArguments: nil))
|
||||||
|
plugin = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertNil(releasedPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPathMonitorHandlerCanDetachItselfDuringDelivery() {
|
||||||
|
let provider = PathMonitorConnectivityProvider()
|
||||||
|
var received: [[ConnectivityType]] = []
|
||||||
|
provider.connectivityUpdateHandler = { types in
|
||||||
|
received.append(types)
|
||||||
|
provider.connectivityUpdateHandler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.deliver([.wifi])
|
||||||
|
provider.deliver([.wiredEthernet])
|
||||||
|
provider.stop()
|
||||||
|
|
||||||
|
XCTAssertEqual(received.count, 1)
|
||||||
|
if case .wifi? = received.first?.first {
|
||||||
|
// Expected.
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected the first delivered type to be Wi-Fi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPathMonitorHandlerAccessIsSafeDuringConcurrentDetachAndDelivery() {
|
||||||
|
let provider = PathMonitorConnectivityProvider()
|
||||||
|
let queue = DispatchQueue(label: "connectivity-handler-race", attributes: .concurrent)
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
for index in 0..<200 {
|
||||||
|
group.enter()
|
||||||
|
queue.async {
|
||||||
|
if index.isMultiple(of: 2) {
|
||||||
|
provider.connectivityUpdateHandler = { _ in }
|
||||||
|
} else {
|
||||||
|
provider.connectivityUpdateHandler = nil
|
||||||
|
}
|
||||||
|
_ = provider.connectivityUpdateHandler
|
||||||
|
provider.deliver([.wifi])
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(group.wait(timeout: .now() + 2), .success)
|
||||||
|
provider.connectivityUpdateHandler = nil
|
||||||
|
provider.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainMainQueue() {
|
||||||
|
let drained = expectation(description: "main queue drained")
|
||||||
|
DispatchQueue.main.async { drained.fulfill() }
|
||||||
|
wait(for: [drained], timeout: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import Foundation
|
||||||
|
import Flutter
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
|
private final class ShelfArtworkURLProtocol: URLProtocol {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var startHandler: ((ShelfArtworkURLProtocol) -> Void)?
|
||||||
|
private static var stopHandler: (() -> Void)?
|
||||||
|
|
||||||
|
static func configure(
|
||||||
|
start: @escaping (ShelfArtworkURLProtocol) -> Void,
|
||||||
|
stop: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
lock.lock()
|
||||||
|
startHandler = start
|
||||||
|
stopHandler = stop
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
lock.lock()
|
||||||
|
startHandler = nil
|
||||||
|
stopHandler = nil
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
|
||||||
|
override func startLoading() {
|
||||||
|
Self.lock.lock()
|
||||||
|
let handler = Self.startHandler
|
||||||
|
Self.lock.unlock()
|
||||||
|
guard let handler else {
|
||||||
|
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func stopLoading() {
|
||||||
|
Self.lock.lock()
|
||||||
|
let handler = Self.stopHandler
|
||||||
|
Self.lock.unlock()
|
||||||
|
handler?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendResponse(contentLength: Int? = nil) {
|
||||||
|
var headers = ["Content-Type": "image/png"]
|
||||||
|
if let contentLength { headers["Content-Length"] = String(contentLength) }
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!,
|
||||||
|
statusCode: 200,
|
||||||
|
httpVersion: "HTTP/1.1",
|
||||||
|
headerFields: headers
|
||||||
|
)!
|
||||||
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ data: Data) {
|
||||||
|
client?.urlProtocol(self, didLoad: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() {
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SystemShelfSyncHarness {
|
||||||
|
let defaults: UserDefaults
|
||||||
|
let root: URL
|
||||||
|
private(set) var state = SystemShelfMutationState()
|
||||||
|
private(set) var epoch: UInt64 = 0
|
||||||
|
|
||||||
|
private struct ScheduledPrune {
|
||||||
|
let deadline: TimeInterval
|
||||||
|
let batch: SystemShelfPruneBatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private let suiteName: String
|
||||||
|
private var pruneClock: TimeInterval = 0
|
||||||
|
private var scheduledPrunes: [ScheduledPrune] = []
|
||||||
|
|
||||||
|
var now: () -> Date = { Date(timeIntervalSince1970: 1_000) }
|
||||||
|
var loaderShouldFail = false
|
||||||
|
private(set) var requestedURLs: [URL] = []
|
||||||
|
private(set) var notificationCount = 0
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
suiteName = "SystemShelfSyncHarness.\(UUID().uuidString)"
|
||||||
|
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
epoch = state.beginEngineSession()
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func beginEngineSession() -> UInt64 {
|
||||||
|
epoch = state.beginEngineSession()
|
||||||
|
return epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
func sync(
|
||||||
|
generation: Int64,
|
||||||
|
items: [[String: Any]],
|
||||||
|
ownerId: String = "profile-a",
|
||||||
|
engineEpoch: UInt64? = nil
|
||||||
|
) -> Bool {
|
||||||
|
let environment = SystemShelfSyncEnvironment(
|
||||||
|
defaults: defaults,
|
||||||
|
artworkRoot: root,
|
||||||
|
now: now,
|
||||||
|
loadArtwork: { [unowned self] url, _, _ in
|
||||||
|
requestedURLs.append(url)
|
||||||
|
guard !loaderShouldFail else { return nil }
|
||||||
|
return BoundedArtworkDownload(data: Self.png, mimeType: "image/png")
|
||||||
|
},
|
||||||
|
schedulePrune: { [unowned self] batches in
|
||||||
|
scheduledPrunes.append(
|
||||||
|
contentsOf: batches.map {
|
||||||
|
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
notifyChange: { [unowned self] in
|
||||||
|
notificationCount += 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return SystemShelfPlugin.sync(
|
||||||
|
envelope: envelope(
|
||||||
|
generation: generation,
|
||||||
|
ownerId: ownerId,
|
||||||
|
engineEpoch: engineEpoch
|
||||||
|
),
|
||||||
|
rawItems: items,
|
||||||
|
state: &state,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear(generation: Int64, ownerId: String = "profile-a") -> Bool {
|
||||||
|
SystemShelfPlugin.clearCache(
|
||||||
|
envelope: envelope(generation: generation, ownerId: ownerId),
|
||||||
|
state: &state,
|
||||||
|
defaults: defaults,
|
||||||
|
artworkRoot: root,
|
||||||
|
notifyChange: { [unowned self] in
|
||||||
|
notificationCount += 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(generation: Int64, contentId: String) -> Bool {
|
||||||
|
SystemShelfPlugin.removeItem(
|
||||||
|
envelope: envelope(generation: generation),
|
||||||
|
contentId: contentId,
|
||||||
|
state: &state,
|
||||||
|
defaults: defaults,
|
||||||
|
artworkRoot: root,
|
||||||
|
schedulePrune: { [unowned self] batches in
|
||||||
|
scheduledPrunes.append(
|
||||||
|
contentsOf: batches.map {
|
||||||
|
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
notifyChange: { [unowned self] in
|
||||||
|
notificationCount += 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envelope(
|
||||||
|
generation: Int64,
|
||||||
|
ownerId: String = "profile-a",
|
||||||
|
engineEpoch: UInt64? = nil
|
||||||
|
) -> SystemShelfMutationEnvelope {
|
||||||
|
SystemShelfMutationEnvelope(
|
||||||
|
ownerId: ownerId,
|
||||||
|
generation: generation,
|
||||||
|
engineEpoch: engineEpoch ?? epoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentItems() throws -> [[String: Any]] {
|
||||||
|
let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
||||||
|
let payload = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
)
|
||||||
|
let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
||||||
|
return sections.flatMap { $0["items"] as? [[String: Any]] ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func artworkURL(for key: String) -> URL? {
|
||||||
|
guard
|
||||||
|
let enumerator = FileManager.default.enumerator(
|
||||||
|
at: root,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
return enumerator.compactMap { $0 as? URL }.first { $0.lastPathComponent == key }
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancePrunes(by interval: TimeInterval) {
|
||||||
|
pruneClock += interval
|
||||||
|
let due = scheduledPrunes.filter { $0.deadline <= pruneClock }
|
||||||
|
scheduledPrunes.removeAll { $0.deadline <= pruneClock }
|
||||||
|
for scheduled in due {
|
||||||
|
let candidates = state.claimPruning(scheduled.batch)
|
||||||
|
SystemShelfPlugin.pruneUnreferenced(
|
||||||
|
candidates,
|
||||||
|
defaults: defaults,
|
||||||
|
root: root
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func item(
|
||||||
|
source: String? = nil,
|
||||||
|
progress: Int = 0,
|
||||||
|
contentId: String = "movie-1"
|
||||||
|
) -> [String: Any] {
|
||||||
|
var item: [String: Any] = [
|
||||||
|
"contentId": contentId,
|
||||||
|
"title": "Movie",
|
||||||
|
"lastPlaybackPosition": progress,
|
||||||
|
]
|
||||||
|
if let source {
|
||||||
|
item["posterSourceUri"] = source
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
static let png = Data(
|
||||||
|
base64Encoded:
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SystemShelfPluginTests: XCTestCase {
|
||||||
|
override func tearDown() {
|
||||||
|
ShelfArtworkURLProtocol.reset()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncRemoveAndClearUpdateCommittedShelf() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [
|
||||||
|
SystemShelfSyncHarness.item(
|
||||||
|
source: "https://shelf.test/a.png",
|
||||||
|
contentId: "movie-a"
|
||||||
|
),
|
||||||
|
SystemShelfSyncHarness.item(
|
||||||
|
source: "https://shelf.test/b.png",
|
||||||
|
contentId: "movie-b"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initialItems = try harness.currentItems()
|
||||||
|
XCTAssertEqual(Set(initialItems.compactMap { $0["contentId"] as? String }), ["movie-a", "movie-b"])
|
||||||
|
let removedKey = try XCTUnwrap(
|
||||||
|
initialItems.first { $0["contentId"] as? String == "movie-a" }?["artworkKey"] as? String
|
||||||
|
)
|
||||||
|
let removedArtwork = try XCTUnwrap(harness.artworkURL(for: removedKey))
|
||||||
|
|
||||||
|
XCTAssertTrue(harness.remove(generation: 2, contentId: "movie-a"))
|
||||||
|
XCTAssertEqual(try harness.currentItems().compactMap { $0["contentId"] as? String }, ["movie-b"])
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: removedArtwork.path))
|
||||||
|
harness.advancePrunes(by: 60)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: removedArtwork.path))
|
||||||
|
|
||||||
|
XCTAssertTrue(harness.clear(generation: 3))
|
||||||
|
XCTAssertNil(harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: harness.root.path))
|
||||||
|
XCTAssertEqual(harness.notificationCount, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplacementEngineRejectsStaleEngineMutation() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
let staleEpoch = harness.epoch
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 50,
|
||||||
|
items: [SystemShelfSyncHarness.item(progress: 50)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = harness.beginEngineSession()
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(progress: 1)],
|
||||||
|
ownerId: "profile-b"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(
|
||||||
|
harness.sync(
|
||||||
|
generation: 51,
|
||||||
|
items: [SystemShelfSyncHarness.item(progress: 99)],
|
||||||
|
engineEpoch: staleEpoch
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let item = try XCTUnwrap(harness.currentItems().first)
|
||||||
|
XCTAssertEqual(item["lastPlaybackPosition"] as? Int, 1)
|
||||||
|
let data = try XCTUnwrap(
|
||||||
|
harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
||||||
|
)
|
||||||
|
let payload = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(payload["ownerId"] as? String, "profile-b")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHigherGenerationRemainsLastWriter() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 2,
|
||||||
|
items: [SystemShelfSyncHarness.item(progress: 20)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(progress: 10)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
try harness.currentItems().first?["lastPlaybackPosition"] as? Int,
|
||||||
|
20
|
||||||
|
)
|
||||||
|
XCTAssertEqual(harness.notificationCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProgressOnlySyncReusesCommittedArtworkWithoutRequestingItAgain() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
let source = "https://shelf.test/movie-a.png"
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: source, progress: 10)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 2,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: source, progress: 45)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let updatedItem = try XCTUnwrap(harness.currentItems().first)
|
||||||
|
XCTAssertEqual(updatedItem["lastPlaybackPosition"] as? Int, 45)
|
||||||
|
XCTAssertEqual(updatedItem["artworkKey"] as? String, firstKey)
|
||||||
|
XCTAssertEqual(harness.requestedURLs.map(\.absoluteString), [source])
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedReplacementKeepsLastCommittedArtwork() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
let firstSource = "https://shelf.test/movie-a.png"
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: firstSource)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||||
|
|
||||||
|
harness.loaderShouldFail = true
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 2,
|
||||||
|
items: [
|
||||||
|
SystemShelfSyncHarness.item(source: "https://shelf.test/movie-b.png", progress: 20)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let item = try XCTUnwrap(harness.currentItems().first)
|
||||||
|
XCTAssertEqual(item["artworkKey"] as? String, firstKey)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
XCTAssertEqual(
|
||||||
|
harness.requestedURLs.map(\.absoluteString),
|
||||||
|
[firstSource, "https://shelf.test/movie-b.png"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEachSupersededArtworkReceivesFullPruneGrace() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 2,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
||||||
|
|
||||||
|
harness.advancePrunes(by: 59)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 3,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/c.png")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let currentKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let currentArtwork = try XCTUnwrap(harness.artworkURL(for: currentKey))
|
||||||
|
|
||||||
|
harness.advancePrunes(by: 1)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||||
|
harness.advancePrunes(by: 58)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||||
|
harness.advancePrunes(by: 1)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: currentArtwork.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruneRechecksCurrentArtworkAndRelaunchRecoveryPreservesIt() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 1,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||||
|
XCTAssertTrue(
|
||||||
|
harness.sync(
|
||||||
|
generation: 2,
|
||||||
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||||
|
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
||||||
|
|
||||||
|
try replaceCommittedArtworkKey(
|
||||||
|
in: harness.defaults,
|
||||||
|
with: firstKey
|
||||||
|
)
|
||||||
|
harness.advancePrunes(by: 60)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
|
||||||
|
let relaunchedAt = Date(timeIntervalSince1970: 10_000)
|
||||||
|
let recovered = SystemShelfPlugin.recoverableArtwork(
|
||||||
|
root: harness.root,
|
||||||
|
keeping: [firstArtwork],
|
||||||
|
now: relaunchedAt
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
recovered[secondArtwork],
|
||||||
|
relaunchedAt.addingTimeInterval(60)
|
||||||
|
)
|
||||||
|
XCTAssertNil(recovered[firstArtwork])
|
||||||
|
|
||||||
|
var relaunchedState = SystemShelfMutationState()
|
||||||
|
_ = relaunchedState.beginEngineSession()
|
||||||
|
XCTAssertNotNil(relaunchedState.adoptPersistedOwner("profile-a"))
|
||||||
|
relaunchedState.cancelPruning(keeping: [firstArtwork])
|
||||||
|
let batches = relaunchedState.preparePruning(
|
||||||
|
removing: Set(recovered.keys),
|
||||||
|
after: 60
|
||||||
|
)
|
||||||
|
XCTAssertEqual(batches.count, 1)
|
||||||
|
XCTAssertEqual(batches.first?.delay, 60)
|
||||||
|
let candidates = relaunchedState.claimPruning(try XCTUnwrap(batches.first))
|
||||||
|
SystemShelfPlugin.pruneUnreferenced(
|
||||||
|
candidates,
|
||||||
|
defaults: harness.defaults,
|
||||||
|
root: harness.root
|
||||||
|
)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruneDoesNotEscapeArtworkRoot() throws {
|
||||||
|
let harness = try SystemShelfSyncHarness()
|
||||||
|
defer { harness.cleanup() }
|
||||||
|
let outside = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("\(UUID().uuidString).art")
|
||||||
|
let inside = harness.root.appendingPathComponent("orphan.art")
|
||||||
|
let outsideDirectory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let outsideViaSymlink = outsideDirectory.appendingPathComponent("linked.art")
|
||||||
|
let linkedDirectory = harness.root.appendingPathComponent("linked", isDirectory: true)
|
||||||
|
let linkedCandidate = linkedDirectory.appendingPathComponent("linked.art")
|
||||||
|
try Data([0x01]).write(to: outside)
|
||||||
|
try Data([0x02]).write(to: inside)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outsideDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data([0x03]).write(to: outsideViaSymlink)
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
at: linkedDirectory,
|
||||||
|
withDestinationURL: outsideDirectory
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: outside)
|
||||||
|
try? FileManager.default.removeItem(at: outsideDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemShelfPlugin.pruneUnreferenced(
|
||||||
|
[outside, inside, linkedCandidate],
|
||||||
|
defaults: harness.defaults,
|
||||||
|
root: harness.root
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: outsideViaSymlink.path))
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: inside.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArtworkLoaderCancelsUnknownLengthBodyAtConfiguredCap() {
|
||||||
|
let stopped = expectation(description: "oversized request cancelled")
|
||||||
|
ShelfArtworkURLProtocol.configure(
|
||||||
|
start: { request in
|
||||||
|
request.sendResponse()
|
||||||
|
request.send(Data(repeating: 0x41, count: 5))
|
||||||
|
request.send(Data(repeating: 0x42, count: 5))
|
||||||
|
request.finish()
|
||||||
|
},
|
||||||
|
stop: { stopped.fulfill() }
|
||||||
|
)
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
||||||
|
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
||||||
|
|
||||||
|
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
||||||
|
|
||||||
|
XCTAssertNil(result)
|
||||||
|
XCTAssertTrue(loader.exceededLimit)
|
||||||
|
XCTAssertLessThanOrEqual(loader.peakBufferedBytes, 8)
|
||||||
|
wait(for: [stopped], timeout: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArtworkLoaderReturnsBoundedChunkedBodyAndMimeType() {
|
||||||
|
let expected = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])
|
||||||
|
ShelfArtworkURLProtocol.configure { request in
|
||||||
|
request.sendResponse()
|
||||||
|
request.send(expected.prefix(3))
|
||||||
|
request.send(expected.suffix(4))
|
||||||
|
request.finish()
|
||||||
|
}
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
||||||
|
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
||||||
|
|
||||||
|
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
||||||
|
|
||||||
|
XCTAssertEqual(result?.data, expected)
|
||||||
|
XCTAssertEqual(result?.mimeType, "image/png")
|
||||||
|
XCTAssertEqual(loader.peakBufferedBytes, expected.count)
|
||||||
|
XCTAssertFalse(loader.exceededLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceCommittedArtworkKey(
|
||||||
|
in defaults: UserDefaults,
|
||||||
|
with key: String
|
||||||
|
) throws {
|
||||||
|
let data = try XCTUnwrap(
|
||||||
|
defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
||||||
|
)
|
||||||
|
var payload = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
)
|
||||||
|
var sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
||||||
|
var items = try XCTUnwrap(sections.first?["items"] as? [[String: Any]])
|
||||||
|
items[0]["artworkKey"] = key
|
||||||
|
sections[0]["items"] = items
|
||||||
|
payload["sections"] = sections
|
||||||
|
defaults.set(
|
||||||
|
try JSONSerialization.data(withJSONObject: payload),
|
||||||
|
forKey: SystemShelfPlugin.cacheDataKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require 'fileutils'
|
||||||
|
require 'json'
|
||||||
|
require 'minitest/autorun'
|
||||||
|
require 'open3'
|
||||||
|
require 'rbconfig'
|
||||||
|
require 'tmpdir'
|
||||||
|
require 'xcodeproj'
|
||||||
|
|
||||||
|
class WireMpvTest < Minitest::Test
|
||||||
|
SOURCE_NAMES = %w[
|
||||||
|
MpvPlayerCoreBase.swift
|
||||||
|
MpvPlayerPluginShared.swift
|
||||||
|
MpvPlayerCore.swift
|
||||||
|
MpvPlayerPlugin.swift
|
||||||
|
MpvPipController.swift
|
||||||
|
MpvAudioPlayerCore.swift
|
||||||
|
MpvAudioPlayerPlugin.swift
|
||||||
|
AtmosProbePlugin.swift
|
||||||
|
].freeze
|
||||||
|
AFFECTED_NAMES = %w[
|
||||||
|
MpvAudioPlayerCore.swift
|
||||||
|
MpvAudioPlayerPlugin.swift
|
||||||
|
AtmosProbePlugin.swift
|
||||||
|
].freeze
|
||||||
|
MPVKIT_PIN = {
|
||||||
|
'location' => 'https://github.com/edde746/MPVKit',
|
||||||
|
'revision' => '93101dc1d0903c48fa3054652805acacbb75e856',
|
||||||
|
'version' => '1.0.13',
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def setup
|
||||||
|
@temporary_root = Dir.mktmpdir('wire-mpv-test')
|
||||||
|
@tvos_root = File.join(@temporary_root, 'tvos')
|
||||||
|
FileUtils.mkdir_p(File.join(@tvos_root, 'scripts'))
|
||||||
|
FileUtils.cp_r(File.expand_path('../Runner.xcodeproj', __dir__), @tvos_root)
|
||||||
|
FileUtils.cp(File.expand_path('wire_mpv.rb', __dir__), File.join(@tvos_root, 'scripts'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@temporary_root)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_restores_missing_references_and_is_idempotent
|
||||||
|
edit_project do |_project, _runner, group|
|
||||||
|
AFFECTED_NAMES.each do |name|
|
||||||
|
group.files.find { |file| file.display_name == name }&.remove_from_project
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
run_wire_mpv
|
||||||
|
run_wire_mpv
|
||||||
|
assert_complete_source_graph
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_restores_membership_when_references_remain
|
||||||
|
edit_project do |_project, runner, group|
|
||||||
|
affected = group.files.select { |file| AFFECTED_NAMES.include?(file.display_name) }
|
||||||
|
runner.source_build_phase.files.each do |build_file|
|
||||||
|
build_file.remove_from_project if affected.include?(build_file.file_ref)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
run_wire_mpv
|
||||||
|
assert_complete_source_graph
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_restores_missing_package_product_and_framework_edges
|
||||||
|
edit_project do |_project, runner, _group|
|
||||||
|
runner.package_product_dependencies
|
||||||
|
.select { |product| product.product_name == 'MPVKit' }
|
||||||
|
.each(&:remove_from_project)
|
||||||
|
end
|
||||||
|
|
||||||
|
run_wire_mpv
|
||||||
|
assert_complete_source_graph
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_all_apple_targets_resolve_the_same_mpvkit_source
|
||||||
|
repository_root = File.expand_path('../..', __dir__)
|
||||||
|
%w[ios macos tvos].each do |platform|
|
||||||
|
resolved_path =
|
||||||
|
File.join(
|
||||||
|
repository_root,
|
||||||
|
platform,
|
||||||
|
'Runner.xcworkspace',
|
||||||
|
'xcshareddata',
|
||||||
|
'swiftpm',
|
||||||
|
'Package.resolved'
|
||||||
|
)
|
||||||
|
resolved = JSON.parse(File.read(resolved_path))
|
||||||
|
pin = resolved.fetch('pins').find { |candidate| candidate.fetch('identity') == 'mpvkit' }
|
||||||
|
refute_nil pin, "#{platform} must resolve MPVKit"
|
||||||
|
assert_equal MPVKIT_PIN['location'], pin['location'], "#{platform} MPVKit source"
|
||||||
|
assert_equal MPVKIT_PIN['revision'], pin.dig('state', 'revision'), "#{platform} MPVKit revision"
|
||||||
|
assert_equal MPVKIT_PIN['version'], pin.dig('state', 'version'), "#{platform} MPVKit version"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def project_path
|
||||||
|
File.join(@tvos_root, 'Runner.xcodeproj')
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_project
|
||||||
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
runner = project.targets.find { |target| target.name == 'Runner' }
|
||||||
|
group = project.main_group['Runner']['MpvPlayer']
|
||||||
|
yield project, runner, group
|
||||||
|
project.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_wire_mpv
|
||||||
|
script = File.join(@tvos_root, 'scripts', 'wire_mpv.rb')
|
||||||
|
output, status = Open3.capture2e(RbConfig.ruby, script)
|
||||||
|
assert status.success?, output
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_complete_source_graph
|
||||||
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
runner = project.targets.find { |target| target.name == 'Runner' }
|
||||||
|
group = project.main_group['Runner']['MpvPlayer']
|
||||||
|
|
||||||
|
SOURCE_NAMES.each do |name|
|
||||||
|
references = group.files.select { |file| file.display_name == name }
|
||||||
|
assert_equal 1, references.count, "expected one reference for #{name}"
|
||||||
|
memberships = runner.source_build_phase.files_references.count { |file| file == references.first }
|
||||||
|
assert_equal 1, memberships, "expected one Runner source membership for #{name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
products = runner.package_product_dependencies.select { |product| product.product_name == 'MPVKit' }
|
||||||
|
assert_equal 1, products.count, 'expected one MPVKit product dependency'
|
||||||
|
framework_links = runner.frameworks_build_phase.files.count { |file| file.product_ref == products.first }
|
||||||
|
assert_equal 1, framework_links, 'expected one MPVKit framework link'
|
||||||
|
end
|
||||||
|
end
|
||||||
+35
-20
@@ -24,48 +24,63 @@ sources = [
|
|||||||
{ name: 'MpvPlayerCore.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerCore.swift', tree: '<source_root>' },
|
{ name: 'MpvPlayerCore.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerCore.swift', tree: '<source_root>' },
|
||||||
{ name: 'MpvPlayerPlugin.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift', tree: '<source_root>' },
|
{ name: 'MpvPlayerPlugin.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift', tree: '<source_root>' },
|
||||||
{ name: 'MpvPipController.swift', path: '../ios/Runner/MpvPlayer/MpvPipController.swift', tree: '<source_root>' },
|
{ name: 'MpvPipController.swift', path: '../ios/Runner/MpvPlayer/MpvPipController.swift', tree: '<source_root>' },
|
||||||
|
{ name: 'MpvAudioPlayerCore.swift', path: '../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift', tree: '<source_root>' },
|
||||||
|
{ name: 'MpvAudioPlayerPlugin.swift', path: '../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift', tree: '<source_root>' },
|
||||||
|
{ name: 'AtmosProbePlugin.swift', path: '../shared/apple/AtmosProbe/AtmosProbePlugin.swift', tree: '<source_root>' },
|
||||||
]
|
]
|
||||||
|
|
||||||
sources_phase = runner_target.source_build_phase
|
sources_phase = runner_target.source_build_phase
|
||||||
sources.each do |src|
|
sources.each do |src|
|
||||||
existing = mpv_group.files.find { |f| f.display_name == src[:name] }
|
ref = mpv_group.files.find { |file| file.display_name == src[:name] }
|
||||||
if existing
|
unless ref
|
||||||
puts "[skip] #{src[:name]} already present"
|
ref = mpv_group.new_file(src[:path])
|
||||||
next
|
ref.name = src[:name]
|
||||||
|
ref.source_tree = src[:tree]
|
||||||
|
puts "[add ] #{src[:name]} reference"
|
||||||
|
end
|
||||||
|
|
||||||
|
if sources_phase.files_references.include?(ref)
|
||||||
|
puts "[skip] #{src[:name]} source membership already present"
|
||||||
|
else
|
||||||
|
sources_phase.add_file_reference(ref, true)
|
||||||
|
puts "[add ] #{src[:name]} source membership"
|
||||||
end
|
end
|
||||||
ref = mpv_group.new_file(src[:path])
|
|
||||||
ref.name = src[:name]
|
|
||||||
ref.source_tree = src[:tree]
|
|
||||||
sources_phase.add_file_reference(ref, true)
|
|
||||||
puts "[add ] #{src[:name]}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Swift Package: MPVKit.
|
# Swift Package: MPVKit. Restore each graph edge independently so a project
|
||||||
|
# with a surviving package reference cannot silently omit the Runner linkage.
|
||||||
pkg_url = 'https://github.com/edde746/MPVKit'
|
pkg_url = 'https://github.com/edde746/MPVKit'
|
||||||
pkg_version = '1.0.13'
|
pkg_version = '1.0.13'
|
||||||
existing_pkg = project.root_object.package_references.find do |p|
|
pkg = project.root_object.package_references.find do |candidate|
|
||||||
p.repositoryURL == pkg_url rescue false
|
candidate.repositoryURL == pkg_url rescue false
|
||||||
end
|
end
|
||||||
|
|
||||||
if existing_pkg
|
unless pkg
|
||||||
existing_pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
|
||||||
puts "[set ] MPVKit SPM package version"
|
|
||||||
else
|
|
||||||
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
||||||
pkg.repositoryURL = pkg_url
|
pkg.repositoryURL = pkg_url
|
||||||
pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
|
||||||
project.root_object.package_references << pkg
|
project.root_object.package_references << pkg
|
||||||
|
puts "[add ] MPVKit SPM package reference"
|
||||||
|
end
|
||||||
|
pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
||||||
|
puts "[set ] MPVKit SPM package version"
|
||||||
|
|
||||||
|
product = runner_target.package_product_dependencies.find do |candidate|
|
||||||
|
candidate.product_name == 'MPVKit'
|
||||||
|
end
|
||||||
|
unless product
|
||||||
product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
||||||
product.package = pkg
|
|
||||||
product.product_name = 'MPVKit'
|
product.product_name = 'MPVKit'
|
||||||
runner_target.package_product_dependencies << product
|
runner_target.package_product_dependencies << product
|
||||||
|
puts "[add ] MPVKit Runner product dependency"
|
||||||
|
end
|
||||||
|
product.package = pkg
|
||||||
|
|
||||||
frameworks_phase = runner_target.frameworks_build_phase
|
frameworks_phase = runner_target.frameworks_build_phase
|
||||||
|
unless frameworks_phase.files.any? { |build_file| build_file.product_ref == product }
|
||||||
build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile)
|
build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile)
|
||||||
build_file.product_ref = product
|
build_file.product_ref = product
|
||||||
frameworks_phase.files << build_file
|
frameworks_phase.files << build_file
|
||||||
puts "[add ] MPVKit SPM package + framework linkage"
|
puts "[add ] MPVKit framework linkage"
|
||||||
end
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ project.files.select { |file| file.display_name == 'Foundation.framework' }.each
|
|||||||
end
|
end
|
||||||
runner_test_sources = %w[
|
runner_test_sources = %w[
|
||||||
TvosEventDeliveryCoordinatorTests.swift
|
TvosEventDeliveryCoordinatorTests.swift
|
||||||
|
ConnectivityPlusPluginTests.swift
|
||||||
|
SystemShelfPluginTests.swift
|
||||||
]
|
]
|
||||||
test_target.source_build_phase.files.delete_if do |build_file|
|
test_target.source_build_phase.files.delete_if do |build_file|
|
||||||
file_ref = build_file.file_ref
|
file_ref = build_file.file_ref
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS)
|
|||||||
target_compile_definitions(mpv_player_property_contract_test PRIVATE "NOMINMAX")
|
target_compile_definitions(mpv_player_property_contract_test PRIVATE "NOMINMAX")
|
||||||
target_link_libraries(
|
target_link_libraries(
|
||||||
mpv_player_property_contract_test
|
mpv_player_property_contract_test
|
||||||
PRIVATE flutter "${MPV_LIB_DIR}/libmpv.dll.a" simdutf "user32.lib"
|
PRIVATE flutter_wrapper_plugin "${MPV_LIB_DIR}/libmpv.dll.a" simdutf "comctl32.lib" "user32.lib"
|
||||||
)
|
)
|
||||||
target_include_directories(
|
target_include_directories(
|
||||||
mpv_player_property_contract_test
|
mpv_player_property_contract_test
|
||||||
@@ -101,3 +101,21 @@ if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS)
|
|||||||
add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test)
|
add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test)
|
||||||
add_test(NAME mpv_player_property_contract_test COMMAND mpv_player_property_contract_test)
|
add_test(NAME mpv_player_property_contract_test COMMAND mpv_player_property_contract_test)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
option(PLEZY_BUILD_DISPLAY_RECOVERY_TESTS
|
||||||
|
"Build the focused Windows display recovery transaction tests" OFF)
|
||||||
|
if(PLEZY_BUILD_DISPLAY_RECOVERY_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
|
||||||
|
add_executable(display_mode_manager_test
|
||||||
|
"mpv/display_mode_manager.cpp"
|
||||||
|
"mpv/display_mode_manager_test.cpp"
|
||||||
|
)
|
||||||
|
apply_standard_settings(display_mode_manager_test)
|
||||||
|
target_compile_definitions(
|
||||||
|
display_mode_manager_test PRIVATE "NOMINMAX" "PLEZY_DISPLAY_MODE_MANAGER_TESTING"
|
||||||
|
)
|
||||||
|
target_link_libraries(display_mode_manager_test PRIVATE "advapi32.lib" "user32.lib")
|
||||||
|
|
||||||
|
add_test(NAME display_mode_manager_test COMMAND display_mode_manager_test)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
#include "mpv/display_mode_manager.h"
|
||||||
#include "mpv/mpv_plugin.h"
|
#include "mpv/mpv_plugin.h"
|
||||||
|
|
||||||
// Registry key for window placement persistence
|
// Registry key for window placement persistence
|
||||||
@@ -158,6 +159,10 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (message) {
|
switch (message) {
|
||||||
|
case WM_DISPLAYCHANGE:
|
||||||
|
// One bounded, serialized retry for a display that may have reconnected.
|
||||||
|
mpv::DisplayModeManager::RecoverIfNeeded();
|
||||||
|
break;
|
||||||
case WM_FONTCHANGE:
|
case WM_FONTCHANGE:
|
||||||
flutter_controller_->engine()->ReloadSystemFonts();
|
flutter_controller_->engine()->ReloadSystemFonts();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t* command
|
|||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|
||||||
// Recover display mode if a prior crash left it changed.
|
// Recover display mode if a prior crash left it changed.
|
||||||
mpv::DisplayModeManager::RecoverIfNeeded(::GetAncestor(window.GetHandle(), GA_ROOT));
|
mpv::DisplayModeManager::RecoverIfNeeded();
|
||||||
|
|
||||||
::MSG msg;
|
::MSG msg;
|
||||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||||
|
|||||||
@@ -2,19 +2,53 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <mutex>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "sdk_26100.h"
|
#include "sdk_26100.h"
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
static const wchar_t* kRegistryPath = L"Software\\Plezy\\DisplayModeOverride";
|
namespace {
|
||||||
static const wchar_t* kRegDeviceName = L"DeviceName";
|
|
||||||
static const wchar_t* kRegOriginalRefreshRate = L"OriginalRefreshRate";
|
constexpr wchar_t kRegistryPath[] = L"Software\\Plezy\\DisplayModeOverride";
|
||||||
static const wchar_t* kRegOriginalWidth = L"OriginalWidth";
|
constexpr wchar_t kRegVersion[] = L"Version";
|
||||||
static const wchar_t* kRegOriginalHeight = L"OriginalHeight";
|
constexpr DWORD kRecoveryVersion = 1;
|
||||||
static const wchar_t* kRegOriginalHDR = L"OriginalHDREnabled";
|
constexpr wchar_t kRegModeDeviceName[] = L"ModeDeviceName";
|
||||||
static const wchar_t* kRegModeChanged = L"ModeChanged";
|
constexpr wchar_t kRegLegacyDeviceName[] = L"DeviceName";
|
||||||
static const wchar_t* kRegHDRChanged = L"HDRChanged";
|
constexpr wchar_t kRegHDRDeviceName[] = L"HDRDeviceName";
|
||||||
|
constexpr wchar_t kRegOriginalRefreshRate[] = L"OriginalRefreshRate";
|
||||||
|
constexpr wchar_t kRegOriginalWidth[] = L"OriginalWidth";
|
||||||
|
constexpr wchar_t kRegOriginalHeight[] = L"OriginalHeight";
|
||||||
|
constexpr wchar_t kRegOriginalHDR[] = L"OriginalHDREnabled";
|
||||||
|
constexpr wchar_t kRegModeChanged[] = L"ModeChanged";
|
||||||
|
constexpr wchar_t kRegHDRChanged[] = L"HDRChanged";
|
||||||
|
|
||||||
|
std::recursive_mutex g_display_override_mutex;
|
||||||
|
bool g_live_mode_recovery_record = false;
|
||||||
|
bool g_live_hdr_recovery_record = false;
|
||||||
|
bool g_recovery_in_progress = false;
|
||||||
|
|
||||||
|
class RecoveryRunGuard {
|
||||||
|
public:
|
||||||
|
RecoveryRunGuard() : acquired_(!g_recovery_in_progress) {
|
||||||
|
if (acquired_) g_recovery_in_progress = true;
|
||||||
|
}
|
||||||
|
~RecoveryRunGuard() {
|
||||||
|
if (acquired_) g_recovery_in_progress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool acquired() const { return acquired_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool acquired_;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
|
||||||
|
bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled);
|
||||||
|
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker);
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
DisplayModeManager::DisplayModeManager() {}
|
DisplayModeManager::DisplayModeManager() {}
|
||||||
|
|
||||||
@@ -149,13 +183,27 @@ void DisplayModeManager::SaveOriginalMode(HWND window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, DWORD refresh_rate) {
|
bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||||
std::wstring device_name = GetMonitorDeviceName(window);
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
|
||||||
|
const std::wstring device_name = GetMonitorDeviceName(window);
|
||||||
if (device_name.empty()) return false;
|
if (device_name.empty()) return false;
|
||||||
|
|
||||||
// Save original mode if not already saved.
|
const bool mode_was_changed = mode_changed_;
|
||||||
if (!mode_changed_) {
|
if (!mode_changed_) SaveOriginalMode(window);
|
||||||
SaveOriginalMode(window);
|
if (original_device_name_.empty() || original_devmode_.dmPelsWidth == 0 || original_devmode_.dmPelsHeight == 0 ||
|
||||||
|
original_devmode_.dmDisplayFrequency == 0) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!PrepareModeRecoveryAtRegistry(
|
||||||
|
original_device_name_, original_devmode_.dmPelsWidth, original_devmode_.dmPelsHeight,
|
||||||
|
original_devmode_.dmDisplayFrequency)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the record live before calling Windows. ChangeDisplaySettingsExW can
|
||||||
|
// synchronously deliver WM_DISPLAYCHANGE; that event must not recover the
|
||||||
|
// override that is currently being applied.
|
||||||
|
g_live_mode_recovery_record = true;
|
||||||
|
|
||||||
DEVMODEW dm = {};
|
DEVMODEW dm = {};
|
||||||
dm.dmSize = sizeof(dm);
|
dm.dmSize = sizeof(dm);
|
||||||
@@ -187,37 +235,49 @@ bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height,
|
|||||||
|
|
||||||
// Standard path / fallback.
|
// Standard path / fallback.
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
const LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||||
if (rc == DISP_CHANGE_SUCCESSFUL) changed = true;
|
changed = rc == DISP_CHANGE_SUCCESSFUL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
mode_changed_ = true;
|
mode_changed_ = true;
|
||||||
WriteRecoveryState();
|
} else if (!mode_was_changed) {
|
||||||
|
// Do not discard an independently persisted HDR operation owned by
|
||||||
|
// another manager or retained from startup recovery.
|
||||||
|
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
|
||||||
|
g_live_mode_recovery_record = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::RestoreOriginalMode(HWND window) {
|
bool DisplayModeManager::RestoreOriginalMode(HWND) {
|
||||||
if (!mode_changed_ || original_device_name_.empty()) return false;
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
if (!mode_changed_) return false;
|
||||||
|
if (original_device_name_.empty()) {
|
||||||
|
g_live_mode_recovery_record = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
original_devmode_.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
original_devmode_.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||||
|
|
||||||
LONG rc =
|
LONG rc =
|
||||||
ChangeDisplaySettingsExW(original_device_name_.c_str(), &original_devmode_, nullptr, CDS_FULLSCREEN, nullptr);
|
ChangeDisplaySettingsExW(original_device_name_.c_str(), &original_devmode_, nullptr, CDS_FULLSCREEN, nullptr);
|
||||||
|
|
||||||
if (rc == DISP_CHANGE_SUCCESSFUL) {
|
if (rc != DISP_CHANGE_SUCCESSFUL) {
|
||||||
mode_changed_ = false;
|
// Fallback: restore registry defaults.
|
||||||
if (!hdr_changed_) ClearRecoveryState();
|
rc = ChangeDisplaySettingsExW(original_device_name_.c_str(), nullptr, nullptr, 0, nullptr);
|
||||||
return true;
|
}
|
||||||
|
if (rc != DISP_CHANGE_SUCCESSFUL) {
|
||||||
|
// The explicit owner has given up. Keep the durable marker, but release it
|
||||||
|
// so a later topology notification can restore a reconnected target.
|
||||||
|
g_live_mode_recovery_record = false;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: restore registry defaults.
|
mode_changed_ = false;
|
||||||
rc = ChangeDisplaySettingsExW(original_device_name_.c_str(), nullptr, nullptr, 0, nullptr);
|
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
|
||||||
mode_changed_ = (rc != DISP_CHANGE_SUCCESSFUL);
|
g_live_mode_recovery_record = false;
|
||||||
if (!mode_changed_ && !hdr_changed_) ClearRecoveryState();
|
return true;
|
||||||
return rc == DISP_CHANGE_SUCCESSFUL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HDR ---
|
// --- HDR ---
|
||||||
@@ -297,26 +357,39 @@ void DisplayModeManager::SaveOriginalHDRState(HWND window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
|
bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
|
||||||
std::wstring device_name = GetMonitorDeviceName(window);
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
|
||||||
|
const std::wstring device_name = GetMonitorDeviceName(window);
|
||||||
if (device_name.empty()) return false;
|
if (device_name.empty()) return false;
|
||||||
|
|
||||||
auto target_id = GetDisplayTargetId(device_name);
|
const auto target_id = GetDisplayTargetId(device_name);
|
||||||
if (!target_id) return false;
|
if (!target_id) return false;
|
||||||
|
|
||||||
// Save original state if not already saved.
|
const bool hdr_was_changed = hdr_changed_;
|
||||||
if (!hdr_changed_) {
|
if (!hdr_changed_) SaveOriginalHDRState(window);
|
||||||
SaveOriginalHDRState(window);
|
if (original_hdr_device_name_.empty() ||
|
||||||
|
!PrepareHDRRecoveryAtRegistry(original_hdr_device_name_, original_hdr_enabled_)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See SetDisplayMode: keep synchronous topology notifications from treating
|
||||||
|
// this process's just-persisted marker as crash recovery.
|
||||||
|
g_live_hdr_recovery_record = true;
|
||||||
|
|
||||||
// Save DEVMODEW before toggle — Windows changes display mode on HDR state change.
|
// Save DEVMODEW before toggle — Windows changes display mode on HDR state change.
|
||||||
// Source: Kodi WIN32Util.cpp:1252-1257.
|
// Source: Kodi WIN32Util.cpp:1252-1257.
|
||||||
DEVMODEW pre_toggle_dm = {};
|
DEVMODEW pre_toggle_dm = {};
|
||||||
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
||||||
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
||||||
|
|
||||||
// Toggle HDR.
|
const LONG result = SetHDRStateForTarget(*target_id, enabled);
|
||||||
LONG result = SetHDRStateForTarget(*target_id, enabled);
|
if (result != ERROR_SUCCESS) {
|
||||||
if (result != ERROR_SUCCESS) return false;
|
if (!hdr_was_changed) {
|
||||||
|
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
|
||||||
|
g_live_hdr_recovery_record = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore DEVMODEW after toggle — Windows may have changed the display mode.
|
// Restore DEVMODEW after toggle — Windows may have changed the display mode.
|
||||||
// Source: Kodi WIN32Util.cpp:1276-1288.
|
// Source: Kodi WIN32Util.cpp:1276-1288.
|
||||||
@@ -326,40 +399,48 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdr_changed_ = true;
|
hdr_changed_ = true;
|
||||||
WriteRecoveryState();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
|
bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
|
||||||
if (!hdr_changed_ || original_hdr_device_name_.empty()) return false;
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
if (!hdr_changed_) return false;
|
||||||
bool current = IsHDREnabled(window);
|
if (original_hdr_device_name_.empty()) {
|
||||||
if (current == original_hdr_enabled_) {
|
g_live_hdr_recovery_record = false;
|
||||||
hdr_changed_ = false;
|
return false;
|
||||||
if (!mode_changed_) ClearRecoveryState();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to actually toggle back.
|
const auto target_id = GetDisplayTargetId(original_hdr_device_name_);
|
||||||
auto target_id = GetDisplayTargetId(original_hdr_device_name_);
|
if (!target_id) {
|
||||||
if (!target_id) return false;
|
g_live_hdr_recovery_record = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Save DEVMODEW before restore toggle.
|
if (IsHDREnabled(window) != original_hdr_enabled_) {
|
||||||
DEVMODEW pre_toggle_dm = {};
|
// The original target was resolved above even when the currently observed
|
||||||
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
// HDR state already matches, so a disconnected target cannot be mistaken
|
||||||
EnumDisplaySettingsW(original_hdr_device_name_.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
// for a successful restore.
|
||||||
|
|
||||||
LONG result = SetHDRStateForTarget(*target_id, original_hdr_enabled_);
|
// Save DEVMODEW before restore toggle.
|
||||||
if (result != ERROR_SUCCESS) return false;
|
DEVMODEW pre_toggle_dm = {};
|
||||||
|
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
||||||
|
EnumDisplaySettingsW(original_hdr_device_name_.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
||||||
|
|
||||||
// Restore DEVMODEW after toggle.
|
if (SetHDRStateForTarget(*target_id, original_hdr_enabled_) != ERROR_SUCCESS) {
|
||||||
if (pre_toggle_dm.dmDisplayFrequency != 0) {
|
g_live_hdr_recovery_record = false;
|
||||||
pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
return false;
|
||||||
ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
}
|
||||||
|
|
||||||
|
// Restore DEVMODEW after toggle.
|
||||||
|
if (pre_toggle_dm.dmDisplayFrequency != 0) {
|
||||||
|
pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||||
|
ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hdr_changed_ = false;
|
hdr_changed_ = false;
|
||||||
if (!mode_changed_) ClearRecoveryState();
|
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
|
||||||
|
g_live_hdr_recovery_record = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,152 +466,410 @@ LONG DisplayModeManager::SetHDRStateForTarget(const DisplayConfigId& target, boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::WriteRegistryDWORD(const wchar_t* value_name, DWORD value) {
|
namespace {
|
||||||
|
|
||||||
|
bool WriteRegistryDWORD(const wchar_t* value_name, DWORD value) {
|
||||||
HKEY key;
|
HKEY key;
|
||||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
||||||
ERROR_SUCCESS)
|
ERROR_SUCCESS) {
|
||||||
return false;
|
return false;
|
||||||
LONG result = RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
|
}
|
||||||
|
const LONG result =
|
||||||
|
RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
return result == ERROR_SUCCESS;
|
return result == ERROR_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::WriteRegistryString(const wchar_t* value_name, const std::wstring& value) {
|
bool WriteRegistryString(const wchar_t* value_name, const std::wstring& value) {
|
||||||
HKEY key;
|
HKEY key;
|
||||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
||||||
ERROR_SUCCESS)
|
ERROR_SUCCESS) {
|
||||||
return false;
|
return false;
|
||||||
LONG result = RegSetValueExW(
|
}
|
||||||
|
const LONG result = RegSetValueExW(
|
||||||
key, value_name, 0, REG_SZ, reinterpret_cast<const BYTE*>(value.c_str()),
|
key, value_name, 0, REG_SZ, reinterpret_cast<const BYTE*>(value.c_str()),
|
||||||
static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t)));
|
static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t)));
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
return result == ERROR_SUCCESS;
|
return result == ERROR_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::ReadRegistryDWORD(const wchar_t* value_name, DWORD& value) {
|
bool ReadRegistryDWORD(const wchar_t* value_name, DWORD& value) {
|
||||||
HKEY key;
|
HKEY key;
|
||||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
||||||
DWORD size = sizeof(value);
|
DWORD size = sizeof(value);
|
||||||
DWORD type = 0;
|
DWORD type = 0;
|
||||||
LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast<BYTE*>(&value), &size);
|
const LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast<BYTE*>(&value), &size);
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
return result == ERROR_SUCCESS && type == REG_DWORD;
|
return result == ERROR_SUCCESS && type == REG_DWORD && size == sizeof(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::ReadRegistryString(const wchar_t* value_name, std::wstring& value) {
|
bool ReadRegistryString(const wchar_t* value_name, std::wstring& value) {
|
||||||
HKEY key;
|
HKEY key;
|
||||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
||||||
DWORD size = 0;
|
DWORD size = 0;
|
||||||
DWORD type = 0;
|
DWORD type = 0;
|
||||||
RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &size);
|
const LONG size_result = RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &size);
|
||||||
if (type != REG_SZ || size == 0) {
|
if (size_result != ERROR_SUCCESS || type != REG_SZ || size == 0 || size % sizeof(wchar_t) != 0) {
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
value.resize(size / sizeof(wchar_t));
|
value.resize(size / sizeof(wchar_t));
|
||||||
LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast<BYTE*>(&value[0]), &size);
|
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast<BYTE*>(value.data()), &size);
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
if (result != ERROR_SUCCESS) return false;
|
if (result != ERROR_SUCCESS) return false;
|
||||||
// Remove trailing null.
|
|
||||||
while (!value.empty() && value.back() == L'\0') value.pop_back();
|
while (!value.empty() && value.back() == L'\0') value.pop_back();
|
||||||
return true;
|
return !value.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DisplayModeManager::DeleteRegistryValue(const wchar_t* value_name) {
|
bool RecoveryRecordExists() {
|
||||||
HKEY key;
|
HKEY key;
|
||||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_WRITE, &key) != ERROR_SUCCESS) return false;
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) {
|
||||||
RegDeleteValueW(key, value_name);
|
return false;
|
||||||
|
}
|
||||||
|
bool exists = false;
|
||||||
|
for (const wchar_t* value_name :
|
||||||
|
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
|
||||||
|
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
|
||||||
|
DWORD size = 0;
|
||||||
|
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, nullptr, &size);
|
||||||
|
if (result == ERROR_SUCCESS || result == ERROR_MORE_DATA) {
|
||||||
|
exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
RegCloseKey(key);
|
RegCloseKey(key);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class Win32DisplayRecoveryBackend final : public DisplayRecoveryBackend {
|
||||||
|
public:
|
||||||
|
bool RecordExists() const override { return RecoveryRecordExists(); }
|
||||||
|
|
||||||
|
bool ReadDWORD(const wchar_t* value_name, DWORD& value) override { return ReadRegistryDWORD(value_name, value); }
|
||||||
|
|
||||||
|
bool ReadString(const wchar_t* value_name, std::wstring& value) override {
|
||||||
|
return ReadRegistryString(value_name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WriteDWORD(const wchar_t* value_name, DWORD value) override { return WriteRegistryDWORD(value_name, value); }
|
||||||
|
|
||||||
|
bool WriteString(const wchar_t* value_name, const std::wstring& value) override {
|
||||||
|
return WriteRegistryString(value_name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsDevicePresent(const std::wstring& device_name) const override {
|
||||||
|
DEVMODEW mode = {};
|
||||||
|
mode.dmSize = sizeof(mode);
|
||||||
|
return EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &mode) != FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override {
|
||||||
|
DEVMODEW dm = {};
|
||||||
|
dm.dmSize = sizeof(dm);
|
||||||
|
dm.dmPelsWidth = width;
|
||||||
|
dm.dmPelsHeight = height;
|
||||||
|
dm.dmDisplayFrequency = refresh_rate;
|
||||||
|
dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY;
|
||||||
|
return ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr) ==
|
||||||
|
DISP_CHANGE_SUCCESSFUL;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
|
||||||
|
const auto target_id = DisplayModeManager::GetDisplayTargetId(device_name);
|
||||||
|
if (!target_id) return false;
|
||||||
|
|
||||||
|
DEVMODEW pre_toggle_mode = {};
|
||||||
|
pre_toggle_mode.dmSize = sizeof(pre_toggle_mode);
|
||||||
|
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_mode);
|
||||||
|
|
||||||
|
if (DisplayModeManager::SetHDRStateForTarget(*target_id, enabled) != ERROR_SUCCESS) return false;
|
||||||
|
|
||||||
|
if (pre_toggle_mode.dmDisplayFrequency != 0) {
|
||||||
|
pre_toggle_mode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||||
|
if (ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_mode, nullptr, CDS_FULLSCREEN, nullptr) !=
|
||||||
|
DISP_CHANGE_SUCCESSFUL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ClearMarker(const wchar_t* value_name) override { return WriteRegistryDWORD(value_name, 0); }
|
||||||
|
|
||||||
|
bool DeleteRecord() override {
|
||||||
|
HKEY key;
|
||||||
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_SET_VALUE, &key) != ERROR_SUCCESS) {
|
||||||
|
return !RecordExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool deleted = true;
|
||||||
|
for (const wchar_t* value_name :
|
||||||
|
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
|
||||||
|
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
|
||||||
|
const LONG result = RegDeleteValueW(key, value_name);
|
||||||
|
deleted = deleted && (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
RegCloseKey(key);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool ReadValidModeValues(
|
||||||
|
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& width,
|
||||||
|
DWORD& height, DWORD& refresh_rate) {
|
||||||
|
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalWidth, width) &&
|
||||||
|
width > 0 && backend.ReadDWORD(kRegOriginalHeight, height) && height > 0 &&
|
||||||
|
backend.ReadDWORD(kRegOriginalRefreshRate, refresh_rate) && refresh_rate > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadValidHDRValues(
|
||||||
|
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& original_hdr) {
|
||||||
|
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalHDR, original_hdr) &&
|
||||||
|
original_hdr <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadValidMarkedMode(
|
||||||
|
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& width, DWORD& height, DWORD& refresh_rate) {
|
||||||
|
DWORD version = 0;
|
||||||
|
DWORD marker = 0;
|
||||||
|
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
|
||||||
|
backend.ReadDWORD(kRegModeChanged, marker) && marker == 1 &&
|
||||||
|
ReadValidModeValues(backend, kRegModeDeviceName, device_name, width, height, refresh_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadValidMarkedHDR(DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& original_hdr) {
|
||||||
|
DWORD version = 0;
|
||||||
|
DWORD marker = 0;
|
||||||
|
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
|
||||||
|
backend.ReadDWORD(kRegHDRChanged, marker) && marker == 1 &&
|
||||||
|
ReadValidHDRValues(backend, kRegHDRDeviceName, device_name, original_hdr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) {
|
||||||
|
DWORD mode_marker = 0;
|
||||||
|
DWORD hdr_marker = 0;
|
||||||
|
if (!backend.ReadDWORD(kRegModeChanged, mode_marker) || !backend.ReadDWORD(kRegHDRChanged, hdr_marker) ||
|
||||||
|
mode_marker != 0 || hdr_marker != 0) {
|
||||||
|
// Missing, malformed, or active evidence is retained conservatively.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Deletion is best effort after both operation markers are durably clear.
|
||||||
|
backend.DeleteRecord();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplayModeManager::WriteRecoveryState() {
|
bool CompleteRecoveryOperation(DisplayRecoveryBackend& backend, const wchar_t* marker) {
|
||||||
std::wstring device = mode_changed_ ? original_device_name_ : original_hdr_device_name_;
|
if (!backend.ClearMarker(marker)) return false;
|
||||||
if (device.empty()) return;
|
DeleteRecordIfNoMarkedOperations(backend);
|
||||||
|
return true;
|
||||||
WriteRegistryString(kRegDeviceName, device);
|
|
||||||
WriteRegistryDWORD(kRegModeChanged, mode_changed_ ? 1 : 0);
|
|
||||||
WriteRegistryDWORD(kRegHDRChanged, hdr_changed_ ? 1 : 0);
|
|
||||||
|
|
||||||
if (mode_changed_) {
|
|
||||||
WriteRegistryDWORD(kRegOriginalRefreshRate, original_devmode_.dmDisplayFrequency);
|
|
||||||
WriteRegistryDWORD(kRegOriginalWidth, original_devmode_.dmPelsWidth);
|
|
||||||
WriteRegistryDWORD(kRegOriginalHeight, original_devmode_.dmPelsHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hdr_changed_) {
|
|
||||||
WriteRegistryDWORD(kRegOriginalHDR, original_hdr_enabled_ ? 1 : 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplayModeManager::ClearRecoveryState() {
|
bool PreserveValidModeSiblingOrClear(DisplayRecoveryBackend& backend) {
|
||||||
// Delete the entire key.
|
DWORD marker = 0;
|
||||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
if (!backend.ReadDWORD(kRegModeChanged, marker)) {
|
||||||
}
|
return backend.WriteDWORD(kRegModeChanged, 0);
|
||||||
|
}
|
||||||
|
if (marker == 0) return true;
|
||||||
|
|
||||||
bool DisplayModeManager::RecoverIfNeeded(HWND window) {
|
|
||||||
DWORD mode_changed = 0, hdr_changed = 0;
|
|
||||||
std::wstring device_name;
|
std::wstring device_name;
|
||||||
|
DWORD width = 0;
|
||||||
|
DWORD height = 0;
|
||||||
|
DWORD refresh_rate = 0;
|
||||||
|
if (marker == 1 && ReadValidMarkedMode(backend, device_name, width, height, refresh_rate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return backend.ClearMarker(kRegModeChanged);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ReadRegistryString(kRegDeviceName, device_name)) return false;
|
bool PreserveValidHDRSiblingOrClear(DisplayRecoveryBackend& backend) {
|
||||||
ReadRegistryDWORD(kRegModeChanged, mode_changed);
|
DWORD marker = 0;
|
||||||
ReadRegistryDWORD(kRegHDRChanged, hdr_changed);
|
if (!backend.ReadDWORD(kRegHDRChanged, marker)) {
|
||||||
|
return backend.WriteDWORD(kRegHDRChanged, 0);
|
||||||
|
}
|
||||||
|
if (marker == 0) return true;
|
||||||
|
|
||||||
if (!mode_changed && !hdr_changed) {
|
std::wstring device_name;
|
||||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
DWORD original_hdr = 0;
|
||||||
|
if (marker == 1 && ReadValidMarkedHDR(backend, device_name, original_hdr)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return backend.ClearMarker(kRegHDRChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool DisplayModeManager::PrepareModeRecovery(
|
||||||
|
DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||||
|
if (device_name.empty() || width == 0 || height == 0 || refresh_rate == 0) return false;
|
||||||
|
if (!PreserveValidHDRSiblingOrClear(backend)) return false;
|
||||||
|
|
||||||
|
DWORD existing_width = 0;
|
||||||
|
DWORD existing_height = 0;
|
||||||
|
DWORD existing_refresh_rate = 0;
|
||||||
|
std::wstring existing_device_name;
|
||||||
|
if (ReadValidMarkedMode(backend, existing_device_name, existing_width, existing_height, existing_refresh_rate)) {
|
||||||
|
// A valid marked original is already protecting a live or failed
|
||||||
|
// operation. Reuse it only when this manager has the same original;
|
||||||
|
// replacing it would lose the only restoration point.
|
||||||
|
return existing_device_name == device_name && existing_width == width && existing_height == height &&
|
||||||
|
existing_refresh_rate == refresh_rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate an incomplete old mode operation before replacing any
|
||||||
|
// originals. A crash anywhere before the final write is therefore a
|
||||||
|
// harmless pre-mutation prefix.
|
||||||
|
if (!backend.ClearMarker(kRegModeChanged)) return false;
|
||||||
|
|
||||||
|
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegModeDeviceName, device_name) &&
|
||||||
|
backend.WriteDWORD(kRegOriginalWidth, width) && backend.WriteDWORD(kRegOriginalHeight, height) &&
|
||||||
|
backend.WriteDWORD(kRegOriginalRefreshRate, refresh_rate) && backend.WriteDWORD(kRegModeChanged, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DisplayModeManager::PrepareHDRRecovery(
|
||||||
|
DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled) {
|
||||||
|
if (device_name.empty()) return false;
|
||||||
|
if (!PreserveValidModeSiblingOrClear(backend)) return false;
|
||||||
|
|
||||||
|
DWORD existing_original = 0;
|
||||||
|
std::wstring existing_device_name;
|
||||||
|
if (ReadValidMarkedHDR(backend, existing_device_name, existing_original)) {
|
||||||
|
return existing_device_name == device_name && existing_original == (enabled ? 1u : 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backend.ClearMarker(kRegHDRChanged)) return false;
|
||||||
|
|
||||||
|
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegHDRDeviceName, device_name) &&
|
||||||
|
backend.WriteDWORD(kRegOriginalHDR, enabled ? 1 : 0) && backend.WriteDWORD(kRegHDRChanged, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||||
|
Win32DisplayRecoveryBackend backend;
|
||||||
|
return DisplayModeManager::PrepareModeRecovery(backend, device_name, width, height, refresh_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled) {
|
||||||
|
Win32DisplayRecoveryBackend backend;
|
||||||
|
return DisplayModeManager::PrepareHDRRecovery(backend, device_name, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker) {
|
||||||
|
Win32DisplayRecoveryBackend backend;
|
||||||
|
return CompleteRecoveryOperation(backend, marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) {
|
||||||
|
if (!backend.RecordExists()) return false;
|
||||||
|
|
||||||
|
DWORD version = 0;
|
||||||
|
const bool has_version = backend.ReadDWORD(kRegVersion, version);
|
||||||
|
const wchar_t* mode_device_value_name = kRegModeDeviceName;
|
||||||
|
const wchar_t* hdr_device_value_name = kRegHDRDeviceName;
|
||||||
|
if (has_version) {
|
||||||
|
if (version != kRecoveryVersion) {
|
||||||
|
backend.DeleteRecord();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The released layout had no Version and shared one DeviceName between
|
||||||
|
// mode and HDR. Require that discriminator before interpreting any
|
||||||
|
// versionless values as recovery evidence.
|
||||||
|
std::wstring legacy_device_name;
|
||||||
|
if (!backend.ReadString(kRegLegacyDeviceName, legacy_device_name)) {
|
||||||
|
backend.DeleteRecord();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
mode_device_value_name = kRegLegacyDeviceName;
|
||||||
|
hdr_device_value_name = kRegLegacyDeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD mode_marker = 0;
|
||||||
|
const bool mode_marker_read = backend.ReadDWORD(kRegModeChanged, mode_marker);
|
||||||
|
std::wstring mode_device_name;
|
||||||
|
DWORD width = 0;
|
||||||
|
DWORD height = 0;
|
||||||
|
DWORD refresh_rate = 0;
|
||||||
|
const bool mode_requested =
|
||||||
|
mode_marker_read && mode_marker == 1 &&
|
||||||
|
ReadValidModeValues(backend, mode_device_value_name, mode_device_name, width, height, refresh_rate);
|
||||||
|
if (!mode_is_live && (!mode_marker_read || mode_marker > 1 || (mode_marker == 1 && !mode_requested))) {
|
||||||
|
// Malformation in one operation does not erase a valid or live sibling.
|
||||||
|
backend.ClearMarker(kRegModeChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD hdr_marker = 0;
|
||||||
|
const bool hdr_marker_read = backend.ReadDWORD(kRegHDRChanged, hdr_marker);
|
||||||
|
std::wstring hdr_device_name;
|
||||||
|
DWORD original_hdr = 0;
|
||||||
|
const bool hdr_requested = hdr_marker_read && hdr_marker == 1 &&
|
||||||
|
ReadValidHDRValues(backend, hdr_device_value_name, hdr_device_name, original_hdr);
|
||||||
|
if (!hdr_is_live && (!hdr_marker_read || hdr_marker > 1 || (hdr_marker == 1 && !hdr_requested))) {
|
||||||
|
backend.ClearMarker(kRegHDRChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool recover_mode = mode_requested && !mode_is_live;
|
||||||
|
const bool recover_hdr = hdr_requested && !hdr_is_live;
|
||||||
|
if (!recover_mode && !recover_hdr) {
|
||||||
|
DeleteRecordIfNoMarkedOperations(backend);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool recovered = false;
|
bool completed = true;
|
||||||
|
if (recover_mode) {
|
||||||
// Restore refresh rate / resolution.
|
if (backend.IsDevicePresent(mode_device_name) &&
|
||||||
if (mode_changed) {
|
backend.RestoreMode(mode_device_name, width, height, refresh_rate)) {
|
||||||
DWORD width = 0, height = 0, refresh = 0;
|
// A failed marker clear leaves an idempotent restoration for a later pass.
|
||||||
ReadRegistryDWORD(kRegOriginalWidth, width);
|
completed = CompleteRecoveryOperation(backend, kRegModeChanged) && completed;
|
||||||
ReadRegistryDWORD(kRegOriginalHeight, height);
|
} else {
|
||||||
ReadRegistryDWORD(kRegOriginalRefreshRate, refresh);
|
completed = false;
|
||||||
|
|
||||||
if (width > 0 && height > 0 && refresh > 0) {
|
|
||||||
DEVMODEW dm = {};
|
|
||||||
dm.dmSize = sizeof(dm);
|
|
||||||
dm.dmPelsWidth = width;
|
|
||||||
dm.dmPelsHeight = height;
|
|
||||||
dm.dmDisplayFrequency = refresh;
|
|
||||||
dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY;
|
|
||||||
|
|
||||||
LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
|
||||||
if (rc == DISP_CHANGE_SUCCESSFUL) recovered = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore HDR state.
|
if (recover_hdr) {
|
||||||
if (hdr_changed) {
|
if (backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0)) {
|
||||||
DWORD hdr_was_enabled = 0;
|
completed = CompleteRecoveryOperation(backend, kRegHDRChanged) && completed;
|
||||||
ReadRegistryDWORD(kRegOriginalHDR, hdr_was_enabled);
|
} else {
|
||||||
|
completed = false;
|
||||||
auto target_id = GetDisplayTargetId(device_name);
|
|
||||||
if (target_id) {
|
|
||||||
// Save DEVMODEW before toggle.
|
|
||||||
DEVMODEW pre_dm = {};
|
|
||||||
pre_dm.dmSize = sizeof(pre_dm);
|
|
||||||
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_dm);
|
|
||||||
|
|
||||||
LONG result = SetHDRStateForTarget(*target_id, hdr_was_enabled != 0);
|
|
||||||
|
|
||||||
if (result == ERROR_SUCCESS) {
|
|
||||||
recovered = true;
|
|
||||||
// Restore display mode after HDR toggle.
|
|
||||||
if (pre_dm.dmDisplayFrequency != 0) {
|
|
||||||
pre_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
|
||||||
ChangeDisplaySettingsExW(device_name.c_str(), &pre_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return completed;
|
||||||
// Clean up registry regardless of success.
|
|
||||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
|
||||||
return recovered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool DisplayModeManager::RecoverIfNeeded() {
|
||||||
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
RecoveryRunGuard run;
|
||||||
|
if (!run.acquired()) return false;
|
||||||
|
Win32DisplayRecoveryBackend backend;
|
||||||
|
return RecoverRecord(backend, g_live_mode_recovery_record, g_live_hdr_recovery_record);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DisplayModeManager::RecoverIfNeeded(DisplayRecoveryBackend& backend) {
|
||||||
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
RecoveryRunGuard run;
|
||||||
|
if (!run.acquired()) return false;
|
||||||
|
return RecoverRecord(backend, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
|
||||||
|
bool DisplayModeManager::CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode) {
|
||||||
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
return CompleteRecoveryOperation(backend, mode ? kRegModeChanged : kRegHDRChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DisplayModeManager::RecoverIfNeededForTesting(
|
||||||
|
DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) {
|
||||||
|
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||||
|
RecoveryRunGuard run;
|
||||||
|
if (!run.acquired()) return false;
|
||||||
|
return RecoverRecord(backend, mode_is_live, hdr_is_live);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
#include <Windows.h>
|
#include <Windows.h>
|
||||||
|
|
||||||
#include <map>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -22,6 +21,24 @@ struct DisplayConfigId {
|
|||||||
UINT32 id;
|
UINT32 id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Windows-runner-internal boundary for deterministic crash-recovery tests.
|
||||||
|
// Production uses the Win32/registry implementation in display_mode_manager.cpp.
|
||||||
|
class DisplayRecoveryBackend {
|
||||||
|
public:
|
||||||
|
virtual ~DisplayRecoveryBackend() = default;
|
||||||
|
|
||||||
|
virtual bool RecordExists() const = 0;
|
||||||
|
virtual bool ReadDWORD(const wchar_t* value_name, DWORD& value) = 0;
|
||||||
|
virtual bool ReadString(const wchar_t* value_name, std::wstring& value) = 0;
|
||||||
|
virtual bool WriteDWORD(const wchar_t* value_name, DWORD value) = 0;
|
||||||
|
virtual bool WriteString(const wchar_t* value_name, const std::wstring& value) = 0;
|
||||||
|
virtual bool IsDevicePresent(const std::wstring& device_name) const = 0;
|
||||||
|
virtual bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) = 0;
|
||||||
|
virtual bool RestoreHDR(const std::wstring& device_name, bool enabled) = 0;
|
||||||
|
virtual bool ClearMarker(const wchar_t* value_name) = 0;
|
||||||
|
virtual bool DeleteRecord() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Manages Windows display mode switching (refresh rate, HDR) for video playback.
|
// Manages Windows display mode switching (refresh rate, HDR) for video playback.
|
||||||
// Pure Win32 utility — no mpv or Flutter dependency.
|
// Pure Win32 utility — no mpv or Flutter dependency.
|
||||||
//
|
//
|
||||||
@@ -88,17 +105,27 @@ class DisplayModeManager {
|
|||||||
|
|
||||||
// --- Crash recovery ---
|
// --- Crash recovery ---
|
||||||
|
|
||||||
// Write current override state to registry for crash recovery.
|
// Persist a complete original followed by its operation marker. These
|
||||||
void WriteRecoveryState();
|
// runner-internal seams make the crash ordering deterministic in tests.
|
||||||
|
static bool PrepareModeRecovery(
|
||||||
|
DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
|
||||||
|
static bool PrepareHDRRecovery(DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled);
|
||||||
|
|
||||||
// Clear the recovery state from registry.
|
// Check for and recover from a prior crash that left display settings
|
||||||
void ClearRecoveryState();
|
// changed. Successful operation markers are cleared independently. Failed
|
||||||
|
// operations remain for the next startup or display-topology notification.
|
||||||
// Check for and recover from a prior crash that left display settings changed.
|
static bool RecoverIfNeeded();
|
||||||
// Should be called early in app startup. Returns true if recovery was performed.
|
static bool RecoverIfNeeded(DisplayRecoveryBackend& backend);
|
||||||
static bool RecoverIfNeeded(HWND window);
|
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
|
||||||
|
// Exercise persisted lifecycle exits and per-operation live ownership without
|
||||||
|
// touching a real display or registry.
|
||||||
|
static bool CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode);
|
||||||
|
static bool RecoverIfNeededForTesting(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live);
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
friend class Win32DisplayRecoveryBackend;
|
||||||
|
|
||||||
// Get the GDI device name for the monitor containing the window.
|
// Get the GDI device name for the monitor containing the window.
|
||||||
static std::wstring GetMonitorDeviceName(HWND window);
|
static std::wstring GetMonitorDeviceName(HWND window);
|
||||||
|
|
||||||
@@ -115,13 +142,6 @@ class DisplayModeManager {
|
|||||||
// Toggle HDR via DisplayConfig (version-dispatched).
|
// Toggle HDR via DisplayConfig (version-dispatched).
|
||||||
static LONG SetHDRStateForTarget(const DisplayConfigId& target, bool enabled);
|
static LONG SetHDRStateForTarget(const DisplayConfigId& target, bool enabled);
|
||||||
|
|
||||||
// Registry helpers for crash recovery.
|
|
||||||
static bool WriteRegistryDWORD(const wchar_t* value_name, DWORD value);
|
|
||||||
static bool WriteRegistryString(const wchar_t* value_name, const std::wstring& value);
|
|
||||||
static bool ReadRegistryDWORD(const wchar_t* value_name, DWORD& value);
|
|
||||||
static bool ReadRegistryString(const wchar_t* value_name, std::wstring& value);
|
|
||||||
static bool DeleteRegistryValue(const wchar_t* value_name);
|
|
||||||
|
|
||||||
// Stored original mode for restoration.
|
// Stored original mode for restoration.
|
||||||
std::wstring original_device_name_;
|
std::wstring original_device_name_;
|
||||||
DEVMODEW original_devmode_ = {};
|
DEVMODEW original_devmode_ = {};
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
#include "display_mode_manager.h"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mpv {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr wchar_t kVersion[] = L"Version";
|
||||||
|
constexpr wchar_t kModeDeviceName[] = L"ModeDeviceName";
|
||||||
|
constexpr wchar_t kLegacyDeviceName[] = L"DeviceName";
|
||||||
|
constexpr wchar_t kHDRDeviceName[] = L"HDRDeviceName";
|
||||||
|
constexpr wchar_t kOriginalRefreshRate[] = L"OriginalRefreshRate";
|
||||||
|
constexpr wchar_t kOriginalWidth[] = L"OriginalWidth";
|
||||||
|
constexpr wchar_t kOriginalHeight[] = L"OriginalHeight";
|
||||||
|
constexpr wchar_t kOriginalHDR[] = L"OriginalHDREnabled";
|
||||||
|
constexpr wchar_t kModeChanged[] = L"ModeChanged";
|
||||||
|
constexpr wchar_t kHDRChanged[] = L"HDRChanged";
|
||||||
|
constexpr wchar_t kModeDevice[] = L"\\\\.\\DISPLAY1";
|
||||||
|
constexpr wchar_t kHDRDevice[] = L"\\\\.\\DISPLAY2";
|
||||||
|
|
||||||
|
void Check(bool condition, const char* message) {
|
||||||
|
if (!condition) {
|
||||||
|
std::cerr << "display_mode_manager_test: " << message << '\n';
|
||||||
|
std::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRecoveryBackend final : public DisplayRecoveryBackend {
|
||||||
|
public:
|
||||||
|
std::map<std::wstring, DWORD> dwords;
|
||||||
|
std::map<std::wstring, std::wstring> strings;
|
||||||
|
std::map<std::wstring, bool> device_present = {{kModeDevice, true}, {kHDRDevice, true}};
|
||||||
|
std::vector<std::wstring> events;
|
||||||
|
std::wstring expected_mode_device = kModeDevice;
|
||||||
|
std::wstring expected_hdr_device = kHDRDevice;
|
||||||
|
bool mode_restore_succeeds = true;
|
||||||
|
bool hdr_restore_succeeds = true;
|
||||||
|
bool mode_marker_clear_succeeds = true;
|
||||||
|
bool hdr_marker_clear_succeeds = true;
|
||||||
|
bool delete_succeeds = true;
|
||||||
|
bool final_mode_marker_write_succeeds = true;
|
||||||
|
bool final_hdr_marker_write_succeeds = true;
|
||||||
|
int delete_attempts = 0;
|
||||||
|
|
||||||
|
void SeedBoth() {
|
||||||
|
dwords[kVersion] = 1;
|
||||||
|
dwords[kModeChanged] = 1;
|
||||||
|
dwords[kHDRChanged] = 1;
|
||||||
|
dwords[kOriginalWidth] = 3840;
|
||||||
|
dwords[kOriginalHeight] = 2160;
|
||||||
|
dwords[kOriginalRefreshRate] = 60;
|
||||||
|
dwords[kOriginalHDR] = 0;
|
||||||
|
strings[kModeDeviceName] = kModeDevice;
|
||||||
|
strings[kHDRDeviceName] = kHDRDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RecordExists() const override { return !dwords.empty() || !strings.empty(); }
|
||||||
|
|
||||||
|
bool ReadDWORD(const wchar_t* value_name, DWORD& value) override {
|
||||||
|
const auto it = dwords.find(value_name);
|
||||||
|
if (it == dwords.end()) return false;
|
||||||
|
value = it->second;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadString(const wchar_t* value_name, std::wstring& value) override {
|
||||||
|
const auto it = strings.find(value_name);
|
||||||
|
if (it == strings.end()) return false;
|
||||||
|
value = it->second;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WriteDWORD(const wchar_t* value_name, DWORD value) override {
|
||||||
|
const std::wstring name(value_name);
|
||||||
|
events.push_back(L"write:" + name + L"=" + std::to_wstring(value));
|
||||||
|
if ((name == kModeChanged && value == 1 && !final_mode_marker_write_succeeds) ||
|
||||||
|
(name == kHDRChanged && value == 1 && !final_hdr_marker_write_succeeds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dwords[name] = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WriteString(const wchar_t* value_name, const std::wstring& value) override {
|
||||||
|
const std::wstring name(value_name);
|
||||||
|
events.push_back(L"write:" + name);
|
||||||
|
strings[name] = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsDevicePresent(const std::wstring& device_name) const override {
|
||||||
|
const auto it = device_present.find(device_name);
|
||||||
|
return it != device_present.end() && it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override {
|
||||||
|
Check(device_name == expected_mode_device, "mode restore must use its persisted display");
|
||||||
|
Check(width == 3840 && height == 2160 && refresh_rate == 60, "mode restore must use persisted originals");
|
||||||
|
events.push_back(L"restore:mode");
|
||||||
|
return mode_restore_succeeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
|
||||||
|
Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display");
|
||||||
|
Check(!enabled, "HDR restore must use the persisted original state");
|
||||||
|
events.push_back(L"restore:hdr");
|
||||||
|
return hdr_restore_succeeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ClearMarker(const wchar_t* value_name) override {
|
||||||
|
const std::wstring name(value_name);
|
||||||
|
events.push_back(L"clear:" + name);
|
||||||
|
const bool succeeds = name == kModeChanged ? mode_marker_clear_succeeds : hdr_marker_clear_succeeds;
|
||||||
|
if (succeeds) dwords[name] = 0;
|
||||||
|
return succeeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeleteRecord() override {
|
||||||
|
++delete_attempts;
|
||||||
|
events.push_back(L"delete");
|
||||||
|
if (!delete_succeeds) return false;
|
||||||
|
dwords.clear();
|
||||||
|
strings.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
size_t EventIndex(const std::vector<std::wstring>& events, const std::wstring& event) {
|
||||||
|
for (size_t index = 0; index < events.size(); ++index) {
|
||||||
|
if (events[index] == event) return index;
|
||||||
|
}
|
||||||
|
return events.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplyModeAfterPreparing(FakeRecoveryBackend& backend) {
|
||||||
|
if (!DisplayModeManager::PrepareModeRecovery(backend, kModeDevice, 3840, 2160, 60)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
backend.events.push_back(L"os:mode");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplyHDRAfterPreparing(FakeRecoveryBackend& backend) {
|
||||||
|
if (!DisplayModeManager::PrepareHDRRecovery(backend, kHDRDevice, false)) return false;
|
||||||
|
backend.events.push_back(L"os:hdr");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestMarkersArePersistedBeforeMutation() {
|
||||||
|
FakeRecoveryBackend mode;
|
||||||
|
Check(ApplyModeAfterPreparing(mode), "a complete mode recovery record must admit the OS mutation");
|
||||||
|
const size_t mode_marker = EventIndex(mode.events, L"write:ModeChanged=1");
|
||||||
|
const size_t mode_os = EventIndex(mode.events, L"os:mode");
|
||||||
|
Check(mode_marker < mode_os, "the mode marker must be durable before the OS mutation");
|
||||||
|
Check(
|
||||||
|
EventIndex(mode.events, L"write:ModeDeviceName") < mode_marker &&
|
||||||
|
EventIndex(mode.events, L"write:OriginalWidth=3840") < mode_marker &&
|
||||||
|
EventIndex(mode.events, L"write:OriginalHeight=2160") < mode_marker &&
|
||||||
|
EventIndex(mode.events, L"write:OriginalRefreshRate=60") < mode_marker,
|
||||||
|
"all mode originals must precede the operation marker");
|
||||||
|
|
||||||
|
FakeRecoveryBackend hdr;
|
||||||
|
Check(ApplyHDRAfterPreparing(hdr), "a complete HDR recovery record must admit the OS mutation");
|
||||||
|
const size_t hdr_marker = EventIndex(hdr.events, L"write:HDRChanged=1");
|
||||||
|
Check(
|
||||||
|
EventIndex(hdr.events, L"write:HDRDeviceName") < hdr_marker &&
|
||||||
|
EventIndex(hdr.events, L"write:OriginalHDREnabled=0") < hdr_marker &&
|
||||||
|
hdr_marker < EventIndex(hdr.events, L"os:hdr"),
|
||||||
|
"the HDR original and marker must be durable before the OS mutation");
|
||||||
|
|
||||||
|
FakeRecoveryBackend failed_marker;
|
||||||
|
failed_marker.final_mode_marker_write_succeeds = false;
|
||||||
|
Check(
|
||||||
|
!ApplyModeAfterPreparing(failed_marker),
|
||||||
|
"an OS mutation must not run when its final recovery marker cannot be persisted");
|
||||||
|
Check(
|
||||||
|
EventIndex(failed_marker.events, L"os:mode") == failed_marker.events.size(),
|
||||||
|
"a failed marker write must leave the display untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestMalformedRecordIsIgnored() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords[kVersion] = 2;
|
||||||
|
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||||
|
|
||||||
|
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an unknown recovery version must be ignored");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") == backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||||
|
"a malformed record must not reach display APIs");
|
||||||
|
Check(backend.delete_attempts == 1 && !backend.RecordExists(), "a malformed record must be discarded");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestValidModeSurvivesMalformedHDR() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords[kOriginalHDR] = 2;
|
||||||
|
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeeded(backend),
|
||||||
|
"malformed HDR evidence must not discard an independently valid mode restore");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||||
|
"only the valid mode operation may reach a display API");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"clear:HDRChanged") < backend.events.size(),
|
||||||
|
"the malformed HDR operation must be discarded independently");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestValidHDRSurvivesMalformedMode() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords.erase(kOriginalHeight);
|
||||||
|
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeeded(backend),
|
||||||
|
"malformed mode evidence must not discard an independently valid HDR restore");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") == backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||||
|
"only the valid HDR operation may reach a display API");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"clear:ModeChanged") < backend.events.size(),
|
||||||
|
"the malformed mode operation must be discarded independently");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPreparationClearsMalformedSibling() {
|
||||||
|
FakeRecoveryBackend mode;
|
||||||
|
mode.SeedBoth();
|
||||||
|
mode.dwords[kModeChanged] = 0;
|
||||||
|
mode.dwords[kOriginalHDR] = 2;
|
||||||
|
Check(ApplyModeAfterPreparing(mode), "a malformed HDR sibling must not block a new valid mode operation");
|
||||||
|
Check(mode.dwords[kHDRChanged] == 0, "mode preparation must not preserve malformed HDR evidence");
|
||||||
|
|
||||||
|
FakeRecoveryBackend hdr;
|
||||||
|
hdr.SeedBoth();
|
||||||
|
hdr.dwords[kHDRChanged] = 0;
|
||||||
|
hdr.dwords.erase(kOriginalHeight);
|
||||||
|
Check(ApplyHDRAfterPreparing(hdr), "a malformed mode sibling must not block a new valid HDR operation");
|
||||||
|
Check(hdr.dwords[kModeChanged] == 0, "HDR preparation must not preserve malformed mode evidence");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestModeAndHDRRestoreIndependently() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.mode_restore_succeeds = false;
|
||||||
|
|
||||||
|
Check(!DisplayModeManager::RecoverIfNeeded(backend), "one failed restore must retain the record");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||||
|
"mode failure must not prevent the independent HDR restore");
|
||||||
|
Check(backend.dwords[kModeChanged] == 1, "the failed mode marker must remain set");
|
||||||
|
Check(backend.dwords[kHDRChanged] == 0, "the successful HDR marker must be cleared");
|
||||||
|
|
||||||
|
backend.events.clear();
|
||||||
|
backend.mode_restore_succeeds = true;
|
||||||
|
Check(DisplayModeManager::RecoverIfNeeded(backend), "a later pass must finish the retained mode restore");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||||
|
"a later pass must not repeat the completed HDR restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestFailedRestoreRemainsForTopologyRetry() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords[kHDRChanged] = 0;
|
||||||
|
backend.device_present[kModeDevice] = false;
|
||||||
|
|
||||||
|
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an absent display must retain its marked restore");
|
||||||
|
Check(backend.dwords[kModeChanged] == 1, "an absent display must keep its operation marker");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") == backend.events.size(),
|
||||||
|
"an absent display must not call its restore API");
|
||||||
|
|
||||||
|
backend.events.clear();
|
||||||
|
backend.device_present[kModeDevice] = true;
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeeded(backend), "a synchronous topology retry must restore a reconnected display");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||||
|
"the topology retry must attempt the retained restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestMarkerClearFailureRemainsRetryable() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords[kHDRChanged] = 0;
|
||||||
|
backend.mode_marker_clear_succeeds = false;
|
||||||
|
|
||||||
|
Check(!DisplayModeManager::RecoverIfNeeded(backend), "marker persistence is part of recovery completion");
|
||||||
|
Check(backend.dwords[kModeChanged] == 1, "a failed marker clear must retain idempotent recovery evidence");
|
||||||
|
|
||||||
|
backend.events.clear();
|
||||||
|
backend.mode_marker_clear_succeeds = true;
|
||||||
|
Check(DisplayModeManager::RecoverIfNeeded(backend), "a later pass must retry after marker persistence failure");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||||
|
"the retained marker must cause the restore to be retried");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestLifecycleCleanupPreservesPersistedSibling() {
|
||||||
|
FakeRecoveryBackend mode_completed;
|
||||||
|
mode_completed.SeedBoth();
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::CompleteRecoveryOperationForTesting(mode_completed, true),
|
||||||
|
"successful mode cleanup must durably clear its own marker");
|
||||||
|
Check(
|
||||||
|
mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kHDRChanged] == 1,
|
||||||
|
"mode cleanup must preserve a persisted HDR sibling even without local HDR ownership");
|
||||||
|
Check(
|
||||||
|
mode_completed.dwords[kOriginalHDR] == 0 && mode_completed.strings[kHDRDeviceName] == kHDRDevice &&
|
||||||
|
mode_completed.delete_attempts == 0,
|
||||||
|
"mode cleanup must retain the HDR original and avoid deleting its record");
|
||||||
|
|
||||||
|
FakeRecoveryBackend hdr_failed_apply;
|
||||||
|
hdr_failed_apply.SeedBoth();
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::CompleteRecoveryOperationForTesting(hdr_failed_apply, false),
|
||||||
|
"failed HDR apply cleanup must durably clear its own marker");
|
||||||
|
Check(
|
||||||
|
hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kModeChanged] == 1,
|
||||||
|
"HDR cleanup must preserve a persisted mode sibling even without local mode ownership");
|
||||||
|
Check(
|
||||||
|
hdr_failed_apply.dwords[kOriginalWidth] == 3840 && hdr_failed_apply.strings[kModeDeviceName] == kModeDevice &&
|
||||||
|
hdr_failed_apply.delete_attempts == 0,
|
||||||
|
"HDR cleanup must retain the mode original and avoid deleting its record");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestReleasedLiveOperationRecoversAfterReconnect() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.device_present[kModeDevice] = false;
|
||||||
|
|
||||||
|
Check(
|
||||||
|
!DisplayModeManager::RecoverIfNeededForTesting(backend, true, true),
|
||||||
|
"topology recovery must not take either genuinely live operation");
|
||||||
|
Check(backend.events.empty(), "live operations must not reach restore or persistence APIs");
|
||||||
|
|
||||||
|
Check(
|
||||||
|
!DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
|
||||||
|
"a released operation must remain marked while its target is absent");
|
||||||
|
Check(
|
||||||
|
backend.dwords[kModeChanged] == 1 && backend.dwords[kHDRChanged] == 1,
|
||||||
|
"an absent released mode and its live HDR sibling must both retain their markers");
|
||||||
|
|
||||||
|
backend.events.clear();
|
||||||
|
backend.device_present[kModeDevice] = true;
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
|
||||||
|
"a topology retry must restore the released mode after reconnect");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||||
|
"reconnect recovery must restore the released mode without stealing live HDR");
|
||||||
|
Check(
|
||||||
|
backend.dwords[kModeChanged] == 0 && backend.dwords[kHDRChanged] == 1 && backend.delete_attempts == 0,
|
||||||
|
"reconnect recovery must preserve the genuinely live sibling record");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestVersionlessModeRecoveryAndCleanup() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.dwords[kModeChanged] = 1;
|
||||||
|
backend.dwords[kHDRChanged] = 0;
|
||||||
|
backend.dwords[kOriginalWidth] = 3840;
|
||||||
|
backend.dwords[kOriginalHeight] = 2160;
|
||||||
|
backend.dwords[kOriginalRefreshRate] = 60;
|
||||||
|
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||||
|
|
||||||
|
Check(DisplayModeManager::RecoverIfNeeded(backend), "the released versionless mode layout must be recovered");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||||
|
"versionless mode recovery must use the backend restore");
|
||||||
|
Check(
|
||||||
|
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||||
|
"completed versionless mode recovery must clean the legacy DeviceName and record");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestVersionlessHDRRecoveryAndCleanup() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.dwords[kModeChanged] = 0;
|
||||||
|
backend.dwords[kHDRChanged] = 1;
|
||||||
|
backend.dwords[kOriginalHDR] = 0;
|
||||||
|
backend.strings[kLegacyDeviceName] = kHDRDevice;
|
||||||
|
|
||||||
|
Check(DisplayModeManager::RecoverIfNeeded(backend), "the released versionless HDR layout must be recovered");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||||
|
"versionless HDR recovery must use the backend restore");
|
||||||
|
Check(
|
||||||
|
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||||
|
"completed versionless HDR recovery must clean the legacy DeviceName and record");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestVersionlessModeAndHDRUseSharedDevice() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.dwords[kModeChanged] = 1;
|
||||||
|
backend.dwords[kHDRChanged] = 1;
|
||||||
|
backend.dwords[kOriginalWidth] = 3840;
|
||||||
|
backend.dwords[kOriginalHeight] = 2160;
|
||||||
|
backend.dwords[kOriginalRefreshRate] = 60;
|
||||||
|
backend.dwords[kOriginalHDR] = 0;
|
||||||
|
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||||
|
backend.expected_hdr_device = kModeDevice;
|
||||||
|
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeeded(backend),
|
||||||
|
"both versionless operations must recover from their shared DeviceName");
|
||||||
|
Check(
|
||||||
|
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||||
|
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||||
|
"the released shared-device layout must restore mode and HDR independently");
|
||||||
|
Check(
|
||||||
|
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||||
|
"shared versionless recovery must remove the legacy record after both markers clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestCleanupFailureDoesNotBlockNewOverride() {
|
||||||
|
FakeRecoveryBackend backend;
|
||||||
|
backend.SeedBoth();
|
||||||
|
backend.dwords[kHDRChanged] = 0;
|
||||||
|
backend.delete_succeeds = false;
|
||||||
|
|
||||||
|
Check(
|
||||||
|
DisplayModeManager::RecoverIfNeeded(backend),
|
||||||
|
"successful restoration must complete even when stale-value deletion fails");
|
||||||
|
Check(backend.dwords[kModeChanged] == 0, "the successful restore marker must be clear");
|
||||||
|
Check(backend.delete_attempts == 1, "completed recovery must make one best-effort cleanup attempt");
|
||||||
|
|
||||||
|
backend.events.clear();
|
||||||
|
Check(ApplyModeAfterPreparing(backend), "failed cleanup must not block persistence or admission of a fresh override");
|
||||||
|
Check(
|
||||||
|
backend.dwords[kModeChanged] == 1 &&
|
||||||
|
EventIndex(backend.events, L"write:ModeChanged=1") < EventIndex(backend.events, L"os:mode"),
|
||||||
|
"the fresh override must replace stale values with a pre-mutation marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace mpv
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
mpv::TestMarkersArePersistedBeforeMutation();
|
||||||
|
mpv::TestMalformedRecordIsIgnored();
|
||||||
|
mpv::TestValidModeSurvivesMalformedHDR();
|
||||||
|
mpv::TestValidHDRSurvivesMalformedMode();
|
||||||
|
mpv::TestPreparationClearsMalformedSibling();
|
||||||
|
mpv::TestModeAndHDRRestoreIndependently();
|
||||||
|
mpv::TestFailedRestoreRemainsForTopologyRetry();
|
||||||
|
mpv::TestMarkerClearFailureRemainsRetryable();
|
||||||
|
mpv::TestLifecycleCleanupPreservesPersistedSibling();
|
||||||
|
mpv::TestReleasedLiveOperationRecoversAfterReconnect();
|
||||||
|
mpv::TestVersionlessModeRecoveryAndCleanup();
|
||||||
|
mpv::TestVersionlessHDRRecoveryAndCleanup();
|
||||||
|
mpv::TestVersionlessModeAndHDRUseSharedDevice();
|
||||||
|
mpv::TestCleanupFailureDoesNotBlockNewOverride();
|
||||||
|
std::cout << "display_mode_manager_test: PASS\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
#include "mpv_player.h"
|
#include "mpv_player.h"
|
||||||
|
|
||||||
|
#include <commctrl.h>
|
||||||
#include <windowsx.h>
|
#include <windowsx.h>
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
#include "sanitize_utf8.h"
|
#include "sanitize_utf8.h"
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
|
struct InnerWindowSubclassState {
|
||||||
|
HWND hwnd = nullptr;
|
||||||
|
std::atomic<HWND> forward_target{nullptr};
|
||||||
|
std::atomic<bool> active{false};
|
||||||
|
UINT_PTR subclass_id = 0;
|
||||||
|
// Guarded by g_inner_subclasses_mutex.
|
||||||
|
bool installed = false;
|
||||||
|
// While true, the window thread may still remove this generation, so a
|
||||||
|
// replacement must not adopt it.
|
||||||
|
bool removal_pending = false;
|
||||||
|
};
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
||||||
@@ -51,8 +66,8 @@ flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
|||||||
// DComp-mode input forwarding. mpv's inner window lives on mpv's own thread
|
// DComp-mode input forwarding. mpv's inner window lives on mpv's own thread
|
||||||
// and consumes input over the video (WS_EX_TRANSPARENT hit-test skipping is
|
// and consumes input over the video (WS_EX_TRANSPARENT hit-test skipping is
|
||||||
// same-thread-only, and disabling the subtree makes the system drop the input
|
// same-thread-only, and disabling the subtree makes the system drop the input
|
||||||
// entirely instead of routing it to a sibling). Subclass the inner window
|
// entirely instead of routing it to a sibling). Use the common-controls
|
||||||
// (legal within one process, even across threads) and forward mouse and pointer
|
// subclass chain with per-window reference data, and forward mouse/pointer
|
||||||
// input to the Flutter view. Pointer messages must be sent synchronously:
|
// input to the Flutter view. Pointer messages must be sent synchronously:
|
||||||
// Flutter calls GetPointerInfo while handling them, and Windows only retains
|
// Flutter calls GetPointerInfo while handling them, and Windows only retains
|
||||||
// that data for the current or forwarded message.
|
// that data for the current or forwarded message.
|
||||||
@@ -74,24 +89,37 @@ static_assert(IsFlutterPointerMessage(WM_POINTERUP));
|
|||||||
static_assert(IsFlutterPointerMessage(WM_POINTERLEAVE));
|
static_assert(IsFlutterPointerMessage(WM_POINTERLEAVE));
|
||||||
static_assert(!IsFlutterPointerMessage(WM_MOUSEMOVE));
|
static_assert(!IsFlutterPointerMessage(WM_MOUSEMOVE));
|
||||||
|
|
||||||
WNDPROC g_mpv_inner_original_proc = nullptr;
|
std::mutex g_inner_subclasses_mutex;
|
||||||
HWND g_mpv_inner_hwnd = nullptr;
|
std::unordered_map<HWND, std::shared_ptr<InnerWindowSubclassState>> g_inner_subclasses;
|
||||||
HWND g_forward_target_view = nullptr;
|
std::atomic<uint64_t> g_next_inner_subclass_generation{1};
|
||||||
|
|
||||||
LRESULT CALLBACK MpvInnerSubclassProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
std::shared_ptr<InnerWindowSubclassState> FindInnerSubclassState(HWND hwnd, DWORD_PTR reference_data) {
|
||||||
if (IsFlutterPointerMessage(message)) {
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
HWND view = g_forward_target_view;
|
const auto it = g_inner_subclasses.find(hwnd);
|
||||||
if (view) {
|
if (it == g_inner_subclasses.end() || reinterpret_cast<DWORD_PTR>(it->second.get()) != reference_data) {
|
||||||
// WM_POINTER coordinates are already in screen space. SendMessage also
|
return nullptr;
|
||||||
// preserves the message association required by GetPointerInfo in the
|
}
|
||||||
// Flutter view's window procedure.
|
return it->second;
|
||||||
::SendMessageW(view, message, wparam, lparam);
|
}
|
||||||
}
|
|
||||||
|
LRESULT CALLBACK MpvInnerSubclassProc(
|
||||||
|
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR subclass_id, DWORD_PTR reference_data) {
|
||||||
|
const auto state = FindInnerSubclassState(hwnd, reference_data);
|
||||||
|
if (!state) {
|
||||||
|
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool active = state->active.load(std::memory_order_acquire);
|
||||||
|
HWND view = active ? state->forward_target.load(std::memory_order_acquire) : nullptr;
|
||||||
|
if (active && view && IsFlutterPointerMessage(message)) {
|
||||||
|
// WM_POINTER coordinates are already in screen space. SendMessage also
|
||||||
|
// preserves the message association required by GetPointerInfo in the
|
||||||
|
// Flutter view's window procedure.
|
||||||
|
::SendMessageW(view, message, wparam, lparam);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) {
|
if (active && message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) {
|
||||||
HWND view = g_forward_target_view;
|
|
||||||
if (view) {
|
if (view) {
|
||||||
LPARAM forwarded = lparam;
|
LPARAM forwarded = lparam;
|
||||||
if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) {
|
if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) {
|
||||||
@@ -101,25 +129,287 @@ LRESULT CALLBACK MpvInnerSubclassProc(HWND hwnd, UINT message, WPARAM wparam, LP
|
|||||||
::MapWindowPoints(hwnd, view, &pt, 1);
|
::MapWindowPoints(hwnd, view, &pt, 1);
|
||||||
forwarded = MAKELPARAM(pt.x, pt.y);
|
forwarded = MAKELPARAM(pt.x, pt.y);
|
||||||
}
|
}
|
||||||
::PostMessage(view, message, wparam, forwarded);
|
::PostMessageW(view, message, wparam, forwarded);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return ::CallWindowProc(g_mpv_inner_original_proc, hwnd, message, wparam, lparam);
|
|
||||||
|
if (message == WM_NCDESTROY) {
|
||||||
|
state->active.store(false, std::memory_order_release);
|
||||||
|
state->forward_target.store(nullptr, std::memory_order_release);
|
||||||
|
::RemoveWindowSubclass(hwnd, MpvInnerSubclassProc, subclass_id);
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(hwnd);
|
||||||
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||||
|
g_inner_subclasses.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subclass mpv's lazily-created inner window if it exists and isn't yet
|
constexpr UINT kSubclassOwnershipMessage = WM_APP + 0x0504;
|
||||||
// subclassed (or was recreated). Idempotent; callable from any thread in
|
|
||||||
// this process.
|
enum class SubclassOwnershipActionPhase {
|
||||||
void EnsureMpvInnerSubclassed(HWND host) {
|
kPending,
|
||||||
if (!host) {
|
kRunning,
|
||||||
|
kCompleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SubclassOwnershipAction {
|
||||||
|
std::shared_ptr<InnerWindowSubclassState> state;
|
||||||
|
bool install;
|
||||||
|
std::mutex mutex;
|
||||||
|
SubclassOwnershipActionPhase phase = SubclassOwnershipActionPhase::kPending;
|
||||||
|
bool cancelled = false;
|
||||||
|
bool success = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::mutex g_subclass_actions_mutex;
|
||||||
|
std::unordered_map<UINT_PTR, std::shared_ptr<SubclassOwnershipAction>> g_subclass_actions;
|
||||||
|
std::atomic<UINT_PTR> g_next_subclass_action{1};
|
||||||
|
|
||||||
|
void ForgetInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||||
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||||
|
g_inner_subclasses.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplySubclassOwnershipAction(const SubclassOwnershipAction& action) {
|
||||||
|
if (action.install) {
|
||||||
|
return ::SetWindowSubclass(
|
||||||
|
action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id,
|
||||||
|
reinterpret_cast<DWORD_PTR>(action.state.get())) != FALSE;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(action.state->hwnd);
|
||||||
|
if (it == g_inner_subclasses.end() || it->second.get() != action.state.get()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool removed =
|
||||||
|
::RemoveWindowSubclass(action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id) != FALSE;
|
||||||
|
if (removed) {
|
||||||
|
g_inner_subclasses.erase(it);
|
||||||
|
} else {
|
||||||
|
action.state->removal_pending = false;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ExecuteSubclassOwnershipAction(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
if (action->phase != SubclassOwnershipActionPhase::kPending) return;
|
||||||
|
if (action->cancelled) {
|
||||||
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||||
|
if (action->install) {
|
||||||
|
ForgetInnerSubclassState(action->state);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action->phase = SubclassOwnershipActionPhase::kRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool applied = ApplySubclassOwnershipAction(*action);
|
||||||
|
bool cancelled_install = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
cancelled_install = action->install && action->cancelled;
|
||||||
|
if (!cancelled_install) {
|
||||||
|
action->success = applied;
|
||||||
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled_install) {
|
||||||
|
if (action->install && !applied) {
|
||||||
|
ForgetInnerSubclassState(action->state);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HWND inner = ::FindWindowExW(host, nullptr, nullptr, nullptr);
|
|
||||||
if (inner && inner != g_mpv_inner_hwnd) {
|
// A timeout can race an action that the window thread has already begun.
|
||||||
g_mpv_inner_hwnd = inner;
|
// Remove a late install on that same thread before releasing the action's
|
||||||
g_mpv_inner_original_proc = reinterpret_cast<WNDPROC>(
|
// shared ownership of the reference data used by the subclass callback.
|
||||||
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(MpvInnerSubclassProc)));
|
const bool detached = !applied || ::RemoveWindowSubclass(
|
||||||
|
action->state->hwnd, MpvInnerSubclassProc, action->state->subclass_id) != FALSE;
|
||||||
|
if (detached) {
|
||||||
|
ForgetInnerSubclassState(action->state);
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
action->success = false;
|
||||||
|
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK SubclassOwnershipHook(int code, WPARAM wparam, LPARAM lparam) {
|
||||||
|
if (code >= 0) {
|
||||||
|
const auto* message = reinterpret_cast<const CWPSTRUCT*>(lparam);
|
||||||
|
if (message && message->message == kSubclassOwnershipMessage) {
|
||||||
|
std::shared_ptr<SubclassOwnershipAction> action;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||||
|
const auto it = g_subclass_actions.find(static_cast<UINT_PTR>(message->wParam));
|
||||||
|
if (it != g_subclass_actions.end() && it->second->state->hwnd == message->hwnd) {
|
||||||
|
action = it->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
ExecuteSubclassOwnershipAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ::CallNextHookEx(nullptr, code, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RunSubclassOwnershipActionOnWindowThread(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
||||||
|
DWORD window_thread = ::GetWindowThreadProcessId(action->state->hwnd, nullptr);
|
||||||
|
if (!window_thread) return false;
|
||||||
|
if (window_thread == ::GetCurrentThreadId()) {
|
||||||
|
ExecuteSubclassOwnershipAction(action);
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
return action->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
HHOOK hook = ::SetWindowsHookExW(WH_CALLWNDPROC, SubclassOwnershipHook, nullptr, window_thread);
|
||||||
|
if (!hook) return false;
|
||||||
|
|
||||||
|
const UINT_PTR action_id = g_next_subclass_action.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||||
|
g_subclass_actions[action_id] = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD_PTR message_result = 0;
|
||||||
|
::SendMessageTimeoutW(
|
||||||
|
action->state->hwnd, kSubclassOwnershipMessage, action_id, 0, SMTO_ABORTIFHUNG, 1000, &message_result);
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
{
|
||||||
|
// Completion and cancellation use the same lock. If the callback won the
|
||||||
|
// race, its acknowledged result is authoritative. Otherwise it observes
|
||||||
|
// cancellation and cannot leave a late install referencing released data.
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
if (action->phase == SubclassOwnershipActionPhase::kCompleted) {
|
||||||
|
success = action->success;
|
||||||
|
} else {
|
||||||
|
action->cancelled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||||
|
const auto it = g_subclass_actions.find(action_id);
|
||||||
|
if (it != g_subclass_actions.end() && it->second == action) {
|
||||||
|
g_subclass_actions.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
::UnhookWindowsHookEx(hook);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<InnerWindowSubclassState> InstallMpvInnerSubclass(HWND inner, HWND forward_target) {
|
||||||
|
if (!inner || !forward_target) return nullptr;
|
||||||
|
|
||||||
|
std::shared_ptr<InnerWindowSubclassState> state;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto existing = g_inner_subclasses.find(inner);
|
||||||
|
if (existing != g_inner_subclasses.end()) {
|
||||||
|
const auto& retained = existing->second;
|
||||||
|
if (!retained->installed || retained->active.load(std::memory_order_acquire) || retained->removal_pending) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timed-out detach that never reached the window thread leaves the
|
||||||
|
// helper-chain entry installed. Adopt that exact generation rather than
|
||||||
|
// stacking a duplicate subclass or retaining a permanently inert entry.
|
||||||
|
retained->forward_target.store(forward_target, std::memory_order_release);
|
||||||
|
retained->active.store(true, std::memory_order_release);
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
state = std::make_shared<InnerWindowSubclassState>();
|
||||||
|
state->hwnd = inner;
|
||||||
|
state->forward_target.store(forward_target, std::memory_order_relaxed);
|
||||||
|
state->subclass_id = g_next_inner_subclass_generation.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Publish the state before installing the helper-chain entry. The
|
||||||
|
// callback's reference data identifies this exact generation, so an old
|
||||||
|
// callback can never resolve a replacement generation that reuses HWND.
|
||||||
|
g_inner_subclasses[inner] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto action = std::make_shared<SubclassOwnershipAction>();
|
||||||
|
action->state = state;
|
||||||
|
action->install = true;
|
||||||
|
if (!RunSubclassOwnershipActionOnWindowThread(action)) {
|
||||||
|
bool action_never_started = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
action_never_started = action->phase == SubclassOwnershipActionPhase::kPending;
|
||||||
|
}
|
||||||
|
if (action_never_started) {
|
||||||
|
ForgetInnerSubclassState(state);
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(inner);
|
||||||
|
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
state->installed = true;
|
||||||
|
state->active.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetachMpvInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
// Invalidate forwarding before removing the helper-chain entry. A callback
|
||||||
|
// already holding this generation can still call DefSubclassProc, but can
|
||||||
|
// no longer target a replacement Flutter/player generation.
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||||
|
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state->active.store(false, std::memory_order_release);
|
||||||
|
state->forward_target.store(nullptr, std::memory_order_release);
|
||||||
|
state->removal_pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto action = std::make_shared<SubclassOwnershipAction>();
|
||||||
|
action->state = state;
|
||||||
|
action->install = false;
|
||||||
|
const bool detached = RunSubclassOwnershipActionOnWindowThread(action);
|
||||||
|
if (!detached) {
|
||||||
|
bool removal_not_applied = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(action->mutex);
|
||||||
|
removal_not_applied = action->phase == SubclassOwnershipActionPhase::kPending ||
|
||||||
|
(action->phase == SubclassOwnershipActionPhase::kCompleted && !action->success);
|
||||||
|
}
|
||||||
|
if (removal_not_applied) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||||
|
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||||
|
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||||
|
// RunSubclassOwnershipActionOnWindowThread has already unregistered
|
||||||
|
// the cancelled action and hook. The installed entry is now stable
|
||||||
|
// and can be reactivated by a replacement owner.
|
||||||
|
state->removal_pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!detached && !::IsWindow(state->hwnd)) {
|
||||||
|
// A destroyed HWND has already discarded its subclass chain, so no
|
||||||
|
// callback can retain the reference data even if dispatch was unavailable.
|
||||||
|
ForgetInnerSubclassState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +419,29 @@ MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
|
|||||||
|
|
||||||
MpvPlayer::~MpvPlayer() { Dispose(); }
|
MpvPlayer::~MpvPlayer() { Dispose(); }
|
||||||
|
|
||||||
|
void MpvPlayer::EnsureMpvInnerSubclassed() {
|
||||||
|
if (!hwnd_ || !forward_target_view_) return;
|
||||||
|
|
||||||
|
HWND inner = ::FindWindowExW(hwnd_, nullptr, nullptr, nullptr);
|
||||||
|
if (!inner) return;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
||||||
|
if (inner_subclass_ && inner_subclass_->hwnd == inner && inner_subclass_->active.load(std::memory_order_acquire)) {
|
||||||
|
inner_subclass_->forward_target.store(forward_target_view_, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DetachMpvInnerSubclassState(inner_subclass_);
|
||||||
|
inner_subclass_.reset();
|
||||||
|
inner_subclass_ = InstallMpvInnerSubclass(inner, forward_target_view_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MpvPlayer::DetachMpvInnerSubclass() {
|
||||||
|
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
||||||
|
DetachMpvInnerSubclassState(inner_subclass_);
|
||||||
|
inner_subclass_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
bool MpvPlayer::Initialize(HWND view) {
|
bool MpvPlayer::Initialize(HWND view) {
|
||||||
if (mpv_) {
|
if (mpv_) {
|
||||||
return true; // Already initialized.
|
return true; // Already initialized.
|
||||||
@@ -166,7 +479,7 @@ bool MpvPlayer::Initialize(HWND view) {
|
|||||||
mpv_ = nullptr;
|
mpv_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
g_forward_target_view = view;
|
forward_target_view_ = view;
|
||||||
|
|
||||||
// Set the wid option to embed mpv in our window.
|
// Set the wid option to embed mpv in our window.
|
||||||
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
|
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
|
||||||
@@ -208,6 +521,8 @@ bool MpvPlayer::Initialize(HWND view) {
|
|||||||
int err = mpv_initialize(mpv_);
|
int err = mpv_initialize(mpv_);
|
||||||
if (err < 0) {
|
if (err < 0) {
|
||||||
if (hwnd_) {
|
if (hwnd_) {
|
||||||
|
DetachMpvInnerSubclass();
|
||||||
|
forward_target_view_ = nullptr;
|
||||||
::DestroyWindow(hwnd_);
|
::DestroyWindow(hwnd_);
|
||||||
hwnd_ = nullptr;
|
hwnd_ = nullptr;
|
||||||
}
|
}
|
||||||
@@ -244,17 +559,15 @@ void MpvPlayer::Dispose() {
|
|||||||
auto* handle = mpv_;
|
auto* handle = mpv_;
|
||||||
mpv_ = nullptr;
|
mpv_ = nullptr;
|
||||||
|
|
||||||
|
// The input subclass must stop referencing this player generation before
|
||||||
|
// either the host HWND or the player object can be destroyed.
|
||||||
|
DetachMpvInnerSubclass();
|
||||||
|
forward_target_view_ = nullptr;
|
||||||
|
|
||||||
if (hwnd_) {
|
if (hwnd_) {
|
||||||
::ShowWindow(hwnd_, SW_HIDE);
|
::ShowWindow(hwnd_, SW_HIDE);
|
||||||
::DestroyWindow(hwnd_);
|
::DestroyWindow(hwnd_);
|
||||||
hwnd_ = nullptr;
|
hwnd_ = nullptr;
|
||||||
|
|
||||||
// The subclassed inner window died with hwnd_; clear the forwarding
|
|
||||||
// state. Only the owner of the window may do this: the audio-only core
|
|
||||||
// (which never has an hwnd_) must not wipe the video instance's state.
|
|
||||||
g_mpv_inner_hwnd = nullptr;
|
|
||||||
g_mpv_inner_original_proc = nullptr;
|
|
||||||
g_forward_target_view = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -351,7 +664,7 @@ void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) {
|
|||||||
// mpv creates its inner window lazily on its own thread; subclass it (and
|
// mpv creates its inner window lazily on its own thread; subclass it (and
|
||||||
// re-subclass if mpv ever recreates it) so mouse and pointer input over the
|
// re-subclass if mpv ever recreates it) so mouse and pointer input over the
|
||||||
// video is forwarded to the Flutter view.
|
// video is forwarded to the Flutter view.
|
||||||
EnsureMpvInnerSubclassed(hwnd_);
|
EnsureMpvInnerSubclassed();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::SetVisible(bool visible) {
|
void MpvPlayer::SetVisible(bool visible) {
|
||||||
@@ -388,11 +701,11 @@ void MpvPlayer::LogRecovery(const std::string& text) {
|
|||||||
SendEvent("log-message", data);
|
SendEvent("log-message", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
|
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
|
||||||
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
||||||
const std::string reason_copy = reason;
|
const std::string reason_copy = reason;
|
||||||
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
|
CommandAsync({"ao-reload"}, [this, reason_copy, attempt, request_generation](int error) {
|
||||||
audio_recovery_.CompleteReload();
|
audio_recovery_.CompleteReload(request_generation);
|
||||||
LogRecovery(
|
LogRecovery(
|
||||||
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
||||||
", error=" + std::to_string(error) + ")");
|
", error=" + std::to_string(error) + ")");
|
||||||
@@ -405,7 +718,7 @@ void MpvPlayer::MaybeRunAudioRecovery() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
||||||
TryAudioReload(reason, action.attempt);
|
TryAudioReload(reason, action.attempt, action.request_generation);
|
||||||
if (action.exhausted) {
|
if (action.exhausted) {
|
||||||
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
||||||
}
|
}
|
||||||
@@ -557,7 +870,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
// mpv's inner window exists by now (vo is configured); make sure the
|
// mpv's inner window exists by now (vo is configured); make sure the
|
||||||
// DComp-mode input forwarding subclass is installed. SetRect alone can
|
// DComp-mode input forwarding subclass is installed. SetRect alone can
|
||||||
// miss it: the rect often settles before mpv creates the window.
|
// miss it: the rect often settles before mpv creates the window.
|
||||||
EnsureMpvInnerSubclassed(hwnd_);
|
EnsureMpvInnerSubclassed();
|
||||||
SendEvent("playback-restart");
|
SendEvent("playback-restart");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "../../../shared/mpv/mpv_player_common.h"
|
#include "../../../shared/mpv/mpv_player_common.h"
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
struct InnerWindowSubclassState;
|
||||||
|
|
||||||
// Wrapper for libmpv that handles initialization, commands, properties,
|
// Wrapper for libmpv that handles initialization, commands, properties,
|
||||||
// and event dispatching.
|
// and event dispatching.
|
||||||
@@ -96,12 +97,17 @@ class MpvPlayer {
|
|||||||
void SendPropertyChange(const char* name, mpv_node* data);
|
void SendPropertyChange(const char* name, mpv_node* data);
|
||||||
void SendEvent(const std::string& name, const flutter::EncodableMap& data = {});
|
void SendEvent(const std::string& name, const flutter::EncodableMap& data = {});
|
||||||
void MaybeRunAudioRecovery();
|
void MaybeRunAudioRecovery();
|
||||||
void TryAudioReload(const char* reason, int attempt);
|
void TryAudioReload(const char* reason, int attempt, uint64_t request_generation);
|
||||||
void LogRecovery(const std::string& text);
|
void LogRecovery(const std::string& text);
|
||||||
|
void EnsureMpvInnerSubclassed();
|
||||||
|
void DetachMpvInnerSubclass();
|
||||||
|
|
||||||
const bool audio_only_;
|
const bool audio_only_;
|
||||||
mpv_handle* mpv_ = nullptr;
|
mpv_handle* mpv_ = nullptr;
|
||||||
HWND hwnd_ = nullptr;
|
HWND hwnd_ = nullptr;
|
||||||
|
HWND forward_target_view_ = nullptr;
|
||||||
|
std::mutex inner_subclass_mutex_;
|
||||||
|
std::shared_ptr<InnerWindowSubclassState> inner_subclass_;
|
||||||
|
|
||||||
std::thread event_thread_;
|
std::thread event_thread_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
#include <atomic>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <future>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "mpv_player.h"
|
#include "mpv_player.h"
|
||||||
@@ -11,6 +14,25 @@ class MpvPlayerPropertyContractTestPeer {
|
|||||||
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
|
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
|
||||||
player.pending_requests_.RegisterStatus(std::move(callback));
|
player.pending_requests_.RegisterStatus(std::move(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void RegisterPendingPropertyRead(MpvPlayer& player, MpvPlayer::GetPropertyCallback callback) {
|
||||||
|
player.pending_requests_.RegisterProperty(std::move(callback));
|
||||||
|
}
|
||||||
|
static void ConfigureInnerSubclass(MpvPlayer& player, HWND host, HWND target) {
|
||||||
|
player.hwnd_ = host;
|
||||||
|
player.forward_target_view_ = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void EnsureInnerSubclass(MpvPlayer& player) { player.EnsureMpvInnerSubclassed(); }
|
||||||
|
|
||||||
|
static void DetachInnerSubclass(MpvPlayer& player) { player.DetachMpvInnerSubclass(); }
|
||||||
|
static const void* InnerSubclassIdentity(const MpvPlayer& player) { return player.inner_subclass_.get(); }
|
||||||
|
|
||||||
|
static void ReleaseTestWindows(MpvPlayer& player) {
|
||||||
|
player.DetachMpvInnerSubclass();
|
||||||
|
player.hwnd_ = nullptr;
|
||||||
|
player.forward_target_view_ = nullptr;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -22,6 +44,33 @@ void Check(bool condition, const char* message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::atomic<int> g_forwarded_mouse_messages{0};
|
||||||
|
std::atomic<int> g_forwarded_pointer_messages{0};
|
||||||
|
constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505;
|
||||||
|
std::atomic<HANDLE> g_block_entered{nullptr};
|
||||||
|
std::atomic<HANDLE> g_block_release{nullptr};
|
||||||
|
|
||||||
|
LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||||
|
if (message == kBlockWindowThreadMessage) {
|
||||||
|
const HANDLE entered = g_block_entered.load(std::memory_order_acquire);
|
||||||
|
const HANDLE release = g_block_release.load(std::memory_order_acquire);
|
||||||
|
if (entered && release) {
|
||||||
|
::SetEvent(entered);
|
||||||
|
::WaitForSingleObject(release, INFINITE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_POINTERUPDATE) {
|
||||||
|
g_forwarded_pointer_messages.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_MOUSEMOVE) {
|
||||||
|
g_forwarded_mouse_messages.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ::DefWindowProcW(hwnd, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
void TestUnavailablePropertyWriteFails() {
|
void TestUnavailablePropertyWriteFails() {
|
||||||
MpvPlayer player;
|
MpvPlayer player;
|
||||||
int callback_count = 0;
|
int callback_count = 0;
|
||||||
@@ -53,12 +102,271 @@ void TestPendingPropertyWriteFailsOnDispose() {
|
|||||||
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
|
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestPendingRequestTypesRemainDistinctOnDispose() {
|
||||||
|
MpvPlayer player;
|
||||||
|
int write_count = 0;
|
||||||
|
int read_count = 0;
|
||||||
|
std::string read_value = "unexpected";
|
||||||
|
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
|
||||||
|
Check(error < 0, "cancelled property write must receive an error");
|
||||||
|
++write_count;
|
||||||
|
});
|
||||||
|
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyRead(player, [&](int error, const std::string& value) {
|
||||||
|
Check(error < 0, "cancelled property read must receive an error");
|
||||||
|
++read_count;
|
||||||
|
read_value = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
player.Dispose();
|
||||||
|
Check(write_count == 1, "dispose must complete the typed write request exactly once");
|
||||||
|
Check(read_count == 1, "dispose must complete the typed read request exactly once");
|
||||||
|
Check(read_value.empty(), "cancelled property reads must not manufacture a value");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestInnerSubclassOwnershipIsSerializedAndDetached() {
|
||||||
|
struct TestWindows {
|
||||||
|
HWND target;
|
||||||
|
HWND host;
|
||||||
|
HWND inner;
|
||||||
|
WNDPROC inner_original;
|
||||||
|
DWORD owner_thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::promise<TestWindows> windows_created;
|
||||||
|
auto windows_future = windows_created.get_future();
|
||||||
|
std::thread window_owner([&]() {
|
||||||
|
HWND target =
|
||||||
|
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
|
||||||
|
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
|
||||||
|
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
|
||||||
|
const auto target_original = reinterpret_cast<WNDPROC>(
|
||||||
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
|
||||||
|
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
|
||||||
|
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
|
||||||
|
|
||||||
|
MSG message;
|
||||||
|
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||||
|
::TranslateMessage(&message);
|
||||||
|
::DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
|
||||||
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
|
||||||
|
::DestroyWindow(inner);
|
||||||
|
::DestroyWindow(host);
|
||||||
|
::DestroyWindow(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestWindows windows = windows_future.get();
|
||||||
|
Check(windows.target && windows.host && windows.inner, "test windows must be created");
|
||||||
|
MpvPlayer player;
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
|
||||||
|
std::thread first([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
|
||||||
|
std::thread second([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
|
||||||
|
first.join();
|
||||||
|
second.join();
|
||||||
|
|
||||||
|
const auto installed = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
|
||||||
|
Check(installed && installed != windows.inner_original, "exactly one subclass procedure must be installed");
|
||||||
|
::SendMessageW(windows.inner, WM_NULL, 0, 0);
|
||||||
|
|
||||||
|
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(4, 7));
|
||||||
|
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
|
||||||
|
::Sleep(10);
|
||||||
|
}
|
||||||
|
Check(g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "active generation must forward mouse input");
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(player);
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
|
||||||
|
"detach must restore the original procedure before window destruction");
|
||||||
|
|
||||||
|
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(8, 9));
|
||||||
|
::Sleep(30);
|
||||||
|
Check(
|
||||||
|
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1,
|
||||||
|
"a callback after detaching the old generation must be ignored");
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
|
||||||
|
const auto replacement = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
|
||||||
|
Check(replacement && replacement != windows.inner_original, "a replacement generation must install cleanly");
|
||||||
|
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(10, 11));
|
||||||
|
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 2; ++attempt) {
|
||||||
|
::Sleep(10);
|
||||||
|
}
|
||||||
|
Check(
|
||||||
|
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 2,
|
||||||
|
"replacement generation must own forwarding after installation");
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
|
||||||
|
"replacement detach must restore the original procedure");
|
||||||
|
|
||||||
|
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
|
||||||
|
window_owner.join();
|
||||||
|
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestTimedOutSubclassDetachCanBeAdopted() {
|
||||||
|
struct TestWindows {
|
||||||
|
HWND target;
|
||||||
|
HWND host;
|
||||||
|
HWND inner;
|
||||||
|
WNDPROC inner_original;
|
||||||
|
DWORD owner_thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::promise<TestWindows> windows_created;
|
||||||
|
auto windows_future = windows_created.get_future();
|
||||||
|
std::thread window_owner([&]() {
|
||||||
|
HWND target =
|
||||||
|
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
|
||||||
|
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
|
||||||
|
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
|
||||||
|
const auto target_original = reinterpret_cast<WNDPROC>(
|
||||||
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
|
||||||
|
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
|
||||||
|
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
|
||||||
|
|
||||||
|
MSG message;
|
||||||
|
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||||
|
::TranslateMessage(&message);
|
||||||
|
::DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
|
||||||
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
|
||||||
|
::DestroyWindow(inner);
|
||||||
|
::DestroyWindow(host);
|
||||||
|
::DestroyWindow(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestWindows windows = windows_future.get();
|
||||||
|
Check(windows.target && windows.host && windows.inner, "detach-timeout test windows must be created");
|
||||||
|
const HANDLE block_entered = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||||
|
const HANDLE block_release = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||||
|
Check(block_entered && block_release, "detach-timeout synchronization events must be created");
|
||||||
|
g_block_entered.store(block_entered, std::memory_order_release);
|
||||||
|
g_block_release.store(block_release, std::memory_order_release);
|
||||||
|
g_forwarded_pointer_messages.store(0, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
{
|
||||||
|
MpvPlayer original;
|
||||||
|
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(original, windows.host, windows.target);
|
||||||
|
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(original);
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
|
||||||
|
"the initial generation must be installed before forcing detach timeout");
|
||||||
|
const void* retained_generation = MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(original);
|
||||||
|
Check(retained_generation != nullptr, "the initial generation must have live state");
|
||||||
|
|
||||||
|
Check(
|
||||||
|
::PostMessageW(windows.target, kBlockWindowThreadMessage, 0, 0) != FALSE,
|
||||||
|
"the owner-thread blocking message must be posted");
|
||||||
|
Check(
|
||||||
|
::WaitForSingleObject(block_entered, 1000) == WAIT_OBJECT_0,
|
||||||
|
"the owner thread must enter the deterministic blocking message");
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(original);
|
||||||
|
|
||||||
|
MpvPlayer replacement;
|
||||||
|
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(replacement, windows.host, windows.target);
|
||||||
|
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(replacement);
|
||||||
|
Check(
|
||||||
|
MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(replacement) == retained_generation,
|
||||||
|
"replacement must atomically adopt the retained generation");
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
|
||||||
|
"replacement must adopt the retained installed generation without duplicate subclassing");
|
||||||
|
|
||||||
|
::SetEvent(block_release);
|
||||||
|
::SendMessageW(windows.inner, WM_POINTERUPDATE, 0, MAKELPARAM(12, 13));
|
||||||
|
Check(
|
||||||
|
g_forwarded_pointer_messages.load(std::memory_order_relaxed) == 1,
|
||||||
|
"the adopted generation must resume pointer forwarding");
|
||||||
|
|
||||||
|
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(replacement);
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
|
||||||
|
"adopted generation cleanup must eventually restore the original procedure");
|
||||||
|
}
|
||||||
|
|
||||||
|
g_block_entered.store(nullptr, std::memory_order_release);
|
||||||
|
g_block_release.store(nullptr, std::memory_order_release);
|
||||||
|
::CloseHandle(block_entered);
|
||||||
|
::CloseHandle(block_release);
|
||||||
|
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
|
||||||
|
window_owner.join();
|
||||||
|
g_forwarded_pointer_messages.store(0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestTimedOutSubclassInstallCannotOutliveItsState() {
|
||||||
|
struct TestWindows {
|
||||||
|
HWND target;
|
||||||
|
HWND host;
|
||||||
|
HWND inner;
|
||||||
|
WNDPROC inner_original;
|
||||||
|
DWORD owner_thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::promise<TestWindows> windows_created;
|
||||||
|
auto windows_future = windows_created.get_future();
|
||||||
|
std::promise<void> begin_dispatch;
|
||||||
|
auto begin_dispatch_future = begin_dispatch.get_future();
|
||||||
|
std::thread window_owner([&]() {
|
||||||
|
HWND target =
|
||||||
|
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
|
||||||
|
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
|
||||||
|
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
|
||||||
|
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
|
||||||
|
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
|
||||||
|
|
||||||
|
// Keep the owning thread alive but unavailable long enough for
|
||||||
|
// SendMessageTimeoutW to cancel the cross-thread ownership action.
|
||||||
|
begin_dispatch_future.wait();
|
||||||
|
MSG message;
|
||||||
|
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||||
|
::TranslateMessage(&message);
|
||||||
|
::DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
|
||||||
|
::DestroyWindow(inner);
|
||||||
|
::DestroyWindow(host);
|
||||||
|
::DestroyWindow(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestWindows windows = windows_future.get();
|
||||||
|
Check(windows.target && windows.host && windows.inner, "timeout test windows must be created");
|
||||||
|
{
|
||||||
|
MpvPlayer player;
|
||||||
|
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
|
||||||
|
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
|
||||||
|
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The action and its subclass reference data have now left caller scope.
|
||||||
|
// Dispatching the timed-out message must neither install late nor touch the
|
||||||
|
// destroyed caller state.
|
||||||
|
begin_dispatch.set_value();
|
||||||
|
::SendMessageW(windows.inner, WM_NULL, 0, 0);
|
||||||
|
Check(
|
||||||
|
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
|
||||||
|
"a timed-out action must remain cancelled after the window thread resumes");
|
||||||
|
|
||||||
|
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
|
||||||
|
window_owner.join();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
mpv::TestUnavailablePropertyWriteFails();
|
mpv::TestUnavailablePropertyWriteFails();
|
||||||
mpv::TestPendingPropertyWriteFailsOnDispose();
|
mpv::TestPendingPropertyWriteFailsOnDispose();
|
||||||
|
mpv::TestPendingRequestTypesRemainDistinctOnDispose();
|
||||||
|
mpv::TestInnerSubclassOwnershipIsSerializedAndDetached();
|
||||||
|
mpv::TestTimedOutSubclassDetachCanBeAdopted();
|
||||||
|
mpv::TestTimedOutSubclassInstallCannotOutliveItsState();
|
||||||
std::cout << "mpv_player_property_contract_test: PASS\n";
|
std::cout << "mpv_player_property_contract_test: PASS\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef
|
|||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50;
|
constexpr UINT kPlatformTaskMessage = WM_APP + 0x04D0;
|
||||||
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x4D51;
|
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x04D1;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void MpvPlayerPlugin::RegisterWithRegistrar(
|
void MpvPlayerPlugin::RegisterWithRegistrar(
|
||||||
@@ -67,6 +67,7 @@ MpvPlayerPlugin::MpvPlayerPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
MpvPlayerPlugin::~MpvPlayerPlugin() {
|
MpvPlayerPlugin::~MpvPlayerPlugin() {
|
||||||
|
player_generation_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
// Join the mpv event thread before draining: it enqueues platform tasks,
|
// Join the mpv event thread before draining: it enqueues platform tasks,
|
||||||
// and platform_tasks_/platform_tasks_mutex_ are destroyed before player_
|
// and platform_tasks_/platform_tasks_mutex_ are destroyed before player_
|
||||||
// (reverse declaration order).
|
// (reverse declaration order).
|
||||||
@@ -180,12 +181,14 @@ void MpvPlayerPlugin::HandleMethodCall(
|
|||||||
// core is windowless, so it gets no view at all.
|
// core is windowless, so it gets no view at all.
|
||||||
HWND view = audio_only_ ? nullptr : GetChildWindow();
|
HWND view = audio_only_ ? nullptr : GetChildWindow();
|
||||||
|
|
||||||
|
const uint64_t generation = player_generation_.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||||
player_ = std::make_unique<MpvPlayer>(audio_only_);
|
player_ = std::make_unique<MpvPlayer>(audio_only_);
|
||||||
bool success = player_->Initialize(view);
|
bool success = player_->Initialize(view);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Set up event callback.
|
// Set up event callback.
|
||||||
player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); });
|
player_->SetEventCallback(
|
||||||
|
[this, generation](const flutter::EncodableValue& event) { SendEvent(generation, event); });
|
||||||
|
|
||||||
if (!audio_only_) {
|
if (!audio_only_) {
|
||||||
// Start hidden.
|
// Start hidden.
|
||||||
@@ -197,6 +200,7 @@ void MpvPlayerPlugin::HandleMethodCall(
|
|||||||
result->Error("INIT_FAILED", "Failed to initialize MPV player");
|
result->Error("INIT_FAILED", "Failed to initialize MPV player");
|
||||||
}
|
}
|
||||||
} else if (method == "dispose") {
|
} else if (method == "dispose") {
|
||||||
|
player_generation_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
if (player_) {
|
if (player_) {
|
||||||
player_->Dispose();
|
player_->Dispose();
|
||||||
player_.reset();
|
player_.reset();
|
||||||
@@ -524,12 +528,13 @@ void MpvPlayerPlugin::HandleMethodCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayerPlugin::SendEvent(const flutter::EncodableValue& event) {
|
void MpvPlayerPlugin::SendEvent(uint64_t player_generation, const flutter::EncodableValue& event) {
|
||||||
// mpv events arrive on the mpv event thread; Flutter channel APIs are
|
// mpv events arrive on the mpv event thread; Flutter channel APIs are
|
||||||
// platform-thread-only, so marshal onto the platform thread (the sink
|
// platform-thread-only. Capture the player generation at receipt so queued
|
||||||
// null-check then also runs on the same thread as onListen/onCancel).
|
// property/event callbacks from a disposed player cannot publish into its
|
||||||
PostToPlatformThread([this, event]() {
|
// replacement's stream.
|
||||||
if (event_sink_) {
|
PostToPlatformThread([this, player_generation, event]() {
|
||||||
|
if (player_generation_.load(std::memory_order_acquire) == player_generation && event_sink_) {
|
||||||
event_sink_->Success(event);
|
event_sink_->Success(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#include <flutter/plugin_registrar_windows.h>
|
#include <flutter/plugin_registrar_windows.h>
|
||||||
#include <flutter/standard_method_codec.h>
|
#include <flutter/standard_method_codec.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -44,7 +46,7 @@ class MpvPlayerPlugin : public flutter::Plugin {
|
|||||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||||
|
|
||||||
void SendEvent(const flutter::EncodableValue& event);
|
void SendEvent(uint64_t player_generation, const flutter::EncodableValue& event);
|
||||||
void PostToPlatformThread(std::function<void()> task);
|
void PostToPlatformThread(std::function<void()> task);
|
||||||
void DrainPlatformTasks();
|
void DrainPlatformTasks();
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ class MpvPlayerPlugin : public flutter::Plugin {
|
|||||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>> event_sink_;
|
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>> event_sink_;
|
||||||
|
|
||||||
std::unique_ptr<MpvPlayer> player_;
|
std::unique_ptr<MpvPlayer> player_;
|
||||||
|
std::atomic<uint64_t> player_generation_{0};
|
||||||
DisplayModeManager display_mode_manager_;
|
DisplayModeManager display_mode_manager_;
|
||||||
std::optional<int32_t> proc_id_;
|
std::optional<int32_t> proc_id_;
|
||||||
std::mutex platform_tasks_mutex_;
|
std::mutex platform_tasks_mutex_;
|
||||||
|
|||||||
Reference in New Issue
Block a user