diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 243855f5..f75e9a4d 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -59,6 +59,40 @@ jobs: name: test-reports-jvm path: '**/build/reports/tests/' + macos-video-dimensions-timeout: + needs: build-natives + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Download macOS native libraries + uses: actions/download-artifact@v8 + with: + name: native-macos + path: mediaplayer/src/jvmMain/resources/composemediaplayer/native/ + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Validate configurable macOS video dimensions timeout + run: | + ./gradlew :mediaplayer:compileKotlinJvm :mediaplayer:jvmTest \ + --tests '*MacVideoDimensionsTimeout*Test' --no-daemon --no-configuration-cache + env: + COMPOSE_MEDIA_PLAYER_RUN_NATIVE_TIMEOUT_TEST: '1' + + - name: Upload test reports + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-reports-macos-video-dimensions-timeout + path: '**/build/reports/tests/' + android: runs-on: ubuntu-latest steps: diff --git a/README_VIDEO.MD b/README_VIDEO.MD index 4dbe8560..d1396c1d 100644 --- a/README_VIDEO.MD +++ b/README_VIDEO.MD @@ -128,6 +128,27 @@ Before using Compose Media Player, you need to create a state for the video play val playerState = rememberVideoPlayerState() ``` +### macOS Video Dimensions Timeout (Desktop JVM) + +The macOS backend polls AVFoundation for video dimensions for up to **5 seconds** by default. +For remote sources whose metadata takes longer to arrive, desktop JVM code can opt into a +longer window: + +```kotlin +val playerState = rememberVideoPlayerState(macVideoDimensionsTimeoutMillis = 30_000L) +``` + +The desktop overload manages disposal automatically. Changing the timeout recreates the player +state. The value must be positive and is ignored by the Windows and Linux backends. Existing +`rememberVideoPlayerState()` calls retain the five-second window. + +For manually managed states, use `DefaultVideoPlayerState(macVideoDimensionsTimeoutMillis = 30_000L)` +or, on macOS, `MacVideoPlayerState(videoDimensionsTimeoutMillis = 30_000L)`. Call `dispose()` when a +manually managed state is no longer needed. Existing no-argument constructors remain available. + +This setting controls dimension polling only. Native initialization and the separate duration +polling window retain their existing timeouts; it does not limit the total media-open time. + ### Displaying the Video Surface After initializing the player state, you can display the surface of the video using `VideoPlayerSurface`: diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index cd719b08..97542bc7 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -109,6 +109,8 @@ kotlin { implementation(kotlin("test")) implementation(kotlin("test-junit")) implementation(libs.kotlinx.coroutines.test) + // Real AVFoundation tests render frames through Skia. + runtimeOnly(compose.desktop.currentOs) } if (Os.isFamily(Os.FAMILY_MAC)) { @@ -267,3 +269,13 @@ mavenPublishing { } } + +// Enabling native timeout regressions must invalidate an earlier skipped test run. +tasks.withType().configureEach { + if (name == "jvmTest") { + inputs.property( + "nativeVideoDimensionsTimeoutTest", + providers.environmentVariable("COMPOSE_MEDIA_PLAYER_RUN_NATIVE_TIMEOUT_TEST").orElse("0"), + ) + } +} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index e0323d2e..7956a442 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -1,9 +1,13 @@ package io.github.kdroidfilter.composemediaplayer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import io.github.kdroidfilter.composemediaplayer.linux.LinuxVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.mac.DEFAULT_MAC_VIDEO_DIMENSIONS_TIMEOUT_MILLIS import io.github.kdroidfilter.composemediaplayer.mac.MacVideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState @@ -14,6 +18,25 @@ actual fun createVideoPlayerState( cacheConfig: CacheConfig, ): VideoPlayerState = DefaultVideoPlayerState() +/** + * Desktop overload with a configurable macOS video-dimensions polling window. + * + * [macVideoDimensionsTimeoutMillis] must be positive. Windows and Linux keep their existing + * behavior. Changing the value creates a new state and disposes the previous state. + * Use the common no-argument overload to retain the default five-second macOS window. + */ +@Composable +fun rememberVideoPlayerState(macVideoDimensionsTimeoutMillis: Long): VideoPlayerState { + val playerState = + remember(macVideoDimensionsTimeoutMillis) { + DefaultVideoPlayerState(macVideoDimensionsTimeoutMillis) + } + DisposableEffect(playerState) { + onDispose { playerState.dispose() } + } + return playerState +} + /** * Represents the state and behavior of a video player. This class provides properties * and methods to control video playback, manage the playback state, and interact with @@ -35,13 +58,22 @@ actual fun createVideoPlayerState( * - `stop()`: Stops playback and resets the player state. * - `seekTo(value: Float)`: Seeks to a specific playback position based on the provided normalized value. * - `dispose()`: Releases resources used by the video player and disposes of the state. + * + * @param macVideoDimensionsTimeoutMillis Maximum macOS video-dimensions polling time in + * milliseconds. Must be positive; defaults to 5,000 ms. Ignored by Windows and Linux backends. */ @Stable -open class DefaultVideoPlayerState : VideoPlayerState { +open class DefaultVideoPlayerState( + macVideoDimensionsTimeoutMillis: Long = DEFAULT_MAC_VIDEO_DIMENSIONS_TIMEOUT_MILLIS, +) : VideoPlayerState { + init { + require(macVideoDimensionsTimeoutMillis > 0) { "Video dimensions timeout must be positive." } + } + val delegate: VideoPlayerState = when (CurrentPlatform.os) { CurrentPlatform.OS.WINDOWS -> WindowsVideoPlayerState() - CurrentPlatform.OS.MAC -> MacVideoPlayerState() + CurrentPlatform.OS.MAC -> MacVideoPlayerState(macVideoDimensionsTimeoutMillis) CurrentPlatform.OS.LINUX -> LinuxVideoPlayerState() } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeout.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeout.kt new file mode 100644 index 00000000..2a07ceb0 --- /dev/null +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeout.kt @@ -0,0 +1,24 @@ +package io.github.kdroidfilter.composemediaplayer.mac + +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +internal const val DEFAULT_MAC_VIDEO_DIMENSIONS_TIMEOUT_MILLIS = 5_000L +private const val DIMENSION_POLL_INTERVAL_MILLIS = 250L + +/** Wait for asynchronous AVFoundation dimensions while the current open remains active. */ +internal suspend fun awaitMacVideoDimensions( + timeoutMillis: Long, + isCurrentOpen: () -> Boolean, + readDimensions: () -> Pair, +): Boolean { + require(timeoutMillis > 0) { "Video dimensions timeout must be positive." } + return withTimeoutOrNull(timeoutMillis) { + while (isCurrentOpen()) { + val (width, height) = readDimensions() + if (width > 0 && height > 0) return@withTimeoutOrNull true + delay(DIMENSION_POLL_INTERVAL_MILLIS) + } + false + } ?: false +} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt index 035445b6..35cab6a9 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt @@ -74,8 +74,18 @@ private const val POSITION_POLL_INTERVAL_MS = 200L * separate polling loop. The one place macOS deliberately diverges is the aspect ratio: it uses * AVFoundation's display aspect ratio (pixel-aspect-ratio / clean-aperture corrected) so * anamorphic content renders correctly — see [displayAspectRatio]. + * + * @param videoDimensionsTimeoutMillis Maximum time to poll for video dimensions after opening + * media. Must be positive; defaults to the existing five-second polling window. The separate + * duration polling and native-player initialization timeouts are unchanged. */ -class MacVideoPlayerState : VideoPlayerState { +class MacVideoPlayerState( + private val videoDimensionsTimeoutMillis: Long = DEFAULT_MAC_VIDEO_DIMENSIONS_TIMEOUT_MILLIS, +) : VideoPlayerState { + init { + require(videoDimensionsTimeoutMillis > 0) { "Video dimensions timeout must be positive." } + } + /** Native AVFoundation player. AtomicLong allows lock-free reads from the frame hot path. */ private val playerPtrAtomic = AtomicLong(0L) private val playerPtr: Long get() = playerPtrAtomic.get() @@ -408,7 +418,8 @@ class MacVideoPlayerState : VideoPlayerState { // Phase 2 (UNLOCKED): AVFoundation loads asynchronously, so these polls can take // several seconds. Running them outside mediaOperationMutex keeps volume/other // operations responsive during a slow open. - pollDimensionsUntilReady(ptr) + pollDimensionsUntilReady(ptr, myToken) + if (isDisposing.get() || myToken != openToken.get()) return@launch val resolvedDuration = pollDurationUntilReady(ptr) if (isDisposing.get() || myToken != openToken.get()) return@launch @@ -475,30 +486,28 @@ class MacVideoPlayerState : VideoPlayerState { _hasMedia = false } finally { // Only clear the flag if a newer open hasn't taken over. - if (myToken == openToken.get()) isOpening.set(false) - if (!_hasMedia) isLoading = false + if (myToken == openToken.get()) { + isOpening.set(false) + if (!_hasMedia) isLoading = false + } } } } - /** - * Loops several times (every 250 ms) until width/height are no longer zero, or until - * [maxAttempts] is reached. - */ + /** Polls video dimensions without holding [mediaOperationMutex] during asynchronous loading. */ private suspend fun pollDimensionsUntilReady( ptr: Long, - maxAttempts: Int = 20, + token: Long, ) { - for (attempt in 1..maxAttempts) { - val width = MacNativeBridge.nGetFrameWidth(ptr) - val height = MacNativeBridge.nGetFrameHeight(ptr) - if (width > 0 && height > 0) { - macLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } - return - } - delay(250) + val ready = + awaitMacVideoDimensions( + timeoutMillis = videoDimensionsTimeoutMillis, + isCurrentOpen = { !isDisposing.get() && token == openToken.get() && ptr == playerPtr }, + readDimensions = { MacNativeBridge.nGetFrameWidth(ptr) to MacNativeBridge.nGetFrameHeight(ptr) }, + ) + if (!ready && !isDisposing.get() && token == openToken.get()) { + macLogger.e { "Unable to retrieve valid dimensions within $videoDimensionsTimeoutMillis ms" } } - macLogger.e { "Unable to retrieve valid dimensions after $maxAttempts attempts" } } /** diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutNativeTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutNativeTest.kt new file mode 100644 index 00000000..7e249daa --- /dev/null +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutNativeTest.kt @@ -0,0 +1,97 @@ +package io.github.kdroidfilter.composemediaplayer.mac + +import com.sun.net.httpserver.HttpServer +import io.github.kdroidfilter.composemediaplayer.DefaultVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assume.assumeTrue +import java.net.InetSocketAddress +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** AVFoundation integration against a synthetic MP4 served by a delayed local HTTP server. */ +class MacVideoDimensionsTimeoutNativeTest { + @Test + fun defaultWindowStillRejectsDelayedDimensions() = verifySlowStartup("video.mp4", null) + + @Test + fun configuredWindowAllowsDelayedMp4Playback() = verifySlowStartup("video.mp4", 30_000L) + + @Test + fun configuredWindowAllowsDelayedOpaqueUriPlayback() = verifySlowStartup("media", 30_000L) + + private fun verifySlowStartup( + path: String, + timeoutMillis: Long?, + ) = runBlocking { + assumeTrue(System.getenv("COMPOSE_MEDIA_PLAYER_RUN_NATIVE_TIMEOUT_TEST") == "1") + assumeTrue(System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) + val media = checkNotNull(javaClass.getResourceAsStream("/startup/metadata-startup.mp4")).use { it.readBytes() } + val firstRequestAt = AtomicLong(0L) + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val executor = Executors.newCachedThreadPool() + server.executor = executor + server.createContext("/$path") { exchange -> + try { + firstRequestAt.compareAndSet(0L, System.nanoTime()) + // Every request waits for the same deadline, including AVFoundation range requests. + while (System.nanoTime() - firstRequestAt.get() < METADATA_DELAY_NANOS) Thread.sleep(50) + val range = exchange.requestHeaders.getFirst("Range") + val bounds = range?.removePrefix("bytes=")?.split("-") + val start = bounds?.getOrNull(0)?.toIntOrNull() ?: 0 + val end = (bounds?.getOrNull(1)?.toIntOrNull() ?: media.lastIndex).coerceAtMost(media.lastIndex) + exchange.responseHeaders.set("Content-Type", "video/mp4") + exchange.responseHeaders.set("Accept-Ranges", "bytes") + if (range != null) exchange.responseHeaders.set("Content-Range", "bytes $start-$end/${media.size}") + exchange.sendResponseHeaders(if (range == null) 200 else 206, (end - start + 1).toLong()) + exchange.responseBody.use { it.write(media, start, end - start + 1) } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + exchange.close() + } + } + server.start() + val player = if (timeoutMillis == null) DefaultVideoPlayerState() else DefaultVideoPlayerState(timeoutMillis) + try { + assertIs(player.delegate) + player.openUri("http://127.0.0.1:${server.address.port}/$path") + if (timeoutMillis == null) { + withTimeout(12_000L) { + while (player.error == null) delay(50) + } + assertEquals( + "Failed to retrieve video size", + assertIs(player.error).message, + ) + assertFalse(player.hasMedia) + assertTrue(System.nanoTime() - firstRequestAt.get() < METADATA_DELAY_NANOS) + } else { + withTimeout(40_000L) { + while (!player.hasMedia || player.currentTime < 0.5) { + assertNull(player.error) + delay(50) + } + } + assertTrue(player.hasMedia) + assertTrue(player.isPlaying) + assertTrue(player.duration > 0) + assertTrue(System.nanoTime() - firstRequestAt.get() >= METADATA_DELAY_NANOS) + } + } finally { + player.dispose() + server.stop(0) + executor.shutdownNow() + } + } +} + +private const val METADATA_DELAY_NANOS = 16_000_000_000L diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutTest.kt new file mode 100644 index 00000000..f0e3ace7 --- /dev/null +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoDimensionsTimeoutTest.kt @@ -0,0 +1,123 @@ +package io.github.kdroidfilter.composemediaplayer.mac + +import io.github.kdroidfilter.composemediaplayer.DefaultVideoPlayerState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MacVideoDimensionsTimeoutTest { + @Test + fun noArgumentJvmConstructorsRemainAvailable() { + assertNotNull(MacVideoPlayerState::class.java.getConstructor()) + assertNotNull(DefaultVideoPlayerState::class.java.getConstructor()) + } + + @Test + fun defaultRetainsFiveSecondPollingWindow() = + runTest { + var reads = 0 + assertFalse( + awaitMacVideoDimensions(DEFAULT_MAC_VIDEO_DIMENSIONS_TIMEOUT_MILLIS, { true }) { + reads++ + 0 to 0 + }, + ) + assertEquals(5_000L, currentTime) + assertEquals(20, reads) + } + + @Test + fun readyDimensionsReturnImmediately() = + runTest { + assertTrue(awaitMacVideoDimensions(30_000L, { true }) { 1920 to 1080 }) + assertEquals(0L, currentTime) + } + + @Test + fun longerWindowAllowsDelayedDimensions() = + runTest { + assertTrue( + awaitMacVideoDimensions(30_000L, { true }) { + if (currentTime >= 16_000L) 1920 to 1080 else 0 to 0 + }, + ) + assertEquals(16_000L, currentTime) + } + + @Test + fun customWindowStillHasABoundedDeadline() = + runTest { + assertFalse(awaitMacVideoDimensions(30_000L, { true }) { 0 to 0 }) + assertEquals(30_000L, currentTime) + } + + @Test + fun shorterWindowUsesConfiguredDeadline() = + runTest { + assertFalse(awaitMacVideoDimensions(600L, { true }) { 0 to 0 }) + assertEquals(600L, currentTime) + } + + @Test + fun bothDimensionsMustBePositive() = + runTest { + assertFalse(awaitMacVideoDimensions(1_000L, { true }) { 1920 to 0 }) + } + + @Test + fun supersededOpenStopsPolling() = + runTest { + var current = true + var reads = 0 + val wait = + async { + awaitMacVideoDimensions(30_000L, { current }) { + reads++ + 0 to 0 + } + } + advanceTimeBy(1_000L) + current = false + assertFalse(wait.await()) + assertEquals(4, reads) + assertEquals(1_000L, currentTime) + } + + @Test + fun cancellationStopsPolling() = + runTest { + var reads = 0 + val wait = + async { + awaitMacVideoDimensions(30_000L, { true }) { + reads++ + 0 to 0 + } + } + advanceTimeBy(1_000L) + wait.cancel() + wait.join() + advanceTimeBy(30_000L) + assertTrue(wait.isCancelled) + assertEquals(4, reads) + } + + @Test + fun nonPositiveTimeoutIsRejectedBeforeNativeInitialization() = + runTest { + for (timeout in listOf(0L, -1L)) { + assertFailsWith { awaitMacVideoDimensions(timeout, { true }) { 0 to 0 } } + assertFailsWith { MacVideoPlayerState(timeout) } + assertFailsWith { DefaultVideoPlayerState(timeout) } + } + } +} diff --git a/mediaplayer/src/jvmTest/resources/startup/metadata-startup.mp4 b/mediaplayer/src/jvmTest/resources/startup/metadata-startup.mp4 new file mode 100644 index 00000000..4e387c62 Binary files /dev/null and b/mediaplayer/src/jvmTest/resources/startup/metadata-startup.mp4 differ