Add custom theming (#2081)

Adds support for user-created custom themes. Custom theme interface is
tucked into the global settings in a non-invasive manner to avoid major
design changes. Builds on the theme structure established by the dark
theme update.

<img width="1486" height="953" alt="image"
src="https://github.com/user-attachments/assets/716bcfc7-af74-41dc-b14a-cfc2f2d2caa9"
/>

<img width="1486" height="956" alt="image"
src="https://github.com/user-attachments/assets/a00f9620-0b1d-4f67-b010-e94dda5dc212"
/>



Here's a few examples of what teams could do, using a few color schemes
from local teams. Imagine the possibilities!

<img width="1485" height="951" alt="image"
src="https://github.com/user-attachments/assets/c3da37b8-f6be-4152-81e0-533297f517fc"
/>

<img width="1483" height="951" alt="image"
src="https://github.com/user-attachments/assets/0d453f7a-cf6f-4c27-97db-603b54c1f73e"
/>

<img width="1485" height="952" alt="image"
src="https://github.com/user-attachments/assets/bf8c7770-e60d-4875-9580-ed7e54e089f4"
/>

<img width="1484" height="952" alt="image"
src="https://github.com/user-attachments/assets/326d89e6-dd6e-4e05-a9fa-c9fc6f880847"
/>

<img width="1482" height="951" alt="image"
src="https://github.com/user-attachments/assets/eb5a2a5d-c103-482c-a62a-5ccd5ba21cc5"
/>

<img width="1482" height="950" alt="image"
src="https://github.com/user-attachments/assets/4831ca56-f322-4345-97af-8963ae8539b1"
/>



Looking for high contrast? Just moments away:
<img width="1484" height="949" alt="image"
src="https://github.com/user-attachments/assets/7ffc65c6-7000-4566-b4f0-c8247f75fb3d"
/>
This commit is contained in:
Devon Doyle
2025-09-07 00:33:37 -04:00
committed by GitHub
parent 3300b90823
commit b43d0dde20
9 changed files with 397 additions and 38 deletions

View File

@@ -3,10 +3,12 @@ import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { AutoReconnectingWebsocket } from "@/lib/AutoReconnectingWebsocket";
import { inject } from "vue";
import { inject, onBeforeMount } from "vue";
import PhotonSidebar from "@/components/app/photon-sidebar.vue";
import PhotonLogView from "@/components/app/photon-log-view.vue";
import PhotonErrorSnackbar from "@/components/app/photon-error-snackbar.vue";
import { useTheme } from "vuetify";
import { restoreThemeConfig } from "@/lib/ThemeManager";
const is_demo = import.meta.env.MODE === "demo";
if (!is_demo) {
@@ -50,6 +52,11 @@ if (!is_demo) {
);
useStateStore().$patch({ websocket: websocket });
}
const theme = useTheme();
onBeforeMount(() => {
restoreThemeConfig(theme);
});
</script>
<template>

View File

@@ -2,10 +2,10 @@
import { computed, inject, ref, onBeforeUnmount } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import loadingImage from "@/assets/images/loading-transparent.svg";
import type { StyleValue } from "vue";
import PvIcon from "@/components/common/pv-icon.vue";
import type { UiCameraConfiguration } from "@/types/SettingTypes";
import PvLoading from "@/components/common/pv-loading.vue";
const props = defineProps<{
streamType: "Raw" | "Processed";
@@ -92,7 +92,7 @@ onBeforeUnmount(() => {
<template>
<div class="stream-container" :style="containerStyle">
<img :src="loadingImage" class="stream-loading" />
<pv-loading class="stream-loading" />
<img
:id="id"
ref="mjpgStream"

View File

@@ -5,7 +5,7 @@ import { useStateStore } from "@/stores/StateStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
import { onBeforeMount } from "vue";
import { toggleTheme } from "@/lib/ThemeManager";
const compact = computed<boolean>({
get: () => {
@@ -19,19 +19,6 @@ const { mdAndUp } = useDisplay();
const theme = useTheme();
const changeTheme = () => {
const newTheme = theme.global.name.value === "LightTheme" ? "DarkTheme" : "LightTheme";
theme.global.name.value = newTheme;
localStorage.setItem("theme", newTheme);
};
onBeforeMount(() => {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) {
theme.global.name.value = storedTheme;
}
});
const renderCompact = computed<boolean>(() => compact.value || !mdAndUp.value);
</script>
@@ -88,7 +75,7 @@ const renderCompact = computed<boolean>(() => compact.value || !mdAndUp.value);
<v-list-item
link
:prepend-icon="theme.global.name.value === 'LightTheme' ? 'mdi-white-balance-sunny' : 'mdi-weather-night'"
@click="changeTheme"
@click="() => toggleTheme(theme)"
>
<v-list-item-title>Theme</v-list-item-title>
</v-list-item>

View File

@@ -90,14 +90,7 @@ const fpsTooLow = computed<boolean>(() => {
</div>
</v-card-text>
<v-card-text class="pt-0">
<v-btn-toggle
v-model="value"
:multiple="true"
mandatory
class="fill"
style="width: 100%"
base-color="surface-variant"
>
<v-btn-toggle v-model="value" :multiple="true" mandatory class="fill" style="width: 100%">
<v-btn
color="buttonPassive"
class="fill"

View File

@@ -0,0 +1,205 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
width="200"
height="200"
style="shape-rendering: auto; display: block; background: rgba(0, 100, 146, 0)"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g>
<g transform="translate(80,50)">
<g transform="rotate(0)">
<circle class="loader-circle" fill-opacity="1" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.8177570093457943s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.8177570093457943s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(71.21320343559643,71.21320343559643)">
<g transform="rotate(45)">
<circle class="loader-circle" fill-opacity="0.875" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.7009345794392523s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.7009345794392523s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(50,80)">
<g transform="rotate(90)">
<circle class="loader-circle" fill-opacity="0.75" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.5841121495327103s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.5841121495327103s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(28.786796564403577,71.21320343559643)">
<g transform="rotate(135)">
<circle class="loader-circle" fill-opacity="0.625" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.4672897196261682s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.4672897196261682s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(20,50.00000000000001)">
<g transform="rotate(180)">
<circle class="loader-circle" fill-opacity="0.5" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.35046728971962615s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.35046728971962615s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(28.78679656440357,28.786796564403577)">
<g transform="rotate(225)">
<circle class="loader-circle" fill-opacity="0.375" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.2336448598130841s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.2336448598130841s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(49.99999999999999,20)">
<g transform="rotate(270)">
<circle class="loader-circle" fill-opacity="0.25" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="-0.11682242990654206s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="-0.11682242990654206s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g transform="translate(71.21320343559643,28.78679656440357)">
<g transform="rotate(315)">
<circle class="loader-circle" fill-opacity="0.125" fill="#ffd943" r="6" cy="0" cx="0">
<animateTransform
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
values="1.5 1.5;1 1"
begin="0s"
type="scale"
attributeName="transform"
></animateTransform>
<animate
begin="0s"
values="1;0"
repeatCount="indefinite"
dur="0.9345794392523364s"
keyTimes="0;1"
attributeName="fill-opacity"
></animate>
</circle>
</g>
</g>
<g></g>
</g>
<!-- [ldio] generated by https://loading.io -->
</svg>
</template>
<style scoped lang="scss">
.loader-circle {
fill: rgb(var(--v-theme-buttonActive));
}
</style>

View File

@@ -28,7 +28,7 @@ const processingMode = computed<number>({
<v-row class="pa-3 pb-0 align-center">
<v-col class="pa-4">
<p style="color: white">Processing Mode</p>
<v-btn-toggle v-model="processingMode" mandatory base-color="surface-variant" class="fill w-100">
<v-btn-toggle v-model="processingMode" mandatory class="fill w-100">
<v-btn
color="buttonPassive"
:disabled="!useCameraSettingsStore().hasConnected"
@@ -59,7 +59,7 @@ const processingMode = computed<number>({
<v-row class="pa-3 pt-0 align-center">
<v-col class="pa-4 pt-0">
<p style="color: white">Stream Display</p>
<v-btn-toggle v-model="value" :multiple="true" mandatory base-color="surface-variant" class="fill w-100">
<v-btn-toggle v-model="value" :multiple="true" mandatory class="fill w-100">
<v-btn
color="buttonPassive"
class="fill w-50"

View File

@@ -8,6 +8,7 @@ import PvSelect from "@/components/common/pv-select.vue";
import { type ConfigurableNetworkSettings, NetworkConnectionType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import { useTheme } from "vuetify";
import { getThemeColor, setThemeColor, resetTheme } from "@/lib/ThemeManager";
const theme = useTheme();
@@ -19,6 +20,19 @@ const resetTempSettingsStruct = () => {
const settingsValid = ref(true);
const showThemeConfig = ref(false);
const backgroundColor = ref("");
const primaryColor = ref("");
const secondaryColor = ref("");
const surfaceColor = ref("");
const loadCurrentColors = () => {
backgroundColor.value = getThemeColor(theme, "background");
primaryColor.value = getThemeColor(theme, "primary");
secondaryColor.value = getThemeColor(theme, "secondary");
surfaceColor.value = getThemeColor(theme, "surface");
};
const isValidNetworkTablesIP = (v: string | undefined): boolean => {
// Check if it is a valid team number between 1-99999 (5 digits)
const teamNumberRegex = /^[1-9][0-9]{0,4}$/;
@@ -139,10 +153,23 @@ watchEffect(() => {
<template>
<v-card class="mb-3 rounded-12" color="surface">
<v-card-title>Global Settings</v-card-title>
<v-card-title style="display: flex; justify-content: space-between">
<span>Global Settings</span>
<v-btn
variant="text"
@click="
() => {
loadCurrentColors();
showThemeConfig = true;
}
"
>
<v-icon size="x-large">mdi-palette-outline</v-icon>
Theme
</v-btn>
</v-card-title>
<div class="pa-5 pt-0">
<v-divider class="pb-2" />
<v-card-title class="pl-0 pt-3 pb-10px">Networking</v-card-title>
<v-card-title class="pl-0 pt-0 pb-10px">Networking</v-card-title>
<v-form ref="form" v-model="settingsValid">
<pv-input
v-model="tempSettingsStruct.ntServerAddress"
@@ -203,7 +230,6 @@ watchEffect(() => {
useSettingsStore().network.networkingDisabled
"
/>
<v-divider class="mt-10px pb-2" />
<v-card-title class="pl-0 pt-3 pb-10px">Advanced Networking</v-card-title>
<pv-switch
v-show="!useSettingsStore().network.networkingDisabled"
@@ -254,7 +280,6 @@ watchEffect(() => {
icon="mdi-information-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
<v-divider class="mt-10px pb-2" />
<v-card-title class="pl-0 pt-3 pb-10px">Miscellaneous</v-card-title>
<pv-switch
v-model="tempSettingsStruct.shouldPublishProto"
@@ -270,10 +295,10 @@ watchEffect(() => {
icon="mdi-information-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
<v-divider class="mt-10px pb-5" />
</v-form>
<v-btn
color="primary"
class="mt-3"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
style="color: black; width: 100%"
:disabled="!settingsValid || !settingsHaveChanged()"
@@ -282,6 +307,84 @@ watchEffect(() => {
Save
</v-btn>
</div>
<v-dialog v-model="showThemeConfig" width="800" dark>
<v-card color="surface" flat>
<v-card-title class="text-center">Theme Configuration</v-card-title>
<v-card-text class="pt-0 pb-10px">
<v-row>
<v-col class="text-center">
Background
<v-color-picker
class="ma-auto pt-3"
elevation="0"
mode="hex"
:modes="['hex']"
v-model:model-value="backgroundColor"
v-on:update:model-value="(hex) => setThemeColor(theme, 'background', hex)"
></v-color-picker>
</v-col>
<v-col class="text-center">
Surface
<v-color-picker
class="ma-auto pt-3"
elevation="0"
mode="hex"
:modes="['hex']"
v-model:model-value="surfaceColor"
v-on:update:model-value="(hex) => setThemeColor(theme, 'surface', hex)"
></v-color-picker>
</v-col>
</v-row>
<v-row>
<v-col class="text-center">
Primary
<v-color-picker
class="ma-auto pt-3"
elevation="0"
mode="hex"
:modes="['hex']"
v-model:model-value="primaryColor"
v-on:update:model-value="(hex) => setThemeColor(theme, 'primary', hex)"
></v-color-picker>
</v-col>
<v-col class="text-center">
Secondary
<v-color-picker
class="ma-auto pt-3"
elevation="0"
mode="hex"
:modes="['hex']"
v-model:model-value="secondaryColor"
v-on:update:model-value="(hex) => setThemeColor(theme, 'secondary', hex)"
></v-color-picker>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-5 pt-0">
<v-btn
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
color="buttonPassive"
class="text-black"
@click="showThemeConfig = false"
>
Close
</v-btn>
<v-btn
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
color="buttonActive"
class="text-black"
@click="
() => {
resetTheme(theme);
loadCurrentColors();
}
"
>
Reset Default
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>

View File

@@ -0,0 +1,65 @@
import { type ThemeInstance } from "vuetify";
import { LightTheme, DarkTheme } from "@/plugins/vuetify";
export const resetTheme = (theme: ThemeInstance) => {
const themeType = theme.global.name.value === "LightTheme" ? "light" : "dark";
localStorage.removeItem(`${themeType}-background`);
localStorage.removeItem(`${themeType}-primary`);
localStorage.removeItem(`${themeType}-secondary`);
localStorage.removeItem(`${themeType}-surface`);
restoreThemeConfig(theme);
};
export const getThemeColor = (theme: ThemeInstance, color: string): string => {
const themeType = theme.global.name.value === "LightTheme" ? "light" : "dark";
const defaultTheme = theme.global.name.value === "LightTheme" ? LightTheme : DarkTheme;
return localStorage.getItem(`${themeType}-${color}`) ?? defaultTheme.colors![color]!;
};
export const setThemeColor = (theme: ThemeInstance, color: string, value: string | null) => {
const themeType = theme.global.name.value === "LightTheme" ? "light" : "dark";
if (value) localStorage.setItem(`${themeType}-${color}`, value);
else localStorage.removeItem(`${themeType}-${color}`);
restoreThemeConfig(theme);
};
export const toggleTheme = (theme: ThemeInstance) => {
const currentTheme = localStorage.getItem("theme");
localStorage.setItem("theme", currentTheme === "LightTheme" ? "DarkTheme" : "LightTheme");
restoreThemeConfig(theme);
};
export const restoreThemeConfig = (theme: ThemeInstance) => {
// Restore theme preference
const storedTheme = localStorage.getItem("theme");
if (storedTheme) theme.global.name.value = storedTheme;
// Restore custom theme colors
const themeType = theme.global.name.value === "LightTheme" ? "light" : "dark";
const defaultTheme = theme.global.name.value === "LightTheme" ? LightTheme : DarkTheme;
const customBackground = localStorage.getItem(`${themeType}-background`);
const customPrimary = localStorage.getItem(`${themeType}-primary`);
const customSecondary = localStorage.getItem(`${themeType}-secondary`);
const customSurface = localStorage.getItem(`${themeType}-surface`);
theme.themes.value[theme.global.name.value].colors.background = customBackground ?? defaultTheme.colors!.background!;
theme.themes.value[theme.global.name.value].colors.sidebar = theme.themes.value[theme.global.name.value].dark
? (customBackground ?? defaultTheme.colors!.sidebar!)
: (customSurface ?? defaultTheme.colors!.sidebar!);
theme.themes.value[theme.global.name.value].colors.primary = customPrimary ?? defaultTheme.colors!.primary!;
theme.themes.value[theme.global.name.value].colors.buttonActive = customPrimary ?? defaultTheme.colors!.buttonActive!;
theme.themes.value[theme.global.name.value].colors.secondary = customSecondary ?? defaultTheme.colors!.secondary!;
theme.themes.value[theme.global.name.value].colors.buttonPassive =
customSecondary ?? defaultTheme.colors!.buttonPassive!;
theme.themes.value[theme.global.name.value].colors.accent = customSecondary ?? defaultTheme.colors!.accent!;
theme.themes.value[theme.global.name.value].colors.toggle = customSecondary ?? defaultTheme.colors!.toggle!;
theme.themes.value[theme.global.name.value].colors.surface = customSurface ?? defaultTheme.colors!.surface!;
};

View File

@@ -12,7 +12,7 @@ const CommonColors = {
lightGray: "#232C37"
};
const DarkTheme: ThemeDefinition = {
export const DarkTheme: ThemeDefinition = {
dark: true,
colors: {
background: CommonColors.darkGray,
@@ -39,7 +39,7 @@ const DarkTheme: ThemeDefinition = {
}
};
const LightTheme: ThemeDefinition = {
export const LightTheme: ThemeDefinition = {
dark: false,
colors: {
background: CommonColors.lightGray,
@@ -56,8 +56,7 @@ const LightTheme: ThemeDefinition = {
buttonActive: CommonColors.photonYellow,
buttonPassive: CommonColors.lightBlue,
"surface-variant": "#358AB0",
"surface-light": CommonColors.photonYellow,
"surface-variant": "#8f8f8fff",
error: "#b80000",
info: "#2196F3",