Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature]: Support using system fonts #304

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
Menu,
nativeImage,
BrowserWindowConstructorOptions,
protocol,
net,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
Expand All @@ -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);
});
Expand Down Expand Up @@ -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', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/main/preload/local-settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ipcRenderer, webFrame } from 'electron';
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';

const store = new Store();
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
43 changes: 37 additions & 6 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]);
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

type Font = {
label: string;
value: string;
};

const FONT_OPTIONS = [
const FONT_OPTIONS: Font[] = [
{ label: 'Archivo', value: 'Archivo' },
{ label: 'Fredoka', value: 'Fredoka' },
{ label: 'Inter', value: 'Inter' },
Expand All @@ -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[] = [
{
Expand All @@ -36,27 +139,90 @@ export const ApplicationSettings = () => {
isHidden: false,
title: 'Language',
},
{
control: (
<Select
data={FONT_TYPES}
value={fontSettings.type}
onChange={(e) => {
if (!e) return;
setSettings({
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}
defaultValue={settings.fontContent}
value={fontSettings.builtIn}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
fontContent: e,
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
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/preload.d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -76,6 +76,7 @@ declare global {
remote?: Remote;
utils?: Utils;
};
queryLocalFonts?: () => Promise<FontData[]>;
}
}

Expand Down
Loading