From 861625306577c099ab5a5524c168882e492d09fd Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Wed, 4 Nov 2026 21:43:24 +0100 Subject: [PATCH] feat(themes): crossfade theme changes and fix window overflow Crossfade user-selected themes, keep the theme volume popover open while dragging its slider, and clip the fixed app shell so oversized theme layers cannot create page scrollbars on Windows. Remove the redundant in-use badge from the active theme card. --- src/app.css | 33 ++++++++- .../components/ui/titlebar/titlebar.svelte | 45 ++++++++++- src/lib/theme/themes.ts | 74 ++++++++++++++----- src/routes/+layout.svelte | 1 + src/screens/Launch.svelte | 7 +- 5 files changed, 132 insertions(+), 28 deletions(-) diff --git a/src/app.css b/src/app.css index c780a41..f1fbd71 100644 --- a/src/app.css +++ b/src/app.css @@ -162,7 +162,10 @@ -ms-user-select: none; user-select: none; } - html { + html, + body { + height: 100%; + overflow: hidden; overscroll-behavior: none; } body { @@ -197,3 +200,31 @@ video::-webkit-media-controls-overlay-enclosure { width: 0 !important; height: 0 !important; } + +/* Crossfade the rendered page when switching themes via the View Transitions API. */ +::view-transition-old(root) { + animation: 320ms cubic-bezier(0.4, 0, 0.2, 1) both theme-crossfade-out; +} + +::view-transition-new(root) { + animation: 320ms cubic-bezier(0.4, 0, 0.2, 1) both theme-crossfade-in; +} + +@keyframes theme-crossfade-out { + to { + opacity: 0; + } +} + +@keyframes theme-crossfade-in { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none !important; + } +} diff --git a/src/lib/components/ui/titlebar/titlebar.svelte b/src/lib/components/ui/titlebar/titlebar.svelte index 63b66ea..e12ffc4 100644 --- a/src/lib/components/ui/titlebar/titlebar.svelte +++ b/src/lib/components/ui/titlebar/titlebar.svelte @@ -16,6 +16,34 @@ const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET); + let volumeOpen = $state(false); + let volumeDragging = $state(false); + let volumeControl: HTMLDivElement | undefined = $state(); + + function openVolume() { + volumeOpen = true; + } + + function closeVolume() { + if (!volumeDragging) volumeOpen = false; + } + + function startVolumeDrag(event: PointerEvent) { + if (event.button !== 0) return; + volumeDragging = true; + volumeOpen = true; + } + + function endVolumeDrag(event: PointerEvent) { + if (!volumeDragging) return; + volumeDragging = false; + + const element = document.elementFromPoint(event.clientX, event.clientY); + if (!element || !volumeControl?.contains(element)) { + volumeOpen = false; + } + } + onMount(() => { const appWindow = getCurrentWindow(); @@ -26,9 +54,18 @@ }); + +
{#if $custom_theme_audio_playing || ($active_custom_theme && $active_custom_theme.name !== 'Default')} -
+
@@ -68,6 +106,7 @@ max="1" step="0.01" bind:value={$custom_theme_volume} + onpointerdown={startVolumeDrag} oninput={() => { config.value('volume').set($custom_theme_volume); }} diff --git a/src/lib/theme/themes.ts b/src/lib/theme/themes.ts index a68101a..7445230 100644 --- a/src/lib/theme/themes.ts +++ b/src/lib/theme/themes.ts @@ -7,6 +7,7 @@ import { convertFileSrc, invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import * as path from '@tauri-apps/api/path'; import * as fs from '@tauri-apps/plugin-fs'; +import { tick } from 'svelte'; export type Theme = { folder_name: string; @@ -486,34 +487,67 @@ export const downloadTheme = async ( return { success: true }; }; -export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => { - // Theme media is buffered as blob URLs; the previous theme's URLs are no - // longer needed once it is replaced. - clearThemeMediaCache(); - active_custom_theme.set(theme); - custom_theme_audio_playing.set(false); - themeContainer.innerHTML = ''; - if (theme.definition) { +type LoadThemeOptions = { + /** Crossfade the rendered page when switching themes. */ + animate?: boolean; +}; + +export const loadTheme = async ( + theme: Theme, + themeContainer: HTMLElement, + volume = 0.15, + options: LoadThemeOptions = {} +) => { + const applyTheme = async () => { + // Theme media is buffered as blob URLs; the previous theme's URLs are no + // longer needed once it is replaced. + clearThemeMediaCache(); + active_custom_theme.set(theme); + custom_theme_audio_playing.set(false); + themeContainer.innerHTML = ''; + if (theme.definition) { + setGlobalVolume(volume); + disableMediaControls(); + void invoke('unlock_media_autoplay').catch(() => {}); + return; + } + if (theme.scriptUrl.length > 0) { + const themeScript = await import(/* @vite-ignore */ theme.scriptUrl); + themeScript.mountTheme(themeContainer, { assets: theme.assets }); + } setGlobalVolume(volume); disableMediaControls(); + + // Ensure all media elements mounted by v1 themes start playback automatically + themeContainer.querySelectorAll('video, audio').forEach((media) => { + media.muted = false; + media.volume = volume; + playThemeMedia(media); + }); + void invoke('unlock_media_autoplay').catch(() => {}); + }; + + const canAnimate = + options.animate && + typeof document !== 'undefined' && + typeof document.startViewTransition === 'function'; + + if (!canAnimate) { + await applyTheme(); return; } - if (theme.scriptUrl.length > 0) { - const themeScript = await import(/* @vite-ignore */ theme.scriptUrl); - themeScript.mountTheme(themeContainer, { assets: theme.assets }); - } - setGlobalVolume(volume); - disableMediaControls(); - // Ensure all media elements mounted by v1 themes start playback automatically - themeContainer.querySelectorAll('video, audio').forEach((media) => { - media.muted = false; - media.volume = volume; - playThemeMedia(media); + const transition = document.startViewTransition(async () => { + await applyTheme(); + // Let Svelte paint the new theme before the transition takes its snapshot. + await tick(); }); - void invoke('unlock_media_autoplay').catch(() => {}); + // Theme activation must not wait for, or fail because of, the visual + // transition. `finished` can reject when a transition is skipped. + void transition.finished.catch(() => {}); + await transition.updateCallbackDone; }; export const deleteTheme = async (themeToUninstall: Theme) => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b717617..8d5e4b7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -583,6 +583,7 @@
('theme').set(theme.name); }; @@ -4511,9 +4513,6 @@
- {#if isActive} - In use - {/if} {#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion}