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}