feat: support nuvio themes in player controls

This commit is contained in:
tapframe 2026-06-08 23:50:04 +05:30
parent a955c431d9
commit 0747657233
6 changed files with 197 additions and 71 deletions

View file

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

View file

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

View file

@ -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<String?>(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<String?>(null) }
PlatformPlayerSurface(
sourceUrl = smokePlayerUrl,
modifier = Modifier.fillMaxSize(),
onControllerReady = {},
onSnapshot = {},
onError = { error = it },
)
error?.let { println("Nuvio desktop player smoke error: $it") }
}
}
}
}

View file

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

View file

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

View file

@ -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 @@
</nav>
</header>
<section class="center-status" id="bufferingStatus" aria-live="polite" aria-label="Buffering" aria-hidden="true">
<section class="center-status" id="bufferingStatus" aria-label="Buffering" aria-hidden="true">
<span class="center-spinner" aria-hidden="true"></span>
<span id="bufferingLabel">Buffering...</span>
</section>
<section class="progress" aria-label="Playback progress">
@ -1773,7 +1775,6 @@
const positionLabel = document.getElementById("position");
const durationLabel = document.getElementById("duration");
const bufferingStatus = document.getElementById("bufferingStatus");
const bufferingLabel = document.getElementById("bufferingLabel");
const toggle = document.getElementById("toggle");
const toggleIcon = document.getElementById("toggleIcon");
const lockIcon = document.getElementById("lockIcon");
@ -1906,7 +1907,6 @@
externalPlayerLabel: "External",
playLabel: "Play",
pauseLabel: "Pause",
bufferingLabel: "Buffering...",
closeLabel: "Close player",
lockLabel: "Lock player controls",
unlockLabel: "Unlock player controls",
@ -1960,6 +1960,18 @@
resetDefaultsLabel: "Reset Defaults",
onLabel: "On",
offLabel: "Off",
themeAccentColor: "#2f6fed",
themeAccentStrongColor: "#3c7bff",
themeOnAccentColor: "#fff",
themeFocusColor: "#9ecaff",
themeSelectedSurfaceColor: "#26384f",
themeSelectedSurfaceHoverColor: "#2d4565",
themeSelectedRingColor: "rgba(47, 111, 237, .35)",
themeTimelineFillColor: "#fff",
themeTimelineTrackColor: "rgba(255, 255, 255, .28)",
themeBufferingColor: "#fff",
themeBufferingTrackColor: "rgba(255, 255, 255, .28)",
themeControlForegroundColor: "#fff",
isPlaying: false,
isLoading: true,
isLocked: false,
@ -2042,6 +2054,30 @@
if (bridge) bridge.postMessage({ type, value });
};
const cssColorOrFallback = (value, fallback) => {
const text = String(value || "").trim();
return /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))$/.test(text) ? text : fallback;
};
const applyTheme = () => {
const style = document.documentElement.style;
const setColor = (name, value, fallback) => {
style.setProperty(name, cssColorOrFallback(value, fallback));
};
setColor("--theme-accent", state.themeAccentColor, "#2f6fed");
setColor("--theme-accent-strong", state.themeAccentStrongColor, "#3c7bff");
setColor("--theme-on-accent", state.themeOnAccentColor, "#fff");
setColor("--theme-focus", state.themeFocusColor, "#9ecaff");
setColor("--theme-selected-surface", state.themeSelectedSurfaceColor, "#26384f");
setColor("--theme-selected-surface-hover", state.themeSelectedSurfaceHoverColor, "#2d4565");
setColor("--theme-selected-ring", state.themeSelectedRingColor, "rgba(47, 111, 237, .35)");
setColor("--theme-timeline-fill", state.themeTimelineFillColor, "#fff");
setColor("--theme-timeline-track", state.themeTimelineTrackColor, "rgba(255, 255, 255, .28)");
setColor("--theme-buffering", state.themeBufferingColor, "#fff");
setColor("--theme-buffering-track", state.themeBufferingTrackColor, "rgba(255, 255, 255, .28)");
setColor("--theme-control-foreground", state.themeControlForegroundColor, "#fff");
};
const formatTime = milliseconds => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) milliseconds = 0;
const total = Math.floor(milliseconds / 1000);
@ -2777,8 +2813,6 @@
episodesLabel.textContent = state.episodesLabel || "Episodes";
externalLabel.textContent = state.externalPlayerLabel || "External";
lockedLabel.textContent = state.tapToUnlockLabel || "Tap to unlock";
bufferingLabel.textContent = state.bufferingLabel || "Buffering...";
const showBuffering = Boolean(state.isLoading && !state.isLocked && !activeModal && !showOpening);
bufferingStatus.classList.toggle("visible", showBuffering);
bufferingStatus.setAttribute("aria-hidden", showBuffering ? "false" : "true");
@ -2807,6 +2841,7 @@
};
const render = () => {
applyTheme();
renderChrome();
renderActiveModal();
};