diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d766f95..896c5ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Startup — themed loading splash before the app bundle loads + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)** + +* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch. +* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset. +* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility. + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/index.html b/index.html index 8e5a8e26..c476fc52 100644 --- a/index.html +++ b/index.html @@ -6,9 +6,91 @@ Psysonic + + +
+ + + Loading +
+ diff --git a/public/startup-splash-preflight.js b/public/startup-splash-preflight.js new file mode 100644 index 00000000..34099c32 --- /dev/null +++ b/public/startup-splash-preflight.js @@ -0,0 +1,161 @@ +/** + * Synchronous startup splash theme (before the Vite bundle loads). + * Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`. + */ +(function startupSplashPreflight() { + var THEME_KEY = 'psysonic_theme'; + var INSTALLED_KEY = 'psysonic_installed_themes'; + var DEFAULT = { + bg: '#1e1e2e', + text: '#cdd6f4', + muted: '#a6adc8', + accent: '#cba6f7', + track: '#313244', + logoStart: '#cba6f7', + logoEnd: '#89b4fa', + }; + var BUILTIN = { + mocha: DEFAULT, + latte: { + bg: '#eff1f5', + text: '#4c4f69', + muted: '#6c6f85', + accent: '#8839ef', + track: '#ccd0da', + logoStart: '#8839ef', + logoEnd: '#1e66f5', + }, + 'kanagawa-wave': { + bg: '#1F1F28', + text: '#DCD7BA', + muted: '#727169', + accent: '#7E9CD8', + track: '#2A2A37', + logoStart: '#7E9CD8', + logoEnd: '#957FB8', + }, + 'stark-hud': { + bg: '#0b0f15', + text: '#e0f7fa', + muted: '#7da5aa', + accent: '#00f2ff', + track: '#141b24', + logoStart: '#00f2ff', + logoEnd: '#7df9ff', + }, + 'vision-dark': { + bg: '#0d0b12', + text: '#f2eef8', + muted: '#a6a2b8', + accent: '#ffd700', + track: '#16131e', + logoStart: '#ffd700', + logoEnd: '#a07af8', + }, + 'vision-navy': { + bg: '#0a1628', + text: '#e8eef8', + muted: '#9caac2', + accent: '#ffd700', + track: '#12213a', + logoStart: '#ffd700', + logoEnd: '#a07af8', + }, + }; + + function readCssVar(css, name) { + var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);')); + var value = match && match[1] ? match[1].trim() : ''; + return value || null; + } + + function resolveScheduledTheme(state) { + if (!state.enableThemeScheduler) return state.theme; + var now = new Date(); + var nowMins = now.getHours() * 60 + now.getMinutes(); + var dayParts = state.timeDayStart.split(':').map(Number); + var nightParts = state.timeNightStart.split(':').map(Number); + var dayMins = dayParts[0] * 60 + dayParts[1]; + var nightMins = nightParts[0] * 60 + nightParts[1]; + var isDay = dayMins < nightMins + ? nowMins >= dayMins && nowMins < nightMins + : nowMins >= dayMins || nowMins < nightMins; + return isDay ? state.themeDay : state.themeNight; + } + + function readThemeState() { + try { + var raw = localStorage.getItem(THEME_KEY); + if (!raw) return null; + var parsed = JSON.parse(raw); + var s = parsed && parsed.state; + if (!s) return null; + return { + enableThemeScheduler: !!s.enableThemeScheduler, + theme: String(s.theme || 'mocha'), + themeDay: String(s.themeDay || 'latte'), + themeNight: String(s.themeNight || 'mocha'), + timeDayStart: String(s.timeDayStart || '07:00'), + timeNightStart: String(s.timeNightStart || '19:00'), + }; + } catch (_err) { + return null; + } + } + + function readInstalledThemes() { + try { + var raw = localStorage.getItem(INSTALLED_KEY); + if (!raw) return []; + var parsed = JSON.parse(raw); + var themes = parsed && parsed.state && parsed.state.themes; + return Array.isArray(themes) ? themes : []; + } catch (_err) { + return []; + } + } + + function paletteForTheme(themeId, installedThemes) { + if (BUILTIN[themeId]) return BUILTIN[themeId]; + for (var i = 0; i < installedThemes.length; i += 1) { + var theme = installedThemes[i]; + if (!theme || theme.id !== themeId || !theme.css) continue; + var bg = readCssVar(theme.css, '--bg-app'); + var accent = readCssVar(theme.css, '--accent'); + if (!bg || !accent) break; + var logoStart = readCssVar(theme.css, '--logo-color-start') || accent; + var logoEnd = readCssVar(theme.css, '--logo-color-end') + || readCssVar(theme.css, '--accent-2') + || accent; + return { + bg: bg, + text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text, + muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted, + accent: accent, + track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track, + logoStart: logoStart, + logoEnd: logoEnd, + }; + } + return DEFAULT; + } + + function applyPalette(themeId, palette) { + var root = document.documentElement; + root.setAttribute('data-theme', themeId); + root.style.setProperty('--startup-splash-bg', palette.bg); + root.style.setProperty('--startup-splash-text', palette.text); + root.style.setProperty('--startup-splash-muted', palette.muted); + root.style.setProperty('--startup-splash-accent', palette.accent); + root.style.setProperty('--startup-splash-track', palette.track); + root.style.setProperty('--startup-splash-logo-start', palette.logoStart); + root.style.setProperty('--startup-splash-logo-end', palette.logoEnd); + root.style.background = palette.bg; + if (document.body) document.body.style.background = palette.bg; + } + + var persisted = readThemeState(); + var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha'; + var palette = paletteForTheme(themeId, readInstalledThemes()); + applyPalette(themeId, palette); +})(); diff --git a/public/startup-splash-reveal.js b/public/startup-splash-reveal.js new file mode 100644 index 00000000..7c3c9513 --- /dev/null +++ b/public/startup-splash-reveal.js @@ -0,0 +1,28 @@ +/** + * Show the native window after the inline startup splash has painted. + * __TAURI_INTERNALS__ may not exist yet when this script first runs. + */ +(function startupSplashReveal() { + var MAX_ATTEMPTS = 60; + + function tryShowMainWindow() { + var internals = window.__TAURI_INTERNALS__; + if (!internals || typeof internals.invoke !== 'function') return false; + internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {}); + return true; + } + + function reveal(attempt) { + if (tryShowMainWindow()) return; + if (attempt >= MAX_ATTEMPTS) return; + window.setTimeout(function () { + reveal(attempt + 1); + }, 50); + } + + requestAnimationFrame(function () { + requestAnimationFrame(function () { + reveal(0); + }); + }); +})(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80b137bc..a137ca0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin( tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::all() + & !tauri_plugin_window_state::StateFlags::VISIBLE, + ) .with_denylist(&["mini"]) .build() ) @@ -625,6 +629,24 @@ pub fn run() { } } }) + .on_page_load(|webview, payload| { + if webview.label() != "main" { + return; + } + + match payload.event() { + tauri::webview::PageLoadEvent::Started => { + let window = webview.window().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(48)); + let _ = window.show(); + }); + } + tauri::webview::PageLoadEvent::Finished => { + let _ = webview.window().show(); + } + } + }) .invoke_handler(tauri::generate_handler![ greet, theme_import::import_theme_zip, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc7586e4..4c671c49 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,8 @@ "fullscreen": false, "decorations": true, "transparent": false, - "visible": true, + "visible": false, + "backgroundColor": "#1e1e2e", "dragDropEnabled": false } ], diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 15258183..5957e92b 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -1,4 +1,5 @@ import { installQueueUndoHotkey } from '../store/queueUndoHotkey'; +import { configureStartupSplash } from './startupSplash'; import { invoke } from '@tauri-apps/api/core'; import { getWindowKind } from './windowKind'; import { migrateThemeSelection } from '../utils/themes/themeMigration'; @@ -112,6 +113,7 @@ export function runPreReactBootstrap(): void { migrateThemeSelection(); // Paint the correct theme on the very first frame (no Mocha flash). applyThemeAtStartup(); + configureStartupSplash(); installCrossWindowThemeSync(); markDevBuildDocument(); pushUserAgentToBackend(); diff --git a/src/app/startupSplash.test.ts b/src/app/startupSplash.test.ts new file mode 100644 index 00000000..6a66770e --- /dev/null +++ b/src/app/startupSplash.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + STARTUP_SPLASH_ID, + configureStartupSplash, + dismissStartupSplash, +} from './startupSplash'; + +vi.mock('./windowKind', () => ({ + getWindowKind: vi.fn(() => 'main'), +})); + +vi.mock('../utils/themes/startupThemeAppearance', () => ({ + applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'), +})); + +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })), +})); + +import { getWindowKind } from './windowKind'; +import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance'; + +describe('startupSplash', () => { + beforeEach(() => { + document.body.innerHTML = `
`; + vi.mocked(getWindowKind).mockReturnValue('main'); + vi.mocked(applyStartupSplashThemeFromStorage).mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('removes splash on mini player webview', () => { + vi.mocked(getWindowKind).mockReturnValue('mini'); + configureStartupSplash(); + expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull(); + }); + + it('re-applies theme from storage on main window', () => { + configureStartupSplash(); + expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled(); + }); + + it('fades out and removes splash', () => { + vi.useFakeTimers(); + dismissStartupSplash(); + expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true); + vi.runAllTimers(); + expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull(); + vi.useRealTimers(); + }); +}); diff --git a/src/app/startupSplash.ts b/src/app/startupSplash.ts new file mode 100644 index 00000000..495985ad --- /dev/null +++ b/src/app/startupSplash.ts @@ -0,0 +1,43 @@ +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance'; +import { getWindowKind } from './windowKind'; + +export const STARTUP_SPLASH_ID = 'app-startup-splash'; + +/** Ensure the native shell is visible once the webview bundle is alive. */ +export function revealStartupWindow(): void { + if (getWindowKind() === 'mini') return; + void getCurrentWebviewWindow().show().catch(() => {}); +} + +/** Re-apply splash colors after bootstrap theme migration/injection. */ +export function configureStartupSplash(): void { + const splash = document.getElementById(STARTUP_SPLASH_ID); + if (!splash) return; + + if (getWindowKind() === 'mini') { + splash.remove(); + return; + } + + applyStartupSplashThemeFromStorage(); + revealStartupWindow(); +} + +/** Fade out the splash after the first React commit. */ +export function dismissStartupSplash(): void { + const splash = document.getElementById(STARTUP_SPLASH_ID); + if (!splash || splash.classList.contains('app-startup-splash--hide')) return; + + splash.classList.add('app-startup-splash--hide'); + const remove = () => splash.remove(); + splash.addEventListener('transitionend', remove, { once: true }); + window.setTimeout(remove, 500); +} + +/** Schedule dismiss on the frame after the first paint. */ +export function scheduleStartupSplashDismiss(): void { + requestAnimationFrame(() => { + requestAnimationFrame(dismissStartupSplash); + }); +} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index d6cd2b30..dfd6ce77 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -154,6 +154,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)', 'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)', 'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)', + 'Themed startup splash before Vite loads — deferred window show, per-theme logo gradient (PR #1030)', ], }, { diff --git a/src/config/startupSplashPalettes.ts b/src/config/startupSplashPalettes.ts new file mode 100644 index 00000000..e0b87ce9 --- /dev/null +++ b/src/config/startupSplashPalettes.ts @@ -0,0 +1,71 @@ +/** Splash colors aligned with bundled theme semantic tokens (`--bg-app`, etc.). */ +export type StartupSplashPalette = { + bg: string; + text: string; + muted: string; + accent: string; + track: string; + /** Sidebar logo gradient start (`--logo-color-start` / `--accent`). */ + logoStart: string; + /** Sidebar logo gradient end (`--logo-color-end` / `--accent-2`). */ + logoEnd: string; +}; + +export const BUILTIN_SPLASH_PALETTES: Record = { + mocha: { + bg: '#1e1e2e', + text: '#cdd6f4', + muted: '#a6adc8', + accent: '#cba6f7', + track: '#313244', + logoStart: '#cba6f7', + logoEnd: '#89b4fa', + }, + latte: { + bg: '#eff1f5', + text: '#4c4f69', + muted: '#6c6f85', + accent: '#8839ef', + track: '#ccd0da', + logoStart: '#8839ef', + logoEnd: '#1e66f5', + }, + 'kanagawa-wave': { + bg: '#1F1F28', + text: '#DCD7BA', + muted: '#727169', + accent: '#7E9CD8', + track: '#2A2A37', + logoStart: '#7E9CD8', + logoEnd: '#957FB8', + }, + 'stark-hud': { + bg: '#0b0f15', + text: '#e0f7fa', + muted: '#7da5aa', + accent: '#00f2ff', + track: '#141b24', + logoStart: '#00f2ff', + logoEnd: '#7df9ff', + }, + 'vision-dark': { + bg: '#0d0b12', + text: '#f2eef8', + muted: '#a6a2b8', + accent: '#ffd700', + track: '#16131e', + logoStart: '#ffd700', + logoEnd: '#a07af8', + }, + 'vision-navy': { + bg: '#0a1628', + text: '#e8eef8', + muted: '#9caac2', + accent: '#ffd700', + track: '#12213a', + logoStart: '#ffd700', + logoEnd: '#a07af8', + }, +}; + +export const BUILTIN_THEME_IDS = Object.keys(BUILTIN_SPLASH_PALETTES); diff --git a/src/main.tsx b/src/main.tsx index 470a66eb..b1889cfd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { runPreReactBootstrap } from './app/bootstrap'; +import { scheduleStartupSplashDismiss } from './app/startupSplash'; import './i18n'; import './styles/themes/index.css'; import './styles/layout/index.css'; @@ -15,3 +16,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render( ); + +scheduleStartupSplashDismiss(); diff --git a/src/utils/themes/startupThemeAppearance.test.ts b/src/utils/themes/startupThemeAppearance.test.ts new file mode 100644 index 00000000..3c7ed76f --- /dev/null +++ b/src/utils/themes/startupThemeAppearance.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BUILTIN_SPLASH_PALETTES } from '../../config/startupSplashPalettes'; +import { + applyStartupSplashPalette, + resolveEffectiveThemeId, + resolveScheduledThemeId, + resolveSplashPalette, +} from './startupThemeAppearance'; + +describe('startupThemeAppearance', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-theme'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('resolves scheduled day/night theme', () => { + const theme = resolveScheduledThemeId({ + enableThemeScheduler: true, + theme: 'mocha', + themeDay: 'latte', + themeNight: 'stark-hud', + timeDayStart: '00:00', + timeNightStart: '12:00', + }); + expect(['latte', 'stark-hud']).toContain(theme); + }); + + it('reads effective theme from persisted store', () => { + localStorage.setItem('psysonic_theme', JSON.stringify({ + state: { + theme: 'vision-dark', + enableThemeScheduler: false, + }, + })); + expect(resolveEffectiveThemeId()).toBe('vision-dark'); + }); + + it('parses community theme css into splash palette', () => { + const palette = resolveSplashPalette('my-theme', [{ + id: 'my-theme', + css: `[data-theme='my-theme'] { --bg-app: #112233; --accent: #aabbcc; --text-primary: #ddeeff; --text-muted: #99aabb; --bg-card: #223344; }`, + }]); + expect(palette).toMatchObject({ + bg: '#112233', + accent: '#aabbcc', + text: '#ddeeff', + muted: '#99aabb', + track: '#223344', + logoStart: '#aabbcc', + logoEnd: '#aabbcc', + }); + }); + + it('applies palette css variables on document root', () => { + applyStartupSplashPalette('kanagawa-wave', BUILTIN_SPLASH_PALETTES['kanagawa-wave']); + expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave'); + expect(document.documentElement.style.getPropertyValue('--startup-splash-bg').trim()).toBe('#1F1F28'); + expect(document.documentElement.style.getPropertyValue('--startup-splash-accent').trim()).toBe('#7E9CD8'); + expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-start').trim()).toBe('#7E9CD8'); + expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-end').trim()).toBe('#957FB8'); + }); + + it('reads custom logo gradient from community theme css', () => { + const palette = resolveSplashPalette('brand', [{ + id: 'brand', + css: `[data-theme='brand'] { --bg-app: #111; --accent: #f00; --logo-color-start: #0f0; --logo-color-end: #00f; }`, + }]); + expect(palette.logoStart).toBe('#0f0'); + expect(palette.logoEnd).toBe('#00f'); + }); + + it('keeps public preflight palettes in sync with bundled theme map', () => { + const preflight = readFileSync( + resolve(process.cwd(), 'public/startup-splash-preflight.js'), + 'utf8', + ); + for (const themeId of Object.keys(BUILTIN_SPLASH_PALETTES)) { + expect(preflight).toContain(`'${themeId}'`); + expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].bg); + expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].logoStart); + } + }); +}); diff --git a/src/utils/themes/startupThemeAppearance.ts b/src/utils/themes/startupThemeAppearance.ts new file mode 100644 index 00000000..679ea2b4 --- /dev/null +++ b/src/utils/themes/startupThemeAppearance.ts @@ -0,0 +1,145 @@ +import { + BUILTIN_SPLASH_PALETTES, + BUILTIN_THEME_IDS, + type StartupSplashPalette, +} from '../../config/startupSplashPalettes'; + +export type PersistedThemeState = { + enableThemeScheduler: boolean; + theme: string; + themeDay: string; + themeNight: string; + timeDayStart: string; + timeNightStart: string; +}; + +export type InstalledThemeSnapshot = { + id: string; + css: string; +}; + +const THEME_STORAGE_KEY = 'psysonic_theme'; +const INSTALLED_THEMES_STORAGE_KEY = 'psysonic_installed_themes'; + +export function resolveScheduledThemeId(state: PersistedThemeState): string { + if (!state.enableThemeScheduler) return state.theme; + const now = new Date(); + const nowMins = now.getHours() * 60 + now.getMinutes(); + const [dh, dm] = state.timeDayStart.split(':').map(Number); + const [nh, nm] = state.timeNightStart.split(':').map(Number); + const dayMins = dh * 60 + dm; + const nightMins = nh * 60 + nm; + const isDay = dayMins < nightMins + ? nowMins >= dayMins && nowMins < nightMins + : nowMins >= dayMins || nowMins < nightMins; + return isDay ? state.themeDay : state.themeNight; +} + +export function readPersistedThemeState(): PersistedThemeState | null { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { state?: Record }; + const s = parsed.state; + if (!s) return null; + return { + enableThemeScheduler: !!s.enableThemeScheduler, + theme: String(s.theme ?? 'mocha'), + themeDay: String(s.themeDay ?? 'latte'), + themeNight: String(s.themeNight ?? 'mocha'), + timeDayStart: String(s.timeDayStart ?? '07:00'), + timeNightStart: String(s.timeNightStart ?? '19:00'), + }; + } catch { + return null; + } +} + +export function readInstalledThemesFromStorage(): InstalledThemeSnapshot[] { + try { + const raw = localStorage.getItem(INSTALLED_THEMES_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as { state?: { themes?: InstalledThemeSnapshot[] } }; + const themes = parsed.state?.themes; + return Array.isArray(themes) ? themes : []; + } catch { + return []; + } +} + +export function resolveEffectiveThemeId(): string { + const persisted = readPersistedThemeState(); + if (!persisted) return 'mocha'; + return resolveScheduledThemeId(persisted); +} + +function readCssVar(css: string, name: string): string | null { + const match = css.match(new RegExp(`${name}\\s*:\\s*([^;]+);`)); + const value = match?.[1]?.trim(); + return value && value.length > 0 ? value : null; +} + +function resolveLogoColors( + css: string, + accent: string, +): Pick { + const logoStart = readCssVar(css, '--logo-color-start') ?? accent; + const logoEnd = readCssVar(css, '--logo-color-end') + ?? readCssVar(css, '--accent-2') + ?? accent; + return { logoStart, logoEnd }; +} + +function paletteFromCommunityCss(css: string, fallbackTrack: string): StartupSplashPalette | null { + const bg = readCssVar(css, '--bg-app'); + const accent = readCssVar(css, '--accent'); + if (!bg || !accent) return null; + const text = readCssVar(css, '--text-primary') ?? readCssVar(css, '--ctp-text') ?? '#cdd6f4'; + const muted = readCssVar(css, '--text-muted') ?? readCssVar(css, '--ctp-subtext0') ?? text; + const track = readCssVar(css, '--bg-card') ?? readCssVar(css, '--border-subtle') ?? fallbackTrack; + return { bg, text, muted, accent, track, ...resolveLogoColors(css, accent) }; +} + +export function resolveSplashPalette( + themeId: string, + installedThemes: InstalledThemeSnapshot[] = readInstalledThemesFromStorage(), +): StartupSplashPalette { + const builtin = BUILTIN_SPLASH_PALETTES[themeId]; + if (builtin) return builtin; + + const installed = installedThemes.find(theme => theme.id === themeId); + if (installed) { + const fromCss = paletteFromCommunityCss(installed.css, BUILTIN_SPLASH_PALETTES.mocha.track); + if (fromCss) return fromCss; + } + + return BUILTIN_SPLASH_PALETTES.mocha; +} + +export function applyStartupSplashPalette( + themeId: string, + palette: StartupSplashPalette, +): void { + const root = document.documentElement; + root.setAttribute('data-theme', themeId); + root.style.setProperty('--startup-splash-bg', palette.bg); + root.style.setProperty('--startup-splash-text', palette.text); + root.style.setProperty('--startup-splash-muted', palette.muted); + root.style.setProperty('--startup-splash-accent', palette.accent); + root.style.setProperty('--startup-splash-track', palette.track); + root.style.setProperty('--startup-splash-logo-start', palette.logoStart); + root.style.setProperty('--startup-splash-logo-end', palette.logoEnd); + root.style.background = palette.bg; + document.body.style.background = palette.bg; +} + +export function applyStartupSplashThemeFromStorage(): string { + const themeId = resolveEffectiveThemeId(); + const palette = resolveSplashPalette(themeId); + applyStartupSplashPalette(themeId, palette); + return themeId; +} + +export function isBuiltinThemeId(themeId: string): boolean { + return BUILTIN_THEME_IDS.includes(themeId); +}