Started new color theme system

This commit is contained in:
WardPearce
2026-04-07 18:42:12 +12:00
parent 3f263fab28
commit 2f0b106264
7 changed files with 294 additions and 117 deletions
@@ -1,24 +1,18 @@
<script lang="ts">
import { bookmarkletSaveToUrl } from '$lib/externalSettings/index';
import { letterCase, titleCases, type TitleCase } from '$lib/letterCasing';
import { setAmoledTheme } from '$lib/theme';
import { Capacitor } from '@capacitor/core';
import ui from 'beercss';
import { iso31661 } from 'iso-3166';
import type { RgbaColor, HsvaColor, Colord } from 'colord';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { isUnrestrictedPlatform, timeout, shareURL } from '$lib/misc';
import { getPages, type Pages } from '$lib/navPages';
import { setInvidiousInstance, goToInvidiousLogin, invidiousLogout } from '$lib/auth';
import ColorPicker from 'svelte-awesome-color-picker';
import {
invidiousAuthStore,
backendInUseStore,
darkModeStore,
invidiousInstanceStore,
interfaceAllowInsecureRequests,
interfaceAmoledTheme,
interfaceAndroidUseNativeShare,
interfaceAutoExpandChapters,
interfaceAutoExpandComments,
@@ -30,46 +24,14 @@
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore,
themeColorStore,
watchHistoryEnabledStore
} from '../../store';
import { tick } from 'svelte';
import { isOwnBackend } from '$lib/shared';
import ComboBox from '../ComboBox.svelte';
let invidiousInstance = $state(get(invidiousInstanceStore));
let invalidInstance = $state(false);
let colorPickerOpen = $state(false);
let colorPickerDebounce: ReturnType<typeof setTimeout>;
async function setColor(color: {
hsv: HsvaColor | null;
rgb: RgbaColor | null;
hex: string | null;
color: Colord | null;
}) {
if (!color.hex) return;
if (colorPickerDebounce) clearTimeout(colorPickerDebounce);
colorPickerDebounce = setTimeout(async () => {
themeColorStore.set(color.hex);
await tick();
setAmoledTheme();
}, 10);
}
function toggleDarkMode() {
const isDark = get(darkModeStore);
if (isDark) {
ui('mode', 'light');
darkModeStore.set(false);
} else {
ui('mode', 'dark');
darkModeStore.set(true);
}
}
async function setInstance(event: Event) {
event.preventDefault();
@@ -188,57 +150,6 @@
{/if}
{/if}
<button onclick={toggleDarkMode} class="no-margin surface-container-highest">
{#if !$darkModeStore}
<i>dark_mode</i>
<span>{$_('layout.theme.darkMode')}</span>
{:else}
<i>light_mode</i>
<span>{$_('layout.theme.lightMode')}</span>
{/if}
</button>
<button class="surface-container-highest" onclick={() => (colorPickerOpen = !colorPickerOpen)}>
<i>palette</i>
<span>{$_('layout.theme.color')}</span>
</button>
{#if colorPickerOpen}
<div class="space"></div>
<div class="color-picker">
<ColorPicker
isTextInput={false}
isDialog={false}
onInput={setColor}
position="responsive"
isAlpha={false}
hex={get(themeColorStore)}
sliderDirection="horizontal"
/>
</div>
{/if}
<div class="space"></div>
{#if $darkModeStore}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.theme.AmoledTheme')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$interfaceAmoledTheme}
onclick={() => interfaceAmoledTheme.set(!$interfaceAmoledTheme)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
{/if}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -436,22 +347,3 @@
</button>
</div>
{/if}
<style>
.color-picker {
--cp-bg-color: var(--surface-container);
--cp-border-color: transparent;
--cp-text-color: var(--on-surface);
--cp-input-color: var(--surface);
--cp-button-hover-color: var(--surface-variant);
--slider-width: 50px;
--picker-width: 500px;
width: 100%;
}
@media screen and (max-width: 640px) {
.color-picker {
--picker-width: 95vw;
}
}
</style>
@@ -16,6 +16,7 @@
import { mergeAttrs } from 'melt';
import Filters from './Filters.svelte';
import ExportImport from './ExportImport.svelte';
import Theme from './Theme.svelte';
type TabCategories =
| 'interface'
@@ -27,7 +28,8 @@
| 'engine'
| 'account'
| 'filters'
| 'export';
| 'export'
| 'theme';
const tabCategories: Tabs<TabCategories> = new Tabs({
value: 'interface',
@@ -42,6 +44,7 @@
let tabs: { id: TabCategories; label: string; icon: string; component: Component }[] = $state([
{ id: 'interface', label: $_('layout.interface'), icon: 'grid_view', component: Interface },
{ id: 'theme', label: $_('layout.theme.theme'), icon: 'colors', component: Theme },
{ id: 'player', label: $_('layout.player.title'), icon: 'smart_display', component: Player },
{ id: 'filters', label: $_('layout.filter.title'), icon: 'filter_alt', component: Filters },
{ id: 'ryd', label: 'Return YT Dislike', icon: 'thumb_down', component: Ryd },
@@ -0,0 +1,180 @@
<script lang="ts">
import {
getDynamicTheme,
setAmoledTheme,
setThemeColor,
type ThemeColors,
type ThemeKey
} from '$lib/theme';
import ui from 'beercss';
import type { RgbaColor, HsvaColor, Colord } from 'colord';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import ColorPicker from 'svelte-awesome-color-picker';
import {
darkModeStore,
interfaceAdvancedThemingStore,
interfaceAmoledTheme,
themeColorStore
} from '../../store';
import { onMount, tick } from 'svelte';
import { titleCase } from '$lib/letterCasing';
let colorPickerOpen = $state(false);
let colorPickerDebounce: ReturnType<typeof setTimeout>;
let currentThemeColors: ThemeColors | undefined = $state();
onMount(async () => {
currentThemeColors = await getDynamicTheme();
});
async function colorOnInput(
color: {
hsv: HsvaColor | null;
rgb: RgbaColor | null;
hex: string | null;
color: Colord | null;
},
propetyKey: ThemeKey
) {
if (colorPickerDebounce) clearTimeout(colorPickerDebounce);
colorPickerDebounce = setTimeout(async () => {
if (!color.hex) return;
setThemeColor(propetyKey, color.hex);
await tick();
currentThemeColors = await getDynamicTheme();
interfaceAdvancedThemingStore.set(currentThemeColors);
}, 100);
}
async function themeColorOnInput(color: {
hsv: HsvaColor | null;
rgb: RgbaColor | null;
hex: string | null;
color: Colord | null;
}) {
if (!color.hex) return;
if (colorPickerDebounce) clearTimeout(colorPickerDebounce);
colorPickerDebounce = setTimeout(async () => {
themeColorStore.set(color.hex);
await tick();
setAmoledTheme();
currentThemeColors = await getDynamicTheme();
}, 100);
}
async function toggleDarkMode() {
const isDark = get(darkModeStore);
if (isDark) {
ui('mode', 'light');
darkModeStore.set(false);
} else {
ui('mode', 'dark');
darkModeStore.set(true);
}
currentThemeColors = await getDynamicTheme();
}
</script>
<button onclick={toggleDarkMode} class="no-margin surface-container-highest">
{#if !$darkModeStore}
<i>dark_mode</i>
<span>{$_('layout.theme.darkMode')}</span>
{:else}
<i>light_mode</i>
<span>{$_('layout.theme.lightMode')}</span>
{/if}
</button>
<button class="surface-container-highest" onclick={() => (colorPickerOpen = !colorPickerOpen)}>
<i>palette</i>
<span>{$_('layout.theme.color')}</span>
</button>
{#if colorPickerOpen}
<div class="space"></div>
<div class="color-picker global-color-picker">
<ColorPicker
isTextInput={false}
isDialog={false}
onInput={themeColorOnInput}
position="responsive"
isAlpha={false}
hex={get(themeColorStore)}
sliderDirection="horizontal"
/>
</div>
{/if}
<div class="space"></div>
{#if $darkModeStore}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.theme.AmoledTheme')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$interfaceAmoledTheme}
onclick={async () => {
interfaceAmoledTheme.set(!$interfaceAmoledTheme);
currentThemeColors = await getDynamicTheme();
}}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
{/if}
<h5>{$_('layout.theme.advanced')}</h5>
<div class="space"></div>
{#if currentThemeColors}
{#each Object.entries(currentThemeColors) as [themeVar, themeColor] (themeVar)}
<div class="color-picker">
<ColorPicker
hex={themeColor}
label={titleCase(themeVar.replaceAll('--', '').replaceAll('-', ' '))}
position="responsive"
textInputModes={['hex']}
sliderDirection="vertical"
onInput={async (color) => await colorOnInput(color, themeVar as ThemeKey)}
/>
</div>
{/each}
{/if}
<style>
.color-picker {
--cp-bg-color: var(--surface-container);
--cp-border-color: transparent;
--cp-text-color: var(--on-surface);
--cp-input-color: var(--surface);
--cp-button-hover-color: var(--surface-variant);
--slider-width: 30px;
--picker-width: 350px;
width: 100%;
--input-size: 40px;
}
.global-color-picker {
--slider-width: 50px;
--picker-width: 500px;
}
@media screen and (max-width: 640px) {
.global-color-picker {
--picker-width: 95vw;
}
}
</style>
+1 -1
View File
@@ -103,7 +103,7 @@ menu {
right: 0;
}
.color-picker .picker {
.global-color-picker .picker {
display: none !important;
}
+2 -1
View File
@@ -199,7 +199,8 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"AmoledTheme": "Amoled theme",
"color": "Color"
"color": "Color",
"advanced": "Advanced theming"
},
"searchSuggestions": "Search suggestions",
"previewVideoOnHover": "Preview video on hover",
+6
View File
@@ -24,6 +24,7 @@ import { ensureNoTrailingSlash, getPublicEnv } from './misc';
import type { EngineFallback } from './api/misc';
import type z from 'zod';
import type { zFilterSchema } from './filtering/index';
import type { ThemeColors } from './theme';
function createListenerFunctions(): {
callListeners: (eventKey: string, newValue: any) => void;
@@ -260,6 +261,11 @@ export const interfaceAndroidUseNativeShare = persist(
createStorage(),
'androidUseNativeShare'
);
export const interfaceAdvancedThemingStore: Writable<ThemeColors> = persist(
writable({}),
createStorage(),
'advancedTheming'
);
export const sponsorBlockStore = persist(writable(true), createStorage(), 'sponsorBlock');
export const sponsorBlockUrlStore: Writable<string | null | undefined> = persist(
+101 -6
View File
@@ -4,16 +4,61 @@ import { get } from 'svelte/store';
import { SystemBars, SystemBarsStyle } from '@capacitor/core';
import { darkModeStore, interfaceAmoledTheme } from './store';
export async function getDynamicTheme(mode?: string): Promise<Record<string, string>> {
export type ThemeKey =
| '--primary'
| '--on-primary'
| '--primary-container'
| '--on-primary-container'
| '--secondary'
| '--on-secondary'
| '--secondary-container'
| '--on-secondary-container'
| '--tertiary'
| '--on-tertiary'
| '--tertiary-container'
| '--on-tertiary-container'
| '--error'
| '--on-error'
| '--error-container'
| '--on-error-container'
| '--background'
| '--on-background'
| '--surface'
| '--on-surface'
| '--surface-variant'
| '--on-surface-variant'
| '--outline'
| '--outline-variant'
| '--shadow'
| '--scrim'
| '--inverse-surface'
| '--inverse-on-surface'
| '--inverse-primary'
| '--surface-dim'
| '--surface-bright'
| '--surface-container-lowest'
| '--surface-container-low'
| '--surface-container'
| '--surface-container-high'
| '--surface-container-highest';
export type ThemeColors = Partial<Record<ThemeKey, string>>;
export async function getDynamicTheme(mode?: string): Promise<ThemeColors> {
const givenSettings = await ui('theme');
if (typeof givenSettings !== 'object') return {};
// @ts-expect-error Works as expected
const themes: string = givenSettings[mode ? mode : (ui('mode') as string)];
const themeVars: Record<string, string> = {};
themes.split(';').forEach((keyVar) => {
for (const keyVar of themes.split(';')) {
const [key, value] = keyVar.split(':');
themeVars[key] = value;
});
themeVars[key] = window.getComputedStyle(document.body).getPropertyValue(key) ?? value;
}
delete themeVars[''];
return themeVars;
}
@@ -35,7 +80,7 @@ export function setAmoledTheme() {
const isDark = get(darkModeStore);
if (isAmoled && isDark) {
const rootVars = [
const rootVars: ThemeKey[] = [
'--surface-container',
'--surface',
'--surface-container-lowest',
@@ -44,7 +89,7 @@ export function setAmoledTheme() {
'--surface-container-highest'
];
rootVars.forEach((varName) => {
document.body.style.setProperty(varName, '#000');
setThemeColor(varName, '#000');
});
} else {
setTheme();
@@ -70,3 +115,53 @@ export function setTheme() {
}
}
}
export const SUPPORTED_THEME_KEYS: ThemeKey[] = [
'--primary',
'--on-primary',
'--primary-container',
'--on-primary-container',
'--secondary',
'--on-secondary',
'--secondary-container',
'--on-secondary-container',
'--tertiary',
'--on-tertiary',
'--tertiary-container',
'--on-tertiary-container',
'--error',
'--on-error',
'--error-container',
'--on-error-container',
'--background',
'--on-background',
'--surface',
'--on-surface',
'--surface-variant',
'--on-surface-variant',
'--outline',
'--outline-variant',
'--shadow',
'--scrim',
'--inverse-surface',
'--inverse-on-surface',
'--inverse-primary',
'--surface-dim',
'--surface-bright',
'--surface-container-lowest',
'--surface-container-low',
'--surface-container',
'--surface-container-high',
'--surface-container-highest'
] as const;
export function setThemeColors(theme: ThemeColors) {
for (const [themeKey, themeValue] of Object.entries(theme)) {
setThemeColor(themeKey as ThemeKey, themeValue.trim());
}
}
export function setThemeColor(theme: ThemeKey, color: string) {
document.documentElement.style.setProperty(theme, color.trim());
document.body.style.setProperty(theme, color.trim());
}