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:
nrobi144
2026-03-17 13:49:39 +02:00
parent 7d3d633e03
commit dffb996335
5 changed files with 523 additions and 124 deletions
@@ -48,6 +48,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.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.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout 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.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
@@ -370,25 +374,32 @@ fun main() {
} }
} }
App( val immersiveFullscreenState = remember { mutableStateOf(false) }
layoutMode = layoutMode, CompositionLocalProvider(
deckState = deckState, LocalWindowState provides windowState,
accountManager = accountManager, LocalAwtWindow provides window,
showComposeDialog = showComposeDialog, LocalIsImmersiveFullscreen provides immersiveFullscreenState,
showAddColumnDialog = showAddColumnDialog, ) {
onShowComposeDialog = { showComposeDialog = true }, App(
onShowReplyDialog = { event -> layoutMode = layoutMode,
replyToNote = event deckState = deckState,
showComposeDialog = true accountManager = accountManager,
}, showComposeDialog = showComposeDialog,
onDismissComposeDialog = { showAddColumnDialog = showAddColumnDialog,
showComposeDialog = false onShowComposeDialog = { showComposeDialog = true },
replyToNote = null onShowReplyDialog = { event ->
}, replyToNote = event
onDismissAddColumnDialog = { showAddColumnDialog = false }, showComposeDialog = true
onShowAddColumnDialog = { showAddColumnDialog = true }, },
replyToNote = replyToNote, onDismissComposeDialog = {
) showComposeDialog = false
replyToNote = null
},
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote,
)
}
} }
} }
} }
@@ -673,6 +684,8 @@ fun MainContent(
} }
} }
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
when (layoutMode) { when (layoutMode) {
@@ -695,20 +708,22 @@ fun MainContent(
} }
LayoutMode.DECK -> { LayoutMode.DECK -> {
DeckSidebar( if (!isImmersive) {
onAddColumn = onShowAddColumnDialog, DeckSidebar(
onOpenSettings = { onAddColumn = onShowAddColumnDialog,
if (deckState.hasColumnOfType(DeckColumnType.Settings)) { onOpenSettings = {
deckState.focusExistingColumn(DeckColumnType.Settings) if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
} else { deckState.focusExistingColumn(DeckColumnType.Settings)
deckState.addColumn(DeckColumnType.Settings) } else {
} deckState.addColumn(DeckColumnType.Settings)
}, }
signerConnectionState = signerConnectionState, },
lastPingTimeSec = lastPingTimeSec, signerConnectionState = signerConnectionState,
) lastPingTimeSec = lastPingTimeSec,
)
VerticalDivider() VerticalDivider()
}
DeckLayout( DeckLayout(
deckState = deckState, deckState = deckState,
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -108,50 +109,56 @@ fun SinglePaneLayout(
val navStack by navState.stack.collectAsState() val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull() val currentOverlay = navStack.lastOrNull()
val isImmersive by LocalIsImmersiveFullscreen.current
Row(modifier = modifier.fillMaxSize()) { Row(modifier = modifier.fillMaxSize()) {
NavigationRail( if (!isImmersive) {
modifier = Modifier.width(80.dp).fillMaxHeight(), NavigationRail(
containerColor = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.width(80.dp).fillMaxHeight(),
) { containerColor = MaterialTheme.colorScheme.surfaceVariant,
navItems.forEach { item -> ) {
NavigationRailItem( navItems.forEach { item ->
selected = currentColumnType == item.type && navStack.isEmpty(), NavigationRailItem(
onClick = { selected = currentColumnType == item.type && navStack.isEmpty(),
currentColumnType = item.type onClick = {
navState.clear() currentColumnType = item.type
}, navState.clear()
icon = { },
Icon( icon = {
item.icon, Icon(
contentDescription = item.label, item.icon,
modifier = Modifier.size(22.dp), contentDescription = item.label,
) modifier = Modifier.size(22.dp),
}, )
label = { },
Text( label = {
item.label, Text(
style = MaterialTheme.typography.labelSmall, item.label,
maxLines = 1, style = MaterialTheme.typography.labelSmall,
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
}, )
},
)
}
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 12.dp),
) )
} }
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 12.dp),
)
} }
VerticalDivider() if (!isImmersive) {
VerticalDivider()
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) { Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box( 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 // Always keep RootContent composed so state (e.g. search results) survives navigation
RootContent( RootContent(
@@ -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
}
}
@@ -20,30 +20,46 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui.media 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.layout.widthIn 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
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.material.icons.filled.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.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 androidx.compose.ui.window.WindowState
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 @Composable
fun LightboxOverlay( fun LightboxOverlay(
@@ -73,25 +121,125 @@ fun LightboxOverlay(
var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) } var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } 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) { LaunchedEffect(Unit) {
focusRequester.requestFocus() 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 currentUrl = urls[currentIndex]
val isVideo = RichTextParser.isVideoUrl(currentUrl) 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( Box(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f)) .background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f))
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) { when (event.key) {
Key.Escape -> { Key.Escape -> {
onDismiss() 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 true
} }
@@ -107,9 +255,7 @@ fun LightboxOverlay(
Key.S -> { Key.S -> {
if (event.isCtrlPressed) { if (event.isCtrlPressed) {
scope.launch { triggerSave()
SaveMediaAction.saveMedia(urls[currentIndex])
}
true true
} else { } else {
false false
@@ -130,71 +276,155 @@ fun LightboxOverlay(
// Main content — video or image // Main content — video or image
if (isVideo) { if (isVideo) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(48.dp), modifier = contentModifier,
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
DesktopVideoPlayer( DesktopVideoPlayer(
url = currentUrl, url = currentUrl,
autoPlay = true, autoPlay = true,
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f, initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
isFullscreen = true, viewMode = viewMode,
onFullscreen = { onDismiss() }, onViewModeChange = { newMode ->
modifier = Modifier.widthIn(max = 1200.dp), 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 { } else {
ZoomableImage( ZoomableImage(
url = currentUrl, url = currentUrl,
modifier = Modifier.fillMaxSize().padding(48.dp), modifier = contentModifier,
) )
} }
// Top bar // Download banner (top)
Row( AnimatedVisibility(
modifier = visible = downloadState !is DownloadState.Idle,
Modifier modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter),
.fillMaxWidth() enter = slideInVertically() + fadeIn(),
.align(Alignment.TopCenter) exit = slideOutVertically() + fadeOut(),
.background(Color.Black.copy(alpha = 0.5f))
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
if (urls.size > 1) { val state = downloadState
Text( Row(
"${currentIndex + 1} / ${urls.size}", modifier =
color = Color.White, Modifier
style = MaterialTheme.typography.bodyMedium, .fillMaxWidth()
) .background(
} when (state) {
is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer
Row { is DownloadState.Done -> Color(0xFF2E7D32)
IconButton( else -> Color(0xFF1565C0)
onClick = { },
scope.launch { ).padding(horizontal = 16.dp, vertical = 8.dp),
SaveMediaAction.saveMedia(urls[currentIndex]) verticalAlignment = Alignment.CenterVertically,
) {
when (state) {
is DownloadState.Downloading -> {
Text(
state.filename,
color = Color.White,
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),
)
} }
}, }
) {
Icon(
Icons.Default.Save,
contentDescription = "Save",
tint = Color.White,
)
}
IconButton(onClick = onDismiss) { is DownloadState.Done -> {
Icon( Icon(
Icons.Default.Close, Icons.Default.Check,
contentDescription = "Close", contentDescription = null,
tint = Color.White, 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,
)
}
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 -> {}
} }
} }
} }
// Navigation arrows // "..." menu (bottom right) — only for images; videos get it in the controls bar
if (urls.size > 1) { // Hidden in fullscreen to keep the view immersive
if (!isVideo && viewMode != ViewMode.FULLSCREEN) {
Box(
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
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 — hidden in fullscreen
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
if (currentIndex > 0) { if (currentIndex > 0) {
IconButton( IconButton(
onClick = { currentIndex-- }, 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()
},
)
}
}
}
@@ -37,8 +37,10 @@ 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.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp 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.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -77,7 +79,9 @@ fun VideoControls(
onVolumeChange: ((Int) -> Unit)? = null, onVolumeChange: ((Int) -> Unit)? = null,
onMuteToggle: (() -> Unit)? = null, onMuteToggle: (() -> Unit)? = null,
onFullscreen: (() -> 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 showControls by remember { mutableStateOf(true) }
var hovering by remember { mutableStateOf(false) } var hovering by remember { mutableStateOf(false) }
@@ -220,17 +224,66 @@ fun VideoControls(
) )
} }
// Fullscreen // Two separate toggle buttons (lightbox) or simple fullscreen (inline)
if (onFullscreen != null) { 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 (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)) { IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) {
Icon( Icon(
if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, Icons.Default.Fullscreen,
contentDescription = if (isFullscreen) "Exit Fullscreen" else "Fullscreen", contentDescription = "Fullscreen",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }
} }
// Trailing controls (e.g. more-options menu)
trailingControls?.invoke()
} }
} }
} }