mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2024-10-06 19:13:36 +00:00
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:
parent
87376e34ca
commit
f196578911
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
fontList.value = uniqueFonts;
|
||||
return model.value;
|
||||
},
|
||||
set(newVal) {
|
||||
if (appWide && newVal) {
|
||||
clientSettings.typography = newVal;
|
||||
}
|
||||
|
||||
model.value = newVal;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@ -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>
|
||||
|
@ -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 };
|
||||
}
|
@ -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>
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -110,21 +110,19 @@ 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();
|
||||
}
|
||||
for (const watcher of this._pausableWatchers) {
|
||||
watcher.pause();
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...toRaw(this._state),
|
||||
...data
|
||||
};
|
||||
const newState = {
|
||||
...toRaw(this._state),
|
||||
...data
|
||||
};
|
||||
|
||||
Object.assign(this._state, newState);
|
||||
Object.assign(this._state, newState);
|
||||
|
||||
for (const watcher of this._pausableWatchers) {
|
||||
watcher.resume();
|
||||
}
|
||||
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 })
|
||||
);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user