From 0747657233934bde50061ebfd3d361271be86c2c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:50:04 +0530 Subject: [PATCH] feat: support nuvio themes in player controls --- .../nuvio/app/features/player/PlayerEngine.kt | 12 ++ .../features/player/PlayerScreenRuntimeUi.kt | 30 +++++ .../desktopMain/kotlin/com/nuvio/app/Main.kt | 63 +++++----- .../player/desktop/NativePlayerBridge.kt | 20 +++ .../player/desktop/NativePlayerController.kt | 24 ++++ .../resources/player-ui/controls.html | 119 +++++++++++------- 6 files changed, 197 insertions(+), 71 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 62dee66eb..ba33bbd0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -111,6 +111,18 @@ data class PlayerControlsState( val resetDefaultsLabel: String = "Reset Defaults", val onLabel: String = "On", val offLabel: String = "Off", + val themeAccentColor: String = "#2f6fed", + val themeAccentStrongColor: String = "#3c7bff", + val themeOnAccentColor: String = "#ffffff", + val themeFocusColor: String = "#9ecaff", + val themeSelectedSurfaceColor: String = "#26384f", + val themeSelectedSurfaceHoverColor: String = "#2d4565", + val themeSelectedRingColor: String = "rgba(47, 111, 237, .35)", + val themeTimelineFillColor: String = "#ffffff", + val themeTimelineTrackColor: String = "rgba(255, 255, 255, .28)", + val themeBufferingColor: String = "#ffffff", + val themeBufferingTrackColor: String = "rgba(255, 255, 255, .28)", + val themeControlForegroundColor: String = "#ffffff", val isPlaying: Boolean = false, val isLoading: Boolean = false, val isLocked: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 6325f9db7..bb1288073 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -7,10 +7,13 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaVideo @@ -28,6 +31,7 @@ import com.nuvio.app.isDesktop import com.nuvio.app.isIos import kotlinx.coroutines.launch import kotlin.math.abs +import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -128,6 +132,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val episodeStreamItems = buildPlayerControlEpisodeStreamItems() val playerControlAddonSubtitles = buildPlayerControlAddonSubtitleItems() val playerControlAutoSyncCues = buildPlayerControlSubtitleCueItems() + val themeColors = MaterialTheme.nuvio.colors val selectedEpisodeLabel = episodeStreamsPanelState.selectedEpisode?.let { selected -> val selectedCode = selected.playerControlsEpisodeCode() buildString { @@ -205,6 +210,18 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { resetDefaultsLabel = stringResource(Res.string.compose_player_reset_defaults), onLabel = stringResource(Res.string.compose_action_on), offLabel = stringResource(Res.string.compose_action_off), + themeAccentColor = themeColors.accent.toCssColorString(), + themeAccentStrongColor = themeColors.accentStrong.toCssColorString(), + themeOnAccentColor = themeColors.onAccent.toCssColorString(), + themeFocusColor = themeColors.focusRing.toCssColorString(), + themeSelectedSurfaceColor = themeColors.accent.copy(alpha = 0.24f).toCssColorString(), + themeSelectedSurfaceHoverColor = themeColors.accent.copy(alpha = 0.34f).toCssColorString(), + themeSelectedRingColor = themeColors.accent.copy(alpha = 0.35f).toCssColorString(), + themeTimelineFillColor = themeColors.playerTimelineFill.toCssColorString(), + themeTimelineTrackColor = themeColors.playerTimelineTrack.toCssColorString(), + themeBufferingColor = themeColors.playerBuffering.toCssColorString(), + themeBufferingTrackColor = themeColors.playerBuffering.copy(alpha = 0.28f).toCssColorString(), + themeControlForegroundColor = themeColors.playerControlsForeground.toCssColorString(), isPlaying = playbackSnapshot.isPlaying, isLoading = playbackSnapshot.isLoading, isLocked = playerControlsLocked, @@ -718,6 +735,19 @@ private fun PlayerScreenRuntime.prepareSourcesForPlayerControls(forceRefresh: Bo ) } +private fun Color.toCssColorString(): String { + val redInt = (red * 255f).roundToInt().coerceIn(0, 255) + val greenInt = (green * 255f).roundToInt().coerceIn(0, 255) + val blueInt = (blue * 255f).roundToInt().coerceIn(0, 255) + val alphaValue = alpha.coerceIn(0f, 1f) + return "rgba($redInt, $greenInt, $blueInt, ${alphaValue.toCssAlphaString()})" +} + +private fun Float.toCssAlphaString(): String { + val rounded = (this * 1000f).roundToInt() / 1000f + return rounded.toString().trimEnd('0').trimEnd('.').ifEmpty { "0" } +} + private fun PlayerScreenRuntime.prepareEpisodesForPlayerControls() { if (!isSeries) return if (playerMetaVideos.isEmpty()) { diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt index b8c250228..f036f4e70 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -12,42 +12,47 @@ import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.unit.dp import com.nuvio.app.features.player.PlatformPlayerSurface +import com.nuvio.app.features.player.desktop.preloadNativePlayerBridgeAsync import java.awt.Color as AwtColor import javax.swing.JComponent private val NuvioDesktopNativeBackground = AwtColor(0x0D, 0x0D, 0x0D) -fun main() = application { - val smokePlayerUrl = ( - System.getProperty("nuvio.desktop.smokePlayerUrl") - ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") - ) - ?.takeIf { it.isNotBlank() } +fun main() { + preloadNativePlayerBridgeAsync() - Window( - onCloseRequest = ::exitApplication, - title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke", - state = WindowState(width = 1280.dp, height = 820.dp), - ) { - SideEffect { - window.background = NuvioDesktopNativeBackground - window.rootPane.background = NuvioDesktopNativeBackground - window.contentPane.background = NuvioDesktopNativeBackground - (window.contentPane as? JComponent)?.isOpaque = true - } - - if (smokePlayerUrl == null) { - App() - } else { - var error by remember { mutableStateOf(null) } - PlatformPlayerSurface( - sourceUrl = smokePlayerUrl, - modifier = Modifier.fillMaxSize(), - onControllerReady = {}, - onSnapshot = {}, - onError = { error = it }, + application { + val smokePlayerUrl = ( + System.getProperty("nuvio.desktop.smokePlayerUrl") + ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") ) - error?.let { println("Nuvio desktop player smoke error: $it") } + ?.takeIf { it.isNotBlank() } + + Window( + onCloseRequest = ::exitApplication, + title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke", + state = WindowState(width = 1280.dp, height = 820.dp), + ) { + SideEffect { + window.background = NuvioDesktopNativeBackground + window.rootPane.background = NuvioDesktopNativeBackground + window.contentPane.background = NuvioDesktopNativeBackground + (window.contentPane as? JComponent)?.isOpaque = true + } + + if (smokePlayerUrl == null) { + App() + } else { + var error by remember { mutableStateOf(null) } + PlatformPlayerSurface( + sourceUrl = smokePlayerUrl, + modifier = Modifier.fillMaxSize(), + onControllerReady = {}, + onSnapshot = {}, + onError = { error = it }, + ) + error?.let { println("Nuvio desktop player smoke error: $it") } + } } } } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt index 24d3425cb..d0995ae5d 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -2,12 +2,15 @@ package com.nuvio.app.features.player.desktop import java.io.File import java.nio.file.Files +import java.util.concurrent.atomic.AtomicBoolean internal fun interface NativePlayerEventSink { fun onPlayerEvent(type: String, value: Double) } internal object NativePlayerBridge { + private val preloadStarted = AtomicBoolean(false) + init { loadNativeLibrary() } @@ -62,6 +65,17 @@ internal object NativePlayerBridge { input.bufferedReader().use { it.readText() } } + fun preloadAsync() { + if (!preloadStarted.compareAndSet(false, true)) return + Thread { + runCatching { controlsHtml } + }.apply { + name = "nuvio-native-player-preload" + isDaemon = true + start() + } + } + private fun loadNativeLibrary() { val platform = DesktopHostOs.current require(platform == DesktopHostOs.MACOS) { @@ -112,3 +126,9 @@ internal object NativePlayerBridge { DesktopHostOs.UNKNOWN -> "player_bridge" } } + +internal fun preloadNativePlayerBridgeAsync() { + if (DesktopHostOs.current == DesktopHostOs.MACOS) { + NativePlayerBridge.preloadAsync() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index 3f53b77fb..12520ae7f 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -583,6 +583,30 @@ private fun PlayerControlsState.toControlsJson(): String = append(',') appendJsonField("offLabel", offLabel) append(',') + appendJsonField("themeAccentColor", themeAccentColor) + append(',') + appendJsonField("themeAccentStrongColor", themeAccentStrongColor) + append(',') + appendJsonField("themeOnAccentColor", themeOnAccentColor) + append(',') + appendJsonField("themeFocusColor", themeFocusColor) + append(',') + appendJsonField("themeSelectedSurfaceColor", themeSelectedSurfaceColor) + append(',') + appendJsonField("themeSelectedSurfaceHoverColor", themeSelectedSurfaceHoverColor) + append(',') + appendJsonField("themeSelectedRingColor", themeSelectedRingColor) + append(',') + appendJsonField("themeTimelineFillColor", themeTimelineFillColor) + append(',') + appendJsonField("themeTimelineTrackColor", themeTimelineTrackColor) + append(',') + appendJsonField("themeBufferingColor", themeBufferingColor) + append(',') + appendJsonField("themeBufferingTrackColor", themeBufferingTrackColor) + append(',') + appendJsonField("themeControlForegroundColor", themeControlForegroundColor) + append(',') appendJsonField("isPlaying", isPlaying) append(',') appendJsonField("isLoading", isLoading) diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index ca5b76c74..74911057d 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -26,6 +26,18 @@ --motion-standard: 180ms; --motion-panel: 220ms; --ease-out: cubic-bezier(.2, .8, .2, 1); + --theme-accent: #2f6fed; + --theme-accent-strong: #3c7bff; + --theme-on-accent: #fff; + --theme-focus: #9ecaff; + --theme-selected-surface: #26384f; + --theme-selected-surface-hover: #2d4565; + --theme-selected-ring: rgba(47, 111, 237, .35); + --theme-timeline-fill: #fff; + --theme-timeline-track: rgba(255, 255, 255, .28); + --theme-buffering: #fff; + --theme-buffering-track: rgba(255, 255, 255, .28); + --theme-control-foreground: #fff; color-scheme: dark; } @@ -356,18 +368,10 @@ position: absolute; left: 50%; top: 50%; - display: flex; - align-items: center; - gap: 10px; - min-height: 44px; - padding: 10px 16px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, .14); - background: rgba(0, 0, 0, .56); - color: rgba(255, 255, 255, .92); - font-size: 13px; - line-height: 1.2; - font-weight: 700; + width: 48px; + height: 48px; + display: grid; + place-items: center; opacity: 0; pointer-events: none; transform: translate(-50%, calc(-50% - var(--center-lift) + 10px)) scale(.98); @@ -383,12 +387,11 @@ } .center-spinner { - width: 18px; - height: 18px; - flex: 0 0 auto; + width: 42px; + height: 42px; border-radius: 999px; - border: 2px solid rgba(255, 255, 255, .3); - border-top-color: #fff; + border: 3px solid var(--theme-buffering-track); + border-top-color: var(--theme-buffering); animation: center-spin 760ms linear infinite; } @@ -546,8 +549,8 @@ width: 54px; height: 54px; border-radius: 999px; - border: 3px solid rgba(229, 9, 20, .22); - border-top-color: #e50914; + border: 3px solid var(--theme-buffering-track); + border-top-color: var(--theme-buffering); animation: center-spin 760ms linear infinite; } @@ -585,7 +588,7 @@ width: 0%; height: 100%; border-radius: inherit; - background: rgba(255, 255, 255, .85); + background: var(--theme-timeline-fill); transition: width 900ms cubic-bezier(.4, 0, .2, 1); } @@ -697,7 +700,7 @@ height: 4px; border-radius: 999px; background: - linear-gradient(to right, #fff 0 var(--progress, 0%), rgba(255, 255, 255, .28) var(--progress, 0%) 100%); + linear-gradient(to right, var(--theme-timeline-fill) 0 var(--progress, 0%), var(--theme-timeline-track) var(--progress, 0%) 100%); } .scrub::-webkit-slider-thumb { @@ -706,7 +709,7 @@ height: 14px; margin-top: -5px; border-radius: 999px; - background: #fff; + background: var(--theme-control-foreground); box-shadow: 0 0 0 1px rgba(255, 255, 255, .18); transition: transform var(--motion-fast) var(--ease-out), @@ -920,15 +923,15 @@ .panel-button.primary, .filter-chip.selected, .segment-button.selected { - background: #2f6fed; - color: #fff; + background: var(--theme-accent); + color: var(--theme-on-accent); border-color: rgba(255, 255, 255, .18); } .panel-button.primary:hover:not(:disabled), .filter-chip.selected:hover:not(:disabled), .segment-button.selected:hover:not(:disabled) { - background: #3c7bff; + background: var(--theme-accent-strong); } .filter-row { @@ -964,8 +967,8 @@ .subtitle-tab.selected { border-radius: 12px; - background: #2f6fed; - color: #fff; + background: var(--theme-accent); + color: var(--theme-on-accent); } .style-panel { @@ -1062,8 +1065,8 @@ } .swatch.selected { - border-color: #9ecaff; - box-shadow: 0 0 0 2px rgba(47, 111, 237, .35); + border-color: var(--theme-focus); + box-shadow: 0 0 0 2px var(--theme-selected-ring); } .auto-sync-card { @@ -1089,7 +1092,7 @@ .sync-time { flex: 0 0 auto; - color: #9ecaff; + color: var(--theme-focus); font-size: 11px; line-height: 1.25; font-weight: 800; @@ -1151,13 +1154,13 @@ } .track-row.selected { - background: #26384f; - color: #e2ecff; + background: var(--theme-selected-surface); + color: var(--theme-control-foreground); font-weight: 700; } .track-row.selected:hover:not(:disabled) { - background: #2d4565; + background: var(--theme-selected-surface-hover); } .track-label { @@ -1243,8 +1246,8 @@ } .status-chip { - background: #2f6fed; - color: #fff; + background: var(--theme-accent); + color: var(--theme-on-accent); } .episode-thumb { @@ -1267,7 +1270,7 @@ width: 18px; height: 18px; flex: 0 0 auto; - color: #9ecaff; + color: var(--theme-focus); opacity: 0; } @@ -1553,9 +1556,8 @@ -