refactor&fix: font

* Reduce unnecessary verbosity of the stores
* Extract the current typography of the application as a CSS variable
* Make font selector truly generic and also able to change the typography of the whole app

There are now 3 internal values:
- auto: for following app's font
- system: for following system's font
- default: default app's font

This way subtitles can be truly configurable independently from the app.

App typography follows the same schema, but without 'auto' since it's not applicable there.

Signed-off-by: Fernando Fernández <ferferga@hotmail.com>
This commit is contained in:
Fernando Fernández 2024-09-07 16:12:18 +02:00
parent 87376e34ca
commit f196578911
11 changed files with 191 additions and 102 deletions

View File

@ -27,12 +27,14 @@
"anErrorHappened": "An error happened",
"apiKeys": "API keys",
"apiKeysSettingsDescription": "Add and revoke API keys for external access to your server",
"appDefaultTypography": "Application default typography ({value})",
"appName": "App name",
"appVersion": "App version",
"appearsOn": "Appearances",
"art": "Art",
"artist": "Artist",
"artists": "Artists",
"askAgain": "Ask again",
"audio": "Audio",
"auto": "Automatic",
"backdrop": "Backdrop",
@ -72,6 +74,7 @@
"createKeySuccess": "Successfully created a new API key",
"crew": "Crew",
"criticRating": "Critic rating",
"currentAppTypography": "Current application typography ({value})",
"currentPassword": "Current password",
"customRating": "Custom rating",
"dateAdded": "Date added",
@ -369,6 +372,7 @@
"switchToLightMode": "Switch to light mode",
"syncPlayGroups": "SyncPlay groups",
"syncingSettingsInProgress": "Syncing settings…",
"systemTypography": "System typography",
"tagName": "Tag name",
"tagline": "Tagline",
"tags": "Tags",

View File

@ -1,5 +1,5 @@
* {
font-family: 'Figtree Variable', sans-serif, system-ui !important;
font-family: var(--j-font-family), sans-serif, system-ui !important;
}
html {

View File

@ -5,26 +5,32 @@
:srclang="playerElement.currentExternalSubtitleTrack?.srcLang"
:src="playerElement.currentExternalSubtitleTrack?.src">
<span
class="uno-inline-block"
class="uno-inline-block uno-pb-10px uno-color-white"
:class="{ 'stroked': subtitleSettings.state.stroke }"
:style="subtitleStyle">
<template v-if="preview">
{{ $t('subtitlePreviewText') }}
</template>
<JSafeHtml
v-if="currentSubtitle !== undefined"
v-else-if="currentSubtitle !== undefined"
:html="currentSubtitle.text" />
{{ previewText }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { subtitleSettings } from '@/store/client-settings';
import { mediaControls } from '@/store';
import { computed, type StyleValue } from 'vue';
import { subtitleSettings } from '@/store/client-settings/subtitle-settings';
import { DEFAULT_TYPOGRAPHY, mediaControls } from '@/store';
import { playerElement } from '@/store/player-element';
import { isNil } from '@/utils/validation';
import type { ParsedSubtitleTrack } from '@/utils/subtitles';
defineProps<{
previewText?: string;
const { preview } = defineProps<{
/**
* Whether the subtitle track is in preview mode.
*/
preview?: boolean;
}>();
/**
@ -67,29 +73,43 @@ const currentSubtitle = computed(() =>
? findCurrentSubtitle(playerElement.currentExternalSubtitleTrack.parsed, mediaControls.currentTime.value)
: undefined
);
const fontFamily = computed(() => {
if (subtitleSettings.state.fontFamily === 'default') {
return DEFAULT_TYPOGRAPHY;
} else if (subtitleSettings.state.fontFamily === 'system') {
return 'system-ui';
} else if (subtitleSettings.state.fontFamily !== 'auto') {
return subtitleSettings.state.fontFamily;
}
});
/**
* Computed style for subtitle text element
* reactive to client subtitle appearance settings
*/
const subtitleStyle = computed(() => {
const subtitleAppearance = subtitleSettings.subtitleAppearance;
const subtitleStyle = computed<StyleValue>(() => {
const subtitleAppearance = subtitleSettings.state;
return {
fontFamily: `${subtitleAppearance.fontFamily} !important`,
fontSize: `${subtitleAppearance.fontSize}em`,
marginBottom: `${subtitleAppearance.positionFromBottom}vh`,
backgroundColor: subtitleAppearance.backdrop ? 'rgba(0, 0, 0, 0.5)' : 'transparent',
padding: '10px',
color: 'white',
/**
* Unwrap stroke style if stroke is enabled
* Unwrap font family and stroke style if stroke is enabled
*/
...(subtitleAppearance.stroke && {
TextStrokeColor: 'black',
TextStrokeWidth: '7px',
textShadow: '2px 2px 15px black',
paintOrder: 'stroke fill'
...(fontFamily.value && {
fontFamily: `${fontFamily.value} !important`
})
};
});
</script>
<style scoped>
.stroked {
--webkit-text-stroke-color: black;
--webkit-text-stroke-width: 7px;
text-shadow: 2px 2px 15px black;
paint-order: stroke fill;
}
</style>

View File

@ -6,7 +6,6 @@
icon="$warning">
{{ $t('queryLocalFontsNotSupportedWarning') }}
<br>
<a
class="uno-font-bold"
href="https://caniuse.com/mdn-api_window_querylocalfonts"
@ -30,45 +29,106 @@
rel="noopener">
{{ $t('enablePermission') }}
</a>
<a
class="uno-font-bold"
@click="askForPermission">
{{ $t('askAgain') }}
</a>
</VAlert>
<VSelect
v-model="model"
:label="label"
:items="fontList"
v-model="_model"
v-bind="$attrs"
:items="selection"
:disabled="!isQueryLocalFontsSupported || !fontAccess" />
</template>
<script setup lang="ts">
import { usePermission, useSupported } from '@vueuse/core';
import { ref, computed, watchEffect } from 'vue';
import { computedAsync, usePermission, useSupported } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { clientSettings } from '@/store/client-settings';
import { DEFAULT_TYPOGRAPHY } from '@/store';
defineProps<{
label?: string;
const { appWide } = defineProps<{
/**
* If this font selector is used for selecting the typography for the whole app
*/
appWide?: boolean;
}>();
const model = defineModel<string | undefined>();
const { t } = useI18n();
const model = defineModel<string | undefined>();
const fontList = ref<string[]>([]);
const fontPermission = usePermission('local-fonts');
const { query: permissionQuery, isSupported, state: fontPermission } = usePermission('local-fonts', { controls: true });
const fontAccess = computed(() => fontPermission.value === 'granted');
const isQueryLocalFontsSupported = useSupported(() => 'queryLocalFonts' in window);
const isQueryLocalFontsSupported = useSupported(() => isSupported.value && 'queryLocalFonts' in window);
const askForPermission = async () => isQueryLocalFontsSupported.value
? Promise.all([permissionQuery, window.queryLocalFonts])
: undefined;
watchEffect(async () => {
if (isQueryLocalFontsSupported.value && fontAccess.value) {
const localFonts = await window.queryLocalFonts();
const uniqueFonts: string[] = [];
/**
* Edge at least doesn't allow for querying the permission directly using navigator.permission,
* only after querying the fonts, so we perform the query regardless at the beginning.
*/
const fontList = computedAsync(async () => {
const res: string[] = [];
for (const font of localFonts) {
if (!uniqueFonts.includes(font.family)) {
uniqueFonts.push(font.family);
}
if (fontAccess.value || isQueryLocalFontsSupported.value) {
const set = new Set<string>((await window.queryLocalFonts()).map((font: FontFace) => font.family));
/**
* Removes the current selected tpography (in case it's not the default one)
*/
set.delete(clientSettings.typography);
res.push(...set);
}
fontList.value = uniqueFonts;
return res;
}, []);
const selection = computed(() => {
const res = [
{
title: t('appDefaultTypography', { value: DEFAULT_TYPOGRAPHY }),
value: 'default'
},
{
title: t('systemTypography'),
value: 'system'
}, ...fontList.value.map(f => ({
title: f,
value: f
}))];
if (!appWide && !['system', 'default'].includes(clientSettings.typography)) {
res.unshift(
{
title: t('currentAppTypography', {
value: clientSettings.typography
}),
value: clientSettings.typography
}
);
}
return res;
});
const _model = computed({
get() {
if (appWide) {
return clientSettings.typography;
}
return model.value;
},
set(newVal) {
if (appWide && newVal) {
clientSettings.typography = newVal;
}
model.value = newVal;
}
});
</script>

View File

@ -11,6 +11,7 @@
cursor: wait;
</template>
--j-color-background: rgb(var(--v-theme-background));
--j-font-family: '{{ typography }}';
}
</component>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text vue/require-component-is -->
@ -22,7 +23,20 @@
/**
* TODO: Investigate or propose an RFC to allow style tags inside SFCs
*/
import { computed } from 'vue';
import { useLoading } from '@/composables/use-loading';
import { DEFAULT_TYPOGRAPHY } from '@/store';
import { clientSettings } from '@/store/client-settings';
const { isLoading } = useLoading();
const typography = computed(() => {
if (clientSettings.typography === 'system') {
return 'system-ui';
} else if (clientSettings.typography === 'default') {
return DEFAULT_TYPOGRAPHY;
} else {
return clientSettings.typography;
}
});
</script>

View File

@ -1,34 +0,0 @@
import { ref } from 'vue';
const currentFont = ref('');
/**
* Updates the currentFont reactive reference based on the font family of the document body.
* It retrieves the computed font style of the body and sets the currentFont to the primary font.
*/
const updateFont = () => {
const body = document.body;
const style = window.getComputedStyle(body);
// Remove fallback fonts and quotes around the font name
const fontFamily = style.fontFamily.split(', ')[0].replaceAll(/["']/g, '');
currentFont.value = fontFamily;
};
// Event listener to update the font when the font loading is done
document.fonts.addEventListener('loadingdone', () => {
updateFont();
});
// Initial font retrieval
updateFont();
/**
* Provides a reactive reference for the current font.
*
* @returns An object containing the `currentFont` reactive reference.
*/
export function useFont() {
return { currentFont };
}

View File

@ -1,48 +1,45 @@
<template>
<SettingsPage>
<template #title>
{{ t('subtitles') }}
{{ $t('subtitles') }}
</template>
<template #content>
<VCol
md="6"
class="pt-0 pb-4">
class="uno-pb-4 uno-pt-0">
<FontSelector
v-model="subtitleSettings.subtitleAppearance.fontFamily"
v-model="subtitleSettings.state.fontFamily"
:label="$t('subtitleFont')" />
<VSlider
v-model="subtitleSettings.subtitleAppearance.fontSize"
v-model="subtitleSettings.state.fontSize"
:label="$t('fontSize')"
:min="1"
:max="4.5"
:step="0.1" />
<VSlider
v-model="subtitleSettings.subtitleAppearance.positionFromBottom"
v-model="subtitleSettings.state.positionFromBottom"
:label="$t('positionFromBottom')"
:min="0"
:max="30"
:step="1" />
<VCheckbox
v-model="subtitleSettings.subtitleAppearance.backdrop"
v-model="subtitleSettings.state.backdrop"
:label="$t('backdrop')" />
<VCheckbox
v-model="subtitleSettings.subtitleAppearance.stroke"
v-model="subtitleSettings.state.stroke"
:label="$t('stroke')" />
<SubtitleTrack :preview-text="$t('subtitlePreviewText')" />
<SubtitleTrack preview />
</VCol>
</template>
</SettingsPage>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { subtitleSettings } from '@/store/client-settings';
const { t } = useI18n();
import { subtitleSettings } from '@/store/client-settings/subtitle-settings';
</script>

View File

@ -8,6 +8,7 @@ import { remote } from '@/plugins/remote';
import { vuetify } from '@/plugins/vuetify';
import { sealed } from '@/utils/validation';
import { SyncedStore } from '@/store/super/synced-store';
import type { TypographyChoices } from '@/store';
/**
* == INTERFACES AND TYPES ==
@ -15,6 +16,7 @@ import { SyncedStore } from '@/store/super/synced-store';
*/
export interface ClientSettingsState {
typography: TypographyChoices;
darkMode: 'auto' | boolean;
locale: string;
}
@ -44,6 +46,14 @@ class ClientSettingsStore extends SyncedStore<ClientSettingsState> {
return this._state.locale;
}
public get typography() {
return this._state.typography;
}
public set typography(newVal: ClientSettingsState['typography']) {
this._state.typography = newVal;
}
public set darkMode(newVal: 'auto' | boolean) {
this._state.darkMode = newVal;
}
@ -72,6 +82,7 @@ class ClientSettingsStore extends SyncedStore<ClientSettingsState> {
public constructor() {
super('clientSettings', {
typography: 'default',
darkMode: 'auto',
locale: 'auto'
}, 'localStorage');

View File

@ -2,13 +2,22 @@ import { watch } from 'vue';
import { remote } from '@/plugins/remote';
import { sealed } from '@/utils/validation';
import { SyncedStore } from '@/store/super/synced-store';
import type { TypographyChoices } from '@/store';
/**
* == INTERFACES AND TYPES ==
*/
export interface SubtitleSettingsState {
fontFamily: string;
/**
* default: Default application typography.
*
* system: System typography
*
* auto: Selects the current selected typography for the application
* @default: auto
*/
fontFamily: 'auto' | TypographyChoices;
fontSize: number;
positionFromBottom: number;
backdrop: boolean;

View File

@ -10,6 +10,16 @@ import { isNil } from '@/utils/validation';
* efficient to reuse those, both in components and TS files.
*/
export const DEFAULT_TYPOGRAPHY = 'Figtree Variable';
/**
* Type for the different typography choices across the application
*
* default: Default application typography.
*
* system: System typography
*/
export type TypographyChoices = 'default' | 'system' | (string & {});
/**
* == BLURHASH DEFAULTS ==
* By default, 20x20 pixels with a punch of 1 is returned.

View File

@ -110,7 +110,6 @@ export abstract class SyncedStore<T extends object> extends CommonStore<T> {
try {
const data = await this._fetchState();
if (data) {
for (const watcher of this._pausableWatchers) {
watcher.pause();
}
@ -125,7 +124,6 @@ export abstract class SyncedStore<T extends object> extends CommonStore<T> {
for (const watcher of this._pausableWatchers) {
watcher.resume();
}
}
} catch {
useSnackbar(i18n.t('failedSyncingUserSettings'), 'error');
}
@ -144,12 +142,12 @@ export abstract class SyncedStore<T extends object> extends CommonStore<T> {
if (keys) {
for (const key of keys) {
this._pausableWatchers.push(
watchPausable(() => this._state[key], this._updateState)
watchPausable(() => this._state[key], this._updateState, { deep: true })
);
}
} else {
this._pausableWatchers.push(
watchPausable(this._state, this._updateState)
watchPausable(this._state, this._updateState, { deep: true })
);
}