fix(media): immersive fullscreen via GraphicsDevice, separate view mode buttons
Replace WindowPlacement.Fullscreen (macOS maximize-only) with GraphicsDevice.setFullScreenWindow() for true exclusive fullscreen that hides menu bar and dock. Split single cycling view mode button into two distinct toggles (theater + fullscreen). Hide all navigation chrome (sidebar, nav rail, dividers, arrows, overlay menus) during fullscreen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -89,6 +90,9 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
||||
@@ -370,6 +374,12 @@ fun main() {
|
||||
}
|
||||
}
|
||||
|
||||
val immersiveFullscreenState = remember { mutableStateOf(false) }
|
||||
CompositionLocalProvider(
|
||||
LocalWindowState provides windowState,
|
||||
LocalAwtWindow provides window,
|
||||
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
|
||||
) {
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
@@ -392,6 +402,7 @@ fun main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
@@ -673,6 +684,8 @@ fun MainContent(
|
||||
}
|
||||
}
|
||||
|
||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
when (layoutMode) {
|
||||
@@ -695,6 +708,7 @@ fun MainContent(
|
||||
}
|
||||
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onOpenSettings = {
|
||||
@@ -709,6 +723,7 @@ fun MainContent(
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
|
||||
+8
-1
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -108,7 +109,10 @@ fun SinglePaneLayout(
|
||||
val navStack by navState.stack.collectAsState()
|
||||
val currentOverlay = navStack.lastOrNull()
|
||||
|
||||
val isImmersive by LocalIsImmersiveFullscreen.current
|
||||
|
||||
Row(modifier = modifier.fillMaxSize()) {
|
||||
if (!isImmersive) {
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -146,12 +150,15 @@ fun SinglePaneLayout(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isImmersive) {
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp),
|
||||
) {
|
||||
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||
RootContent(
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 java.awt.GraphicsEnvironment
|
||||
import java.awt.Window
|
||||
|
||||
object FullscreenHelper {
|
||||
fun enterFullscreen(window: Window) {
|
||||
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
|
||||
if (device.isFullScreenSupported) {
|
||||
device.fullScreenWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
fun exitFullscreen() {
|
||||
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
|
||||
device.fullScreenWindow = null
|
||||
}
|
||||
|
||||
fun isFullscreen(): Boolean {
|
||||
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
|
||||
return device.fullScreenWindow != null
|
||||
}
|
||||
}
|
||||
+321
-40
@@ -20,30 +20,46 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.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
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -59,8 +75,40 @@ 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 androidx.compose.ui.window.WindowState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Desktop
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
val LocalWindowState = compositionLocalOf<androidx.compose.ui.window.WindowState?> { null }
|
||||
|
||||
val LocalAwtWindow = compositionLocalOf<java.awt.Window?> { null }
|
||||
|
||||
val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) }
|
||||
|
||||
enum class ViewMode { DEFAULT, THEATER, FULLSCREEN }
|
||||
|
||||
private sealed class DownloadState {
|
||||
data object Idle : DownloadState()
|
||||
|
||||
data class Downloading(
|
||||
val progress: Float,
|
||||
val filename: String,
|
||||
) : DownloadState()
|
||||
|
||||
data class Done(
|
||||
val file: File,
|
||||
) : DownloadState()
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : DownloadState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LightboxOverlay(
|
||||
@@ -73,25 +121,125 @@ fun LightboxOverlay(
|
||||
var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
|
||||
var viewMode by remember { mutableStateOf(ViewMode.DEFAULT) }
|
||||
val awtWindow = LocalAwtWindow.current
|
||||
val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
|
||||
|
||||
// Track what mode we came from before fullscreen, so Esc returns there
|
||||
var preFullscreenMode by remember { mutableStateOf(ViewMode.DEFAULT) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
// Sync exclusive fullscreen with viewMode and signal parent layouts
|
||||
LaunchedEffect(viewMode) {
|
||||
isImmersiveFullscreen.value = viewMode == ViewMode.FULLSCREEN
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
awtWindow?.let { FullscreenHelper.enterFullscreen(it) }
|
||||
} else {
|
||||
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Restore fullscreen on dismiss
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
isImmersiveFullscreen.value = false
|
||||
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-dismiss banner after 3s
|
||||
LaunchedEffect(downloadState) {
|
||||
if (downloadState is DownloadState.Done || downloadState is DownloadState.Failed) {
|
||||
delay(3000)
|
||||
downloadState = DownloadState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
val currentUrl = urls[currentIndex]
|
||||
val isVideo = RichTextParser.isVideoUrl(currentUrl)
|
||||
|
||||
fun triggerSave() {
|
||||
if (downloadState is DownloadState.Downloading) return
|
||||
val url = urls[currentIndex]
|
||||
val filename = url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
|
||||
downloadState = DownloadState.Downloading(progress = -1f, filename = filename)
|
||||
scope.launch {
|
||||
val result =
|
||||
SaveMediaAction.saveMedia(
|
||||
url = url,
|
||||
onProgress = { downloaded, total ->
|
||||
val progress = if (total > 0) downloaded.toFloat() / total else -1f
|
||||
downloadState = DownloadState.Downloading(progress = progress, filename = filename)
|
||||
},
|
||||
)
|
||||
downloadState =
|
||||
if (result != null) {
|
||||
DownloadState.Done(result)
|
||||
} else {
|
||||
DownloadState.Failed("Download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFullscreen() {
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
viewMode = preFullscreenMode
|
||||
} else {
|
||||
preFullscreenMode = viewMode
|
||||
viewMode = ViewMode.FULLSCREEN
|
||||
}
|
||||
}
|
||||
|
||||
// Content modifier based on view mode
|
||||
val contentModifier =
|
||||
when (viewMode) {
|
||||
ViewMode.DEFAULT -> Modifier.fillMaxSize().padding(48.dp)
|
||||
ViewMode.THEATER -> Modifier.fillMaxSize()
|
||||
ViewMode.FULLSCREEN -> Modifier.fillMaxSize()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.9f))
|
||||
.background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f))
|
||||
.focusRequester(focusRequester)
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Escape -> {
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
viewMode = preFullscreenMode
|
||||
} else if (viewMode == ViewMode.THEATER) {
|
||||
viewMode = ViewMode.DEFAULT
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Key.T -> {
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
// Don't toggle theater while fullscreen
|
||||
false
|
||||
} else {
|
||||
viewMode =
|
||||
if (viewMode == ViewMode.THEATER) {
|
||||
ViewMode.DEFAULT
|
||||
} else {
|
||||
ViewMode.THEATER
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
Key.F -> {
|
||||
toggleFullscreen()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -107,9 +255,7 @@ fun LightboxOverlay(
|
||||
|
||||
Key.S -> {
|
||||
if (event.isCtrlPressed) {
|
||||
scope.launch {
|
||||
SaveMediaAction.saveMedia(urls[currentIndex])
|
||||
}
|
||||
triggerSave()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -130,71 +276,155 @@ fun LightboxOverlay(
|
||||
// Main content — video or image
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(48.dp),
|
||||
modifier = contentModifier,
|
||||
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),
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = { newMode ->
|
||||
if (newMode == ViewMode.FULLSCREEN && viewMode != ViewMode.FULLSCREEN) {
|
||||
preFullscreenMode = viewMode
|
||||
}
|
||||
viewMode = newMode
|
||||
},
|
||||
modifier =
|
||||
if (viewMode == ViewMode.DEFAULT) {
|
||||
Modifier.widthIn(max = 1200.dp)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
trailingControls = {
|
||||
MoreOptionsMenu(
|
||||
menuExpanded = menuExpanded,
|
||||
onExpandMenu = { menuExpanded = true },
|
||||
onDismissMenu = { menuExpanded = false },
|
||||
onSave = { triggerSave() },
|
||||
onCopyUrl = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(urls[currentIndex]), null)
|
||||
},
|
||||
onOpenInBrowser = {
|
||||
Desktop.getDesktop().browse(URI(urls[currentIndex]))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ZoomableImage(
|
||||
url = currentUrl,
|
||||
modifier = Modifier.fillMaxSize().padding(48.dp),
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Top bar
|
||||
// Download banner (top)
|
||||
AnimatedVisibility(
|
||||
visible = downloadState !is DownloadState.Idle,
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter),
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
) {
|
||||
val state = downloadState
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
.background(
|
||||
when (state) {
|
||||
is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer
|
||||
is DownloadState.Done -> Color(0xFF2E7D32)
|
||||
else -> Color(0xFF1565C0)
|
||||
},
|
||||
).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (urls.size > 1) {
|
||||
when (state) {
|
||||
is DownloadState.Downloading -> {
|
||||
Text(
|
||||
"${currentIndex + 1} / ${urls.size}",
|
||||
state.filename,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (state.progress >= 0f) {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progress },
|
||||
modifier = Modifier.width(120.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.width(120.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadState.Done -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Saved to ${state.file.name}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
SaveMediaAction.saveMedia(urls[currentIndex])
|
||||
is DownloadState.Failed -> {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
is DownloadState.Idle -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "..." menu (bottom right) — only for images; videos get it in the controls bar
|
||||
// Hidden in fullscreen to keep the view immersive
|
||||
if (!isVideo && viewMode != ViewMode.FULLSCREEN) {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = "Save",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White,
|
||||
MoreOptionsMenu(
|
||||
menuExpanded = menuExpanded,
|
||||
onExpandMenu = { menuExpanded = true },
|
||||
onDismissMenu = { menuExpanded = false },
|
||||
onSave = { triggerSave() },
|
||||
onCopyUrl = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(urls[currentIndex]), null)
|
||||
},
|
||||
onOpenInBrowser = {
|
||||
Desktop.getDesktop().browse(URI(urls[currentIndex]))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation arrows
|
||||
if (urls.size > 1) {
|
||||
// Navigation arrows — hidden in fullscreen
|
||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||
if (currentIndex > 0) {
|
||||
IconButton(
|
||||
onClick = { currentIndex-- },
|
||||
@@ -225,3 +455,54 @@ fun LightboxOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoreOptionsMenu(
|
||||
menuExpanded: Boolean,
|
||||
onExpandMenu: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCopyUrl: () -> Unit,
|
||||
onOpenInBrowser: () -> Unit,
|
||||
) {
|
||||
Box {
|
||||
IconButton(onClick = onExpandMenu, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More options",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save") },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onSave()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy URL") },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onCopyUrl()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open in Browser") },
|
||||
leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onOpenInBrowser()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-6
@@ -37,8 +37,10 @@ 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.CloseFullscreen
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.OpenInFull
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -77,7 +79,9 @@ fun VideoControls(
|
||||
onVolumeChange: ((Int) -> Unit)? = null,
|
||||
onMuteToggle: (() -> Unit)? = null,
|
||||
onFullscreen: (() -> Unit)? = null,
|
||||
isFullscreen: Boolean = false,
|
||||
viewMode: ViewMode = ViewMode.DEFAULT,
|
||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||
trailingControls: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var hovering by remember { mutableStateOf(false) }
|
||||
@@ -220,17 +224,66 @@ fun VideoControls(
|
||||
)
|
||||
}
|
||||
|
||||
// Fullscreen
|
||||
if (onFullscreen != null) {
|
||||
IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) {
|
||||
// Two separate toggle buttons (lightbox) or simple fullscreen (inline)
|
||||
if (onViewModeChange != null) {
|
||||
// Theater toggle — hidden during fullscreen
|
||||
if (viewMode != ViewMode.FULLSCREEN) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onViewModeChange(
|
||||
if (viewMode == ViewMode.THEATER) ViewMode.DEFAULT else ViewMode.THEATER,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
contentDescription = if (isFullscreen) "Exit Fullscreen" else "Fullscreen",
|
||||
if (viewMode == ViewMode.THEATER) {
|
||||
Icons.Default.CloseFullscreen
|
||||
} else {
|
||||
Icons.Default.OpenInFull
|
||||
},
|
||||
contentDescription =
|
||||
if (viewMode == ViewMode.THEATER) "Exit theater" else "Theater mode",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen toggle — always visible
|
||||
IconButton(
|
||||
onClick = {
|
||||
onViewModeChange(
|
||||
if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
Icons.Default.FullscreenExit
|
||||
} else {
|
||||
Icons.Default.Fullscreen
|
||||
},
|
||||
contentDescription =
|
||||
if (viewMode == ViewMode.FULLSCREEN) "Exit fullscreen" else "Fullscreen",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
} else if (onFullscreen != null) {
|
||||
IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Fullscreen,
|
||||
contentDescription = "Fullscreen",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing controls (e.g. more-options menu)
|
||||
trailingControls?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user