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.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -72,7 +73,9 @@ fun DesktopVideoPlayer(
|
|||||||
autoPlay: Boolean = false,
|
autoPlay: Boolean = false,
|
||||||
initialSeekPosition: Float = 0f,
|
initialSeekPosition: Float = 0f,
|
||||||
onFullscreen: ((Float) -> Unit)? = null,
|
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 frame by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
var isPlaying by remember { mutableStateOf(false) }
|
var isPlaying by remember { mutableStateOf(false) }
|
||||||
@@ -92,6 +95,14 @@ fun DesktopVideoPlayer(
|
|||||||
// Lazy activation — don't touch VLC until user clicks play (or autoPlay)
|
// Lazy activation — don't touch VLC until user clicks play (or autoPlay)
|
||||||
var activated by remember { mutableStateOf(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
|
// Pause when another player becomes active
|
||||||
val activeId by ActiveMediaManager.activeId.collectAsState()
|
val activeId by ActiveMediaManager.activeId.collectAsState()
|
||||||
LaunchedEffect(activeId) {
|
LaunchedEffect(activeId) {
|
||||||
@@ -248,7 +259,8 @@ fun DesktopVideoPlayer(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
frame?.let { bitmap ->
|
val displayBitmap = frame ?: thumbnail
|
||||||
|
displayBitmap?.let { bitmap ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
contentDescription = "Video",
|
contentDescription = "Video",
|
||||||
@@ -268,7 +280,7 @@ fun DesktopVideoPlayer(
|
|||||||
currentTime = currentTime,
|
currentTime = currentTime,
|
||||||
volume = volume,
|
volume = volume,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
isFullscreen = isFullscreen,
|
viewMode = viewMode,
|
||||||
onPlayPause = {
|
onPlayPause = {
|
||||||
val p = player
|
val p = player
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
@@ -305,6 +317,8 @@ fun DesktopVideoPlayer(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
onViewModeChange = onViewModeChange,
|
||||||
|
trailingControls = trailingControls,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user