feat(media): video thumbnails in feed via VLC first-frame extraction

Add VideoThumbnailCache that grabs the first rendered frame from VLC
and caches it in memory. Videos in the feed now show a thumbnail
preview instead of a blank background before the user clicks play.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-17 16:12:16 +02:00
parent 89f39fa77e
commit bce6c12ab3
2 changed files with 146 additions and 3 deletions
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.media
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.jetbrains.skia.Image as SkiaImage
object VideoThumbnailCache {
private val cache = ConcurrentHashMap<String, ImageBitmap>()
private val pending = ConcurrentHashMap<String, Boolean>()
fun getCached(url: String): ImageBitmap? = cache[url]
suspend fun getThumbnail(url: String): ImageBitmap? {
cache[url]?.let { return it }
if (pending.putIfAbsent(url, true) != null) return null
return withContext(Dispatchers.IO) {
try {
extractFirstFrame(url)?.also { cache[url] = it }
} finally {
pending.remove(url)
}
}
}
private fun extractFirstFrame(url: String): ImageBitmap? {
if (!VlcjPlayerPool.init()) return null
val player = VlcjPlayerPool.acquire() ?: return null
var result: ImageBitmap? = null
val latch = CountDownLatch(1)
var aspectRatio = 16f / 9f
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat {
if (sourceHeight > 0) {
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
}
return RV32BufferFormat(sourceWidth, sourceHeight)
}
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
}
val renderCallback =
RenderCallback { _, nativeBuffers, bufferFormat ->
if (result != null) return@RenderCallback
try {
val w = bufferFormat.width
val h = bufferFormat.height
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
val bytes = ByteArray(w * h * 4)
val buffer = nativeBuffers[0]
buffer.rewind()
buffer.get(bytes)
bmp.installPixels(bytes)
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
latch.countDown()
} catch (_: Exception) {
latch.countDown()
}
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
if (surface == null) {
VlcjPlayerPool.release(player)
return null
}
player.videoSurface().set(surface)
player.audio().setVolume(0)
player.audio().isMute = true
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun error(mediaPlayer: MediaPlayer) {
latch.countDown()
}
},
)
player.media().play(url)
// Wait up to 5 seconds for first frame
latch.await(5, TimeUnit.SECONDS)
VlcjPlayerPool.release(player)
return result
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -72,7 +73,9 @@ fun DesktopVideoPlayer(
autoPlay: Boolean = false,
initialSeekPosition: Float = 0f,
onFullscreen: ((Float) -> Unit)? = null,
isFullscreen: Boolean = false,
viewMode: ViewMode = ViewMode.DEFAULT,
onViewModeChange: ((ViewMode) -> Unit)? = null,
trailingControls: @Composable (() -> Unit)? = null,
) {
var frame by remember { mutableStateOf<ImageBitmap?>(null) }
var isPlaying by remember { mutableStateOf(false) }
@@ -92,6 +95,14 @@ fun DesktopVideoPlayer(
// Lazy activation — don't touch VLC until user clicks play (or autoPlay)
var activated by remember { mutableStateOf(autoPlay) }
// Load thumbnail when not activated
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
LaunchedEffect(url, activated) {
if (!activated && thumbnail == null) {
thumbnail = VideoThumbnailCache.getThumbnail(url)
}
}
// Pause when another player becomes active
val activeId by ActiveMediaManager.activeId.collectAsState()
LaunchedEffect(activeId) {
@@ -248,7 +259,8 @@ fun DesktopVideoPlayer(
),
contentAlignment = Alignment.Center,
) {
frame?.let { bitmap ->
val displayBitmap = frame ?: thumbnail
displayBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = "Video",
@@ -268,7 +280,7 @@ fun DesktopVideoPlayer(
currentTime = currentTime,
volume = volume,
isMuted = isMuted,
isFullscreen = isFullscreen,
viewMode = viewMode,
onPlayPause = {
val p = player
if (p != null) {
@@ -305,6 +317,8 @@ fun DesktopVideoPlayer(
} else {
null
},
onViewModeChange = onViewModeChange,
trailingControls = trailingControls,
)
}
}