Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions README_VIDEO.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
12 changes: 12 additions & 0 deletions mediaplayer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -267,3 +269,13 @@ mavenPublishing {
}

}

// Enabling native timeout regressions must invalidate an earlier skipped test run.
tasks.withType<Test>().configureEach {
if (name == "jvmTest") {
inputs.property(
"nativeVideoDimensionsTimeoutTest",
providers.environmentVariable("COMPOSE_MEDIA_PLAYER_RUN_NATIVE_TIMEOUT_TEST").orElse("0"),
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, Int>,
): 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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" }
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MacVideoPlayerState>(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<VideoPlayerError.UnknownError>(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
Loading
Loading