From 7d3d633e03bae32a458205c0414a5f5527ace531 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 17 Mar 2026 07:23:42 +0200 Subject: [PATCH] feat(media): bundle VLC, lazy video player, fullscreen with seek continuity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 5 + desktopApp/build.gradle.kts | 12 ++ .../vitorpamplona/amethyst/desktop/Main.kt | 2 + .../service/media/BundledVlcDiscoverer.kt | 45 ++++++ .../service/media/MacOsVlcDiscoverer.kt | 56 +++++++ .../desktop/service/media/VlcjPlayerPool.kt | 31 +++- .../amethyst/desktop/ui/FeedScreen.kt | 20 ++- .../amethyst/desktop/ui/UserProfileScreen.kt | 16 +- .../desktop/ui/media/ActiveMediaManager.kt | 42 +++++ .../desktop/ui/media/DesktopVideoPlayer.kt | 150 +++++++++++++----- .../desktop/ui/media/LightboxOverlay.kt | 32 +++- .../desktop/ui/media/VideoControls.kt | 126 +++++++++++---- .../amethyst/desktop/ui/note/NoteCard.kt | 9 +- 13 files changed, 460 insertions(+), 86 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt diff --git a/.gitignore b/.gitignore index 945f2e02a..b5af05773 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,8 @@ TASKS.md # Claude Code local settings .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/ diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index ed294a9bd..f17e8090e 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) + id("ir.mahozad.vlc-setup") version "0.1.0" } sourceSets { @@ -77,8 +78,10 @@ dependencies { compose.desktop { application { mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" nativeDistributions { + appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 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")) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index da63969f2..9c667fac2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -143,6 +143,8 @@ sealed class DesktopScreen { fun main() { DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() }) + // Pre-init VLC on background thread so first play is fast + Thread { VlcjPlayerPool.init() }.start() application { val windowState = rememberWindowState( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt new file mode 100644 index 000000000..63765f0dd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt @@ -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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt new file mode 100644 index 000000000..8e92dc92e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -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 { + 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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index 68c49dce0..0541476ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.service.media 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.embedded.EmbeddedMediaPlayer import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface @@ -57,11 +58,37 @@ object VlcjPlayerPool { fun init(): Boolean { if (available.get()) return true 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") factory = f available.set(true) + println("VLC: MediaPlayerFactory created successfully") true - } catch (_: Exception) { + } catch (e: Throwable) { + println("VLC: init failed — ${e.message}") available.set(false) false } @@ -115,7 +142,7 @@ object VlcjPlayerPool { val af = audioFactory ?: try { MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } - } catch (_: Exception) { + } catch (_: Throwable) { return null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 650ce53d5..2d05fc0d8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -87,6 +87,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +data class LightboxState( + val urls: List, + val index: Int, + val seekPosition: Float = 0f, +) + /** * Note card with action buttons. */ @@ -102,6 +108,7 @@ fun FeedNoteCard( onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, zapReceipts: List = emptyList(), reactionCount: Int = 0, replyCount: Int = 0, @@ -122,6 +129,7 @@ fun FeedNoteCard( note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, onImageClick = onImageClick, + onMediaClick = onMediaClick, ) // Action buttons (only if logged in) @@ -183,7 +191,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var lightboxState by remember { mutableStateOf, Int>?>(null) } + var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } @@ -605,7 +613,8 @@ fun FeedScreen( onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, 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(), reactionCount = reactionsByEvent[event.id] ?: 0, replyCount = repliesByEvent[event.id] ?: 0, @@ -638,10 +647,11 @@ fun FeedScreen( } // Lightbox overlay - lightboxState?.let { (urls, index) -> + lightboxState?.let { state -> LightboxOverlay( - urls = urls, - initialIndex = index, + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, onDismiss = { lightboxState = null }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index ace8a18aa..6d969e3c4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -150,7 +150,7 @@ fun UserProfileScreen( // Tab and gallery state var selectedTab by remember { mutableStateOf(0) } - var lightboxState by remember { mutableStateOf, Int>?>(null) } + var lightboxState by remember { mutableStateOf(null) } val pictureEvents = remember { mutableStateListOf() } // Follow state @@ -674,7 +674,10 @@ fun UserProfileScreen( onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, 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 -> { GalleryTab( pictureEvents = pictureEvents, - onImageClick = { urls, index -> lightboxState = urls to index }, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, ) } } @@ -694,10 +697,11 @@ fun UserProfileScreen( } // Lightbox overlay - lightboxState?.let { (urls, index) -> + lightboxState?.let { state -> LightboxOverlay( - urls = urls, - initialIndex = index, + urls = state.urls, + initialIndex = state.index, + initialSeekPosition = state.seekPosition, onDismiss = { lightboxState = null }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt new file mode 100644 index 000000000..ac4dbab25 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt @@ -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(null) + val activeId: StateFlow = _activeId + + fun activate(id: String) { + _activeId.value = id + } + + fun deactivate(id: String) { + _activeId.compareAndSet(id, null) + } +} 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 05bfedb62..e8d03f0a6 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -31,8 +32,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,9 +45,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.VlcjPlayerPool +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType 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.format.RV32BufferFormat import java.nio.ByteBuffer +import java.util.UUID import org.jetbrains.skia.Image as SkiaImage @Composable @@ -63,32 +70,56 @@ fun DesktopVideoPlayer( url: String, modifier: Modifier = Modifier, autoPlay: Boolean = false, + initialSeekPosition: Float = 0f, + onFullscreen: ((Float) -> Unit)? = null, + isFullscreen: Boolean = false, ) { var frame by remember { mutableStateOf(null) } var isPlaying by remember { mutableStateOf(false) } + var isBuffering by remember { mutableStateOf(false) } var position by remember { mutableFloatStateOf(0f) } var duration by remember { mutableLongStateOf(0L) } var currentTime by remember { mutableLongStateOf(0L) } var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } var vlcAvailable by remember { mutableStateOf(true) } var player by remember { mutableStateOf(null) } + var volume by remember { mutableIntStateOf(100) } + var isMuted by remember { mutableStateOf(false) } - // Acquire player - DisposableEffect(url) { - if (!VlcjPlayerPool.init()) { - vlcAvailable = false - return@DisposableEffect onDispose {} + // Unique ID for single-player enforcement + val playerId = remember { UUID.randomUUID().toString() } + + // Lazy activation — don't touch VLC until user clicks play (or autoPlay) + 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) { 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 pixelBytes: ByteArray? = null + var didSeek = initialSeekPosition <= 0f val bufferFormatCallback = object : BufferFormatCallback { @@ -100,17 +131,13 @@ fun DesktopVideoPlayer( aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat() } val bmp = Bitmap() - bmp.allocPixels( - ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL), - ) + bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) skBitmap = bmp pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) return RV32BufferFormat(sourceWidth, sourceHeight) } - override fun allocatedBuffers(buffers: Array) { - // No-op: we copy from the buffer in render callback - } + override fun allocatedBuffers(buffers: Array) {} } val renderCallback = @@ -124,19 +151,20 @@ fun DesktopVideoPlayer( frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() } - val surface = - VlcjPlayerPool.createVideoSurface( - bufferFormatCallback, - renderCallback, - ) - + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) acquired.videoSurface().set(surface) - val listener = + acquired.events().addMediaPlayerEventListener( object : MediaPlayerEventAdapter() { override fun playing(mediaPlayer: MediaPlayer) { isPlaying = true + isBuffering = false duration = mediaPlayer.status().length() + // Seek to initial position on first play + if (!didSeek) { + didSeek = true + mediaPlayer.controls().setPosition(initialSeekPosition) + } } override fun paused(mediaPlayer: MediaPlayer) { @@ -145,6 +173,14 @@ fun DesktopVideoPlayer( override fun stopped(mediaPlayer: MediaPlayer) { isPlaying = false + isBuffering = false + } + + override fun buffering( + mediaPlayer: MediaPlayer, + newCache: Float, + ) { + isBuffering = newCache < 100f } override fun positionChanged( @@ -157,25 +193,31 @@ fun DesktopVideoPlayer( override fun finished(mediaPlayer: MediaPlayer) { isPlaying = false + isBuffering = false position = 0f currentTime = 0L } - } - acquired.events().addMediaPlayerEventListener(listener) + override fun error(mediaPlayer: MediaPlayer) { + isBuffering = false + println("VLC: playback error for $url") + } + }, + ) player = acquired + ActiveMediaManager.activate(playerId) + acquired.media().play(url) + } - if (autoPlay) { - acquired.media().play(url) - } else { - acquired.media().prepare(url) - } - + // Clean up player on leave or URL change + DisposableEffect(url) { onDispose { - player = null - acquired.events().removeMediaPlayerEventListener(listener) - VlcjPlayerPool.release(acquired) + ActiveMediaManager.deactivate(playerId) + player?.let { p -> + VlcjPlayerPool.release(p) + player = null + } } } @@ -200,39 +242,69 @@ fun DesktopVideoPlayer( modifier .fillMaxWidth() .aspectRatio(aspectRatio) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), contentAlignment = Alignment.Center, ) { frame?.let { bitmap -> Image( bitmap = bitmap, contentDescription = "Video", - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit, ) } VideoControls( isPlaying = isPlaying, + isBuffering = isBuffering, position = position, duration = duration, currentTime = currentTime, + volume = volume, + isMuted = isMuted, + isFullscreen = isFullscreen, onPlayPause = { - player?.let { p -> + val p = player + if (p != null) { if (isPlaying) { p.controls().pause() } else { + ActiveMediaManager.activate(playerId) if (position <= 0f && !p.status().isPlaying) { p.media().play(url) + isBuffering = true } else { p.controls().play() } } + } else { + // First play — activate lazy init + activated = true } }, onSeek = { 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 .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(8.dp), + ), contentAlignment = Alignment.Center, ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index 88c78ca5b..a5bfd3d4c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.type import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import kotlinx.coroutines.launch @Composable fun LightboxOverlay( urls: List, initialIndex: Int = 0, + initialSeekPosition: Float = 0f, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -75,6 +78,9 @@ fun LightboxOverlay( focusRequester.requestFocus() } + val currentUrl = urls[currentIndex] + val isVideo = RichTextParser.isVideoUrl(currentUrl) + Box( modifier = modifier @@ -121,11 +127,27 @@ fun LightboxOverlay( // Click on backdrop doesn't close — use X button or Esc }, ) { - // Main image - ZoomableImage( - url = urls[currentIndex], - modifier = Modifier.fillMaxSize().padding(48.dp), - ) + // Main content — video or image + if (isVideo) { + Box( + 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 Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt index b986a8056..3e81b574f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -27,14 +27,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.PlayArrow +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -64,6 +71,13 @@ fun VideoControls( onPlayPause: () -> Unit, onSeek: (Float) -> Unit, 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 hovering by remember { mutableStateOf(false) } @@ -101,8 +115,14 @@ fun VideoControls( } }, ) { - // Center play button when paused - if (!isPlaying) { + // Center play/buffering indicator + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(48.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + } else if (!isPlaying) { IconButton( onClick = onPlayPause, 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( visible = showControls, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.align(Alignment.BottomCenter), ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 8.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), - style = MaterialTheme.typography.labelSmall, - color = Color.White, - ) - + // Seek slider (full width, no horizontal competition) Slider( value = position, onValueChange = onSeek, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), colors = SliderDefaults.colors( thumbColor = Color.White, @@ -159,11 +163,75 @@ fun VideoControls( ), ) - Text( - text = formatTime(duration), - style = MaterialTheme.typography.labelSmall, - color = Color.White, - ) + // Buttons row + Row( + modifier = + 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), + ) + } + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 0111a01b6..c6ee3c0f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -82,6 +82,7 @@ fun NoteCard( onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -221,10 +222,16 @@ fun NoteCard( if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) { Spacer(Modifier.height(8.dp)) } - for (url in videoUrls) { + for ((index, url) in videoUrls.withIndex()) { DesktopVideoPlayer( url = url, modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp), + onFullscreen = + if (onMediaClick != null) { + { seekPos -> onMediaClick(videoUrls, index, seekPos) } + } else { + null + }, ) if (url != videoUrls.last()) { Spacer(Modifier.height(4.dp))