feat(ui): themed startup splash with deferred window show (#1030)

* feat(ui): add themed startup splash with deferred window show

Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.

* docs: note startup splash in CHANGELOG and credits for PR #1030

* docs: attribute PR #1030 startup splash to cucadmuh
This commit is contained in:
cucadmuh
2026-06-08 12:59:07 +03:00
committed by GitHub
parent 086c7e43b4
commit 5167d8f49e
14 changed files with 712 additions and 1 deletions
+2
View File
@@ -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();
+53
View File
@@ -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 = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
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();
});
});
+43
View File
@@ -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);
});
}
+1
View File
@@ -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)',
],
},
{
+71
View File
@@ -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<string, StartupSplashPalette> = {
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);
+3
View File
@@ -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(
<App />
</StrictMode>
);
scheduleStartupSplashDismiss();
@@ -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);
}
});
});
+145
View File
@@ -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<string, unknown> };
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<StartupSplashPalette, 'logoStart' | 'logoEnd'> {
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);
}