From 62c1ac04e4aa3156a5420dac314a43c7d187963a Mon Sep 17 00:00:00 2001 From: York Date: Thu, 16 Jul 2026 13:26:05 +0800 Subject: [PATCH] feat: support furigana and romaji generation on web (#2232) * feat: support furigana and romaji generation on web --- electron.vite.config.ts | 21 ++++++-- src/main/features/core/lyrics/furigana.ts | 15 +++++- .../api/development-lyrics-conversion-api.ts | 13 +++++ .../api/electron-lyrics-conversion-api.ts | 7 +++ .../components/lyrics-settings-form.tsx | 2 - .../lyrics/hooks/use-furigana-lyrics.ts | 24 +++------ .../components/general/lyric-settings.tsx | 2 - src/renderer/shims/path.ts | 9 ++++ tsconfig.node.json | 1 + tsconfig.web.json | 4 ++ vite.kuromoji-plugin.ts | 54 +++++++++++++++++++ web.vite.config.ts | 8 +++ 12 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts create mode 100644 src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts create mode 100644 src/renderer/shims/path.ts create mode 100644 vite.kuromoji-plugin.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 491ee0032..91a0eead3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,15 +1,16 @@ -import { externalizeDepsPlugin, UserConfig } from 'electron-vite'; +import { defineConfig, externalizeDepsPlugin, UserConfig } from 'electron-vite'; import { resolve } from 'path'; import conditionalImportPlugin from 'vite-plugin-conditional-import'; import dynamicImportPlugin from 'vite-plugin-dynamic-import'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; +import { kuromojiDictionaryPlugin } from './vite.kuromoji-plugin'; import { createReactPlugin } from './vite.react-plugin'; const currentOSEnv = process.platform; const electronRendererTarget = 'chrome87'; -const config: UserConfig = { +const createConfig = (isDevelopment: boolean): UserConfig => ({ main: { build: { rollupOptions: { @@ -65,16 +66,26 @@ const config: UserConfig = { localsConvention: 'camelCase', }, }, - plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })], + plugins: [ + createReactPlugin(), + ...(isDevelopment ? [kuromojiDictionaryPlugin({ emitDictionary: false })] : []), + ViteEjsPlugin({ web: false }), + ], resolve: { alias: { '/@/i18n': resolve('src/i18n'), + '/@/lyrics-conversion-api': resolve( + isDevelopment + ? 'src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts' + : 'src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts', + ), '/@/remote': resolve('src/remote'), '/@/renderer': resolve('src/renderer'), '/@/shared': resolve('src/shared'), + ...(isDevelopment ? { path: resolve('src/renderer/shims/path.ts') } : {}), }, }, }, -}; +}); -export default config; +export default defineConfig(({ command }) => createConfig(command === 'serve')); diff --git a/src/main/features/core/lyrics/furigana.ts b/src/main/features/core/lyrics/furigana.ts index 5c59a3a85..919d8a666 100644 --- a/src/main/features/core/lyrics/furigana.ts +++ b/src/main/features/core/lyrics/furigana.ts @@ -17,6 +17,14 @@ export type RomajiToken = LyricTextToken & { let kuroshiroInstance: any = null; let initPromise: null | Promise = null; +const getDictionaryPath = (): string | undefined => { + if (typeof document === 'undefined') { + return undefined; + } + + return new URL('./assets/kuromoji/', document.baseURI).href; +}; + const getKuroshiro = async () => { if (initPromise) { await initPromise; @@ -26,8 +34,13 @@ const getKuroshiro = async () => { if (kuroshiroInstance) return kuroshiroInstance; const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + const dictionaryPath = getDictionaryPath(); + const analyzer = dictionaryPath + ? new KuromojiAnalyzer({ dictPath: dictionaryPath }) + : new KuromojiAnalyzer(); + kuroshiroInstance = new KuroshiroClass(); - initPromise = kuroshiroInstance.init(new KuromojiAnalyzer()); + initPromise = kuroshiroInstance.init(analyzer); await initPromise; initPromise = null; diff --git a/src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts b/src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts new file mode 100644 index 000000000..44532e15c --- /dev/null +++ b/src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts @@ -0,0 +1,13 @@ +import isElectron from 'is-electron'; + +import * as browserLyricsApi from '../../../../main/features/core/lyrics/furigana'; + +const lyricsApi = isElectron() ? window.api.lyrics : browserLyricsApi; + +export const { + convertFurigana, + convertFuriganaFragment, + convertRomaji, + convertRomajiTokens, + parseLyricsTextTokens, +} = lyricsApi; diff --git a/src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts b/src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts new file mode 100644 index 000000000..b953082b2 --- /dev/null +++ b/src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts @@ -0,0 +1,7 @@ +export const { + convertFurigana, + convertFuriganaFragment, + convertRomaji, + convertRomajiTokens, + parseLyricsTextTokens, +} = window.api.lyrics; diff --git a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx index d106a129a..a8034623e 100644 --- a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx @@ -372,7 +372,6 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => description: t('setting.enableFurigana', { context: 'description', }), - isHidden: !isElectron(), title: t('setting.enableFurigana'), }, { @@ -386,7 +385,6 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => description: t('setting.enableRomaji', { context: 'description', }), - isHidden: !isElectron(), title: t('setting.enableRomaji'), }, { diff --git a/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts index 7a00e14fd..848531991 100644 --- a/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts +++ b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import isElectron from 'is-electron'; +import * as lyricsApi from '/@/lyrics-conversion-api'; import { alignFuriganaToWordCues, alignRomajiTokensToWordCues, @@ -10,15 +10,9 @@ import { import { normalizeLyrics } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types'; -const lyricsApi = isElectron() ? window.api.lyrics : null; - const convertSyncedLyricsFurigana = async ( lyrics: SynchronizedLyrics, ): Promise => { - if (!lyricsApi) { - return lyrics; - } - return Promise.all( normalizeLyrics(lyrics).map(async (line) => ({ ...line, @@ -76,9 +70,9 @@ const convertSyncedLyricsRomaji = async ( export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => { return useQuery({ - enabled: enabled && !!lyrics && !!lyricsApi, + enabled: enabled && !!lyrics, queryFn: async () => { - if (!lyrics || !lyricsApi || !enabled) return lyrics; + if (!lyrics || !enabled) return lyrics; if (typeof lyrics === 'string') { return await lyricsApi.convertFurigana(lyrics); @@ -97,9 +91,9 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => { return useQuery({ - enabled: enabled && !!lyrics && !!lyricsApi, + enabled: enabled && !!lyrics, queryFn: async () => { - if (!lyrics || !lyricsApi || !enabled) return lyrics; + if (!lyrics || !enabled) return lyrics; if (typeof lyrics === 'string') { return await lyricsApi.convertRomaji(lyrics); @@ -129,10 +123,6 @@ const buildSyncedRomajiLine = async ( continue; } - if (!lyricsApi) { - return cueLines.map(() => null); - } - const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[]; if (!tokens.length) { romajiCueLines.push(null); @@ -160,9 +150,9 @@ export const useSyncedRomajiLyrics = ( enabled: boolean, ) => { return useQuery({ - enabled: enabled && !!lyrics && !!lyricsApi, + enabled: enabled && !!lyrics, queryFn: async (): Promise => { - if (!lyrics || !lyricsApi || !enabled) { + if (!lyrics || !enabled) { return null; } diff --git a/src/renderer/features/settings/components/general/lyric-settings.tsx b/src/renderer/features/settings/components/general/lyric-settings.tsx index 0068d1bce..e7a02e63a 100644 --- a/src/renderer/features/settings/components/general/lyric-settings.tsx +++ b/src/renderer/features/settings/components/general/lyric-settings.tsx @@ -104,7 +104,6 @@ export const LyricSettings = memo(() => { description: t('setting.enableFurigana', { context: 'description', }), - isHidden: !isElectron(), title: t('setting.enableFurigana'), }, { @@ -118,7 +117,6 @@ export const LyricSettings = memo(() => { description: t('setting.enableRomaji', { context: 'description', }), - isHidden: !isElectron(), title: t('setting.enableRomaji'), }, { diff --git a/src/renderer/shims/path.ts b/src/renderer/shims/path.ts new file mode 100644 index 000000000..e76090ea9 --- /dev/null +++ b/src/renderer/shims/path.ts @@ -0,0 +1,9 @@ +export const join = (...parts: string[]): string => + parts + .filter(Boolean) + .map((part, index) => + index === 0 ? part.replace(/\/+$/u, '') : part.replace(/^\/+|\/+$/gu, ''), + ) + .join('/'); + +export default { join }; diff --git a/tsconfig.node.json b/tsconfig.node.json index 851753f9e..348b342b1 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "electron.vite.config.*", + "vite.kuromoji-plugin.ts", "vite.react-plugin.ts", "src/main/**/*", "src/preload/**/*", diff --git a/tsconfig.web.json b/tsconfig.web.json index 370f5a819..5dfa700cb 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -6,6 +6,7 @@ "src/renderer/**/*.tsx", "src/preload/*.d.ts", "src/i18n/**/*", + "src/main/features/core/lyrics/furigana.ts", "src/shared/**/*", "src/remote/**/*", "package.json" @@ -18,6 +19,9 @@ "/@/renderer/*": [ "src/renderer/*" ], + "/@/lyrics-conversion-api": [ + "src/main/features/core/lyrics/furigana.ts" + ], "/@/shared/*": [ "src/shared/*" ], diff --git a/vite.kuromoji-plugin.ts b/vite.kuromoji-plugin.ts new file mode 100644 index 000000000..9561fe34e --- /dev/null +++ b/vite.kuromoji-plugin.ts @@ -0,0 +1,54 @@ +import type { Connect, Plugin } from 'vite'; + +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const analyzerRequire = createRequire(require.resolve('kuroshiro-analyzer-kuromoji/package.json')); +const dictionaryDirectory = path.join( + path.dirname(analyzerRequire.resolve('kuromoji/package.json')), + 'dict', +); +const dictionaryFiles = fs + .readdirSync(dictionaryDirectory) + .filter((fileName) => fileName.endsWith('.dat.gz')); + +const serveDictionary: Connect.NextHandleFunction = (request, response, next) => { + const fileName = path.basename(new URL(request.url ?? '/', 'http://localhost').pathname); + + if (!dictionaryFiles.includes(fileName)) { + next(); + return; + } + + response.setHeader('Content-Type', 'application/gzip'); + fs.createReadStream(path.join(dictionaryDirectory, fileName)).pipe(response); +}; + +type KuromojiDictionaryPluginOptions = { + emitDictionary?: boolean; +}; + +export const kuromojiDictionaryPlugin = ({ + emitDictionary = true, +}: KuromojiDictionaryPluginOptions = {}): Plugin => ({ + configurePreviewServer(server) { + server.middlewares.use('/assets/kuromoji', serveDictionary); + }, + configureServer(server) { + server.middlewares.use('/assets/kuromoji', serveDictionary); + }, + generateBundle() { + if (!emitDictionary) return; + + for (const fileName of dictionaryFiles) { + this.emitFile({ + fileName: `assets/kuromoji/${fileName}`, + source: fs.readFileSync(path.join(dictionaryDirectory, fileName)), + type: 'asset', + }); + } + }, + name: 'kuromoji-dictionary', +}); diff --git a/web.vite.config.ts b/web.vite.config.ts index fcaa17095..8cb28ee3e 100644 --- a/web.vite.config.ts +++ b/web.vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, normalizePath } from 'vite'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; import { VitePWA } from 'vite-plugin-pwa'; +import { kuromojiDictionaryPlugin } from './vite.kuromoji-plugin'; import { createReactPlugin } from './vite.react-plugin'; export default defineConfig({ @@ -65,6 +66,7 @@ export default defineConfig({ }, plugins: [ createReactPlugin(), + kuromojiDictionaryPlugin(), ViteEjsPlugin({ root: normalizePath(path.resolve(__dirname, './src/renderer')), web: true, @@ -134,6 +136,7 @@ export default defineConfig({ workbox: { cleanupOutdatedCaches: true, clientsClaim: true, + globIgnores: ['**/kuromoji/**'], maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB skipWaiting: true, }, @@ -142,9 +145,14 @@ export default defineConfig({ resolve: { alias: { '/@/i18n': path.resolve(__dirname, './src/i18n'), + '/@/lyrics-conversion-api': path.resolve( + __dirname, + './src/main/features/core/lyrics/furigana.ts', + ), '/@/remote': path.resolve(__dirname, './src/remote'), '/@/renderer': path.resolve(__dirname, './src/renderer'), '/@/shared': path.resolve(__dirname, './src/shared'), + path: path.resolve(__dirname, './src/renderer/shims/path.ts'), }, }, root: path.resolve(__dirname, './src/renderer'),