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:
@@ -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(
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
+29
-2
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+10
-6
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+112
-38
@@ -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(
|
||||
|
||||
+27
-5
@@ -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(
|
||||
|
||||
+97
-29
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user