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}