mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user