Compare commits

..

2 Commits

Author SHA1 Message Date
fiso64 5ac0aaeec0 fix(player): step correctly when seeking rapidly (#2084)
* fix(player): step correctly when seeking rapidly

mediaSkipBackward/mediaSkipForward compute the new target relative to
the timestamp store, which is only refreshed by a ~500ms engine poll.
Rapid presses within that window all read the same stale position and
recompute the same target, so the time appears stuck until the poll
catches up.

Update the timestamp store immediately when a seek is issued so
subsequent presses compute from the new position; the poll then just
reconciles to the same value. Also applied to mediaSeekToTimestamp so
absolute seeks reflect in the UI without the poll lag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(player): unify seek comments per review

Use one canonical explanation in mediaSkipBackward and have
mediaSeekToTimestamp/mediaSkipForward reference it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:11:21 +00:00
Kendall Garner 515cadb916 Better cross platform font handling (#2104)
* fix: better handling of custom font

Practically speaking, custom font seems to have only worked on Linux, because
`net.fetch` would include the mime type in the response headers which could validate the payload.
This doesn't appear to be the case on windows/macOS. Instead:

1. On Linux (or if some other system supports it), check the content type. If good, serve as normal
2. Otherwise, fetch the payload. Read the first four to five bytes and check for a valid magic number.

Additionally, to prevent arbitrary requests fetching other paths via injected content, sync the custom font path
to the main process, and then make _every_ request to `feishin:/` point to the same renderer path.
When setting the font, first send the path to the main process. This will register `feishin:/` to point
to the path provided. This is done via a promise-based set.

Finally, provide a default value for the file input (a best effort approximation for the last part of the file path)
on the file input component.

* make the linter happy
2026-06-02 19:34:16 +00:00
8 changed files with 111 additions and 15 deletions
+8
View File
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
}
});
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
if (data.value === null) {
store.delete(data.property);
} else {
store.set(data.property, data.value);
}
});
ipcMain.handle('password-get', (_event, server: string): null | string => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
+59 -10
View File
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
protocol.registerSchemesAsPrivileged([
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
]);
process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error);
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
}
});
const FONT_HEADERS = [
const FONT_HEADERS = new Set([
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
];
]);
const bytesToInt = (array: Uint8Array, length: number): number => {
let value = 0;
for (let i = 0; i < length; i++) {
value = (value << 8) + array[i];
}
return value;
};
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
0x4f54544f, // font/otf
0x774f4632, // font/woff2
0x774f4646, // font/woff
]);
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
0x0001000000, // ttf, collection, sfnt
]);
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
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)) {
protocol.handle('feishin', async () => {
const filePath = store.get('local_font_path');
if (typeof filePath !== 'string') {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
});
}
return response;
const response = await net.fetch('file:' + filePath);
const contentType = response.headers.get('content-type');
// On Linux, the mime type is included in the response header
// In this case, we can forward the response with no further processing
if (contentType && FONT_HEADERS.has(contentType)) {
return response;
}
// Otherwise, let's check the magic number to see if
// the file is a font type. This is either four or five bytes
const payload = await response.arrayBuffer();
const magicNumber = new Uint8Array(payload.slice(0, 5));
const fiveHex = bytesToInt(magicNumber, 5);
const fourHex = bytesToInt(magicNumber, 4);
if (
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
) {
// We have to create a new response with the payload, since it has been read now
return new Response(payload, {
headers: response.headers,
});
}
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+8
View File
@@ -9,6 +9,13 @@ const set = (
ipcRenderer.send('settings-set', { property, value });
};
const setSync = async (
property: string,
value: boolean | null | Record<string, unknown> | string | string[],
) => {
return ipcRenderer.invoke('settings-set-sync', { property, value });
};
const get = async (property: string) => {
return ipcRenderer.invoke('settings-get', { property });
};
@@ -99,6 +106,7 @@ export const localSettings = {
passwordSet,
restart,
set,
setSync,
setZoomFactor,
themeSet,
};
+1
View File
@@ -139,6 +139,7 @@ export const utils = {
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
separator: isWindows() ? '\\' : '/',
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
control: (
<FileInput
accept=".ttc,.ttf,.otf,.woff,.woff2"
onChange={(e) =>
clearable
defaultValue={
fontSettings.custom
? new File([], fontSettings.custom.split(utils?.separator || '').pop()!)
: null
}
onChange={async (e) => {
const custom = e ? getPathForFile?.(e) || null : null;
await localSettings?.setSync('local_font_path', custom);
setSettings({
font: {
...fontSettings,
custom: e ? getPathForFile?.(e) || null : null,
custom,
},
})
}
});
}}
w={300}
/>
),
description: t('setting.customFontPath', {
context: 'description',
}),
isHidden: fontSettings.type !== FontType.CUSTOM,
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
title: t('setting.customFontPath'),
},
{
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
const settingsFromStore = useSettingsStore.getState();
const settings = {
font: settingsFromStore.font,
general: settingsFromStore.general,
hotkeys: settingsFromStore.hotkeys,
lyrics: settingsFromStore.lyrics,
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
mainStoreKey: 'enableNeteaseTranslation',
rendererValue: settings.lyrics.enableNeteaseTranslation,
},
{
mainStoreKey: 'local_font_path',
rendererValue: settings.font.custom,
},
];
// Compare and sync each setting
+11
View File
@@ -1185,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
});
},
mediaSeekToTimestamp: (timestamp: number) => {
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(timestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
});
@@ -1196,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
// Update the timestamp store right away so the UI and any
// subsequent seek compute from the new position instead of the
// stale value left by the ~500ms engine poll (otherwise mashing
// the seek keys repeatedly lands on the same time).
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
@@ -1217,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
+5
View File
@@ -134,6 +134,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
document.body.appendChild(textStyleRef.current);
}
// Note: we change the url to bust caches when changing the path
// The url provided here does NOT matter, validation is done
// on the main process. Any feishin:/ url will fetch the same
// item, which the renderer will check via magic number to be
// some font item
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";