fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+8 -1
View File
@@ -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>
@@ -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))
@@ -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")
} }
@@ -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 {
@@ -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) {
@@ -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
@@ -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) {
@@ -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()
}
}
+6
View File
@@ -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)
@@ -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)
}
}
@@ -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
}
}
}
+305 -59
View File
@@ -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
} }
+2 -2
View File
@@ -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
} }
+31 -2
View File
@@ -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
+586
View File
@@ -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,
+110 -3
View File
@@ -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;
}
} }
+14 -1
View File
@@ -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
View File
@@ -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();
} }
} }
+322 -92
View File
@@ -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
View File
@@ -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);
} }
+17
View File
@@ -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,
+22 -2
View File
@@ -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;
} }
+26 -5
View File
@@ -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;
+32 -11
View File
@@ -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;
+32 -5
View File
@@ -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();
} }
-1
View File
@@ -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,
+32 -4
View File
@@ -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.
+17
View File
@@ -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
+65 -8
View File
@@ -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()
+129
View File
@@ -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
+47
View File
@@ -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_
+129
View File
@@ -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
View File
@@ -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
+59 -6
View File
@@ -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};
+490 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)); }
+6
View File
@@ -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);
+3 -3
View File
@@ -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;
}; };
+126
View File
@@ -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,
@@ -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(
@@ -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)
@@ -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))
+80 -20
View File
@@ -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) {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+235 -34
View File
@@ -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
+292 -100
View File
@@ -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
} }
+6 -4
View File
@@ -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_
+67 -25
View File
@@ -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
+194 -8
View File
@@ -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;
} }
+26
View File
@@ -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);
});
} }
+4 -1
View File
@@ -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'],
]);
}); });
}); });
+739 -2
View File
@@ -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),
+85
View File
@@ -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();
+41
View File
@@ -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');
+25
View File
@@ -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([
+3
View File
@@ -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|
+47
View File
@@ -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,7 +105,7 @@ 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"
} }
@@ -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)
} }
} }
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
)
}
}
+138
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+19 -1
View File
@@ -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()
+5
View File
@@ -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;
+1 -1
View File
@@ -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)) {
+487 -148
View File
@@ -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
+36 -16
View File
@@ -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;
}
+355 -42
View File
@@ -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;
} }
+7 -1
View File
@@ -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;
} }
+13 -8
View File
@@ -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);
} }
}); });
+4 -1
View File
@@ -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_;