diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt index 4c751d9fe..b1c20ca2e 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -3,18 +3,22 @@ package com.nuvio.app.features.player import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.nuvio.app.features.player.desktop.DesktopHostOs +import com.nuvio.app.features.player.desktop.DesktopPlayerLaunchShield import com.nuvio.app.features.player.desktop.NativePlayerController import com.nuvio.app.features.player.desktop.NativePlayerHost import kotlinx.coroutines.delay @@ -86,16 +90,44 @@ private fun NativePlayerSurface( ) { val host = remember { NativePlayerHost() } val controller = remember(host) { NativePlayerController(host) } + val hostFirstPaintComplete = remember { mutableStateOf(false) } + val hostFirstFullSizePaintComplete = remember { mutableStateOf(false) } + LaunchedEffect(sourceUrl) { + DesktopPlayerLaunchShield.showForActiveWindow() + } val playbackHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) } val latestOnPlayerControlsAction = rememberUpdatedState(onPlayerControlsAction) val latestOnPlayerControlsEvent = rememberUpdatedState(onPlayerControlsEvent) val latestOnPlayerControlsScrubChange = rememberUpdatedState(onPlayerControlsScrubChange) val latestOnPlayerControlsScrubFinished = rememberUpdatedState(onPlayerControlsScrubFinished) + val latestOnError = rememberUpdatedState(onError) LaunchedEffect(controller) { onControllerReady(controller) } + DisposableEffect(host) { + host.onDisplayableChanged = { displayable -> + if (!displayable) { + hostFirstPaintComplete.value = false + hostFirstFullSizePaintComplete.value = false + } + } + host.onFirstPaint = { + hostFirstPaintComplete.value = true + } + host.onFirstFullSizePaint = { + hostFirstFullSizePaintComplete.value = true + DesktopPlayerLaunchShield.hideAfter() + } + onDispose { + host.onDisplayableChanged = null + host.onFirstPaint = null + host.onFirstFullSizePaint = null + DesktopPlayerLaunchShield.hide() + } + } + LaunchedEffect(controller) { controller.setControlCallbacks( onAction = { action -> latestOnPlayerControlsAction.value(action) }, @@ -106,14 +138,21 @@ private fun NativePlayerSurface( } DisposableEffect(controller, sourceUrl, playbackHeaders) { + onDispose { controller.dispose() } + } + + LaunchedEffect(controller, sourceUrl, playbackHeaders, hostFirstFullSizePaintComplete.value) { + if (!hostFirstFullSizePaintComplete.value) { + return@LaunchedEffect + } + delay(16L) controller.attach( sourceUrl = sourceUrl, sourceHeaders = playbackHeaders, playWhenReady = playWhenReady, initialPositionMs = initialPositionMs, - onError = onError, + onError = { message -> latestOnError.value(message) }, ) - onDispose { controller.dispose() } } LaunchedEffect(controller, playWhenReady) { @@ -139,10 +178,25 @@ private fun NativePlayerSurface( } } - SwingPanel( - factory = { host }, - modifier = modifier.fillMaxSize(), - ) + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + ) { + SwingPanel( + factory = { + host + }, + modifier = if (hostFirstPaintComplete.value) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.BottomEnd) + .requiredSize(1.dp) + }, + background = Color.Black, + ) + } } @Composable diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt new file mode 100644 index 000000000..1da376998 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.player.desktop + +import java.awt.Color +import java.awt.KeyboardFocusManager +import java.awt.Rectangle +import java.awt.Window +import javax.swing.JWindow +import javax.swing.RootPaneContainer +import javax.swing.SwingUtilities +import javax.swing.Timer + +internal object DesktopPlayerLaunchShield { + private var shieldWindow: JWindow? = null + private var hideTimer: Timer? = null + + fun showForActiveWindow() { + if (DesktopHostOs.current != DesktopHostOs.WINDOWS) return + SwingUtilities.invokeLater { + val owner = activeOwnerWindow() ?: return@invokeLater + val bounds = ownerContentBounds(owner) ?: return@invokeLater + hideTimer?.stop() + hideTimer = null + + val shield = shieldWindow?.takeIf { it.owner === owner } ?: JWindow(owner).also { window -> + window.background = Color.BLACK + window.contentPane.background = Color.BLACK + window.focusableWindowState = false + window.setType(Window.Type.POPUP) + shieldWindow = window + } + shield.bounds = bounds + if (!shield.isVisible) { + shield.isVisible = true + } + shield.toFront() + hideAfter(3_000) + } + } + + fun hideAfter(delayMs: Int = 48) { + SwingUtilities.invokeLater { + hideTimer?.stop() + hideTimer = Timer(delayMs) { + hideNow() + }.apply { + isRepeats = false + start() + } + } + } + + fun hide() { + SwingUtilities.invokeLater { + hideTimer?.stop() + hideTimer = null + hideNow() + } + } + + private fun hideNow() { + val shield = shieldWindow ?: return + if (shield.isVisible) { + shield.isVisible = false + } + } + + private fun activeOwnerWindow(): Window? { + val active = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + if (active?.isShowing == true) return active + return Window.getWindows() + .filter { it.isShowing && it !is JWindow } + .firstOrNull() + } + + private fun ownerContentBounds(owner: Window): Rectangle? { + val content = (owner as? RootPaneContainer)?.contentPane + return runCatching { + if (content != null && content.isShowing) { + val location = content.locationOnScreen + Rectangle(location.x, location.y, content.width, content.height) + } else { + val location = owner.locationOnScreen + Rectangle(location.x, location.y, owner.width, owner.height) + } + }.getOrNull() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt index 2c12ad04a..a1675948a 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt @@ -2,22 +2,52 @@ package com.nuvio.app.features.player.desktop import java.awt.Canvas import java.awt.Color +import java.awt.Graphics internal class NativePlayerHost : Canvas() { var onPeerReady: (() -> Unit)? = null + var onDisplayableChanged: ((Boolean) -> Unit)? = null + var onFirstPaint: (() -> Unit)? = null + var onFirstFullSizePaint: (() -> Unit)? = null + private var firstPaintNotified = false + private var firstFullSizePaintNotified = false init { background = Color.BLACK - ignoreRepaint = true + ignoreRepaint = false + } + + override fun update(graphics: Graphics) { + paint(graphics) + } + + override fun paint(graphics: Graphics) { + graphics.color = Color.BLACK + graphics.fillRect(0, 0, width, height) + if (!firstPaintNotified) { + firstPaintNotified = true + onFirstPaint?.invoke() + } + if (!firstFullSizePaintNotified && width > 1 && height > 1) { + firstFullSizePaintNotified = true + onFirstFullSizePaint?.invoke() + } } override fun addNotify() { super.addNotify() + onDisplayableChanged?.invoke(true) + repaint() onPeerReady?.invoke() } override fun removeNotify() { + onDisplayableChanged?.invoke(false) + firstPaintNotified = false + firstFullSizePaintNotified = false onPeerReady = null + onFirstPaint = null + onFirstFullSizePaint = null super.removeNotify() } }