From 5195d4638e8acdd0950fa9f15d278776354a84e6 Mon Sep 17 00:00:00 2001 From: Steve-xmh <39523898+Steve-xmh@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:30:26 +0800 Subject: [PATCH] =?UTF-8?q?player:=20=E4=BF=AE=E6=94=B9=E5=AF=B9=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E7=94=A8=E8=AF=8D=E5=B9=B6=E6=9B=B4=E5=90=8D?= =?UTF-8?q?=E4=B8=BA=E6=89=A9=E5=B1=95=E7=A8=8B=E5=BA=8F=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=8D=B8=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E5=85=81=E8=AE=B8=E6=B3=A8=E5=85=A5=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=88=B0=E6=9B=B4=E5=A4=9A=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/locales/zh-CN/translation.json | 54 +++-- packages/player/package.json | 1 + packages/player/src/App.tsx | 8 +- .../components/ExtensionContext/ext-ctx.ts | 85 +++++++ .../src/components/ExtensionContext/index.tsx | 227 ++++++++++++++++++ .../components/ExtensionInjectPoint/index.tsx | 74 ++++++ .../components/LocalMusicContext/index.tsx | 38 +-- .../src/components/PluginContext/index.tsx | 210 ---------------- packages/player/src/extension-env.d.ts | 159 ++++++++++++ packages/player/src/pages/main/index.tsx | 7 + ...plugin-manage.svg => extension-manage.svg} | 0 .../settings/{plugin.tsx => extension.tsx} | 120 ++++----- packages/player/src/pages/settings/index.tsx | 48 ++-- packages/player/src/pages/song/index.tsx | 5 + packages/player/src/pages/song/lyric.tsx | 3 + packages/player/src/plugin-env.d.ts | 131 ---------- packages/player/src/states/extension.ts | 162 +++++++++++++ packages/player/src/states/plugin.ts | 161 ------------- packages/player/src/states/updater.ts | 7 +- packages/player/src/utils/player.ts | 11 +- pnpm-lock.yaml | 9 +- 21 files changed, 889 insertions(+), 631 deletions(-) create mode 100644 packages/player/src/components/ExtensionContext/ext-ctx.ts create mode 100644 packages/player/src/components/ExtensionContext/index.tsx create mode 100644 packages/player/src/components/ExtensionInjectPoint/index.tsx delete mode 100644 packages/player/src/components/PluginContext/index.tsx create mode 100644 packages/player/src/extension-env.d.ts rename packages/player/src/pages/settings/{plugin-manage.svg => extension-manage.svg} (100%) rename packages/player/src/pages/settings/{plugin.tsx => extension.tsx} (58%) delete mode 100644 packages/player/src/plugin-env.d.ts create mode 100644 packages/player/src/states/extension.ts delete mode 100644 packages/player/src/states/plugin.ts diff --git a/packages/player/locales/zh-CN/translation.json b/packages/player/locales/zh-CN/translation.json index 86785078d..4f8276b30 100644 --- a/packages/player/locales/zh-CN/translation.json +++ b/packages/player/locales/zh-CN/translation.json @@ -27,6 +27,20 @@ "noResults": "无结果" } }, + "extension": { + "inject": { + "error": { + "calloutText": "扩展程序 {id} 在注入组件 / 功能到 {injectPointName} 槽位时发生错误:", + "toastText": "扩展程序 {id} 在注入组件 / 功能到 {injectPointName} 槽位时发生错误:\n{error}" + } + }, + "error": { + "invaildPluginFile": "无效插件文件", + "missingDependency": "缺失依赖项", + "missingMetadata": "缺失必需元数据", + "pluginIdConflict": "插件 ID 冲突" + } + }, "newPlaylist": { "buttonLabel": "新建播放列表", "dialog": { @@ -106,6 +120,15 @@ "shufflePlayAll": "随机播放" }, "settings": { + "others": { + "restartProgram": "重启程序", + "subtitle": "杂项", + "showStatJSFrame": { + "label": "显示性能统计信息", + "description": "可以看到帧率、帧时间、内存占用(仅 Chromuim 系)等信息,对性能影响较小。" + }, + "enterAmllDevPage": "歌词页面开发用工具" + }, "lyricFont": { "fontPreview": { "defaultText": "字体预览 Font Preview", @@ -238,15 +261,6 @@ "label": "启用音译歌词与翻译歌词互换", "description": "仅上面两者启用后有效" } - }, - "others": { - "subtitle": "杂项", - "showStatJSFrame": { - "label": "显示性能统计信息", - "description": "可以看到帧率、帧时间、内存占用(仅 Chromuim 系)等信息,对性能影响较小。" - }, - "restartProgram": "重启程序", - "enterAmllDevPage": "歌词页面开发用工具" } }, "about": { @@ -305,27 +319,19 @@ } }, "settings": { - "player": { - "tab": "AMLL Player 设置" - }, - "plugin": { - "tab": "插件管理", + "extension": { "safetyWarning": "插件将可以访问并操作你的所有数据,包括你的歌单、播放信息等数据,请务必确保插件来源可靠安全,并只安装你信任的插件!作者不承担使用任何插件后产生的一切后果!", "wipWarning": "插件接口功能仍在开发中,其插件接口有可能随时变更,敬请留意!", "install": { "title": "请选择需要载入的 JavaScript 插件文件" }, "installPlugins": "安装插件", - "openPluginDirectory": "打开插件文件夹" + "openPluginDirectory": "打开插件文件夹", + "tab": "扩展程序管理" + }, + "player": { + "tab": "AMLL Player 设置" } }, - "name": "", - "plugin": { - "error": { - "invaildPluginFile": "无效插件文件", - "missingDependency": "缺失依赖项", - "missingMetadata": "缺失必需元数据", - "pluginIdConflict": "插件 ID 冲突" - } - } + "name": "" } diff --git a/packages/player/package.json b/packages/player/package.json index b6caab10a..62bcbbfed 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -29,6 +29,7 @@ "@tauri-apps/plugin-os": "2.0.0", "@tauri-apps/plugin-shell": "2.0.0", "@tauri-apps/plugin-updater": "2.0.0", + "chalk": "^5.3.0", "classnames": "^2.5.1", "convert-source-map": "^2.0.0", "dexie": "^4.0.8", diff --git a/packages/player/src/App.tsx b/packages/player/src/App.tsx index ede611456..b5f1c8f03 100644 --- a/packages/player/src/App.tsx +++ b/packages/player/src/App.tsx @@ -10,9 +10,10 @@ import { ToastContainer } from "react-toastify"; import Stats from "stats.js"; import styles from "./App.module.css"; import { AMLLWrapper } from "./components/AMLLWrapper"; +import { ExtensionContext } from "./components/ExtensionContext"; +import { ExtensionInjectPoint } from "./components/ExtensionInjectPoint"; import { LocalMusicContext } from "./components/LocalMusicContext"; import { NowPlayingBar } from "./components/NowPlayingBar"; -import { PluginContext } from "./components/PluginContext"; import { UpdateContext } from "./components/UpdateContext"; import { WSProtocolMusicContext } from "./components/WSProtocolMusicContext"; import "./i18n"; @@ -60,15 +61,16 @@ function App() { return ( <> - {/* 上下文组件均不建议被 StrictMode 包含,以免重复加载插件发生问题 */} + {/* 上下文组件均不建议被 StrictMode 包含,以免重复加载扩展程序发生问题 */} {musicContextMode === MusicContextMode.Local && } {musicContextMode === MusicContextMode.WSProtocol && ( )} - + + { + const incomingSourceConv = fromSource(code); + if (!incomingSourceConv) return [code, ""]; + const incomingSourceMap = incomingSourceConv.toObject(); + const consumer = await new SourceMapConsumer(incomingSourceMap); + const generator = new SourceMapGenerator({ + file: incomingSourceMap.file, + sourceRoot: sourceRoot, + }); + consumer.eachMapping((m) => { + // skip invalid (not-connected) mapping + // refs: https://github.com/mozilla/source-map/blob/182f4459415de309667845af2b05716fcf9c59ad/lib/source-map-generator.js#L268-L275 + if ( + typeof m.originalLine === "number" && + 0 < m.originalLine && + typeof m.originalColumn === "number" && + 0 <= m.originalColumn && + m.source + ) { + generator.addMapping({ + source: + m.source && + `${location.origin}/extensions/${sourceRoot}/${m.source.replace(/^(\.*\/)+/, "")}`, + name: m.name, + original: { line: m.originalLine, column: m.originalColumn }, + generated: { + line: m.generatedLine + lineOffset, + column: m.generatedColumn, + }, + }); + } + }); + const outgoingSourceMap = JSON.parse(generator.toString()); + if (typeof incomingSourceMap.sourcesContent !== "undefined") { + outgoingSourceMap.sourcesContent = incomingSourceMap.sourcesContent; + } + return [removeComments(code), fromObject(outgoingSourceMap).toComment()]; +} + +export class PlayerExtensionContext + extends EventTarget + implements ExtensionEnv.ExtensionContext +{ + /** + * @internal + */ + registeredInjectPointComponent: { + [injectPointName: string]: ComponentType | undefined; + } = {}; + constructor( + readonly playerStates: ExtensionEnv.ExtensionContext["playerStates"], + readonly amllStates: ExtensionEnv.ExtensionContext["amllStates"], + readonly i18n: ExtensionEnv.ExtensionContext["i18n"], + readonly jotaiStore: ExtensionEnv.ExtensionContext["jotaiStore"], + readonly extensionMeta: Readonly, + readonly lyric: typeof import("@applemusic-like-lyrics/lyric"), + readonly playerDB: typeof db, + ) { + super(); + } + extensionApiNumber = 1; + registerLocale(localeData: { [langId: string]: T }) { + for (const [lng, data] of Object.entries(localeData)) { + i18n.addResourceBundle(lng, this.extensionMeta.id, data); + } + } + registerComponent(injectPointName: string, injectComponent: ComponentType) { + this.registeredInjectPointComponent[injectPointName] = injectComponent; + } + registerPlayerSource(_idPrefix: string) { + console.warn("Unimplemented"); + } +} diff --git a/packages/player/src/components/ExtensionContext/index.tsx b/packages/player/src/components/ExtensionContext/index.tsx new file mode 100644 index 000000000..c3605c84a --- /dev/null +++ b/packages/player/src/components/ExtensionContext/index.tsx @@ -0,0 +1,227 @@ +import * as lyric from "@applemusic-like-lyrics/lyric"; +import * as amllStates from "@applemusic-like-lyrics/react-full/states"; +import chalk from "chalk"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { type FC, useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { uid } from "uid"; +import { db } from "../../dexie"; +import * as playerStates from "../../states"; +import { + ExtensionLoadResult, + type ExtensionMetaState, + type LoadedExtension, + extensionMetaAtom, + loadedExtensionAtom, +} from "../../states/extension"; +import { PlayerExtensionContext, sourceMapOffsetLines } from "./ext-ctx"; + +const AsyncFunction: FunctionConstructor = Object.getPrototypeOf( + async () => {}, +).constructor; + +class Notify { + promise: Promise; + resolve: () => void; + reject: (err: Error) => void; + constructor() { + let resolve: () => void = () => {}; + let reject: (err: Error) => void = () => {}; + const p = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.promise = p; + this.resolve = resolve; + this.reject = reject; + } + + wait() { + return this.promise; + } + + notify() { + this.resolve(); + } +} + +const LOG_TAG = chalk.bgHex("#00AAFF").hex("#FFFFFF")(" EXTENSION "); + +const SingleExtensionContext: FC<{ + extensionMeta: ExtensionMetaState; + waitForDependency: (extensionId: string) => Promise; + extPromise: readonly [Promise, () => void, (err: Error) => void]; +}> = ({ extensionMeta, waitForDependency, extPromise }) => { + const store = useStore(); + const { i18n } = useTranslation(); + const cancelRef = useRef(); + const setLoadedExtension = useSetAtom(loadedExtensionAtom); + useEffect(() => { + let canceled = false; + const extI18n = i18n.cloneInstance({ + ns: extensionMeta.id, + }); + + const context = new PlayerExtensionContext( + Object.freeze(Object.assign({}, playerStates)), + Object.freeze(Object.assign({}, amllStates)), + extI18n, + store, + extensionMeta, + lyric, + db, + ); + + const loadedExt: LoadedExtension = { + extensionFunc: async () => {}, + extensionMeta, + context, + }; + + (async () => { + const cancelNotify = cancelRef.current; + if (cancelNotify) { + await cancelNotify.wait(); + } + if (canceled) return; + console.log( + LOG_TAG, + "正在加载扩展程序", + extensionMeta.id, + extensionMeta.fileName, + ); + const genFuncName = () => `__amll_internal_${uid()}`; + const resolveFuncName = genFuncName(); + const rejectFuncName = genFuncName(); + const waitForDependencyFuncName = genFuncName(); + const wrapperScript: string[] = []; + wrapperScript.push('"use strict";'); + wrapperScript.push("try {"); + + for (const dependencyId of extensionMeta.dependency) { + wrapperScript.push( + `await ${waitForDependencyFuncName}(${JSON.stringify(dependencyId)})`, + ); + } + + let comment = ""; + const offsetLines = wrapperScript.length + 2; + + try { + // 修正源映射表的行数,方便调试 + const [code, sourceMapComment] = await sourceMapOffsetLines( + extensionMeta.scriptData, + extensionMeta.id, + offsetLines, + ); + if (canceled) return; + wrapperScript.push(code); + comment = sourceMapComment; + } catch (err) { + console.log( + LOG_TAG, + "无法转换源映射表,可能是扩展程序并不包含源映射表", + err, + ); + wrapperScript.push(extensionMeta.scriptData); + } + + wrapperScript.push(`${resolveFuncName}();`); + wrapperScript.push("} catch (err) {"); + wrapperScript.push(`${rejectFuncName}(err);`); + wrapperScript.push("}"); + wrapperScript.push(comment); + + const extensionFunc: () => Promise = new AsyncFunction( + "extensionContext", + resolveFuncName, + rejectFuncName, + waitForDependencyFuncName, + wrapperScript.join("\n"), + ).bind(context, context, extPromise[1], extPromise[2], waitForDependency); + + if (canceled) return; + await extensionFunc(); + context.dispatchEvent(new Event("extension-load")); + + console.log( + LOG_TAG, + "扩展程序", + extensionMeta.id, + extensionMeta.fileName, + "加载完成", + ); + setLoadedExtension((v) => [...v, loadedExt]); + })(); + return () => { + canceled = true; + const notify = new Notify(); + cancelRef.current = notify; + (async () => { + context.dispatchEvent(new Event("extension-unload")); + setLoadedExtension((v) => v.filter((e) => e !== loadedExt)); + notify.notify(); + })(); + }; + }, [ + extensionMeta, + i18n, + store, + waitForDependency, + setLoadedExtension, + extPromise, + ]); + + return null; +}; + +export const ExtensionContext: FC = () => { + const extensionMeta = useAtomValue(extensionMetaAtom); + + const loadableExtensions = useMemo( + () => + extensionMeta.filter( + (v) => v.loadResult === ExtensionLoadResult.Loadable, + ), + [extensionMeta], + ); + const loadingPromisesMap = useMemo( + () => + new Map( + loadableExtensions.map((state) => { + let resolve: () => void = () => {}; + let reject: (err: Error) => void = () => {}; + const p = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return [state.id, [p, resolve, reject] as const] as const; + }), + ), + [loadableExtensions], + ); + + const waitForDependency = useCallback( + async (extensionId: string) => { + const promise = loadingPromisesMap.get(extensionId); + if (promise) { + await promise[0]; + } else { + throw new Error(`Missing Dependency: ${extensionId}`); + } + }, + [loadingPromisesMap], + ); + + return loadableExtensions.map((metaState) => { + const extPromise = loadingPromisesMap.get(metaState.id)!; + return ( + + ); + }); +}; diff --git a/packages/player/src/components/ExtensionInjectPoint/index.tsx b/packages/player/src/components/ExtensionInjectPoint/index.tsx new file mode 100644 index 000000000..6d82a0f37 --- /dev/null +++ b/packages/player/src/components/ExtensionInjectPoint/index.tsx @@ -0,0 +1,74 @@ +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { Callout } from "@radix-ui/themes"; +import { useAtomValue } from "jotai"; +import { type ComponentType, type FC, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { Trans, useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { loadedExtensionAtom } from "../../states/extension"; + +const ErrorCallout: FC< + FallbackProps & { + injectPointName: string; + id: string; + } +> = ({ error, id, injectPointName }) => { + return ( + + + + + +
+ + 扩展程序 {id} 在注入组件 / 功能到 {injectPointName} 槽位时发生错误: + +
+
{error}
+
+
+ ); +}; + +export const ExtensionInjectPoint: FC<{ + injectPointName: string; + hideErrorCallout?: boolean; +}> = ({ injectPointName, hideErrorCallout }) => { + const loadedExtension = useAtomValue(loadedExtensionAtom); + const injectedPoint = useMemo( + () => + loadedExtension + .map( + (v) => + [ + v.extensionMeta.id, + v.context.registeredInjectPointComponent[injectPointName], + ] as [string, ComponentType], + ) + .filter((v) => !!v[1]), + [loadedExtension, injectPointName], + ); + const { t } = useTranslation(); + + return injectedPoint.map(([id, InjectedComponent]) => ( + { + toast.error( + t( + "extension.inject.error.toastText", + "扩展程序 {id} 在注入组件 / 功能到 {injectPointName} 槽位时发生错误:\n{error}", + { + id, + injectPointName, + error: String(error), + }, + ), + ); + }} + fallbackRender={() => (hideErrorCallout ? null : )} + > + + + )); +}; diff --git a/packages/player/src/components/LocalMusicContext/index.tsx b/packages/player/src/components/LocalMusicContext/index.tsx index 7008fe6d4..ef7e715e4 100644 --- a/packages/player/src/components/LocalMusicContext/index.tsx +++ b/packages/player/src/components/LocalMusicContext/index.tsx @@ -36,6 +36,7 @@ import { onRequestPrevSongAtom, onSeekPositionAtom, } from "@applemusic-like-lyrics/react-full"; +import chalk from "chalk"; import { useLiveQuery } from "dexie-react-hooks"; import { useAtomValue, useSetAtom, useStore } from "jotai"; import { type FC, useEffect, useLayoutEffect } from "react"; @@ -225,6 +226,8 @@ const MusicQualityTagText: FC = () => { return null; }; +const TTML_LOG_TAG = chalk.bgHex("#FF5577").hex("#FFFFFF")(" TTML DB "); +const LYRIC_LOG_TAG = chalk.bgHex("#FF4444").hex("#FFFFFF")(" LYRIC "); const LyricContext: FC = () => { const musicId = useAtomValue(musicIdAtom); @@ -238,7 +241,7 @@ const LyricContext: FC = () => { useEffect(() => { const sig = new AbortController(); - console.log("同步 TTML DB 歌词库中"); + console.log(TTML_LOG_TAG, "同步 TTML DB 歌词库中"); (async () => { const fileListRes = await fetch( @@ -251,6 +254,7 @@ const LyricContext: FC = () => { if (fileListRes.status < 200 || fileListRes.status > 399) { console.warn( + TTML_LOG_TAG, "TTML DB 歌词库同步失败", fileListRes.status, fileListRes.statusText, @@ -268,12 +272,12 @@ const LyricContext: FC = () => { localFileList.add(obj.name); }); - console.log("本地已同步歌词数量", localFileList.size); - console.log("远程仓库歌词数量", remoteFileList.size); + console.log(TTML_LOG_TAG, "本地已同步歌词数量", localFileList.size); + console.log(TTML_LOG_TAG, "远程仓库歌词数量", remoteFileList.size); const shouldFetchList = remoteFileList.difference(localFileList); - console.log("需要下载的歌词数量", shouldFetchList.size); + console.log(TTML_LOG_TAG, "需要下载的歌词数量", shouldFetchList.size); let synced = 0; let errored = 0; @@ -315,6 +319,7 @@ const LyricContext: FC = () => { ); console.log( + TTML_LOG_TAG, "歌词同步完成,已同步 ", synced, " 首歌曲,有 ", @@ -335,32 +340,36 @@ const LyricContext: FC = () => { switch (song.lyricFormat) { case "lrc": { parsedLyricLines = parseLrc(song.lyric); - console.log("解析出 LyRiC 歌词", parsedLyricLines); + console.log(LYRIC_LOG_TAG, "解析出 LyRiC 歌词", parsedLyricLines); break; } case "eslrc": { parsedLyricLines = parseEslrc(song.lyric); - console.log("解析出 ESLyRiC 歌词", parsedLyricLines); + console.log(LYRIC_LOG_TAG, "解析出 ESLyRiC 歌词", parsedLyricLines); break; } case "yrc": { parsedLyricLines = parseYrc(song.lyric); - console.log("解析出 YRC 歌词", parsedLyricLines); + console.log(LYRIC_LOG_TAG, "解析出 YRC 歌词", parsedLyricLines); break; } case "qrc": { parsedLyricLines = parseQrc(song.lyric); - console.log("解析出 QRC 歌词", parsedLyricLines); + console.log(LYRIC_LOG_TAG, "解析出 QRC 歌词", parsedLyricLines); break; } case "lys": { parsedLyricLines = parseLys(song.lyric); - console.log("解析出 Lyricify Syllable 歌词", parsedLyricLines); + console.log( + LYRIC_LOG_TAG, + "解析出 Lyricify Syllable 歌词", + parsedLyricLines, + ); break; } case "ttml": { parsedLyricLines = parseTTML(song.lyric).lines; - console.log("解析出 TTML 歌词", parsedLyricLines); + console.log(LYRIC_LOG_TAG, "解析出 TTML 歌词", parsedLyricLines); break; } default: { @@ -375,9 +384,9 @@ const LyricContext: FC = () => { for (const line of translatedLyricLines) { pairLyric(line, parsedLyricLines, "translatedLyric"); } - console.log("已匹配翻译歌词"); + console.log(LYRIC_LOG_TAG, "已匹配翻译歌词"); } catch (err) { - console.warn("解析翻译歌词时出现错误", err); + console.warn(LYRIC_LOG_TAG, "解析翻译歌词时出现错误", err); } } if (song.romanLrc) { @@ -386,9 +395,9 @@ const LyricContext: FC = () => { for (const line of romanLyricLines) { pairLyric(line, parsedLyricLines, "romanLyric"); } - console.log("已匹配音译歌词"); + console.log(LYRIC_LOG_TAG, "已匹配音译歌词"); } catch (err) { - console.warn("解析音译歌词时出现错误", err); + console.warn(LYRIC_LOG_TAG, "解析音译歌词时出现错误", err); } } if (advanceLyricDynamicLyricTime) { @@ -613,7 +622,6 @@ export const LocalMusicContext: FC = () => { break; } case "playListChanged": { - console.log("已更新播放列表"); store.set(currentPlaylistAtom, evtData.data.playlist); store.set( currentPlaylistMusicIndexAtom, diff --git a/packages/player/src/components/PluginContext/index.tsx b/packages/player/src/components/PluginContext/index.tsx deleted file mode 100644 index fbfc6f352..000000000 --- a/packages/player/src/components/PluginContext/index.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import * as AMLLStates from "@applemusic-like-lyrics/react-full/states"; -import { fromObject, fromSource, removeComments } from "convert-source-map"; -import { useStore } from "jotai"; -import { type ComponentType, type FC, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { SourceMapConsumer, SourceMapGenerator } from "source-map-js"; -import { uid } from "uid"; -import i18n from "../../i18n"; -import type PluginEnv from "../../plugin-env"; -import * as PlayerStates from "../../states"; -import { - type LoadedPlugin, - PluginLoadResult, - type PluginMetaState, - loadedPluginsAtom, - pluginMetaAtom, -} from "../../states/plugin"; - -async function sourceMapOffsetLines( - code: string, - sourceRoot: string, - lineOffset: number, -): Promise<[string, string]> { - const incomingSourceConv = fromSource(code); - if (!incomingSourceConv) return [code, ""]; - const incomingSourceMap = incomingSourceConv.toObject(); - const consumer = await new SourceMapConsumer(incomingSourceMap); - const generator = new SourceMapGenerator({ - file: incomingSourceMap.file, - sourceRoot: sourceRoot, - }); - consumer.eachMapping((m) => { - // skip invalid (not-connected) mapping - // refs: https://github.com/mozilla/source-map/blob/182f4459415de309667845af2b05716fcf9c59ad/lib/source-map-generator.js#L268-L275 - if ( - typeof m.originalLine === "number" && - 0 < m.originalLine && - typeof m.originalColumn === "number" && - 0 <= m.originalColumn && - m.source - ) { - generator.addMapping({ - source: - m.source && - `${location.origin}/plugins/${sourceRoot}/${m.source.replace(/^(\.*\/)+/, "")}`, - name: m.name, - original: { line: m.originalLine, column: m.originalColumn }, - generated: { - line: m.generatedLine + lineOffset, - column: m.generatedColumn, - }, - }); - } - }); - const outgoingSourceMap = JSON.parse(generator.toString()); - if (typeof incomingSourceMap.sourcesContent !== "undefined") { - outgoingSourceMap.sourcesContent = incomingSourceMap.sourcesContent; - } - return [removeComments(code), fromObject(outgoingSourceMap).toComment()]; -} - -export class PlayerPluginContext - extends EventTarget - implements PluginEnv.PluginContext -{ - /** - * @internal - */ - settingComponent?: ComponentType; - constructor( - readonly playerStates: PluginEnv.PluginContext["playerStates"], - readonly amllStates: PluginEnv.PluginContext["amllStates"], - readonly i18n: PluginEnv.PluginContext["i18n"], - readonly jotaiStore: PluginEnv.PluginContext["jotaiStore"], - readonly pluginMeta: Readonly, - ) { - super(); - } - registerLocale(localeData: { [langId: string]: T }) { - for (const [lng, data] of Object.entries(localeData)) { - i18n.addResourceBundle(lng, this.pluginMeta.id, data); - } - } - registerSettingPage(settingComponent: ComponentType) { - this.settingComponent = settingComponent; - } - registerPlayerSource(_idPrefix: string) { - console.warn("Unimplemented"); - } -} - -export const PluginContext: FC = () => { - const { i18n } = useTranslation(); - const store = useStore(); - - useEffect(() => { - const loadedPlugins: LoadedPlugin[] = []; - const pluginsLoadPromise = (async () => { - const pluginMetas = await store.get(pluginMetaAtom); - - const pluginLoadedPromiseMap = new Map>(); - const AsyncFunction: FunctionConstructor = - // biome-ignore lint/complexity/useArrowFunction: 需要用来创建异步函数的构造函数 - Object.getPrototypeOf(async function () {}).constructor; - const amllStates = Object.fromEntries(Object.entries(AMLLStates)); - const playerStates = Object.fromEntries(Object.entries(PlayerStates)); - - async function waitForDependency(pluginId: string) { - if (pluginLoadedPromiseMap.has(pluginId)) { - await pluginLoadedPromiseMap.get(pluginId); - } else { - throw new Error(`Missing Dependency: ${pluginId}`); - } - } - - const pluginScripts: (() => Promise)[] = []; - - for (const pluginMeta of pluginMetas) { - if (pluginMeta.loadResult !== PluginLoadResult.Success) continue; - - const genFuncName = () => `__amll_internal_${uid()}`; - const resolveFuncName = genFuncName(); - const rejectFuncName = genFuncName(); - const waitForDependencyFuncName = genFuncName(); - const wrapperScript: string[] = []; - wrapperScript.push("try {"); - - for (const dependencyId of pluginMeta.dependency) { - wrapperScript.push( - `await ${waitForDependencyFuncName}(${JSON.stringify(dependencyId)})`, - ); - } - - let comment = ""; - const offsetLines = wrapperScript.length + 2; - - try { - // 修正源映射表的行数,方便调试 - const [code, sourceMapComment] = await sourceMapOffsetLines( - pluginMeta.scriptData, - pluginMeta.id, - offsetLines, - ); - wrapperScript.push(code); - comment = sourceMapComment; - } catch (err) { - console.log("无法转换源映射表,可能是插件并不包含源映射表", err); - wrapperScript.push(pluginMeta.scriptData); - } - - wrapperScript.push(`${resolveFuncName}();`); - wrapperScript.push("} catch (err) {"); - wrapperScript.push(`${rejectFuncName}(err);`); - wrapperScript.push("}"); - wrapperScript.push(comment); - - const loadedPluginPromise = new Promise((resolve, reject) => { - const context = new PlayerPluginContext( - Object.freeze(Object.assign({}, playerStates)), - Object.freeze(Object.assign({}, amllStates)), - i18n.cloneInstance({ - ns: pluginMeta.id, - }), - store, - pluginMeta, - ); - - const pluginFunc = new AsyncFunction( - "pluginContext", - resolveFuncName, - rejectFuncName, - waitForDependencyFuncName, - wrapperScript.join("\n"), - ).bind(context, context, resolve, reject, waitForDependency); - - pluginScripts.push(pluginFunc); - loadedPlugins.push({ - pluginMeta, - pluginFunc, - context, - }); - }); - pluginLoadedPromiseMap.set(pluginMeta.id, loadedPluginPromise); - } - - console.log("正在加载插件脚本,总计", pluginScripts.length, "个插件"); - - await Promise.all( - pluginScripts.map((v) => - v().catch((err) => console.warn("插件加载失败", err)), - ), - ); - - for (const plugin of loadedPlugins) { - plugin.context.dispatchEvent(new Event("plugin-load")); - } - store.set(loadedPluginsAtom, loadedPlugins); - })(); - return () => { - console.log("插件上下文已卸载,正在触发插件卸载事件"); - pluginsLoadPromise.then(() => { - for (const plugin of loadedPlugins) { - plugin.context.dispatchEvent(new Event("plugin-unload")); - } - }); - }; - }, [store, i18n]); - - return null; -}; diff --git a/packages/player/src/extension-env.d.ts b/packages/player/src/extension-env.d.ts new file mode 100644 index 000000000..25378a330 --- /dev/null +++ b/packages/player/src/extension-env.d.ts @@ -0,0 +1,159 @@ +import type * as amllStates from "@applemusic-like-lyrics/react-full"; +import type { i18n } from "i18next"; +import type { Atom, createStore } from "jotai"; +import type { ComponentType } from "react"; +import type { db } from "./dexie"; +import type * as playerStates from "./states"; + +export type * as RadixTheme from "@radix-ui/themes"; +export type * as Jotai from "jotai"; +export type * as React from "react"; +export type * as ReactDOM from "react-dom"; + +type PlayerStatesExports = typeof playerStates; +type AMLLStatesExports = typeof amllStates; +type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] }; + +declare type PlayerStates = OmitNever<{ + [K in keyof PlayerStatesExports]: PlayerStatesExports[K] extends Atom + ? PlayerStatesExports[K] + : never; +}>; +declare type AMLLStates = OmitNever<{ + [K in keyof AMLLStatesExports]: AMLLStatesExports[K] extends Atom + ? AMLLStatesExports[K] + : never; +}>; + +declare interface AnySongData { + type: string; +} + +declare interface LocalSongData extends AnySongData { + type: "local"; + filePath: string; +} + +declare interface NetworkSongData extends AnySongData { + type: "network"; + url: string; + headers?: Record; +} + +declare interface ExtensionContextEventMap { + /** + * 当所有扩展程序都完成了初步脚本加载的操作时触发 + * + * 此回调是扩展程序生命周期中第一个被调用的函数 + */ + "extension-load": Event; + /** + * 当扩展程序被卸载加载时触发,扩展程序作者可以在此销毁扩展程序资源 + * + * ~~其实考虑到播放器的实际情况可能这个事件永远不会被触发~~ + * + * 此回调是扩展程序生命周期中最后一个被调用的函数 + */ + "extension-unload": Event; +} + +/** + * 当前扩展程序的上下文结构 + */ +declare interface ExtensionContext extends EventTarget { + /** + * 扩展程序接口的版本号,会随着扩展接口更新而递增数字 + */ + extensionApiNumber: readonly number; + jotaiStore: ReturnType; + /** + * 将扩展程序的本地化字段数据注册到 AMLL Player 的国际化上下文中 + * 以此为扩展程序的文字提供国际化能力 + * @param localeData 本地化文字数据 + */ + registerLocale(localeData: { [langId: string]: T }): void; + /** + * 在任何记载过的需要过的组件注入点中注入自定义 React 组件 + * + * 必须在初始化阶段或事件 `extension-load` 触发时调用注册,否则组件将不能正确创建显示出来 + * + * 常用的注入点有如下: + * - `settings`: 扩展程序独有设置的区域 + * - `context`: 应用的上下文区域,与各种上下文同级 + * - `head`: 应用的 Head 元素区域,可以用于添加 `style` 样式 + * + * @param injectPointName 需要注入到的注入点名称 + * @param injectComponent 需要注入的 React 组件类 + */ + registerComponent( + injectPointName: string, + injectComponent: ComponentType, + ): void; + /** + * 注册一个音频源 + * + * 当 AMLL Player 切换到扩展程序提供的歌曲源后,将会让出播放控制权给扩展程序 + * + * 届时扩展程序可以自由控制设置播放状态 + * + * @param idPrefix 音频 ID 的前缀,作为识别扩展程序歌曲源的唯一标识 + */ + registerPlayerSource(idPrefix: string): void; + /** + * 播放器本身的各个状态,考虑到数据竞争问题,在文档注释中没有明确提及可以修改的内容和时机时不建议直接修改状态的值 + */ + playerStates: Readonly; + /** + * 歌词组件的各个状态,任何时候均可读取,但只建议在当前正在播放扩展程序提供的歌曲源时设置其状态 + */ + amllStates: Readonly; + /** + * 开箱即用的歌词解析模块,详情可参考 `@applemusic-like-lyrics/lyric` + */ + lyric: import("@applemusic-like-lyrics/lyric"); + /** + * 播放器的数据库对象,内有存储播放列表,歌曲曲目,TTML DB 歌词等数据 + */ + playerDB: typeof db; + /** + * 用于本地化扩展程序显示内容的工具 + */ + i18n: i18n; + + addEventListener( + type: T, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void; + + removeEventListener( + type: T, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void; +} + +export type { + AMLLStates, + AnySongData, + ExtensionContext, + ExtensionContextEventMap, + LocalSongData, + NetworkSongData, + PlayerStates, +}; + +declare global { + namespace globalThis { + /** + * 当前扩展程序的上下文,在扩展程序的整个生命周期中都可以访问,同时也是整个扩展程序的 `this` 属性 + */ + declare const extensionContext: Readonly; + + // AMLL Player 暴露的常用模块 + declare const React: typeof import("react"); + declare const ReactDOM: typeof import("react-dom"); + declare const Jotai: typeof import("jotai"); + declare const RadixTheme: typeof import("@radix-ui/themes"); + } +} diff --git a/packages/player/src/pages/main/index.tsx b/packages/player/src/pages/main/index.tsx index a56b696b8..831a075e0 100644 --- a/packages/player/src/pages/main/index.tsx +++ b/packages/player/src/pages/main/index.tsx @@ -17,6 +17,7 @@ import { useAtomValue } from "jotai"; import type { FC } from "react"; import { Trans } from "react-i18next"; import { Link } from "react-router-dom"; +import { ExtensionInjectPoint } from "../../components/ExtensionInjectPoint"; import { NewPlaylistButton } from "../../components/NewPlaylistButton"; import { PlaylistCover } from "../../components/PlaylistCover"; import { db } from "../../dexie"; @@ -54,6 +55,7 @@ export const MainPage: FC = () => { + @@ -62,6 +64,7 @@ export const MainPage: FC = () => { + @@ -87,11 +90,14 @@ export const MainPage: FC = () => { 设置 + + + {playlists !== undefined ? ( playlists.length === 0 ? ( @@ -137,6 +143,7 @@ export const MainPage: FC = () => { 加载歌单中 )} + ); }; diff --git a/packages/player/src/pages/settings/plugin-manage.svg b/packages/player/src/pages/settings/extension-manage.svg similarity index 100% rename from packages/player/src/pages/settings/plugin-manage.svg rename to packages/player/src/pages/settings/extension-manage.svg diff --git a/packages/player/src/pages/settings/plugin.tsx b/packages/player/src/pages/settings/extension.tsx similarity index 58% rename from packages/player/src/pages/settings/plugin.tsx rename to packages/player/src/pages/settings/extension.tsx index ab784c67b..4b9274c07 100644 --- a/packages/player/src/pages/settings/plugin.tsx +++ b/packages/player/src/pages/settings/extension.tsx @@ -19,18 +19,18 @@ import { atom, useAtomValue, useStore } from "jotai"; import type { FC } from "react"; import { Trans, useTranslation } from "react-i18next"; import { - PluginLoadResult, - pluginDirAtom, - pluginMetaAtom, -} from "../../states/plugin"; + ExtensionLoadResult, + extensionDirAtom, + extensionMetaAtom, +} from "../../states/extension"; import { restartApp } from "../../utils/player"; const requireRestartAtom = atom(false); -export const PluginTab: FC = () => { +export const ExtensionTab: FC = () => { const store = useStore(); const { t } = useTranslation("translation"); - const pluginMetas = useAtomValue(pluginMetaAtom); + const extensionMetas = useAtomValue(extensionMetaAtom); const requireRestart = useAtomValue(requireRestartAtom); return ( @@ -40,8 +40,8 @@ export const PluginTab: FC = () => { - - 插件将可以访问并操作你的所有数据,包括你的歌单、播放信息等数据,请务必确保插件来源可靠安全,并只安装你信任的插件!作者不承担使用任何插件后产生的一切后果! + + 扩展程序将可以访问并操作你的所有数据,包括你的歌单、播放信息等数据,请务必确保扩展程序来源可靠安全,并只安装你信任的扩展程序!作者不承担使用任何扩展程序后产生的一切后果! @@ -50,8 +50,8 @@ export const PluginTab: FC = () => { - - 插件接口功能仍在开发中,其插件接口有可能随时变更,敬请留意! + + 扩展程序接口功能仍在开发中,其扩展程序接口有可能随时变更,敬请留意! @@ -59,11 +59,11 @@ export const PluginTab: FC = () => { - {pluginMetas.map((meta) => ( + {extensionMetas.map((meta) => ( { color: "white", }} /> - {meta.loadResult === PluginLoadResult.Success && ( + {meta.loadResult === ExtensionLoadResult.Loadable && ( {t("name", meta.id, { ns: meta.id })} {meta.id} )} - {meta.loadResult === PluginLoadResult.Disabled && ( + {meta.loadResult === ExtensionLoadResult.Disabled && ( {t("name", meta.id, { ns: meta.id })} {meta.id} )} - {meta.loadResult === PluginLoadResult.InvaildPluginFile && ( + {meta.loadResult === ExtensionLoadResult.InvaildExtensionFile && ( {meta.fileName} - - 无效插件文件 + + 无效扩展程序文件 )} - {meta.loadResult === PluginLoadResult.MissingDependency && ( + {meta.loadResult === ExtensionLoadResult.MissingDependency && ( {meta.fileName} - + 缺失依赖项 )} - {meta.loadResult === PluginLoadResult.MissingMetadata && ( + {meta.loadResult === ExtensionLoadResult.MissingMetadata && ( {meta.fileName} - + 缺失必需元数据 )} - {meta.loadResult === PluginLoadResult.PluginIdConflict && ( + {meta.loadResult === ExtensionLoadResult.ExtensionIdConflict && ( {meta.id} - - 插件 ID 冲突 + + 扩展程序 ID 冲突 @@ -200,32 +200,38 @@ export const PluginTab: FC = () => { { - const pluginDir = await store.get(pluginDirAtom); - const pluginPath = await path.join(pluginDir, meta.fileName); - if (pluginPath.endsWith(".disabled")) { + const extensionDir = await store.get(extensionDirAtom); + const extensionPath = await path.join( + extensionDir, + meta.fileName, + ); + if (extensionPath.endsWith(".disabled")) { await rename( - pluginPath, - pluginPath.substring(0, pluginPath.length - 9), + extensionPath, + extensionPath.substring(0, extensionPath.length - 9), ); } else { - await rename(pluginPath, `${pluginPath}.disabled`); + await rename(extensionPath, `${extensionPath}.disabled`); } - store.set(pluginMetaAtom); + store.set(extensionMetaAtom); store.set(requireRestartAtom, true); }} /> { - const pluginDir = await store.get(pluginDirAtom); - const pluginPath = await path.join(pluginDir, meta.fileName); - await remove(pluginPath); - store.set(pluginMetaAtom); + const extensionDir = await store.get(extensionDirAtom); + const extensionPath = await path.join( + extensionDir, + meta.fileName, + ); + await remove(extensionPath); + store.set(extensionMetaAtom); store.set(requireRestartAtom, true); }} > diff --git a/packages/player/src/pages/settings/index.tsx b/packages/player/src/pages/settings/index.tsx index 7a3d751c8..e52d4d38e 100644 --- a/packages/player/src/pages/settings/index.tsx +++ b/packages/player/src/pages/settings/index.tsx @@ -11,12 +11,12 @@ import { import { atom, useAtom, useAtomValue } from "jotai"; import { type FC, Suspense } from "react"; import { useTranslation } from "react-i18next"; -import { loadedPluginsAtom } from "../../states/plugin"; +import { loadedExtensionAtom } from "../../states/extension"; import AMLLPlayerSettingIcon from "./amll-player-setting.svg?react"; +import { ExtensionTab } from "./extension"; +import ExtensionManageIcon from "./extension-manage.svg?react"; import styles from "./index.module.css"; import { PlayerSettingsTab } from "./player"; -import { PluginTab } from "./plugin"; -import PluginManageIcon from "./plugin-manage.svg?react"; const currentPageAtom = atom("amll-player"); @@ -30,14 +30,16 @@ const TabButton: FC = ({ children, content, ...props }) => { ); }; -const loadedPluginsWithSettingsAtom = atom((get) => { - const loadedPlugins = get(loadedPluginsAtom); - return loadedPlugins.filter((v) => v.context.settingComponent); +const loadedExtensionsWithSettingsAtom = atom((get) => { + const loadedExtensions = get(loadedExtensionAtom); + return loadedExtensions.filter( + (v) => v.context.registeredInjectPointComponent.settings, + ); }); export const SettingsPage: FC = () => { const [currentPage, setCurrentPage] = useAtom(currentPageAtom); - const loadedPlugins = useAtomValue(loadedPluginsWithSettingsAtom); + const loadedExtensions = useAtomValue(loadedExtensionsWithSettingsAtom); const { t } = useTranslation(); return ( @@ -66,39 +68,41 @@ export const SettingsPage: FC = () => { setCurrentPage("plugins")} + content={t("settings.extension.tab", "扩展程序管理")} + color={currentPage === "extension" ? "indigo" : "gray"} + onClick={() => setCurrentPage("extension")} > - + - {loadedPlugins.map((plugin) => { - const id = plugin.pluginMeta.id; + {loadedExtensions.map((extension) => { + const id = extension.extensionMeta.id; return ( setCurrentPage(`plugin.${id}`)} + color={currentPage === `extension.${id}` ? "indigo" : "gray"} + onClick={() => setCurrentPage(`extension.${id}`)} > - + ); })} {currentPage === "amll-player" && } - {currentPage === "plugins" && ( + {currentPage === "extension" && ( - + )} - {loadedPlugins.map((plugin) => { - const id = plugin.pluginMeta.id; - const SettingComponent = plugin.context.settingComponent!; + {loadedExtensions.map((extension) => { + const id = extension.extensionMeta.id; + const ExtensionComponent = + extension.context.registeredInjectPointComponent.settings; return ( - currentPage === `plugin.${id}` && + currentPage === `extension.${id}` && + ExtensionComponent && ); })} diff --git a/packages/player/src/pages/song/index.tsx b/packages/player/src/pages/song/index.tsx index b91b14c85..edd00e23b 100644 --- a/packages/player/src/pages/song/index.tsx +++ b/packages/player/src/pages/song/index.tsx @@ -12,6 +12,7 @@ import { useLiveQuery } from "dexie-react-hooks"; import { type FC, useContext } from "react"; import { Trans } from "react-i18next"; import { useParams } from "react-router-dom"; +import { ExtensionInjectPoint } from "../../components/ExtensionInjectPoint"; import { db } from "../../dexie"; import { useSongCover } from "../../utils/use-song-cover"; import { BasicTabContent } from "./basic"; @@ -62,6 +63,7 @@ export const SongPage: FC = () => { + 基本 @@ -71,8 +73,10 @@ export const SongPage: FC = () => { 歌词 + + @@ -82,6 +86,7 @@ export const SongPage: FC = () => { + diff --git a/packages/player/src/pages/song/lyric.tsx b/packages/player/src/pages/song/lyric.tsx index ea71bcbec..0cfb34690 100644 --- a/packages/player/src/pages/song/lyric.tsx +++ b/packages/player/src/pages/song/lyric.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { ExtensionInjectPoint } from "../../components/ExtensionInjectPoint"; import { TTMLImportDialog } from "../../components/TTMLImportDialog"; import { db } from "../../dexie"; import { Option } from "./common"; @@ -83,6 +84,7 @@ export const LyricTabContent: FC = () => { return ( <> +