ui: send dropped .webm as video only when it has a video track (#7354)

* ui: send dropped .webm as video only when it has a video track

.webm is as commonly an audio-only container as a video one, so the
extension alone cannot tell whether there is a frame to embed. Read the
container to decide: files with a video track are sent as video, the rest
as files.

Only done for files attached without the user saying how to send them
(drag & drop, paste). An explicitly picked video is still sent as one, so
.webm is now listed in the video file picker too.

* plan: send dropped .webm as video only when it has a video track
This commit is contained in:
Narasimha-sc authored and GitHub committed 2026-08-12 15:29:43 +01:00
1 parent 0a61b0ddea
commit abd5954675
7 files changed
+100 -3

No files matched your search

@@ -7,6 +7,10 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
import uk.co.caprica.vlcj.media.Media
import uk.co.caprica.vlcj.media.MediaEventAdapter
import uk.co.caprica.vlcj.media.MediaParsedStatus
import uk.co.caprica.vlcj.media.ParseFlag
import uk.co.caprica.vlcj.media.VideoOrientation
import uk.co.caprica.vlcj.player.base.*
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
@@ -255,6 +259,43 @@ actual class VideoPlayer actual constructor(
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
}
// Parsing a local container header takes a few dozen ms, this is only a guard against a stuck parse
private const val PARSE_TIMEOUT_MS = 3000L
// Reads container metadata to tell whether there is a video track at all, without decoding a frame.
// libvlc signals the end of parsing with an event, so no polling or frame-decoding budget is needed.
suspend fun hasVideoTrack(uri: URI): Boolean = withContext(previewThread.asCoroutineDispatcher()) {
if (!uri.toFile().exists()) return@withContext false
val media = try {
vlcPreviewFactory.media().newMedia(uri.toFile().absolutePath)
} catch (e: Exception) {
Log.e(TAG, "hasVideoTrack unable to create media: ${e.stackTraceToString()}")
null
} ?: return@withContext false
try {
val parsed = CompletableDeferred<MediaParsedStatus?>()
media.events().addMediaEventListener(object: MediaEventAdapter() {
// vlcj maps an unknown status int to null, and a null here would throw on its event thread
override fun mediaParsedChanged(parsedMedia: Media?, newStatus: MediaParsedStatus?) {
parsed.complete(newStatus)
}
})
if (!media.parsing().parse(PARSE_TIMEOUT_MS.toInt(), ParseFlag.PARSE_LOCAL)) {
return@withContext false
}
if (withTimeoutOrNull(PARSE_TIMEOUT_MS) { parsed.await() } != MediaParsedStatus.DONE) {
media.parsing().stop()
return@withContext false
}
media.info().videoTracks().isNotEmpty()
} catch (e: Exception) {
Log.e(TAG, "hasVideoTrack error: ${e.stackTraceToString()}")
false
} finally {
media.release()
}
}
val playerThread = Executors.newSingleThreadExecutor()
private val previewThread = Executors.newSingleThreadExecutor()
private val playersPool: ArrayList<Component> = ArrayList()
@@ -9,5 +9,6 @@ fun isVideo(uri: URI): Boolean {
path.endsWith(".mp4") ||
path.endsWith(".mpg") ||
path.endsWith(".mpeg") ||
path.endsWith(".mkv")
path.endsWith(".mkv") ||
path.endsWith(".webm")
}
@@ -255,6 +255,8 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea
return VideoPlayer.getBitmapFromVideo(null, uri, withAlertOnException)
}
actual suspend fun hasVideoTrack(uri: URI): Boolean = VideoPlayer.hasVideoTrack(uri)
@OptIn(ExperimentalEncodingApi::class)
actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this)