diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9c667fac2..3f19aeed6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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,25 +374,32 @@ fun main() { } } - App( - layoutMode = layoutMode, - deckState = deckState, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, - replyToNote = replyToNote, - ) + val immersiveFullscreenState = remember { mutableStateOf(false) } + CompositionLocalProvider( + LocalWindowState provides windowState, + LocalAwtWindow provides window, + LocalIsImmersiveFullscreen provides immersiveFullscreenState, + ) { + App( + layoutMode = layoutMode, + deckState = deckState, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + 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()) { Row(Modifier.fillMaxSize()) { when (layoutMode) { @@ -695,20 +708,22 @@ fun MainContent( } LayoutMode.DECK -> { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - ) + if (!isImmersive) { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) - VerticalDivider() + VerticalDivider() + } DeckLayout( deckState = deckState, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index a00dc029a..f18e56db2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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,50 +109,56 @@ fun SinglePaneLayout( val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() + val isImmersive by LocalIsImmersiveFullscreen.current + Row(modifier = modifier.fillMaxSize()) { - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - navItems.forEach { item -> - NavigationRailItem( - selected = currentColumnType == item.type && navStack.isEmpty(), - onClick = { - currentColumnType = item.type - navState.clear() - }, - icon = { - Icon( - item.icon, - contentDescription = item.label, - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - item.label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, + if (!isImmersive) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + 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()) { 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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt new file mode 100644 index 000000000..8cd466742 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index a5bfd3d4c..5593c93ab 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -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 { null } + +val LocalAwtWindow = compositionLocalOf { 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.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 -> { - 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 } @@ -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 - Row( - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Download banner (top) + AnimatedVisibility( + visible = downloadState !is DownloadState.Idle, + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), ) { - if (urls.size > 1) { - Text( - "${currentIndex + 1} / ${urls.size}", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - ) - } - - Row { - IconButton( - onClick = { - scope.launch { - SaveMediaAction.saveMedia(urls[currentIndex]) + val state = downloadState + Row( + modifier = + Modifier + .fillMaxWidth() + .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, + ) { + 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) { - Icon( - Icons.Default.Close, - contentDescription = "Close", - tint = Color.White, - ) + 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, + ) + } + + 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 - if (urls.size > 1) { + // "..." 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), + ) { + 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) { 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() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt index 3e81b574f..b57e6cb43 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -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) { + // 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 (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( - if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - contentDescription = if (isFullscreen) "Exit Fullscreen" else "Fullscreen", + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", tint = Color.White, modifier = Modifier.size(20.dp), ) } } + + // Trailing controls (e.g. more-options menu) + trailingControls?.invoke() } } }