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:
+129
@@ -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
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user