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..f98d3575 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 @@ -62,6 +62,9 @@ internal val macLogger = TaggedLogger("MacVideoPlayerState") */ private const val POSITION_POLL_INTERVAL_MS = 200L +// Polls (250 ms apart) without a video size before media with an audio track counts as audio-only. +private const val AUDIO_ONLY_MIN_POLLS = 8 + /** * macOS implementation of the video player state. * @@ -418,15 +421,19 @@ class MacVideoPlayerState : VideoPlayerState { try { val w = MacNativeBridge.nGetFrameWidth(ptr) val h = MacNativeBridge.nGetFrameHeight(ptr) - if (w <= 0 || h <= 0) { + // Audio-only media (an mp3, an m4a) has no video size but still plays: only a + // source with neither a picture nor any audio or duration is an error. + val isAudioOnly = (w <= 0 || h <= 0) && + (resolvedDuration > 0.0 || MacNativeBridge.nGetAudioChannels(ptr) > 0) + if ((w <= 0 || h <= 0) && !isAudioOnly) { setError("Failed to retrieve video size") return@withLock } - videoWidth = w - videoHeight = h + videoWidth = w.coerceAtLeast(0) + videoHeight = h.coerceAtLeast(0) // Scale output to match the display surface (saves memory for 4K+ video). - if (surfaceWidth > 0 && surfaceHeight > 0) { + if (!isAudioOnly && surfaceWidth > 0 && surfaceHeight > 0) { videoReaderMutex.withLock { MacNativeBridge.nSetOutputSize(ptr, surfaceWidth, surfaceHeight) val sw = MacNativeBridge.nGetFrameWidth(ptr) @@ -461,6 +468,8 @@ class MacVideoPlayerState : VideoPlayerState { MacNativeBridge.nPlay(ptr) } _isPlaying = startPlayback + // No frame will clear the loading state of audio-only media. + if (isAudioOnly) isLoading = false videoJob = startVideoPipeline() } catch (e: Exception) { @@ -496,6 +505,13 @@ class MacVideoPlayerState : VideoPlayerState { macLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } return } + // Audio-only media never gets dimensions: once the asset reports an audio track and a + // while has passed without a video size, stop waiting (a late video size is still + // picked up by the frame pipeline, which keeps reading). + if (attempt >= AUDIO_ONLY_MIN_POLLS && MacNativeBridge.nGetAudioChannels(ptr) > 0) { + macLogger.d { "No video size after $attempt attempts but an audio track: treating as audio-only" } + return + } delay(250) } macLogger.e { "Unable to retrieve valid dimensions after $maxAttempts attempts" } diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift index 36d1318a..5877ebb5 100644 --- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift +++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift @@ -726,16 +726,25 @@ class MacVideoPlayer { // Retrieve the video track to obtain the actual dimensions asset.loadTracks(withMediaType: .video) { [self] tracks, error in guard let videoTrack = tracks?.first, error == nil else { - print( - "Error loading video tracks: \(error?.localizedDescription ?? "Unknown")" - ) // For HLS streams without video track info yet, use default dimensions if isHLSStream { + print("No video track info yet for the HLS stream: \(error?.localizedDescription ?? "none")") frameWidth = 1920 frameHeight = 1080 nativeVideoWidth = frameWidth nativeVideoHeight = frameHeight setupVideoOutputAndPlayer(with: asset) + } else if error == nil { + // Audio-only media (an MP3, an M4A): no video track, but it plays. The player is set up with + // no video output; the Kotlin side reports a 0x0 size as audio-only. + print("No video track: playing as audio-only") + frameWidth = 0 + frameHeight = 0 + nativeVideoWidth = 0 + nativeVideoHeight = 0 + setupVideoOutputAndPlayer(with: asset, hasVideo: false) + } else { + print("Error loading video tracks: \(error?.localizedDescription ?? "Unknown")") } return } @@ -845,15 +854,20 @@ class MacVideoPlayer { } // Helper method to setup video output and player - private func setupVideoOutputAndPlayer(with asset: AVAsset, videoComposition: AVVideoComposition? = nil) { - // Create attributes for the CVPixelBuffer (BGRA format) with IOSurface for better performance - let pixelBufferAttributes: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, - kCVPixelBufferWidthKey as String: frameWidth, - kCVPixelBufferHeightKey as String: frameHeight, - kCVPixelBufferIOSurfacePropertiesKey as String: [:], - ] - videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes) + private func setupVideoOutputAndPlayer(with asset: AVAsset, videoComposition: AVVideoComposition? = nil, hasVideo: Bool = true) { + if hasVideo { + // Create attributes for the CVPixelBuffer (BGRA format) with IOSurface for better performance + let pixelBufferAttributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: frameWidth, + kCVPixelBufferHeightKey as String: frameHeight, + kCVPixelBufferIOSurfacePropertiesKey as String: [:], + ] + videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes) + } else { + // Audio-only: no frames to read (captureFrame and captureInitialFrame need an output and return early). + videoOutput = nil + } let item = AVPlayerItem(asset: asset)