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
@@ -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(
@@ -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
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
}
@@ -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<String>,
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<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
zapReceipts: List<ZapReceipt> = 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<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 followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(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 },
)
}
@@ -150,7 +150,7 @@ fun UserProfileScreen(
// Tab and gallery state
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>() }
// 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 },
)
}
@@ -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.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<ImageBitmap?>(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<EmbeddedMediaPlayer?>(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<out ByteBuffer>) {
// No-op: we copy from the buffer in render callback
}
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
}
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(
@@ -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<String>,
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(
@@ -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),
)
}
}
}
}
}
}
@@ -82,6 +82,7 @@ fun NoteCard(
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> 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 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))