diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt index 4f7c89dd..6d313ad0 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt @@ -83,4 +83,12 @@ internal object LinuxNativeBridge { // Playback completion @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean + + // Stream info and errors + + /** Number of video streams, or -1 while the media is not prerolled yet (0 = audio-only). */ + @JvmStatic external fun nGetVideoStreamCount(handle: Long): Int + + /** Last GStreamer error since the previous call, or null. */ + @JvmStatic external fun nConsumeError(handle: Long): String? } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt index bb7d8ec5..3e8b931d 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt @@ -69,6 +69,9 @@ class LinuxVideoPlayerState : VideoPlayerState { // State tracking private var lastFrameUpdateTime: Long = 0 + + // False for audio-only media: no frames will arrive, so frame-based buffering detection doesn't apply. + @Volatile private var hasVideo = true private var seekInProgress = false private var targetSeekTime: Double? = null @@ -272,7 +275,8 @@ class LinuxVideoPlayerState : VideoPlayerState { linuxLogger.e { "Failed to open URI" } withContext(Dispatchers.Main) { isLoading = false - error = VideoPlayerError.SourceError("Failed to open media source") + // Keep the specific error openMediaUri may have set. + if (error == null) error = VideoPlayerError.SourceError("Failed to open media source") } } } catch (e: Exception) { @@ -355,6 +359,7 @@ class LinuxVideoPlayerState : VideoPlayerState { ptr: Long, maxAttempts: Int = 20, ) { + hasVideo = true for (attempt in 1..maxAttempts) { val width = LinuxNativeBridge.nGetFrameWidth(ptr) val height = LinuxNativeBridge.nGetFrameHeight(ptr) @@ -362,6 +367,14 @@ class LinuxVideoPlayerState : VideoPlayerState { linuxLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } return } + // A source that fails to preroll (unreachable, unsupported, missing decoder) never gets dimensions either. + LinuxNativeBridge.nConsumeError(ptr)?.let { throw IllegalStateException(it) } + // Audio-only media never gets dimensions: stop waiting once the pipeline knows there is no video stream. + if (LinuxNativeBridge.nGetVideoStreamCount(ptr) == 0) { + linuxLogger.d { "Audio-only media (no video stream)" } + hasVideo = false + return + } linuxLogger.d { "Dimensions not ready yet (attempt $attempt/$maxAttempts)" } delay(250) } @@ -455,7 +468,7 @@ class LinuxVideoPlayerState : VideoPlayerState { } private suspend fun checkBufferingState() { - if (isPlaying && !isLoading) { + if (isPlaying && !isLoading && hasVideo) { val timeSinceLastFrame = System.currentTimeMillis() - lastFrameUpdateTime if (timeSinceLastFrame > bufferingTimeoutThreshold) { withContext(Dispatchers.Main) { isLoading = true } @@ -539,10 +552,16 @@ class LinuxVideoPlayerState : VideoPlayerState { private suspend fun updatePositionAsync() { if (!hasMedia || userDragging) return try { - val duration = getDurationSafely() - if (duration <= 0) return + if (reportPendingError()) return + val duration = getDurationSafely() val current = getPositionSafely() + if (duration <= 0) { + // Unknown duration (e.g. raw ADTS AAC): still show the position and detect the end of media. + withContext(Dispatchers.Main) { _positionText.value = formatTime(current) } + checkLoopingAsync(current, duration) + return + } withContext(Dispatchers.Main) { _positionText.value = formatTime(current) @@ -567,6 +586,19 @@ class LinuxVideoPlayerState : VideoPlayerState { } } + /** Surfaces a GStreamer error (e.g. a missing decoder or a network failure) as the player error. */ + private suspend fun reportPendingError(): Boolean { + val ptr = playerPtr + if (ptr == 0L) return false + val message = LinuxNativeBridge.nConsumeError(ptr) ?: return false + linuxLogger.e { "Playback error: $message" } + // Set the error before pausing: pausing cancels the frame update job this usually runs in. + setPlayerError(VideoPlayerError.SourceError(message)) + withContext(Dispatchers.Main) { isPlaying = false } + pauseInBackground() + return true + } + private suspend fun checkLoopingAsync( current: Double, duration: Double, diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c index e8137358..71948e3f 100644 --- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c @@ -41,6 +41,7 @@ struct VideoPlayer { int32_t audio_channels; int32_t audio_sample_rate; float frame_rate; + char* last_error; // pending GStreamer error for Kotlin, consumed by nvp_consume_error // Playback state float volume; @@ -105,6 +106,10 @@ static void process_bus_message(VideoPlayer* p, GstMessage* msg) { gst_message_parse_error(msg, &err, &debug); if (err) { g_printerr("GStreamer error: %s\n", err->message); + pthread_mutex_lock(&p->meta_lock); + free(p->last_error); + p->last_error = strdup(err->message); + pthread_mutex_unlock(&p->meta_lock); g_error_free(err); } if (debug) g_free(debug); @@ -273,6 +278,7 @@ void nvp_destroy(VideoPlayer* p) { pthread_mutex_lock(&p->meta_lock); free(p->title); free(p->mime_type); + free(p->last_error); pthread_mutex_unlock(&p->meta_lock); pthread_mutex_destroy(&p->meta_lock); @@ -303,6 +309,7 @@ int nvp_open_uri(VideoPlayer* p, const char* uri) { pthread_mutex_lock(&p->meta_lock); free(p->title); p->title = NULL; free(p->mime_type); p->mime_type = NULL; + free(p->last_error); p->last_error = NULL; p->bitrate = 0; p->audio_channels = 0; p->audio_sample_rate = 0; @@ -666,3 +673,27 @@ static void update_stream_metadata(VideoPlayer* p) { } } } + +// --------------------------------------------------------------------------- +// Stream info and errors +// --------------------------------------------------------------------------- + +int32_t nvp_get_video_stream_count(VideoPlayer* p) { + if (!p) return -1; + GstState state = GST_STATE_NULL; + // Timeout 0: the current state; stream counts are only known once prerolled. + gst_element_get_state(p->pipeline, &state, NULL, 0); + if (state < GST_STATE_PAUSED) return -1; + gint n_video = 0; + g_object_get(p->pipeline, "n-video", &n_video, NULL); + return (int32_t)n_video; +} + +char* nvp_consume_error(VideoPlayer* p) { + if (!p) return NULL; + pthread_mutex_lock(&p->meta_lock); + char* error = p->last_error; + p->last_error = NULL; + pthread_mutex_unlock(&p->meta_lock); + return error; +} diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h index a74609f8..110cb2d3 100644 --- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h @@ -48,6 +48,12 @@ float nvp_get_frame_rate(VideoPlayer* p); // End-of-stream notification (consumes the flag) int32_t nvp_consume_did_play_to_end(VideoPlayer* p); +// Stream info and errors +// Number of video streams, or -1 while the pipeline is not prerolled yet (0 = audio-only). +int32_t nvp_get_video_stream_count(VideoPlayer* p); +// Last GStreamer error since the previous call (caller frees), or NULL. +char* nvp_consume_error(VideoPlayer* p); + #ifdef __cplusplus } #endif diff --git a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c index 9eb7794f..d9adedf1 100644 --- a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c +++ b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c @@ -103,6 +103,19 @@ static jstring JNICALL jni_GetVideoTitle(JNIEnv* env, jclass cls, jlong handle) return result; } +static jint JNICALL jni_GetVideoStreamCount(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)nvp_get_video_stream_count(toCtx(handle)) : -1; +} + +static jstring JNICALL jni_ConsumeError(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return NULL; + char* s = nvp_consume_error(toCtx(handle)); + if (!s) return NULL; + jstring result = (*env)->NewStringUTF(env, s); + free(s); + return result; +} + static jlong JNICALL jni_GetVideoBitrate(JNIEnv* env, jclass cls, jlong handle) { return handle ? (jlong)nvp_get_bitrate(toCtx(handle)) : 0L; } @@ -161,6 +174,8 @@ static const JNINativeMethod g_methods[] = { { "nGetAudioSampleRate", "(J)I", (void*)jni_GetAudioSampleRate }, { "nGetFrameRate", "(J)F", (void*)jni_GetFrameRate }, { "nConsumeDidPlayToEnd", "(J)Z", (void*)jni_ConsumeDidPlayToEnd }, + { "nGetVideoStreamCount", "(J)I", (void*)jni_GetVideoStreamCount }, + { "nConsumeError", "(J)Ljava/lang/String;", (void*)jni_ConsumeError }, }; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {