[feature]: Support using system fonts (#304)
* [feature]: Support using system fonts Uses the **experimental** queryLocalFonts API, when prompted, to get the fonts and do CSS. Resolves #270 and #288 (by proxy) Caveats/notes: - This is experimental, and is only supported by Chrome/Chromium/Edgeium (see https://caniuse.com/?search=querylocalfonts) - As far as I can tell, the only way to dynamically change the font (shown in https://wicg.github.io/local-font-access/#example-style-with-local-fonts) was by DOM manipulation; css variables did not seem to work - This shows **all** fonts, including their variants (bold/italic/etc); given that the style names could be localized, not sure of a way to parse this (on my system, for instance, I had 859 different combinations) - I made fonts a separate top-level setting because it was easier to manipulate without causing as many rerenders; feel free to put that back * add permission chec * add electron magic to support custom font * restrict content types
This commit is contained in:
parent
e6ed9229c2
commit
74cab01013
8 changed files with 282 additions and 20 deletions
|
@ -21,6 +21,8 @@ import {
|
|||
Menu,
|
||||
nativeImage,
|
||||
BrowserWindowConstructorOptions,
|
||||
protocol,
|
||||
net,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
|
@ -43,6 +45,8 @@ export default class AppUpdater {
|
|||
}
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
|
||||
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
console.log('Error in main process', error);
|
||||
});
|
||||
|
@ -653,8 +657,34 @@ app.on('window-all-closed', () => {
|
|||
}
|
||||
});
|
||||
|
||||
const FONT_HEADERS = [
|
||||
'font/collection',
|
||||
'font/otf',
|
||||
'font/sfnt',
|
||||
'font/ttf',
|
||||
'font/woff',
|
||||
'font/woff2',
|
||||
];
|
||||
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
protocol.handle('feishin', async (request) => {
|
||||
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
||||
const response = await net.fetch(filePath);
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
createWindow();
|
||||
createTray();
|
||||
app.on('activate', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcRenderer, webFrame } from 'electron';
|
||||
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
const store = new Store();
|
||||
|
@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => {
|
|||
webFrame.setZoomFactor(zoomFactor / 100);
|
||||
};
|
||||
|
||||
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
|
||||
ipcRenderer.on('custom-font-error', cb);
|
||||
};
|
||||
|
||||
export const localSettings = {
|
||||
disableMediaKeys,
|
||||
enableMediaKeys,
|
||||
fontError,
|
||||
get,
|
||||
passwordGet,
|
||||
passwordRemove,
|
||||
|
|
|
@ -1130,3 +1130,12 @@ export enum LyricSource {
|
|||
}
|
||||
|
||||
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
|
||||
|
||||
// This type from https://wicg.github.io/local-font-access/#fontdata
|
||||
// NOTE: it is still experimental, so this should be updates as appropriate
|
||||
export type FontData = {
|
||||
family: string;
|
||||
fullName: string;
|
||||
postscriptName: string;
|
||||
style: string;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
|
@ -23,7 +23,7 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
|||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
@ -37,17 +37,48 @@ const remote = isElectron() ? window.electron.remote : null;
|
|||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
const remoteSettings = useRemoteSettings();
|
||||
const textStyleRef = useRef<HTMLStyleElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
if (type === FontType.SYSTEM && system) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', 'dynamic-font');
|
||||
|
||||
if (!textStyleRef.current) {
|
||||
textStyleRef.current = document.createElement('style');
|
||||
document.body.appendChild(textStyleRef.current);
|
||||
}
|
||||
|
||||
textStyleRef.current.textContent = `
|
||||
@font-face {
|
||||
font-family: "dynamic-font";
|
||||
src: local("${system}");
|
||||
}`;
|
||||
} else if (type === FontType.CUSTOM && custom) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', 'dynamic-font');
|
||||
|
||||
if (!textStyleRef.current) {
|
||||
textStyleRef.current = document.createElement('style');
|
||||
document.body.appendChild(textStyleRef.current);
|
||||
}
|
||||
|
||||
textStyleRef.current.textContent = `
|
||||
@font-face {
|
||||
font-family: "dynamic-font";
|
||||
src: url("feishin://${custom}");
|
||||
}`;
|
||||
} else {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', builtIn);
|
||||
}
|
||||
}, [builtIn, custom, system, type]);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return { handlePlayQueueAdd };
|
||||
|
|
|
@ -1,14 +1,27 @@
|
|||
import type { IpcRendererEvent } from 'electron';
|
||||
import isElectron from 'is-electron';
|
||||
import { NumberInput, Select } from '/@/renderer/components';
|
||||
import { FileInput, NumberInput, Select, toast } from '/@/renderer/components';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingOption,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useFontSettings,
|
||||
useGeneralSettings,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontType } from '/@/renderer/types';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
type Font = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const FONT_OPTIONS: Font[] = [
|
||||
{ label: 'Archivo', value: 'Archivo' },
|
||||
{ label: 'Fredoka', value: 'Fredoka' },
|
||||
{ label: 'Inter', value: 'Inter' },
|
||||
|
@ -20,9 +33,99 @@ const FONT_OPTIONS = [
|
|||
{ label: 'Work Sans', value: 'Work Sans' },
|
||||
];
|
||||
|
||||
const FONT_TYPES: Font[] = [{ label: 'Built-in font', value: FontType.BUILT_IN }];
|
||||
|
||||
if (window.queryLocalFonts) {
|
||||
FONT_TYPES.push({ label: 'System font', value: FontType.SYSTEM });
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
FONT_TYPES.push({ label: 'Custom font', value: FontType.CUSTOM });
|
||||
}
|
||||
|
||||
export const ApplicationSettings = () => {
|
||||
const settings = useGeneralSettings();
|
||||
const fontSettings = useFontSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const [localFonts, setLocalFonts] = useState<Font[]>([]);
|
||||
|
||||
const fontList = useMemo(() => {
|
||||
if (fontSettings.custom) {
|
||||
const newFile = new File([], fontSettings.custom.split(/(\\|\/)/g).pop()!);
|
||||
newFile.path = fontSettings.custom;
|
||||
return newFile;
|
||||
}
|
||||
return null;
|
||||
}, [fontSettings.custom]);
|
||||
|
||||
const onFontError = useCallback(
|
||||
(_: IpcRendererEvent, file: string) => {
|
||||
toast.error({
|
||||
message: `${file} is not a valid font file`,
|
||||
});
|
||||
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
custom: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
[fontSettings, setSettings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSettings) {
|
||||
localSettings.fontError(onFontError);
|
||||
|
||||
return () => {
|
||||
ipc!.removeAllListeners('custom-font-error');
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [onFontError]);
|
||||
|
||||
useEffect(() => {
|
||||
const getFonts = async () => {
|
||||
if (
|
||||
fontSettings.type === FontType.SYSTEM &&
|
||||
localFonts.length === 0 &&
|
||||
window.queryLocalFonts
|
||||
) {
|
||||
try {
|
||||
// WARNING (Oct 17 2023): while this query is valid for chromium-based
|
||||
// browsers, it is still experimental, and so Typescript will complain
|
||||
// @ts-ignore
|
||||
const status = await navigator.permissions.query({ name: 'local-fonts' });
|
||||
|
||||
if (status.state === 'denied') {
|
||||
throw new Error('Access denied to local fonts');
|
||||
}
|
||||
|
||||
const data = await window.queryLocalFonts();
|
||||
setLocalFonts(
|
||||
data.map((font) => ({
|
||||
label: font.fullName,
|
||||
value: font.postscriptName,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
message: 'An error occurred when trying to get system fonts',
|
||||
});
|
||||
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
type: FontType.BUILT_IN,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
getFonts();
|
||||
}, [fontSettings, localFonts, setSettings]);
|
||||
|
||||
const options: SettingOption[] = [
|
||||
{
|
||||
|
@ -39,24 +142,87 @@ export const ApplicationSettings = () => {
|
|||
{
|
||||
control: (
|
||||
<Select
|
||||
searchable
|
||||
data={FONT_OPTIONS}
|
||||
defaultValue={settings.fontContent}
|
||||
data={FONT_TYPES}
|
||||
value={fontSettings.type}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
fontContent: e,
|
||||
font: {
|
||||
...fontSettings,
|
||||
type: e as FontType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
'What font to use. Built-in font selects one of the fonts provided by Feishin. System font allows you to select any font provided by your OS. Custom allows you to provide your own font',
|
||||
isHidden: FONT_TYPES.length === 1,
|
||||
title: 'Use system font',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
searchable
|
||||
data={FONT_OPTIONS}
|
||||
value={fontSettings.builtIn}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
builtIn: e,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the application content font',
|
||||
isHidden: false,
|
||||
isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN,
|
||||
title: 'Font (Content)',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
searchable
|
||||
data={localFonts}
|
||||
value={fontSettings.system}
|
||||
w={300}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
system: e,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the application content font',
|
||||
isHidden: !localFonts || fontSettings.type !== FontType.SYSTEM,
|
||||
title: 'Font (Content)',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<FileInput
|
||||
accept=".ttc,.ttf,.otf,.woff,.woff2"
|
||||
defaultValue={fontList}
|
||||
w={300}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
custom: e?.path ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: 'Path to custom font',
|
||||
isHidden: fontSettings.type !== FontType.CUSTOM,
|
||||
title: 'Path to custom font',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
|
|
3
src/renderer/preload.d.ts
vendored
3
src/renderer/preload.d.ts
vendored
|
@ -1,6 +1,6 @@
|
|||
import { IpcRendererEvent } from 'electron';
|
||||
import { PlayerData, PlayerState } from './store';
|
||||
import { InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types';
|
||||
import { FontData, InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types';
|
||||
import { Remote } from '/@/main/preload/remote';
|
||||
import { Mpris } from '/@/main/preload/mpris';
|
||||
import { MpvPLayer, MpvPlayerListener } from '/@/main/preload/mpv-player';
|
||||
|
@ -76,6 +76,7 @@ declare global {
|
|||
remote?: Remote;
|
||||
utils?: Utils;
|
||||
};
|
||||
queryLocalFonts?: () => Promise<FontData[]>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
PlaybackType,
|
||||
TableType,
|
||||
Platform,
|
||||
FontType,
|
||||
} from '/@/renderer/types';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
|
||||
|
@ -111,10 +112,16 @@ export enum BindingActions {
|
|||
}
|
||||
|
||||
export interface SettingsState {
|
||||
font: {
|
||||
builtIn: string;
|
||||
custom: string | null;
|
||||
system: string | null;
|
||||
type: FontType;
|
||||
};
|
||||
general: {
|
||||
defaultFullPlaylist: boolean;
|
||||
followSystemTheme: boolean;
|
||||
fontContent: string;
|
||||
|
||||
playButtonBehavior: Play;
|
||||
resume: boolean;
|
||||
showQueueDrawerButton: boolean;
|
||||
|
@ -208,10 +215,15 @@ const getPlatformDefaultWindowBarStyle = (): Platform => {
|
|||
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
|
||||
|
||||
const initialState: SettingsState = {
|
||||
font: {
|
||||
builtIn: 'Inter',
|
||||
custom: null,
|
||||
system: null,
|
||||
type: FontType.BUILT_IN,
|
||||
},
|
||||
general: {
|
||||
defaultFullPlaylist: true,
|
||||
followSystemTheme: false,
|
||||
fontContent: 'Inter',
|
||||
playButtonBehavior: Play.NOW,
|
||||
resume: false,
|
||||
showQueueDrawerButton: false,
|
||||
|
@ -542,3 +554,5 @@ export const useMpvSettings = () =>
|
|||
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);
|
||||
|
||||
export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow);
|
||||
|
||||
export const useFontSettings = () => useSettingsStore((state) => state.font, shallow);
|
||||
|
|
|
@ -203,3 +203,9 @@ export type SongUpdate = {
|
|||
/** This volume is in range 0-100 */
|
||||
volume?: number;
|
||||
};
|
||||
|
||||
export enum FontType {
|
||||
BUILT_IN = 'builtIn',
|
||||
CUSTOM = 'custom',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
|
Reference in a new issue