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