diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt new file mode 100644 index 000000000..aabb4c5fd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -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() + private val pending = ConcurrentHashMap() + + 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) {} + } + + 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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index e8d03f0a6..488ce1b86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -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(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, ) } }