fix: implement DesktopPlayerLaunchShield for fixing brief white flash

This commit is contained in:
tapframe 2026-06-09 19:17:09 +05:30
parent da9fff8795
commit 5b8a4aff9b
3 changed files with 178 additions and 7 deletions

View file

@ -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

View file

@ -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()
}
}

View file

@ -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()
}
}