Bound video thumbnails and share retriever cleanup ownership

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent d3e83c3c1a
commit 236aeae828
3 files changed
+44 -1

No files matched your search

@@ -1,6 +1,8 @@
package com.utun.chat.data
import android.media.MediaMetadataRetriever
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import java.util.concurrent.ConcurrentHashMap
@@ -11,6 +13,7 @@ internal data class VideoInfo(val width: Int, val height: Int, val rotation: Int
val displayHeight: Int get() = if (rotation == 90 || rotation == 270) width else height
val aspect: Float get() = if (displayHeight > 0) displayWidth.toFloat() / displayHeight else 0f
}
internal data class VideoFilePreview(val bitmap: Bitmap?, val durationMs: Int)
/** Один IO caller читает metadata внутри use; после завершения чтения допускается только повтор close.
* Неудавшийся release удерживает retriever до SDK ACK, retry не обращается к UI/native core. */
@@ -35,6 +38,23 @@ internal class VideoFileMetadata : AutoCloseable {
require(width > 0 && height > 0 && rotation in listOf(0, 90, 180, 270)) { "Invalid video geometry ${width}x$height rotation=$rotation" }
return VideoInfo(width, height, rotation)
}
/** IO: thumbnail не больше 640 по каждой стороне; Bitmap принадлежит caller, duration в мс.
* API 26 сначала извлекает полный кадр и уменьшает до передачи UI; null bitmap не запрещает воспроизведение. */
@Synchronized fun preview(path: String): VideoFilePreview {
check(!closing) { "Metadata reader closing" }
val current = checkNotNull(retriever) { "Metadata reader closed" }
current.setDataSource(path)
val duration = current.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull()?.coerceAtLeast(0) ?: 0
val raw = if (Build.VERSION.SDK_INT >= 27) current.getScaledFrameAtTime(1_000_000,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC, 640, 640) else current.getFrameAtTime(1_000_000)
val image = if (raw != null && maxOf(raw.width, raw.height) > 640) {
val scale = 640.0 / maxOf(raw.width, raw.height)
Bitmap.createScaledBitmap(raw, (raw.width * scale).toInt().coerceAtLeast(1),
(raw.height * scale).toInt().coerceAtLeast(1), true).also { if (it !== raw) raw.recycle() }
} else raw
if (image == null) LogManager.addLog("WARN", "VideoPlayer", "Video preview has no frame owner=${System.identityHashCode(this)}")
return VideoFilePreview(image, duration)
}
@Synchronized override fun close() {
closing = true
val current = retriever ?: return
@@ -0,0 +1,10 @@
package android.graphics
/** Только размеры и владение промежуточным кадром, без пикселей/драйвера. */
class Bitmap(val width: Int, val height: Int) {
var recycled = false
fun recycle() { recycled = true }
companion object {
fun createScaledBitmap(source: Bitmap, width: Int, height: Int, filter: Boolean) = Bitmap(width, height)
}
}
@@ -48,6 +48,7 @@ class MediaPlayer {
class MediaMetadataRetriever {
companion object {
const val METADATA_KEY_VIDEO_WIDTH = 18; const val METADATA_KEY_VIDEO_HEIGHT = 19; const val METADATA_KEY_VIDEO_ROTATION = 24
const val METADATA_KEY_DURATION = 9; const val OPTION_CLOSEST_SYNC = 2
val created = mutableListOf<MediaMetadataRetriever>()
var nextFailures = emptySet<String>()
}
@@ -55,10 +56,22 @@ class MediaMetadataRetriever {
var released = false
var releases = 0
var width = "1280"; var height = "720"; var rotation = "90"
var lastFrame: android.graphics.Bitmap? = null
var scaledReads = 0
init { created.add(this) }
fun setDataSource(path: String) { check("source" !in failures) { "Metadata source failed" } }
fun extractMetadata(key: Int): String? = when (key) {
METADATA_KEY_VIDEO_WIDTH -> width; METADATA_KEY_VIDEO_HEIGHT -> height; METADATA_KEY_VIDEO_ROTATION -> rotation; else -> null
METADATA_KEY_VIDEO_WIDTH -> width; METADATA_KEY_VIDEO_HEIGHT -> height; METADATA_KEY_VIDEO_ROTATION -> rotation
METADATA_KEY_DURATION -> "10000"; else -> null
}
fun getFrameAtTime(time: Long): android.graphics.Bitmap? {
check("frame" !in failures)
return android.graphics.Bitmap(width.toInt(), height.toInt()).also { lastFrame = it }
}
fun getScaledFrameAtTime(time: Long, option: Int, maxWidth: Int, maxHeight: Int): android.graphics.Bitmap? {
scaledReads++; check("frame" !in failures)
val scale = minOf(maxWidth.toDouble() / width.toInt(), maxHeight.toDouble() / height.toInt(), 1.0)
return android.graphics.Bitmap((width.toInt() * scale).toInt(), (height.toInt() * scale).toInt()).also { lastFrame = it }
}
fun release() {
releases++; check("release" !in failures) { "Metadata release failed" }; released = true