feat(media): bundle VLC, lazy video player, fullscreen with seek continuity

- Bundle VLC via ir.mahozad.vlc-setup plugin (all plugins for network playback)
- BundledVlcDiscoverer (Win/Linux) and MacOsVlcDiscoverer for bundled VLC discovery
- Lazy player activation — no VLC calls during composition, only on play click
- Pre-init VLC on background thread at startup
- Video controls: seek slider, volume, mute, fullscreen (two-row layout, no clipping)
- Buffering indicator (CircularProgressIndicator) while loading
- ActiveMediaManager enforces single-player — pauses others when new video plays
- Fullscreen passes seek position so lightbox resumes from current playback point
- LightboxOverlay renders videos (DesktopVideoPlayer) or images (ZoomableImage)
- Gitignore downloaded VLC binaries (appResources/{linux,macos,windows}/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-17 07:23:42 +02:00
parent cd7ee69f50
commit 7d3d633e03
13 changed files with 460 additions and 86 deletions
+5
View File
@@ -153,3 +153,8 @@ TASKS.md
# Claude Code local settings # Claude Code local settings
.claude/settings.local.json .claude/settings.local.json
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
+12
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.jetbrainsComposeCompiler)
id("ir.mahozad.vlc-setup") version "0.1.0"
} }
sourceSets { sourceSets {
@@ -77,8 +78,10 @@ dependencies {
compose.desktop { compose.desktop {
application { application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
nativeDistributions { nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst" packageName = "Amethyst"
@@ -103,3 +106,12 @@ compose.desktop {
} }
} }
} }
vlcSetup {
vlcVersion.set("3.0.21")
shouldCompressVlcFiles.set(true)
shouldIncludeAllVlcFiles.set(true)
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
}
@@ -143,6 +143,8 @@ sealed class DesktopScreen {
fun main() { fun main() {
DesktopImageLoaderSetup.setup() DesktopImageLoaderSetup.setup()
Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() }) Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() })
// Pre-init VLC on background thread so first play is fast
Thread { VlcjPlayerPool.init() }.start()
application { application {
val windowState = val windowState =
rememberWindowState( rememberWindowState(
@@ -0,0 +1,45 @@
/*
* 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 uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on Windows and Linux.
* Reads the Compose application resources directory and looks for a vlc/ subdirectory.
*/
class BundledVlcDiscoverer : NativeDiscoveryStrategy {
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" !in os
}
override fun discover(): String {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return ""
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) vlcDir.absolutePath else ""
}
override fun onFound(path: String): Boolean = true
override fun onSetPluginPath(path: String): Boolean = true
}
@@ -0,0 +1,56 @@
/*
* 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 com.sun.jna.NativeLibrary
import uk.co.caprica.vlcj.binding.lib.LibC
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on macOS.
* Must force-load libvlccore before libvlc to avoid link errors.
*/
class MacOsVlcDiscoverer :
BaseNativeDiscoveryStrategy(
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
arrayOf("%s/plugins"),
) {
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" in os
}
override fun discoveryDirectories(): List<String> {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList()
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList()
}
override fun onFound(path: String): Boolean {
NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path)
NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName())
return true
}
override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.service.media package com.vitorpamplona.amethyst.desktop.service.media
import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
@@ -57,11 +58,37 @@ object VlcjPlayerPool {
fun init(): Boolean { fun init(): Boolean {
if (available.get()) return true if (available.get()) return true
return try { return try {
// Try bundled VLC first, then fall through to system VLC
val discovery =
try {
val nd =
NativeDiscovery(
BundledVlcDiscoverer(),
MacOsVlcDiscoverer(),
)
val found = nd.discover()
if (found) {
println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}")
} else {
println("VLC: bundled discovery failed, falling back to system VLC")
}
found
} catch (e: Throwable) {
println("VLC: bundled discovery threw ${e.message}")
false
}
if (!discovery) {
// Try default system discovery
val systemDiscovery = NativeDiscovery().discover()
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
}
val f = MediaPlayerFactory("--no-xlib") val f = MediaPlayerFactory("--no-xlib")
factory = f factory = f
available.set(true) available.set(true)
println("VLC: MediaPlayerFactory created successfully")
true true
} catch (_: Exception) { } catch (e: Throwable) {
println("VLC: init failed — ${e.message}")
available.set(false) available.set(false)
false false
} }
@@ -115,7 +142,7 @@ object VlcjPlayerPool {
val af = val af =
audioFactory ?: try { audioFactory ?: try {
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
} catch (_: Exception) { } catch (_: Throwable) {
return null return null
} }
@@ -87,6 +87,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
data class LightboxState(
val urls: List<String>,
val index: Int,
val seekPosition: Float = 0f,
)
/** /**
* Note card with action buttons. * Note card with action buttons.
*/ */
@@ -102,6 +108,7 @@ fun FeedNoteCard(
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
onImageClick: ((List<String>, Int) -> Unit)? = null, onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
zapReceipts: List<ZapReceipt> = emptyList(), zapReceipts: List<ZapReceipt> = emptyList(),
reactionCount: Int = 0, reactionCount: Int = 0,
replyCount: Int = 0, replyCount: Int = 0,
@@ -122,6 +129,7 @@ fun FeedNoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onImageClick = onImageClick, onImageClick = onImageClick,
onMediaClick = onMediaClick,
) )
// Action buttons (only if logged in) // Action buttons (only if logged in)
@@ -183,7 +191,7 @@ fun FeedScreen(
} }
val events by eventState.items.collectAsState() val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) } var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<Pair<List<String>, Int>?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) } var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) } var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
@@ -605,7 +613,8 @@ fun FeedScreen(
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = urls to index }, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos) },
zapReceipts = zapsByEvent[event.id] ?: emptyList(), zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0, reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0, replyCount = repliesByEvent[event.id] ?: 0,
@@ -638,10 +647,11 @@ fun FeedScreen(
} }
// Lightbox overlay // Lightbox overlay
lightboxState?.let { (urls, index) -> lightboxState?.let { state ->
LightboxOverlay( LightboxOverlay(
urls = urls, urls = state.urls,
initialIndex = index, initialIndex = state.index,
initialSeekPosition = state.seekPosition,
onDismiss = { lightboxState = null }, onDismiss = { lightboxState = null },
) )
} }
@@ -150,7 +150,7 @@ fun UserProfileScreen(
// Tab and gallery state // Tab and gallery state
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<Pair<List<String>, Int>?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() } val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
// Follow state // Follow state
@@ -674,7 +674,10 @@ fun UserProfileScreen(
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onImageClick = { urls, index -> onImageClick = { urls, index ->
lightboxState = urls to index lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
lightboxState = LightboxState(urls, index, seekPos)
}, },
) )
} }
@@ -686,7 +689,7 @@ fun UserProfileScreen(
1 -> { 1 -> {
GalleryTab( GalleryTab(
pictureEvents = pictureEvents, pictureEvents = pictureEvents,
onImageClick = { urls, index -> lightboxState = urls to index }, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
) )
} }
} }
@@ -694,10 +697,11 @@ fun UserProfileScreen(
} }
// Lightbox overlay // Lightbox overlay
lightboxState?.let { (urls, index) -> lightboxState?.let { state ->
LightboxOverlay( LightboxOverlay(
urls = urls, urls = state.urls,
initialIndex = index, initialIndex = state.index,
initialSeekPosition = state.seekPosition,
onDismiss = { lightboxState = null }, onDismiss = { lightboxState = null },
) )
} }
@@ -0,0 +1,42 @@
/*
* 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.ui.media
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Ensures only one media player is active at a time.
* Each player registers with a unique ID. When a new player activates,
* the previous one observes the change and pauses itself.
*/
object ActiveMediaManager {
private val _activeId = MutableStateFlow<String?>(null)
val activeId: StateFlow<String?> = _activeId
fun activate(id: String) {
_activeId.value = id
}
fun deactivate(id: String) {
_activeId.compareAndSet(id, null)
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -31,8 +32,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -42,9 +45,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap 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.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo import org.jetbrains.skia.ImageInfo
@@ -56,6 +62,7 @@ import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCall
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.UUID
import org.jetbrains.skia.Image as SkiaImage import org.jetbrains.skia.Image as SkiaImage
@Composable @Composable
@@ -63,32 +70,56 @@ fun DesktopVideoPlayer(
url: String, url: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
autoPlay: Boolean = false, autoPlay: Boolean = false,
initialSeekPosition: Float = 0f,
onFullscreen: ((Float) -> Unit)? = null,
isFullscreen: Boolean = false,
) { ) {
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) }
var isBuffering by remember { mutableStateOf(false) }
var position by remember { mutableFloatStateOf(0f) } var position by remember { mutableFloatStateOf(0f) }
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
var currentTime by remember { mutableLongStateOf(0L) } var currentTime by remember { mutableLongStateOf(0L) }
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
var vlcAvailable by remember { mutableStateOf(true) } var vlcAvailable by remember { mutableStateOf(true) }
var player by remember { mutableStateOf<EmbeddedMediaPlayer?>(null) } var player by remember { mutableStateOf<EmbeddedMediaPlayer?>(null) }
var volume by remember { mutableIntStateOf(100) }
var isMuted by remember { mutableStateOf(false) }
// Acquire player // Unique ID for single-player enforcement
DisposableEffect(url) { val playerId = remember { UUID.randomUUID().toString() }
if (!VlcjPlayerPool.init()) {
vlcAvailable = false // Lazy activation — don't touch VLC until user clicks play (or autoPlay)
return@DisposableEffect onDispose {} var activated by remember { mutableStateOf(autoPlay) }
// Pause when another player becomes active
val activeId by ActiveMediaManager.activeId.collectAsState()
LaunchedEffect(activeId) {
if (activeId != null && activeId != playerId && isPlaying) {
player?.controls()?.pause()
} }
}
// Set up player off the UI thread when activated
LaunchedEffect(url, activated) {
if (!activated) return@LaunchedEffect
isBuffering = true
val acquired =
withContext(Dispatchers.IO) {
if (!VlcjPlayerPool.init()) return@withContext null
VlcjPlayerPool.acquire()
}
val acquired = VlcjPlayerPool.acquire()
if (acquired == null) { if (acquired == null) {
vlcAvailable = false vlcAvailable = false
return@DisposableEffect onDispose {} isBuffering = false
return@LaunchedEffect
} }
// Skia bitmap for DirectRendering — pre-allocated to avoid per-frame GC
var skBitmap: Bitmap? = null var skBitmap: Bitmap? = null
var pixelBytes: ByteArray? = null var pixelBytes: ByteArray? = null
var didSeek = initialSeekPosition <= 0f
val bufferFormatCallback = val bufferFormatCallback =
object : BufferFormatCallback { object : BufferFormatCallback {
@@ -100,17 +131,13 @@ fun DesktopVideoPlayer(
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat() aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
} }
val bmp = Bitmap() val bmp = Bitmap()
bmp.allocPixels( bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL),
)
skBitmap = bmp skBitmap = bmp
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
return RV32BufferFormat(sourceWidth, sourceHeight) return RV32BufferFormat(sourceWidth, sourceHeight)
} }
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) { override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
// No-op: we copy from the buffer in render callback
}
} }
val renderCallback = val renderCallback =
@@ -124,19 +151,20 @@ fun DesktopVideoPlayer(
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
} }
val surface = val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
VlcjPlayerPool.createVideoSurface(
bufferFormatCallback,
renderCallback,
)
acquired.videoSurface().set(surface) acquired.videoSurface().set(surface)
val listener = acquired.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() { object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) { override fun playing(mediaPlayer: MediaPlayer) {
isPlaying = true isPlaying = true
isBuffering = false
duration = mediaPlayer.status().length() duration = mediaPlayer.status().length()
// Seek to initial position on first play
if (!didSeek) {
didSeek = true
mediaPlayer.controls().setPosition(initialSeekPosition)
}
} }
override fun paused(mediaPlayer: MediaPlayer) { override fun paused(mediaPlayer: MediaPlayer) {
@@ -145,6 +173,14 @@ fun DesktopVideoPlayer(
override fun stopped(mediaPlayer: MediaPlayer) { override fun stopped(mediaPlayer: MediaPlayer) {
isPlaying = false isPlaying = false
isBuffering = false
}
override fun buffering(
mediaPlayer: MediaPlayer,
newCache: Float,
) {
isBuffering = newCache < 100f
} }
override fun positionChanged( override fun positionChanged(
@@ -157,25 +193,31 @@ fun DesktopVideoPlayer(
override fun finished(mediaPlayer: MediaPlayer) { override fun finished(mediaPlayer: MediaPlayer) {
isPlaying = false isPlaying = false
isBuffering = false
position = 0f position = 0f
currentTime = 0L currentTime = 0L
} }
}
acquired.events().addMediaPlayerEventListener(listener) override fun error(mediaPlayer: MediaPlayer) {
isBuffering = false
println("VLC: playback error for $url")
}
},
)
player = acquired player = acquired
ActiveMediaManager.activate(playerId)
acquired.media().play(url)
}
if (autoPlay) { // Clean up player on leave or URL change
acquired.media().play(url) DisposableEffect(url) {
} else {
acquired.media().prepare(url)
}
onDispose { onDispose {
player = null ActiveMediaManager.deactivate(playerId)
acquired.events().removeMediaPlayerEventListener(listener) player?.let { p ->
VlcjPlayerPool.release(acquired) VlcjPlayerPool.release(p)
player = null
}
} }
} }
@@ -200,39 +242,69 @@ fun DesktopVideoPlayer(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(aspectRatio) .aspectRatio(aspectRatio)
.clip(RoundedCornerShape(8.dp)) .background(
.background(MaterialTheme.colorScheme.surfaceContainerHigh), MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
frame?.let { bitmap -> frame?.let { bitmap ->
Image( Image(
bitmap = bitmap, bitmap = bitmap,
contentDescription = "Video", contentDescription = "Video",
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
) )
} }
VideoControls( VideoControls(
isPlaying = isPlaying, isPlaying = isPlaying,
isBuffering = isBuffering,
position = position, position = position,
duration = duration, duration = duration,
currentTime = currentTime, currentTime = currentTime,
volume = volume,
isMuted = isMuted,
isFullscreen = isFullscreen,
onPlayPause = { onPlayPause = {
player?.let { p -> val p = player
if (p != null) {
if (isPlaying) { if (isPlaying) {
p.controls().pause() p.controls().pause()
} else { } else {
ActiveMediaManager.activate(playerId)
if (position <= 0f && !p.status().isPlaying) { if (position <= 0f && !p.status().isPlaying) {
p.media().play(url) p.media().play(url)
isBuffering = true
} else { } else {
p.controls().play() p.controls().play()
} }
} }
} else {
// First play — activate lazy init
activated = true
} }
}, },
onSeek = { pos -> onSeek = { pos ->
player?.controls()?.setPosition(pos) player?.controls()?.setPosition(pos)
}, },
onVolumeChange = { vol ->
volume = vol
player?.audio()?.setVolume(vol)
},
onMuteToggle = {
isMuted = !isMuted
player?.audio()?.isMute = isMuted
},
onFullscreen =
if (onFullscreen != null) {
{ onFullscreen(position) }
} else {
null
},
) )
} }
} }
@@ -246,8 +318,10 @@ private fun VlcNotAvailableMessage(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .background(
.background(MaterialTheme.colorScheme.surfaceContainerHigh), MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
@@ -58,12 +59,14 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun LightboxOverlay( fun LightboxOverlay(
urls: List<String>, urls: List<String>,
initialIndex: Int = 0, initialIndex: Int = 0,
initialSeekPosition: Float = 0f,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -75,6 +78,9 @@ fun LightboxOverlay(
focusRequester.requestFocus() focusRequester.requestFocus()
} }
val currentUrl = urls[currentIndex]
val isVideo = RichTextParser.isVideoUrl(currentUrl)
Box( Box(
modifier = modifier =
modifier modifier
@@ -121,11 +127,27 @@ fun LightboxOverlay(
// Click on backdrop doesn't close — use X button or Esc // Click on backdrop doesn't close — use X button or Esc
}, },
) { ) {
// Main image // Main content — video or image
ZoomableImage( if (isVideo) {
url = urls[currentIndex], Box(
modifier = Modifier.fillMaxSize().padding(48.dp), modifier = Modifier.fillMaxSize().padding(48.dp),
) contentAlignment = Alignment.Center,
) {
DesktopVideoPlayer(
url = currentUrl,
autoPlay = true,
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
isFullscreen = true,
onFullscreen = { onDismiss() },
modifier = Modifier.widthIn(max = 1200.dp),
)
}
} else {
ZoomableImage(
url = currentUrl,
modifier = Modifier.fillMaxSize().padding(48.dp),
)
}
// Top bar // Top bar
Row( Row(
@@ -27,14 +27,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -64,6 +71,13 @@ fun VideoControls(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Float) -> Unit, onSeek: (Float) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBuffering: Boolean = false,
volume: Int = 100,
isMuted: Boolean = false,
onVolumeChange: ((Int) -> Unit)? = null,
onMuteToggle: (() -> Unit)? = null,
onFullscreen: (() -> Unit)? = null,
isFullscreen: Boolean = false,
) { ) {
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
var hovering by remember { mutableStateOf(false) } var hovering by remember { mutableStateOf(false) }
@@ -101,8 +115,14 @@ fun VideoControls(
} }
}, },
) { ) {
// Center play button when paused // Center play/buffering indicator
if (!isPlaying) { if (isBuffering) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).size(48.dp),
color = Color.White,
strokeWidth = 3.dp,
)
} else if (!isPlaying) {
IconButton( IconButton(
onClick = onPlayPause, onClick = onPlayPause,
modifier = Modifier.align(Alignment.Center).size(64.dp), modifier = Modifier.align(Alignment.Center).size(64.dp),
@@ -116,41 +136,25 @@ fun VideoControls(
} }
} }
// Bottom controls bar // Bottom controls — two rows: seek slider on top, buttons below
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) { ) {
Row( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
IconButton(onClick = onPlayPause, modifier = Modifier.size(32.dp)) { // Seek slider (full width, no horizontal competition)
Icon(
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Text(
text = formatTime(currentTime),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
Slider( Slider(
value = position, value = position,
onValueChange = onSeek, onValueChange = onSeek,
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
colors = colors =
SliderDefaults.colors( SliderDefaults.colors(
thumbColor = Color.White, thumbColor = Color.White,
@@ -159,11 +163,75 @@ fun VideoControls(
), ),
) )
Text( // Buttons row
text = formatTime(duration), Row(
style = MaterialTheme.typography.labelSmall, modifier =
color = Color.White, Modifier
) .fillMaxWidth()
.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(onClick = onPlayPause, modifier = Modifier.size(32.dp)) {
Icon(
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Text(
text = "${formatTime(currentTime)} / ${formatTime(duration)}",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
// Spacer pushes right-side controls to the end
Box(Modifier.weight(1f))
// Volume
if (onMuteToggle != null) {
IconButton(onClick = onMuteToggle, modifier = Modifier.size(32.dp)) {
Icon(
if (isMuted) {
Icons.AutoMirrored.Filled.VolumeOff
} else {
Icons.AutoMirrored.Filled.VolumeUp
},
contentDescription = if (isMuted) "Unmute" else "Mute",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
if (onVolumeChange != null) {
Slider(
value = volume / 100f,
onValueChange = { onVolumeChange((it * 100).toInt()) },
modifier = Modifier.width(80.dp),
colors =
SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White.copy(alpha = 0.7f),
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
),
)
}
// Fullscreen
if (onFullscreen != null) {
IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) {
Icon(
if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
contentDescription = if (isFullscreen) "Exit Fullscreen" else "Fullscreen",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
} }
} }
} }
@@ -82,6 +82,7 @@ fun NoteCard(
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null, onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) { ) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls = val imageUrls =
@@ -221,10 +222,16 @@ fun NoteCard(
if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) { if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
for (url in videoUrls) { for ((index, url) in videoUrls.withIndex()) {
DesktopVideoPlayer( DesktopVideoPlayer(
url = url, url = url,
modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp), modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp),
onFullscreen =
if (onMediaClick != null) {
{ seekPos -> onMediaClick(videoUrls, index, seekPos) }
} else {
null
},
) )
if (url != videoUrls.last()) { if (url != videoUrls.last()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))