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.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,
|
||||||
|
|||||||
+43
-36
@@ -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(
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
|
}
|
||||||
+332
-51
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+58
-5
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user