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.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,
@@ -391,6 +401,7 @@ fun main() {
)
}
}
}
}
@Composable
@@ -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,
@@ -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(
@@ -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
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()
},
)
}
}
}
@@ -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()
}
}
}