fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork) - 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
5
amll-local/packages/docs/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# AMLL Docs
|
||||
|
||||
AMLL 框架的开发文档页面,使用 Astro + React + MDX 编写并部署到 Github Pages。
|
||||
|
||||
[查看文档](https://steve-xmh.github.io/applemusic-like-lyrics/zh-CN)
|
||||
181
amll-local/packages/docs/astro.config.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import react from "@astrojs/react";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlightSidebarTopics from "starlight-sidebar-topics";
|
||||
import { generateTypedocDocs } from "./src/scripts/typedoc";
|
||||
|
||||
const docsSidebar = [
|
||||
{
|
||||
label: "概览",
|
||||
translations: { en: "Overview" },
|
||||
items: [{ slug: "guides/overview/intro" }, { slug: "guides/overview/eco" }],
|
||||
},
|
||||
{
|
||||
label: "歌词组件",
|
||||
translations: { en: "Lyric Component" },
|
||||
items: [
|
||||
{ slug: "guides/component/quickstart" },
|
||||
{ slug: "guides/component/sequence" },
|
||||
{ slug: "guides/component/background" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "歌词格式",
|
||||
translations: { en: "Lyric Formats" },
|
||||
items: [
|
||||
{ slug: "guides/lyric/quickstart" },
|
||||
{ slug: "guides/lyric/formats" },
|
||||
{ slug: "guides/lyric/ttml" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const referenceSidebar = [
|
||||
{
|
||||
label: "Core 核心",
|
||||
translations: { en: "Core" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/core", collapsed: true } }],
|
||||
},
|
||||
{
|
||||
label: "React 绑定",
|
||||
translations: { en: "React Bindings" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/react", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "React Full 组件库",
|
||||
translations: { en: "React Full Components" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/react-full", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vue 绑定",
|
||||
translations: { en: "Vue Bindings" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/vue", collapsed: true } }],
|
||||
},
|
||||
{
|
||||
label: "Lyric 歌词处理",
|
||||
translations: { en: "Lyric Processing" },
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ autogenerate: { directory: "reference/lyric", collapsed: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "TTML 歌词处理",
|
||||
translations: { en: "TTML Processing" },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: "reference/ttml", collapsed: true } }],
|
||||
},
|
||||
];
|
||||
|
||||
const contributeSidebar = [
|
||||
{
|
||||
label: "开发指南",
|
||||
translations: { en: "Development" },
|
||||
items: [
|
||||
{ slug: "contribute/development/environments" },
|
||||
{ slug: "contribute/development/structure" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "仓库规范",
|
||||
translations: { en: "Repository Guidelines" },
|
||||
items: [
|
||||
{ slug: "contribute/guidelines/pr" },
|
||||
{ slug: "contribute/guidelines/publishing" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://amll.dev",
|
||||
trailingSlash: "never",
|
||||
build: {
|
||||
format: "file",
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
starlight({
|
||||
favicon: "favicon.ico",
|
||||
title: "AMLL Docs",
|
||||
logo: {
|
||||
src: "./src/assets/amll-logo.svg",
|
||||
alt: "AMLL",
|
||||
},
|
||||
customCss: [
|
||||
"./src/styles/consts.css",
|
||||
"./src/styles/frame.css",
|
||||
"./src/styles/content.css",
|
||||
],
|
||||
expressiveCode: {
|
||||
themes: ["github-dark", "github-light"],
|
||||
useStarlightDarkModeSwitch: true,
|
||||
useStarlightUiThemeColors: false,
|
||||
},
|
||||
locales: {
|
||||
root: { label: "简体中文", lang: "zh-CN" },
|
||||
en: { label: "English", lang: "en" },
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/amll-dev/applemusic-like-lyrics",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
starlightSidebarTopics([
|
||||
{
|
||||
id: "docs",
|
||||
label: { "zh-CN": "使用文档", en: "Guides" },
|
||||
link: "/guides",
|
||||
icon: "open-book",
|
||||
items: docsSidebar,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
label: { "zh-CN": "API 参考", en: "API Reference" },
|
||||
link: "/reference",
|
||||
icon: "information",
|
||||
items: referenceSidebar,
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: { "zh-CN": "试验场", en: "Playground" },
|
||||
link: "/playground",
|
||||
icon: "puzzle",
|
||||
items: docsSidebar,
|
||||
},
|
||||
{
|
||||
id: "contribute",
|
||||
label: { "zh-CN": "贡献指南", en: "Contributing" },
|
||||
link: "/contribute",
|
||||
icon: "code-branch",
|
||||
items: contributeSidebar,
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: "typedoc",
|
||||
hooks: {
|
||||
"config:setup": async (cfg) => {
|
||||
cfg.logger.info("Generating typedoc...");
|
||||
await generateTypedocDocs(cfg.logger);
|
||||
cfg.logger.info("Finished typedoc generation");
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl:
|
||||
"https://github.com/amll-dev/applemusic-like-lyrics/blob/main/packages/docs/",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
36
amll-local/packages/docs/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@applemusic-like-lyrics/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"build:full": "run-s build:full:projects build:full:copy-playground",
|
||||
"build:full:projects": "cross-env PLAYGROUND_BASE_URL=/playground/ nx run-many --target=build --projects=docs,playground-core",
|
||||
"build:full:copy-playground": "node ./scripts/copy-playground.js",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"imports": {
|
||||
"#components/*": "./src/components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "workspace:^",
|
||||
"@applemusic-like-lyrics/react": "workspace:^",
|
||||
"@astrojs/react": "^5.0.5",
|
||||
"@astrojs/starlight": "^0.39.2",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"astro": "^6.3.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"starlight-sidebar-topics": "^0.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"typedoc": "catalog:",
|
||||
"typedoc-plugin-mark-react-functional-components": "^0.2.2",
|
||||
"typedoc-plugin-markdown": "catalog:",
|
||||
"typedoc-plugin-vue": "^1.5.1"
|
||||
}
|
||||
}
|
||||
BIN
amll-local/packages/docs/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 22 KiB |
5
amll-local/packages/docs/scripts/copy-playground.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
fs.rmSync("dist/playground", { recursive: true, force: true });
|
||||
fs.cpSync("../playground/core/dist", "dist/playground", { recursive: true });
|
||||
console.log("Playground copied to dist/playground");
|
||||
159
amll-local/packages/docs/src/assets/amll-logo.svg
Normal file
@@ -0,0 +1,159 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5_32)">
|
||||
<rect width="64" height="64" fill="url(#paint0_linear_5_32)"/>
|
||||
<g filter="url(#filter0_dii_5_32)">
|
||||
<path d="M29.8252 23.9748C29.8252 24.2495 29.7467 24.4845 29.5896 24.6799C29.4326 24.8754 29.2231 24.9995 28.9614 25.0523L22.1288 26.5576C21.8042 26.6316 21.5921 26.7266 21.4927 26.8428C21.3932 26.959 21.3435 27.2126 21.3435 27.6034V41.3098C21.3435 42.3873 21.1681 43.309 20.8173 44.0748C20.4665 44.8407 20.0084 45.4692 19.4429 45.9605C18.8774 46.4517 18.2727 46.8108 17.6287 47.038C16.9848 47.2651 16.3748 47.3786 15.7989 47.3786C14.5318 47.3786 13.4978 47.0063 12.6967 46.2615C11.8957 45.5168 11.4951 44.5581 11.4951 43.3856C11.4951 42.213 11.859 41.2438 12.5868 40.4779C13.3145 39.712 14.4324 39.1707 15.9402 38.8537L18.312 38.3467C18.9507 38.2199 19.2387 37.8027 19.1759 37.0949L19.1445 20.4413C19.1445 19.575 19.6576 19.0257 20.6838 18.7933L28.3331 17.1137C28.7415 17.0397 29.0923 17.1216 29.3854 17.3593C29.6786 17.597 29.8252 17.9271 29.8252 18.3496V23.9748Z" fill="url(#paint1_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_dii_5_32)">
|
||||
<path d="M36.5359 17.3981C35.4894 17.3981 34.9662 17.3981 34.5851 17.6399C34.4886 17.7012 34.3994 17.7727 34.3191 17.853C34.0379 18.1342 33.8641 18.5225 33.8641 18.9515C33.8641 19.3804 34.0379 19.7688 34.3191 20.0499C34.3994 20.1302 34.4886 20.2018 34.5851 20.263C34.9662 20.5049 35.4894 20.5049 36.5359 20.5049H48.5903C49.6368 20.5049 50.16 20.5049 50.5411 20.263C50.6376 20.2018 50.7268 20.1302 50.8072 20.0499C51.0883 19.7688 51.2621 19.3804 51.2621 18.9515C51.2621 18.5225 51.0883 18.1342 50.8072 17.853C50.7268 17.7727 50.6376 17.7012 50.5411 17.6399C50.16 17.3981 49.6368 17.3981 48.5903 17.3981H36.5359Z" fill="url(#paint2_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_dii_5_32)">
|
||||
<path d="M36.5359 26.0971C35.4894 26.0971 34.9662 26.0971 34.5851 26.3389C34.4886 26.4002 34.3994 26.4717 34.3191 26.5521C34.0379 26.8332 33.8641 27.2215 33.8641 27.6505C33.8641 28.0794 34.0379 28.4678 34.3191 28.7489C34.3994 28.8293 34.4886 28.9008 34.5851 28.9621C34.9662 29.2039 35.4894 29.2039 36.5359 29.2039H48.5903C49.6368 29.2039 50.16 29.2039 50.5411 28.9621C50.6376 28.9008 50.7268 28.8293 50.8072 28.7489C51.0883 28.4678 51.2621 28.0794 51.2621 27.6505C51.2621 27.2215 51.0883 26.8332 50.8072 26.5521C50.7268 26.4717 50.6376 26.4002 50.5411 26.3389C50.16 26.0971 49.6368 26.0971 48.5903 26.0971H36.5359Z" fill="url(#paint3_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_dii_5_32)">
|
||||
<path d="M36.5359 34.7961C35.4894 34.7961 34.9662 34.7961 34.5851 35.0379C34.4886 35.0992 34.3994 35.1707 34.3191 35.2511C34.0379 35.5322 33.8641 35.9206 33.8641 36.3495C33.8641 36.7785 34.0379 37.1668 34.3191 37.4479C34.3994 37.5283 34.4886 37.5998 34.5851 37.6611C34.9662 37.9029 35.4894 37.9029 36.5359 37.9029H48.5903C49.6368 37.9029 50.16 37.9029 50.5411 37.6611C50.6376 37.5998 50.7268 37.5283 50.8072 37.4479C51.0883 37.1668 51.2621 36.7785 51.2621 36.3495C51.2621 35.9206 51.0883 35.5322 50.8072 35.2511C50.7268 35.1707 50.6376 35.0992 50.5411 35.0379C50.16 34.7961 49.6368 34.7961 48.5903 34.7961H36.5359Z" fill="url(#paint4_linear_5_32)"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_dii_5_32)">
|
||||
<path d="M36.5359 43.4951C35.4894 43.4951 34.9662 43.4951 34.5851 43.737C34.4886 43.7982 34.3994 43.8698 34.3191 43.9501C34.0379 44.2312 33.8641 44.6196 33.8641 45.0485C33.8641 45.4775 34.0379 45.8659 34.3191 46.147C34.3994 46.2273 34.4886 46.2989 34.5851 46.3601C34.9662 46.6019 35.4894 46.6019 36.5359 46.6019H48.5903C49.6368 46.6019 50.16 46.6019 50.5411 46.3601C50.6376 46.2989 50.7268 46.2273 50.8072 46.147C51.0883 45.8659 51.2621 45.4775 51.2621 45.0485C51.2621 44.6196 51.0883 44.2312 50.8072 43.9501C50.7268 43.8698 50.6376 43.7982 50.5411 43.737C50.16 43.4951 49.6368 43.4951 48.5903 43.4951H36.5359Z" fill="url(#paint5_linear_5_32)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dii_5_32" x="9.01515" y="15.8474" width="23.2901" height="35.2513" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter1_dii_5_32" x="31.3841" y="16.1581" width="22.3581" height="8.06681" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter2_dii_5_32" x="31.3841" y="24.8571" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter3_dii_5_32" x="31.3841" y="33.5561" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<filter id="filter4_dii_5_32" x="31.3841" y="42.2551" width="22.3581" height="8.06678" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.24"/>
|
||||
<feGaussianBlur stdDeviation="1.24"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_5_32"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_32" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.155"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.531765 0 0 0 0 0.00148115 0 0 0 0 0.00148115 0 0 0 0.25 0"/>
|
||||
<feBlend mode="plus-darker" in2="shape" result="effect2_innerShadow_5_32"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.465"/>
|
||||
<feGaussianBlur stdDeviation="0.2325"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.111024 0 0 0 0 0.111024 0 0 0 0.18 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_5_32" result="effect3_innerShadow_5_32"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5_32" x1="34.375" y1="1.26961e-06" x2="34.375" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F96868"/>
|
||||
<stop offset="1" stop-color="#FF4040"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5_32" x1="20.6602" y1="17.0874" x2="20.6602" y2="47.3786" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_5_32" x1="42.5631" y1="17.3981" x2="42.5631" y2="20.5049" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_5_32" x1="42.5631" y1="26.0971" x2="42.5631" y2="29.2039" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_5_32" x1="42.5631" y1="34.7961" x2="42.5631" y2="37.9029" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_5_32" x1="42.5631" y1="43.4951" x2="42.5631" y2="46.6019" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FEFEFE"/>
|
||||
<stop offset="1" stop-color="#ECECEC"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5_32">
|
||||
<rect width="64" height="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
161
amll-local/packages/docs/src/components/AMLLPreview/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const buildLyricLines = (
|
||||
lyric: string,
|
||||
startTime: number,
|
||||
otherParams: Partial<LyricLine> = {},
|
||||
): LyricLine => {
|
||||
let curTime = startTime;
|
||||
const words = [];
|
||||
for (const word of lyric.split("|")) {
|
||||
const [text, duration] = word.split(",");
|
||||
const endTime = curTime + Number(duration);
|
||||
words.push({
|
||||
word: text,
|
||||
startTime: curTime,
|
||||
endTime,
|
||||
obscene: false,
|
||||
});
|
||||
curTime = endTime;
|
||||
}
|
||||
return {
|
||||
startTime,
|
||||
endTime: curTime + 500,
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
words,
|
||||
...otherParams,
|
||||
};
|
||||
};
|
||||
|
||||
const DEMO_LYRICS: LyricLine[][] = [
|
||||
[
|
||||
buildLyricLines(
|
||||
"Apple ,350|Music ,300|Like ,300|Ly,500|ri,900|cs ,250",
|
||||
2000,
|
||||
{ translatedLyric: "类苹果歌词" },
|
||||
),
|
||||
// A lyric component library for the web
|
||||
buildLyricLines(
|
||||
"A ,200|ly,100|ric ,250|com,200|po,200|nent ,200|li,100|bra,200|ry ,100|for ,100|the ,200|web ,600",
|
||||
5000,
|
||||
{ translatedLyric: "为 Web 而生的歌词组件库" },
|
||||
),
|
||||
// Brought to you with
|
||||
buildLyricLines("Brought ,300|to ,250|you ,800|with ,600", 8000, {
|
||||
translatedLyric: "为你带来",
|
||||
}),
|
||||
// Background Lyric Line
|
||||
buildLyricLines("Background ,750|lyric ,300|line ,500", 8500, {
|
||||
translatedLyric: "背景歌词行",
|
||||
isBG: true,
|
||||
}),
|
||||
// And Duet Lyric Line
|
||||
buildLyricLines("And ,300|Duet ,300|lyric ,500|line ,650", 10500, {
|
||||
translatedLyric: "还有对唱歌词行",
|
||||
isDuet: true,
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
export const AMLLPreview = () => {
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const wRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let selectedDemo = DEMO_LYRICS.length - 1;
|
||||
let endTime = 0;
|
||||
let startTime = 0;
|
||||
let canceled = false;
|
||||
|
||||
const onFrame = (time: number) => {
|
||||
if (canceled) return;
|
||||
if (time - startTime > endTime) {
|
||||
const w = wRef.current;
|
||||
if (!w) {
|
||||
if (canceled) return;
|
||||
return;
|
||||
}
|
||||
if (canceled) return;
|
||||
|
||||
setCurrentTime(0);
|
||||
|
||||
w.animate(
|
||||
{
|
||||
opacity: 0,
|
||||
filter: "blur(10px)",
|
||||
},
|
||||
{
|
||||
duration: 500,
|
||||
easing: "ease-in-out",
|
||||
fill: "forwards",
|
||||
},
|
||||
).onfinish = () => {
|
||||
if (canceled) return;
|
||||
|
||||
selectedDemo = (selectedDemo + 1) % DEMO_LYRICS.length;
|
||||
setLyricLines(JSON.parse(JSON.stringify(DEMO_LYRICS[selectedDemo])));
|
||||
endTime = DEMO_LYRICS[selectedDemo].reduce(
|
||||
(acc, v) => Math.max(acc, v.endTime),
|
||||
0,
|
||||
);
|
||||
startTime = time;
|
||||
|
||||
setTimeout(() => {
|
||||
if (canceled) return;
|
||||
w.animate(
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
},
|
||||
{
|
||||
duration: 300,
|
||||
easing: "ease-in-out",
|
||||
fill: "forwards",
|
||||
},
|
||||
).onfinish = () => {
|
||||
if (canceled) return;
|
||||
requestAnimationFrame(onFrame);
|
||||
};
|
||||
}, 600);
|
||||
};
|
||||
} else {
|
||||
setCurrentTime((time - startTime) | 0);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(onFrame);
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, white 5%, white 95%, transparent 100%)",
|
||||
transition: "opacity 0.5s, filter 0.5s",
|
||||
}}
|
||||
ref={wRef}
|
||||
>
|
||||
<LyricPlayer
|
||||
currentTime={currentTime}
|
||||
lyricLines={lyricLines}
|
||||
alignAnchor="top"
|
||||
alignPosition={0.05}
|
||||
style={{
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
amll-local/packages/docs/src/components/PackageInstall.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import { Code, TabItem, Tabs } from "@astrojs/starlight/components";
|
||||
|
||||
interface Props {
|
||||
packages: string | string[];
|
||||
dev?: boolean;
|
||||
syncKey?: string;
|
||||
}
|
||||
|
||||
const { packages, dev = false, syncKey = "pkg" } = Astro.props;
|
||||
const packageList = Array.isArray(packages)
|
||||
? packages
|
||||
: packages.split(/\s+/).filter(Boolean);
|
||||
const packageNames = packageList.join(" ");
|
||||
const devFlag = dev ? " -D" : "";
|
||||
|
||||
const commands = [
|
||||
{ label: "npm", command: `npm install${devFlag} ${packageNames}` },
|
||||
{ label: "pnpm", command: `pnpm add${devFlag} ${packageNames}` },
|
||||
{ label: "Yarn", command: `yarn add${devFlag} ${packageNames}` },
|
||||
];
|
||||
---
|
||||
|
||||
<Tabs syncKey={syncKey}>
|
||||
{commands.map(({ label, command }) => (
|
||||
<TabItem label={label}>
|
||||
<Code code={command} lang="sh" />
|
||||
</TabItem>
|
||||
))}
|
||||
</Tabs>
|
||||
7
amll-local/packages/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from "astro:content";
|
||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 开发环境配置
|
||||
---
|
||||
|
||||
## 必要环境
|
||||
|
||||
- pnpm([官网](https://pnpm.io/)),建议版本与仓库 `package.json` 中 `packageManager` 一致(当前为 `pnpm@11.1.0`)
|
||||
|
||||
本仓库默认使用 `pnpm nx ...` 执行 Nx 命令,不要求全局安装 Nx。为了本地便利开发,你也可以全局安装 Nx,效果是相同的。
|
||||
|
||||
Node.js 仅在 npm 发布相关 CI 步骤中作为运行时使用(当前发布工作流为 Node 24)。
|
||||
|
||||
### 版本自查
|
||||
|
||||
```bash
|
||||
pnpm --version
|
||||
nx --version # 可选
|
||||
```
|
||||
|
||||
## 首次初始化
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
完成后,执行一次构建所有包:`pnpm run build:libs`,若成功构建完成说明环境无误,可以开始工作。
|
||||
|
||||
### 依赖安装慢或失败
|
||||
|
||||
优先确认 pnpm 版本与锁文件一致,再重试:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 仓库结构介绍
|
||||
---
|
||||
|
||||
import { FileTree } from "@astrojs/starlight/components";
|
||||
|
||||
## Monorepo 结构
|
||||
|
||||
仓库使用 `Nx` 管理任务编排,`pnpm workspace` 管理依赖,主要目录如下:
|
||||
|
||||
<FileTree>
|
||||
|
||||
- packages/
|
||||
- core/ 核心组件,Vanilla JS 实现
|
||||
- react/ 核心组件的 React 绑定
|
||||
- vue/ 核心组件的 Vue 绑定
|
||||
- react-full/ 可直接使用的完整播放器组件,仅 React 版本
|
||||
- ttml/ TTML 解析与处理库
|
||||
- lyric/ 歌词解析与处理库,TTML 实际依赖 ttml 包
|
||||
- docs/ Astro + Starlight 文档站
|
||||
- playground/ 用于开发和测试的示例项目
|
||||
- core/ 使用核心组件的示例
|
||||
- react/ 使用 React 绑定的示例
|
||||
- vue/ 使用 Vue 绑定的示例
|
||||
- react-full/ 使用完整播放器组件的示例
|
||||
- .github/
|
||||
- workflows/
|
||||
- scripts/
|
||||
|
||||
</FileTree>
|
||||
|
||||
## Nx 发布分组
|
||||
|
||||
`nx.json` 中的发布配置包含两个主要 group:
|
||||
|
||||
- `core-bundle` 三个包版本保持一致:
|
||||
- `@applemusic-like-lyrics/core`
|
||||
- `@applemusic-like-lyrics/react`
|
||||
- `@applemusic-like-lyrics/vue`
|
||||
- 其余包版本各自独立:
|
||||
- `@applemusic-like-lyrics/lyric`
|
||||
- `@applemusic-like-lyrics/react-full`
|
||||
- `@applemusic-like-lyrics/ttml`
|
||||
|
||||
## 依赖关系
|
||||
|
||||
你可以在终端执行:
|
||||
|
||||
```sh
|
||||
pnpm nx graph
|
||||
```
|
||||
|
||||
来查看完整的依赖关系图。
|
||||
|
||||
在提升版本号时,如果被依赖的包发生了更新,则依赖它的包也会被自动提升版本号,即使代码没有改动。
|
||||
|
||||
## 命令行
|
||||
|
||||
### 库开发
|
||||
|
||||
Nx 提供了便捷的命令行工具,无需输入 `@applemusic-like-lyrics/` 前缀也可执行命令。
|
||||
|
||||
例如,要构建 `@applemusic-like-lyrics/react` 包,可以直接执行:
|
||||
|
||||
```sh
|
||||
pnpm nx build react
|
||||
```
|
||||
|
||||
Nx 会自动解析依赖,因此会先构建 `core` 包,然后构建 `react` 包。
|
||||
|
||||
我们在 `package/playground` 中准备了用于测试的 web app,基于 Vite,具有 HMR 支持。我们从 `core`、`react`、`vue`、`react-full` 中添加了转发,因此要启动对应环境,只需要:
|
||||
|
||||
```sh
|
||||
# 启动 package/playground/core 的开发服务器
|
||||
pnpm nx dev core
|
||||
|
||||
# 下面同理
|
||||
pnpm nx dev react
|
||||
pnpm nx dev vue
|
||||
pnpm nx dev react-full
|
||||
```
|
||||
|
||||
要构建全部包,执行:
|
||||
|
||||
```sh
|
||||
pnpm build:libs
|
||||
```
|
||||
|
||||
即可自动并行构建所有包,构建顺序由依赖关系决定。
|
||||
|
||||
### 文档开发
|
||||
|
||||
同理,文档站的别名是 `docs`,有如下命令:
|
||||
|
||||
```sh
|
||||
# 启动文档站开发服务器
|
||||
pnpm nx dev docs
|
||||
# 构建文档站
|
||||
pnpm nx build docs
|
||||
```
|
||||
|
||||
会自动处理依赖关系。
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: PR 流程
|
||||
---
|
||||
|
||||
## 基本要求
|
||||
|
||||
- 所有改动通过 PR 合入 `main`。
|
||||
- PR 进入 Ready for Review 后会触发校验工作流:`.github/workflows/pr-release-check.yaml`。
|
||||
- 通过校验后再合并。
|
||||
|
||||
## PR 校验包含什么
|
||||
|
||||
工作流会做两类检查:
|
||||
|
||||
1. `Release metadata`
|
||||
- 判断本次 PR 是否需要 release plan。
|
||||
- 在需要时执行 `pnpm run release:plan:check --base=... --head=...`。
|
||||
|
||||
2. `Build libs`
|
||||
- 安装 pnpm 依赖(`pnpm install --frozen-lockfile`)。
|
||||
- 执行 `pnpm run ci:build:libs`。
|
||||
|
||||
## 什么时候需要 release plan
|
||||
|
||||
检查逻辑来自 `.github/scripts/check-release-requirements.mjs`:
|
||||
|
||||
- 如果改动全部是文档/CI/基础设施等忽略范围,PR 必须打上 `no-release` label。
|
||||
- 否则,即改动包含“非忽略文件”,必须有 release plan(存放于 `.nx/version-plans/`)。
|
||||
- `no-release` 只能用于“全部改动都在忽略范围”的 PR。
|
||||
|
||||
## 创建 release plan(本地)
|
||||
|
||||
可在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
# 只为本次 touched 项目生成计划(默认行为)
|
||||
pnpm nx release plan
|
||||
```
|
||||
|
||||
然后根据提示选择各包变动幅度并输入 changelog。
|
||||
|
||||
命令会在 `.nx/version-plans/` 下生成计划文件,请将该文件一并提交。
|
||||
|
||||
## 关于合并
|
||||
|
||||
目前设置了分支保护规则,仅允许 squash merge,以保持主分支洁净。
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: 发布包
|
||||
---
|
||||
|
||||
## 发布方式
|
||||
|
||||
npm 包发布通过 GitHub Actions 手动触发,工作流文件:
|
||||
|
||||
- `.github/workflows/publish-libs.yaml`
|
||||
|
||||
触发方式为 `workflow_dispatch`,并带 `mode` 参数:
|
||||
|
||||
- `dry-run`:仅演练版本与发布流程,不真正发布。
|
||||
- `publish`:执行真实发版并推送 release commit/tag。
|
||||
|
||||
## 发布前置条件
|
||||
|
||||
- `publish` 必须从 `main` 分支触发,`dry-run` 可以从其他分支触发。
|
||||
- `.nx/version-plans/` 下必须存在 release plan 文件。
|
||||
|
||||
## 工作流执行步骤摘要
|
||||
|
||||
1. 当 `mode=publish` 时,强校验当前分支必须是 `main`。
|
||||
2. 安装依赖与发布环境(pnpm、Node 24)。
|
||||
3. 校验 trusted publishing 运行时(Node 版本等)。
|
||||
4. 执行 `pnpm install --frozen-lockfile`。
|
||||
5. 执行 `npx nx release --skip-publish --preid alpha` 创建 release commit 与 tags。
|
||||
6. 格式化 `package.json` 并 amend release commit。
|
||||
7. 当 `mode=publish` 时:
|
||||
- `git push origin HEAD:main --follow-tags`。
|
||||
- 调整发布前清单(强制 npm 发布器、准备 npm manifests)。
|
||||
- 执行 `npx nx release publish --excludeTaskDependencies` 发布到 npm。
|
||||
|
||||
## 推荐发布流程
|
||||
|
||||
1. 先手动触发一次 `mode=dry-run`,确认版本变更与发布前检查正常(该模式不会推送 tags,也不会发布到 npm)。
|
||||
2. 再触发 `mode=publish` 完成正式发布。
|
||||
3. 发布后检查 npm 与 GitHub tags 是否符合预期。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: 贡献指南
|
||||
description: 项目结构介绍、开发环境配置与仓库规范
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
欢迎参与 AMLL 开发。
|
||||
|
||||
本仓库是一个基于 `Nx + pnpm workspace` 的 monorepo,包含多个可发布 npm 包和一个 Astro 文档站。
|
||||
|
||||
你可以按下面顺序阅读:
|
||||
|
||||
1. [开发环境配置](/contribute/development/environments):本地依赖与常用命令。
|
||||
2. [仓库结构介绍](/contribute/development/structure):各包职责和依赖关系。
|
||||
3. [PR 流程](/contribute/guidelines/pr):以 PR 向 `main` 提交更新的要求。
|
||||
4. [发布包](/contribute/guidelines/publishing):手动触发 Actions 发版流程。
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Development Environment Setup
|
||||
---
|
||||
|
||||
## Required Environment
|
||||
|
||||
- pnpm ([official site](https://pnpm.io/)); use the version specified in the repository `package.json` `packageManager` field when possible (currently `pnpm@10.15.1`)
|
||||
|
||||
This repository uses `pnpm nx ...` by default for Nx commands, so global Nx installation is not required. For local convenience, you can still install Nx globally; behavior is the same.
|
||||
|
||||
Node.js is only used as the runtime in npm publishing related CI steps (currently Node 24 in the publishing workflow).
|
||||
|
||||
### Version Check
|
||||
|
||||
```bash
|
||||
pnpm --version
|
||||
nx --version # optional
|
||||
```
|
||||
|
||||
## First-time Initialization
|
||||
|
||||
Run in the repository root:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
After setup, build all libraries once with `pnpm run build:libs`. If it succeeds, your environment is ready.
|
||||
|
||||
### Dependency installation is slow or fails
|
||||
|
||||
First verify your pnpm version matches the lockfile expectations, then retry:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Repository Structure
|
||||
---
|
||||
|
||||
import { FileTree } from "@astrojs/starlight/components";
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
The repository uses `Nx` for task orchestration and `pnpm workspace` for dependency management. Main directories are:
|
||||
|
||||
<FileTree>
|
||||
|
||||
- packages/
|
||||
- core/ Core components in Vanilla JS
|
||||
- react/ React bindings for core
|
||||
- vue/ Vue bindings for core
|
||||
- react-full/ Ready-to-use full player components (React only)
|
||||
- ttml/ TTML parser and processing library
|
||||
- lyric/ Lyrics parser and processing library (TTML depends on the ttml package)
|
||||
- docs/ Astro + Starlight docs site
|
||||
- playground/ Example projects for development and testing
|
||||
- core/ Example using core
|
||||
- react/ Example using React bindings
|
||||
- vue/ Example using Vue bindings
|
||||
- react-full/ Example using full player components
|
||||
- .github/
|
||||
- workflows/
|
||||
- scripts/
|
||||
|
||||
</FileTree>
|
||||
|
||||
## Nx Release Groups
|
||||
|
||||
Release config in `nx.json` includes two major groups:
|
||||
|
||||
- `core-bundle`: three packages share the same version:
|
||||
- `@applemusic-like-lyrics/core`
|
||||
- `@applemusic-like-lyrics/react`
|
||||
- `@applemusic-like-lyrics/vue`
|
||||
- Other packages are versioned independently:
|
||||
- `@applemusic-like-lyrics/lyric`
|
||||
- `@applemusic-like-lyrics/react-full`
|
||||
- `@applemusic-like-lyrics/ttml`
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm nx graph
|
||||
```
|
||||
|
||||
to view the full dependency graph.
|
||||
|
||||
When bumping versions, if a dependency package changes, dependent packages may also be bumped automatically even when their own code is unchanged.
|
||||
|
||||
## CLI
|
||||
|
||||
### Library Development
|
||||
|
||||
Nx provides convenient aliases; you do not need to type the `@applemusic-like-lyrics/` prefix.
|
||||
|
||||
For example, to build `@applemusic-like-lyrics/react`:
|
||||
|
||||
```sh
|
||||
pnpm nx build react
|
||||
```
|
||||
|
||||
Nx resolves dependencies automatically, so it builds `core` first, then `react`.
|
||||
|
||||
We provide web apps in `package/playground` for testing, based on Vite with HMR support. We added forwarding entries for `core`, `react`, `vue`, and `react-full`, so to start a matching environment:
|
||||
|
||||
```sh
|
||||
# Start dev server for package/playground/core
|
||||
pnpm nx dev core
|
||||
|
||||
# Same for others
|
||||
pnpm nx dev react
|
||||
pnpm nx dev vue
|
||||
pnpm nx dev react-full
|
||||
```
|
||||
|
||||
To build all libraries:
|
||||
|
||||
```sh
|
||||
pnpm build:libs
|
||||
```
|
||||
|
||||
It builds all packages in parallel with dependency-aware ordering.
|
||||
|
||||
### Docs Development
|
||||
|
||||
Similarly, the docs site alias is `docs`, with these commands:
|
||||
|
||||
```sh
|
||||
# Start docs dev server
|
||||
pnpm nx dev docs
|
||||
# Build docs site
|
||||
pnpm nx build docs
|
||||
```
|
||||
|
||||
Dependencies are handled automatically.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: PR Process
|
||||
---
|
||||
|
||||
## Basic Requirements
|
||||
|
||||
- All changes are merged into `main` via PR.
|
||||
- After a PR is set to Ready for Review, the validation workflow `.github/workflows/pr-release-check.yaml` runs.
|
||||
- Merge only after checks pass.
|
||||
|
||||
## What PR Validation Includes
|
||||
|
||||
The workflow runs two groups of checks:
|
||||
|
||||
1. `Release metadata`
|
||||
- Determines whether this PR requires a release plan.
|
||||
- Runs `pnpm run release:plan:check --base=... --head=...` when needed.
|
||||
|
||||
2. `Build libs`
|
||||
- Installs pnpm dependencies (`pnpm install --frozen-lockfile`).
|
||||
- Runs `pnpm run ci:build:libs`.
|
||||
|
||||
## When a Release Plan Is Required
|
||||
|
||||
The check logic is in `.github/scripts/check-release-requirements.mjs`:
|
||||
|
||||
- If all changes are in ignored scopes (docs/CI/infrastructure, etc.), the PR must have the `no-release` label.
|
||||
- Otherwise, if there are non-ignored file changes, a release plan is required (stored in `.nx/version-plans/`).
|
||||
- `no-release` can only be used for PRs where all changes are in ignored scopes.
|
||||
|
||||
## Create a Release Plan Locally
|
||||
|
||||
Run in the repository root:
|
||||
|
||||
```bash
|
||||
# Generate plans only for touched projects (default behavior)
|
||||
pnpm nx release plan
|
||||
```
|
||||
|
||||
Then choose the bump level for each package and enter changelog messages as prompted.
|
||||
|
||||
The command generates plan files in `.nx/version-plans/`; commit them together with your changes.
|
||||
|
||||
## About Merging
|
||||
|
||||
A branch protection rule is enabled to allow only squash merges, keeping `main` clean.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Publishing Packages
|
||||
---
|
||||
|
||||
## Publishing Method
|
||||
|
||||
npm package publishing is triggered manually through GitHub Actions workflow:
|
||||
|
||||
- `.github/workflows/publish-libs.yaml`
|
||||
|
||||
The trigger is `workflow_dispatch` with a `mode` parameter:
|
||||
|
||||
- `dry-run`: rehearses versioning and publishing flow without actual publishing.
|
||||
- `publish`: performs real release and pushes release commit/tag.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `publish` must be triggered from the `main` branch. `dry-run` can be triggered from other branches.
|
||||
- A release plan file must exist in `.nx/version-plans/`.
|
||||
|
||||
## Workflow Steps Summary
|
||||
|
||||
1. When `mode=publish`, strictly validate current branch is `main`.
|
||||
2. Install dependencies and release environment (pnpm, Node 24).
|
||||
3. Validate trusted publishing runtime requirements (Node version, etc.).
|
||||
4. Run `pnpm install --frozen-lockfile`.
|
||||
5. Run `npx nx release --skip-publish --preid alpha` to create release commit and tags.
|
||||
6. Format `package.json` and amend the release commit.
|
||||
7. When `mode=publish`:
|
||||
- `git push origin HEAD:main --follow-tags`
|
||||
- Adjust pre-publish manifest handling (force npm registries, prepare npm manifests)
|
||||
- Run `npx nx release publish --excludeTaskDependencies` to publish to npm.
|
||||
|
||||
## Recommended Process
|
||||
|
||||
1. Trigger `mode=dry-run` first, and confirm version changes and pre-publish checks are correct (no tags pushed and no npm publish in this mode).
|
||||
2. Trigger `mode=publish` to perform the formal release.
|
||||
3. Verify npm packages and GitHub tags after publishing.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Contributing
|
||||
description: Repository structure, development environment setup, and repository guidelines
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
Welcome to AMLL development.
|
||||
|
||||
This repository is a monorepo based on `Nx + pnpm workspace`, containing multiple publishable npm packages and an Astro documentation site.
|
||||
|
||||
You can read in this order:
|
||||
|
||||
1. [Development Environment Setup](/en/contribute/development/environments): local dependencies and common commands.
|
||||
2. [Repository Structure](/en/contribute/development/structure): package responsibilities and dependency relationships.
|
||||
3. [PR Process](/en/contribute/guidelines/pr): requirements for submitting updates to `main` through pull requests.
|
||||
4. [Publishing Packages](/en/contribute/guidelines/publishing): manually triggering the Actions publishing workflow.
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: Dynamic Background
|
||||
---
|
||||
|
||||
AMLL Core provides a standalone background rendering component, [`BackgroundRender`](/en/reference/core/classbackgroundrender). It renders album artwork or album video into an Apple Music style dynamic background. The lyric component still only handles the lyric view itself; audio playback, resource loading, and layer mounting should be managed by the host environment.
|
||||
|
||||
This page mainly explains background integration with the vanilla Core API. If you use the React or Vue bindings, jump directly to the [bindings section](#react-and-vue-bindings).
|
||||
|
||||
## Basic Structure
|
||||
|
||||
The background component consists of two parts:
|
||||
|
||||
- [`BackgroundRender`](/en/reference/core/classbackgroundrender): a unified wrapper that provides methods for setting album media, frame rate, render scale, pause/resume state, and more.
|
||||
- Renderer: Core currently provides [`MeshGradientRenderer`](/en/reference/core/classmeshgradientrenderer) and [`PixiRenderer`](/en/reference/core/classpixirenderer).
|
||||
|
||||
When creating a background, choose one of these renderers:
|
||||
|
||||
```ts
|
||||
// Use Mesh Gradient
|
||||
import {
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
const meshBackground = BackgroundRender.new(MeshGradientRenderer);
|
||||
|
||||
// Use Pixi
|
||||
import { BackgroundRender, PixiRenderer } from "@applemusic-like-lyrics/core";
|
||||
const pixiBackground = BackgroundRender.new(PixiRenderer);
|
||||
```
|
||||
|
||||
## Layering With the Lyric Component
|
||||
|
||||
The background element is a `<canvas>`. Usually, place it in the same container as the lyric component and insert it before the lyric element.
|
||||
|
||||
```ts
|
||||
import {
|
||||
BackgroundRender,
|
||||
DomLyricPlayer,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const background = BackgroundRender.new(MeshGradientRenderer);
|
||||
const lyricPlayer = new DomLyricPlayer();
|
||||
|
||||
function mountPlayer(host: HTMLElement) {
|
||||
const backgroundElement = background.getElement();
|
||||
const lyricElement = lyricPlayer.getElement();
|
||||
|
||||
host.appendChild(backgroundElement);
|
||||
host.appendChild(lyricElement);
|
||||
}
|
||||
|
||||
const host = document.querySelector<HTMLElement>("#player");
|
||||
if (!host) throw new Error("missing #player");
|
||||
|
||||
mountPlayer(host);
|
||||
```
|
||||
|
||||
The canvas size is determined by CSS. Internally, a `ResizeObserver` adjusts the actual drawing size according to the device pixel ratio and render scale. Therefore, the host container should have an explicit width and height.
|
||||
|
||||
AMLL does not define positioning or z-index styles for the background and lyric components. Define those styles in your host application.
|
||||
|
||||
## Setting Album Media
|
||||
|
||||
Call [`setAlbum`](/en/reference/core/classbackgroundrender#setalbum) to set the background source. It accepts an image or video URL, an `HTMLImageElement`, or an `HTMLVideoElement`.
|
||||
|
||||
If you pass a string URL and the resource is a video, set the second parameter to `true`:
|
||||
|
||||
```ts
|
||||
// Image URL
|
||||
await background.setAlbum("/album-cover.jpg");
|
||||
|
||||
// Video URL
|
||||
await background.setAlbum("/album-video.webm", true);
|
||||
```
|
||||
|
||||
If you already have a `File` or `Blob` object, use [`URL.createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) to create an object URL and pass it to `setAlbum`.
|
||||
|
||||
The background renderer draws the resource into a canvas or WebGL texture.
|
||||
|
||||
## Syncing Playback State
|
||||
|
||||
The background component has its own animation loop, so you do not need to manually call `update(delta)` frame by frame as you do with `DomLyricPlayer`. Just call `resume()` and `pause()` when playback starts or pauses:
|
||||
|
||||
```ts
|
||||
audio.addEventListener("play", () => {
|
||||
lyricPlayer.resume();
|
||||
background.resume();
|
||||
});
|
||||
|
||||
audio.addEventListener("pause", () => {
|
||||
lyricPlayer.pause();
|
||||
background.pause();
|
||||
});
|
||||
```
|
||||
|
||||
The background animation state is independent from the lyric animation state. You can also control only the background animation.
|
||||
|
||||
```ts
|
||||
function setBackgroundPlaying(playing: boolean) {
|
||||
if (playing) background.resume();
|
||||
else background.pause();
|
||||
}
|
||||
```
|
||||
|
||||
## Applying Render Settings
|
||||
|
||||
Common settings can be applied after initialization or when the user changes options. They take effect immediately.
|
||||
|
||||
```ts
|
||||
function applyBackgroundSettings() {
|
||||
background.setFPS(60);
|
||||
background.setRenderScale(1);
|
||||
background.setFlowSpeed(0.2);
|
||||
background.setStaticMode(false);
|
||||
background.setLowFreqVolume(1);
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| [`setFPS(fps)`](/en/reference/core/classbackgroundrender#setfps) | Sets the frame rate of the background animation |
|
||||
| [`setRenderScale(scale)`](/en/reference/core/classbackgroundrender#setrenderscale) | Sets the render scale. Higher values are clearer and also more performance-intensive |
|
||||
| [`setFlowSpeed(speed)`](/en/reference/core/classbackgroundrender#setflowspeed) | Sets the background flow speed |
|
||||
| [`setStaticMode(enable)`](/en/reference/core/classbackgroundrender#setstaticmode) | When enabled, the background can stay static after the resource transition animation to save work |
|
||||
| [`setLowFreqVolume(volume)`](/en/reference/core/classbackgroundrender#setlowfreqvolume) | Passes a low-frequency volume hint. Some renderers may use it to adjust dynamic effects |
|
||||
| [`setHasLyric(hasLyric)`](/en/reference/core/classbackgroundrender#sethaslyric) | Tells the renderer whether the current song has lyrics. Some renderers may adjust effects |
|
||||
|
||||
`setRenderScale` is usually a tradeoff between `0.5` and `1`. On mobile or low-performance devices, lower the render scale and frame rate. For a fullscreen player, you can increase the render scale.
|
||||
|
||||
## Changing Renderers
|
||||
|
||||
After `BackgroundRender` is created, its internal renderer cannot be replaced. If the user switches from `MeshGradientRenderer` to `PixiRenderer`, dispose the old instance and create a new one:
|
||||
|
||||
```ts
|
||||
type PlayerBackground =
|
||||
| BackgroundRender<MeshGradientRenderer>
|
||||
| BackgroundRender<PixiRenderer>;
|
||||
|
||||
function switchToPixiRenderer(
|
||||
host: HTMLElement,
|
||||
lyricPlayer: DomLyricPlayer,
|
||||
currentBackground: PlayerBackground,
|
||||
) {
|
||||
currentBackground.dispose();
|
||||
|
||||
const nextBackground = BackgroundRender.new(PixiRenderer);
|
||||
host.insertBefore(nextBackground.getElement(), lyricPlayer.getElement());
|
||||
return nextBackground;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
When the page unloads, the player is destroyed, or you permanently switch implementations, release the background instance:
|
||||
|
||||
```ts
|
||||
background.dispose();
|
||||
lyricPlayer.dispose();
|
||||
```
|
||||
|
||||
`dispose()` releases internal renderer resources and removes the background canvas. If you created an `ObjectURL`, audio event listeners, or other async loading state yourself, clean those up in your host code as well.
|
||||
|
||||
## React and Vue Bindings
|
||||
|
||||
The background component has much less intermediate state than the lyric component, so componentized usage is simpler.
|
||||
|
||||
React and Vue are similar: both set options and maintain state through props. Use the `playing` prop to specify playback state, and use the `album` prop to specify album image or video media. See the API reference for the complete prop list.
|
||||
|
||||
- React prop reference: [BackgroundRenderProps](/en/reference/react/interfacebackgroundrenderprops)
|
||||
- Vue prop reference: [BackgroundRender](/en/reference/vue/classbackgroundrender)
|
||||
|
||||
The component automatically releases internal resources when unmounted. You still need to release listeners, object URLs, and similar resources that you create yourself.
|
||||
|
||||
Here is a minimal React example:
|
||||
|
||||
```tsx
|
||||
import { LyricPlayer, BackgroundRender } from "@applemusic-like-lyrics/react";
|
||||
|
||||
function app() {
|
||||
const albumUrl = "/album-cover.jpg";
|
||||
return (
|
||||
<>
|
||||
<BackgroundRender album={albumUrl} />
|
||||
<LyricPlayer lyricLines={lyricLines} currentTime={currentTime} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Here is a minimal Vue example:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BackgroundRender :album="albumUrl" />
|
||||
<LyricPlayer :lyricLines="lyricLines" :currentTime="currentTime" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackgroundRender, LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const albumUrl = ref("/album-cover.jpg");
|
||||
const currentTime = ref(0);
|
||||
const lyricLines = ref([]);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- The host container has an explicit size.
|
||||
- The background canvas is inserted before the lyric element, and the lyric layer is above the background layer.
|
||||
- Image or video resources allow cross-origin reads, or are same-origin with the page.
|
||||
- Video URLs are passed with `setAlbum(source, true)`.
|
||||
- Playback state is synced to `resume()` / `pause()`.
|
||||
- When switching renderers, call `dispose()` on the old background before creating the new one.
|
||||
- On unmount, release the background, lyric component, and resources created by host code.
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
title: Quick Start
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
import PackageInstall from "#components/PackageInstall.astro";
|
||||
|
||||
This page quickly introduces how to integrate AMLL lyric components into your project. AMLL does not provide a CDN usage mode; you must use a bundler.
|
||||
|
||||
For adjacent utility packages other than the component libraries, see the [API Reference](/en/reference).
|
||||
|
||||
## Dependencies
|
||||
|
||||
Install the AMLL core library:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/core" />
|
||||
|
||||
AMLL also declares several graphics and animation libraries as peer dependencies so it can reuse related dependencies that may already exist in your project. Some package managers or configurations may install peers automatically. If they are not installed, install them manually.
|
||||
|
||||
<PackageInstall
|
||||
packages={[
|
||||
"@pixi/app",
|
||||
"@pixi/core",
|
||||
"@pixi/display",
|
||||
"@pixi/filter-blur",
|
||||
"@pixi/filter-bulge-pinch",
|
||||
"@pixi/filter-color-matrix",
|
||||
"@pixi/sprite",
|
||||
"jss",
|
||||
"jss-preset-default",
|
||||
]}
|
||||
/>
|
||||
|
||||
The examples below use `@applemusic-like-lyrics/lyric` to parse TTML lyric files. If your project can already provide `LyricLine[]` directly, you can skip this package.
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
Next, you can use the vanilla package, or use the React or Vue bindings.
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Use Vanilla JS"
|
||||
description="Without a frontend framework"
|
||||
href="#use-vanilla-js"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Use React Bindings"
|
||||
description="For projects using React"
|
||||
href="#use-react-bindings"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Use Vue Bindings"
|
||||
description="For projects using Vue"
|
||||
href="#use-vue-bindings"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
## Use Vanilla JS
|
||||
|
||||
The AMLL core library is framework-agnostic. You can use this method whether or not your project uses a framework.
|
||||
|
||||
The example below assumes:
|
||||
|
||||
- The page already has `<audio id="audio">` and `<div id="lyric-player">`, and the lyric container has an explicit height.
|
||||
- The TTML lyric file is available at `/lyrics/song.ttml`.
|
||||
|
||||
```js
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audio = document.querySelector("#audio");
|
||||
const playerHost = document.querySelector("#lyric-player");
|
||||
const player = new LyricPlayer();
|
||||
|
||||
playerHost.appendChild(player.getElement());
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setLyricLines(parseTTML(ttml).lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
}
|
||||
|
||||
// Continuously sync audio progress during playback.
|
||||
let lastFrameTime = -1;
|
||||
function onFrame(frameTime) {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
player.update(delta);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", () => player.resume());
|
||||
audio.addEventListener("pause", () => player.pause());
|
||||
audio.addEventListener("seeked", () => {
|
||||
// Align the lyric position immediately after seeking.
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
});
|
||||
|
||||
loadLyric();
|
||||
requestAnimationFrame(onFrame);
|
||||
```
|
||||
|
||||
For more detailed timing management, see [Timing and Lifecycle](./sequence).
|
||||
|
||||
See [API Reference: Core](/en/reference/core) for detailed API documentation.
|
||||
|
||||
## Use React Bindings
|
||||
|
||||
Make sure you have installed `react` and `react-dom`. Then install the AMLL React binding package:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/react" />
|
||||
|
||||
The React binding package exports [`LyricPlayer`](/en/reference/react/variablelyricplayer) as the core component. The example below assumes the TTML lyric file is available at `/lyrics/song.ttml` and the audio file is available at `/music/song.m4a`.
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
function App() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
function syncCurrentTime() {
|
||||
const audio = audioRef.current;
|
||||
if (audio) setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
// parseTTML returns an object with metadata. The component only needs its lines.
|
||||
fetch("/lyrics/song.ttml")
|
||||
.then((res) => res.text())
|
||||
.then((ttml) => {
|
||||
if (!canceled) setLyricLines(parseTTML(ttml).lines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0;
|
||||
const onFrame = () => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) {
|
||||
setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
playing={playing}
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onSeeked={syncCurrentTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
See [API Reference: React Bindings](/en/reference/react) for detailed API documentation.
|
||||
|
||||
## Use Vue Bindings
|
||||
|
||||
Make sure you have installed Vue. Then install the AMLL Vue binding package:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/vue" />
|
||||
|
||||
The Vue binding package exports [`LyricPlayer`](/en/reference/vue/classlyricplayer) as the core component. Lyric objects, playback progress, and other state are passed in as reactive component props. The example below assumes the TTML lyric file is available at `/lyrics/song.ttml` and the audio file is available at `/music/song.m4a`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LyricPlayer
|
||||
class="lyric-player"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime"
|
||||
:playing="playing"
|
||||
/>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onPause"
|
||||
@seeked="syncCurrentTime"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
} from "vue";
|
||||
|
||||
// Bundlers usually handle CSS imports. If anything goes wrong, check your bundler's documentation.
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audioEl = useTemplateRef<HTMLAudioElement>("audioEl");
|
||||
const lyricLines = shallowRef<LyricLine[]>([]);
|
||||
const currentTime = ref(0);
|
||||
const playing = ref(false);
|
||||
let frameId = 0;
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
// parseTTML returns an object with metadata. The component only needs its lines.
|
||||
lyricLines.value = parseTTML(ttml).lines;
|
||||
}
|
||||
|
||||
function syncCurrentTime() {
|
||||
if (audioEl.value) {
|
||||
currentTime.value = Math.round(audioEl.value.currentTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function onFrame() {
|
||||
syncCurrentTime();
|
||||
if (playing.value) frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playing.value = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
onFrame();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
playing.value = false;
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
|
||||
onMounted(() => void loadLyric());
|
||||
onBeforeUnmount(() => cancelAnimationFrame(frameId));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-player {
|
||||
height: 420px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
See [API Reference: Vue Bindings](/en/reference/vue) for detailed API documentation.
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Timing and Lifecycle
|
||||
---
|
||||
|
||||
This page explains timing and lifecycle management for the lyric component.
|
||||
|
||||
The lyric component only handles the lyric view itself. It **does not handle audio playback**. Therefore, **the host environment, which is your code, needs to manage audio playback and bridge the audio playback state to AMLL component state.**
|
||||
|
||||
If you use the React or Vue bindings, the component manages part of the lifecycle for you. If you use the vanilla API directly, you need to manage the full flow yourself. This page mainly covers lifecycle management with the vanilla API and explains the state managed by the bindings.
|
||||
|
||||
## Initialization
|
||||
|
||||
During initialization, you need to:
|
||||
|
||||
1. Create the lyric component and mount its element into a container with an **explicit size**.
|
||||
2. Optionally set custom lyric optimization options. The [`setOptimizeOptions`](/en/reference/core/classlyricplayerbase#setoptimizeoptions) method accepts [`OptimizeLyricOptions`](/en/reference/core/interfaceoptimizelyricoptions).
|
||||
3. Set lyric data. The [`setLyricLines`](/en/reference/core/classlyricplayerbase#setlyriclines) method accepts [`LyricLine[]`](/en/reference/core/interfacelyricline). After passing the objects in, do not modify them.
|
||||
4. Align the lyric position once with the current playback progress.
|
||||
|
||||
A typical vanilla sequence looks like this:
|
||||
|
||||
```ts
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
|
||||
const player = new LyricPlayer();
|
||||
host.appendChild(player.getElement());
|
||||
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setOptimizeOptions({}); // Optional
|
||||
player.setLyricLines(lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
Lyric optimization runs when lyrics are set, so if you need to change these options, call `setOptimizeOptions` before `setLyricLines`. Changes after `setLyricLines` do not automatically reprocess existing lyrics; set the lyrics again to apply them.
|
||||
|
||||
Also note that `currentTime` is in milliseconds and should be an integer. `audio.currentTime` is in seconds, so multiply it by `1000`.
|
||||
|
||||
## Play and Pause
|
||||
|
||||
`pause()` and `resume()` control the lyric component's internal presentation state, including word-by-word animation, glow, and interlude dot animation. Call `resume()` when audio starts playing, and call `pause()` when audio pauses, ends, or is externally interrupted.
|
||||
|
||||
For example, when using `<audio>` for playback, drive this with its events:
|
||||
|
||||
```ts
|
||||
const onPlay = () => {
|
||||
player.resume();
|
||||
};
|
||||
const onPause = () => {
|
||||
player.pause();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
```
|
||||
|
||||
## Playback Progress
|
||||
|
||||
### Normal Playback
|
||||
|
||||
During playback, you need to update the lyric component's time progress. **All time values used by AMLL are in milliseconds**.
|
||||
|
||||
Two time values are easy to confuse:
|
||||
|
||||
| Time Type | Accepted By | Meaning |
|
||||
| ----------------- | ------------------------------------------ | ------------------------------- |
|
||||
| Current progress | `setCurrentTime(time)` / `currentTime` prop | Song playback progress |
|
||||
| Frame delta | `update(delta)` | Time elapsed since the previous frame |
|
||||
|
||||
In vanilla usage, `setCurrentTime` updates the lyric timeline, and `update` advances animation. **They are not the same value**.
|
||||
|
||||
```ts
|
||||
let frameId = 0;
|
||||
let lastFrameTime = -1;
|
||||
|
||||
function startFrameLoop() {
|
||||
const onFrame = (frameTime: number) => {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
player.update(delta);
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function stopFrameLoop() {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
lastFrameTime = -1;
|
||||
}
|
||||
```
|
||||
|
||||
**Do not rely on the `<audio>` `timeupdate` event to sync lyrics**. Browsers fire `timeupdate` at a low and unstable frequency, usually far below the animation frame rate. During playback, use `requestAnimationFrame` to sync current progress frame by frame.
|
||||
|
||||
### Seeking
|
||||
|
||||
Outside normal playback, playback progress may jump. Common cases include:
|
||||
|
||||
- Dragging the progress bar
|
||||
- Fast-forwarding or rewinding
|
||||
- Clicking a lyric line to seek
|
||||
- Loop playback, where progress jumps from the end back to the beginning
|
||||
|
||||
When playback progress jumps, set the second parameter of `setCurrentTime` to `true`:
|
||||
|
||||
```ts
|
||||
function onSeeked() {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
}
|
||||
audio.addEventListener("seeked", onSeeked);
|
||||
```
|
||||
|
||||
**This parameter indicates that the current sync is a seek. Normal playback and seek state use different layout and animation behavior:**
|
||||
|
||||
- During normal playback, the component lays out and applies spring animation to each visible line individually for a refined visual effect.
|
||||
- During seeking, the component force-aligns the lyric position and applies layout plus spring animation to all lyric lines as a whole, reducing work and making the animation snappier.
|
||||
|
||||
If seek state is not marked correctly, layout glitches may occur, such as stutters or lyric lines quickly flying from one side of the screen to the other and disappearing. You can see screenshots in [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429).
|
||||
|
||||
### Lyric Line Click Events
|
||||
|
||||
The component provides a `line-click` event, fired when a lyric line is clicked. Its event type is [`LyricLineMouseEvent`](/en/reference/core/classlyriclinemouseevent).
|
||||
|
||||
**The component itself does not respond to lyric line clicks.** The host environment needs to listen to the event and perform actions such as seeking the audio progress. For example:
|
||||
|
||||
```ts
|
||||
import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core";
|
||||
|
||||
player.addEventListener("line-click", (event) => {
|
||||
const lineEvent = event as LyricLineMouseEvent;
|
||||
audio.currentTime = lineEvent.line.getLine().startTime / 1000;
|
||||
player.setCurrentTime(lineEvent.line.getLine().startTime, true);
|
||||
});
|
||||
```
|
||||
|
||||
It is worth noting that clicking a lyric line to jump is also a seek.
|
||||
|
||||
## Changing Lyrics
|
||||
|
||||
When changing songs or lyric sources, set a new lyric line object array with `setLyricLines`. If loading fails, pass an empty array to clear the lyrics.
|
||||
|
||||
```ts
|
||||
player.setLyricLines([]);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
## React and Vue Bindings
|
||||
|
||||
The React and Vue bindings create and destroy the underlying Core component. They also automatically call `update` unless disabled. Therefore, when using bindings, you usually do not need to call the underlying `update` yourself.
|
||||
|
||||
You still need to provide these states:
|
||||
|
||||
| State | React / Vue Input | Description |
|
||||
| ------------------ | ----------------- | ------------------------------------------------ |
|
||||
| Lyric data | `lyricLines` | Parsed `LyricLine[]` |
|
||||
| Current progress | `currentTime` | Synced from audio with `requestAnimationFrame` during playback |
|
||||
| Playback state | `playing` | Pauses or resumes the lyric component's internal presentation |
|
||||
|
||||
The React binding additionally provides an `isSeeking` prop, which you can pass during seeking:
|
||||
|
||||
```tsx
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
isSeeking={isSeeking}
|
||||
playing={playing}
|
||||
/>
|
||||
```
|
||||
|
||||
`isSeeking` should not stay `true` for a long time. Usually, set it to `true` briefly when the user completes a seek, then restore it to `false` after the next sync.
|
||||
|
||||
The Vue binding is currently less complete and does not have a separate `isSeeking` prop. In common scenarios, syncing `currentTime` is enough to work. If you need finer state control, use the vanilla API directly. We will continue improving the Vue binding functionality and usage experience in upcoming versions.
|
||||
|
||||
If `disabled` is set, the binding no longer manages frame-by-frame animation. In that case, you can access the underlying `lyricPlayer` through a component ref and call `update` yourself, just like with the vanilla API.
|
||||
|
||||
## Cleanup
|
||||
|
||||
When the lyric player component is no longer needed, vanilla usage requires cleaning up every resource you created yourself:
|
||||
|
||||
```ts
|
||||
// Clear the requestAnimationFrame loop you defined.
|
||||
stopFrameLoop();
|
||||
|
||||
// Remove listeners you added.
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("seeked", onSeeked);
|
||||
|
||||
// Release component resources.
|
||||
player.dispose();
|
||||
```
|
||||
|
||||
`dispose()` removes the component element and releases internal listeners.
|
||||
|
||||
If you use the React or Vue bindings, the component automatically calls the underlying `dispose()` when unmounted. However, `requestAnimationFrame`, audio event listeners, `ObjectURL`s, and similar resources that you create yourself still need to be cleaned up when the component unmounts.
|
||||
|
||||
## Checklist
|
||||
|
||||
- The container has an explicit size and has been mounted to the DOM.
|
||||
- Lyrics are passed through `setLyricLines(lines, currentTime)` or the `lyricLines` prop.
|
||||
- Playback progress is represented in milliseconds.
|
||||
- During playback, `currentTime` is synced with `requestAnimationFrame`.
|
||||
- In vanilla usage, `update(delta)` is called frame by frame.
|
||||
- Pause, resume, and playback end are synced to `pause()` / `resume()` or `playing`.
|
||||
- Seeking uses the seek flag to align the lyric position.
|
||||
- On unmount, cancel animation frames, remove event listeners, and dispose the component.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Guides
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Introduction"
|
||||
description="Basic information about AMLL"
|
||||
href="/en/guides/overview/intro"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric Component"
|
||||
description="Integrate lyric components into your project"
|
||||
href="/en/guides/component/quickstart"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Background Component"
|
||||
description="Integrate background components into your project"
|
||||
href="/guides/component/background"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric Formats"
|
||||
description="Introducation to lyric formats and lib usage"
|
||||
href="/en/guides/lyric/quickstart"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Lyric Formats
|
||||
---
|
||||
|
||||
This page introduces lyric formats supported by [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric).
|
||||
|
||||
In this document, "word-by-word lyrics" means lyrics with timestamp precision finer than line-level. Depending on platform and format, it may be syllable-level or word-level.
|
||||
|
||||
## TTML
|
||||
|
||||
TTML is the primary lyric storage and exchange format in the AMLL ecosystem. It supports all major AMLL capabilities, including translation/transliteration, background vocals, word-level transliteration, and ruby annotation.
|
||||
|
||||
See [TTML](./ttml) for details.
|
||||
|
||||
**This package provides [`parseTTML`](/en/reference/lyric/functionparsettml) and [`stringifyTTML`](/en/reference/lyric/functionstringifyttml) for TTML parsing and serialization.**
|
||||
|
||||
## LRC
|
||||
|
||||
LRC is the most common lyric file format and supports line-level timestamps only. File extension is `.lrc`.
|
||||
|
||||
Here, "LRC" means basic unextended LRC (sometimes called simple LRC). LRC was not formally standardized by a single organization; many variants exist and details vary.
|
||||
|
||||
Typically, each lyric line starts with a timestamp indicating line start time. Common forms:
|
||||
|
||||
- `[mm:ss]`
|
||||
- `[mm:ss.xx]`
|
||||
- `[mm:ss.xxx]`
|
||||
|
||||
`mm` is minutes and `ss.xxx` is seconds. Fraction digits may be omitted, 2 digits, or 3 digits. One lyric line can have multiple timestamps to indicate repeated occurrences.
|
||||
|
||||
LRC also supports metadata lines at the top in `[tag:content]` format. Some implementations allow comment lines prefixed with `#`.
|
||||
|
||||
Example:
|
||||
|
||||
```lrc
|
||||
[al:崩坏星穹铁道-不虚此行 On the Journey]
|
||||
[ti:不虚此行 On the Journey]
|
||||
[ar:魏晨, Nea]
|
||||
[length: 2:36]
|
||||
|
||||
[00:25.494]We venture through the cosmic sea
|
||||
[00:27.541]A thousand light-years, wild and free
|
||||
[00:30.805]We dance beneath the galaxy
|
||||
[00:32.847]Then will you be with me?
|
||||
# ...
|
||||
[01:45.949]Catching on, our paths unknown
|
||||
[01:50.261]To sink into daylight
|
||||
[01:52.851]Break into the moonlight
|
||||
[01:56.487]Life goes on, through tides of time
|
||||
[02:00.900]Get in the line, to dream alive
|
||||
[02:03.580]In our souls, do we know?
|
||||
[02:05.896][02:08.473][02:11.262]On the journey
|
||||
```
|
||||
|
||||
LRC does not natively support translation/transliteration. Common practice is to provide separate files with matching timestamps.
|
||||
|
||||
More background: [Wikipedia: LRC (file format)](<https://en.wikipedia.org/wiki/LRC_(file_format)>).
|
||||
|
||||
**This package provides [`parseLrc`](/en/reference/lyric/functionparselrc) and [`stringifyLrc`](/en/reference/lyric/functionstringifylrc).**
|
||||
|
||||
## LRC A2
|
||||
|
||||
LRC A2 (from A2 Media Player) extends LRC with inline timestamps in angle brackets `<mm:ss.xx>` for word-level timing. Timestamp format is similar to LRC. Each angle-bracket timestamp indicates the start time of the following text segment.
|
||||
|
||||
File extension is `.lrc` or `.alrc`.
|
||||
|
||||
Example:
|
||||
|
||||
```alrc
|
||||
[ti: Somebody to Love]
|
||||
[ar: Jefferson Airplane]
|
||||
[al: Surrealistic Pillow]
|
||||
[length: 2:58]
|
||||
|
||||
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> Love
|
||||
```
|
||||
|
||||
When parsing, spaces around timestamps should be normalized.
|
||||
|
||||
Because timestamps are embedded with `< >`, escaping literal `<` or `>` in lyric text is not officially standardized. Parsing attempts to match `<mm:ss.xx>` as timestamp patterns; unmatched `<` or `>` are treated as normal text.
|
||||
|
||||
LRC A2 also does not natively support translation/transliteration.
|
||||
|
||||
**This package provides [`parseLrcA2`](/en/reference/lyric/functionparselrca2) and [`stringifyLrcA2`](/en/reference/lyric/functionstringifylrca2).**
|
||||
|
||||
## NetEase YRC and QQ QRC
|
||||
|
||||
Both are private word-level lyric formats used by music platforms:
|
||||
|
||||
- NetEase Cloud Music uses `.yrc`
|
||||
- QQ Music uses `.qrc`
|
||||
|
||||
Both use line timestamps in `[lineStart,lineDur]` format, where values are integer milliseconds. Unlike LRC, they do not support multiple timestamps for one repeated line.
|
||||
|
||||
Word-level syntax differs:
|
||||
|
||||
- YRC: `(sylStart,sylDur,0)text` (timestamp first, then text). The third field is always `0` in known samples.
|
||||
- QRC: `text(sylStart,sylDur)` (text first, then timestamp), without the extra `0` field.
|
||||
|
||||
All start times are absolute (from audio start).
|
||||
|
||||
Example for the same lyrics:
|
||||
|
||||
```
|
||||
# NetEase YRC
|
||||
[190871,1984](190871,361,0)For (191232,172,0)the (191404,376,0)first (191780,1075,0)time
|
||||
[193459,4198](193459,412,0)What's (193871,574,0)past (194445,506,0)is (194951,2706,0)past
|
||||
|
||||
# QQ QRC
|
||||
[190871,1984]For (190871,361)the (191232,172)first (191404,376)time(191780,1075)
|
||||
[193459,4198]What's (193459,412)past (193871,574)is (194445,506)past(194951,2706)
|
||||
```
|
||||
|
||||
QRC and YRC do not have LRC A2 style adjacent-space collapsing.
|
||||
|
||||
Parentheses in lyric text are tricky because timestamps also use `()`. Based on observed platform samples:
|
||||
|
||||
- YRC samples consistently use full-width parentheses `()` in text
|
||||
- QRC samples keep normal parentheses in text
|
||||
|
||||
Based on this behavior:
|
||||
|
||||
- YRC likely avoids half-width parentheses in text; this library replaces half-width parentheses with full-width ones when exporting YRC
|
||||
- QRC parser skips non-timestamp parentheses and treats them as text
|
||||
|
||||
YRC/QRC also do not natively support translation/transliteration; common practice is companion LRC files.
|
||||
|
||||
Also, this library treats fully parenthesized lines as background lines in YRC/QRC and removes outer parentheses during parsing.
|
||||
|
||||
**This package provides [`parseYrc`](/en/reference/lyric/functionparseyrc) and [`stringifyYrc`](/en/reference/lyric/functionstringifyyrc) for YRC; [`parseQrc`](/en/reference/lyric/functionparseqrc) and [`stringifyQrc`](/en/reference/lyric/functionstringifyqrc) for QRC.**
|
||||
|
||||
QQ Music also distributes an encrypted QRC format: XML containing QRC text, encrypted with a DES-like algorithm and base64-encoded. **This package provides [`decryptQrcHex`](/en/reference/lyric/functiondecryptqrchex) to decode such base64 payloads to XML text, and [`encryptQrcHex`](/en/reference/lyric/functionencryptqrchex) to encode plaintext XML to base64.**
|
||||
|
||||
## Lyricify Formats
|
||||
|
||||
[Lyricify](https://lyricify.app) is a popular word-by-word lyric display app. It defines three private formats: Lyricify Lines, Lyricify Syllable, and Lyricify Quick Export.
|
||||
|
||||
- Lyricify Lines: line-level format, extension `.lyl`
|
||||
- Lyricify Syllable: word-level format, extension `.lys`, with background and duet support
|
||||
|
||||
Official docs exist for the first two formats: [Lyricify format docs](https://github.com/WXRIW/Lyricify-App/blob/main/docs/Lyricify%204/Lyrics.md#lyricify-lines-%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83).
|
||||
|
||||
**This package provides [`parseLyl`](/en/reference/lyric/functionparselyl)/[`stringifyLyl`](/en/reference/lyric/functionstringifylyl) and [`parseLys`](/en/reference/lyric/functionparselys)/[`stringifyLys`](/en/reference/lyric/functionstringifylys).**
|
||||
|
||||
Lyricify Quick Export uses extension `.lqe` (Lyricify Quick Export). There is no official formal spec, but the format is straightforward from exported samples:
|
||||
|
||||
```
|
||||
[Lyricify Quick Export]
|
||||
[version:1.0]
|
||||
|
||||
[lyrics: format@Lyricify Syllable]
|
||||
[4]A(365,350)ni(715,307)ro(1022,312)dham (1334,419)a(3203,337)nut(3540,350)pā(3890,306)dam(4196,382)
|
||||
[5]Qua(6206,312)e(6518,350)so (6868,370)do(7238,338)mi(7576,373)ne (7949,413)nos (8362,736)ple(9098,306)ne (9404,338)sal(9742,237)va (9979,244)tam(10223,350)
|
||||
[4]A(6164,1436)nuc(7600,744)che(8344,724)dam (9068,399)a(9467,293)śā(9760,240)śva(10000,225)tam(10225,893)
|
||||
[4]Hi (11851,812)ma(12663,344)ma (13007,369)ja(13376,263)gad (13639,237)i(13876,212)daṃ(14088,800)
|
||||
|
||||
|
||||
[translation: format@LRC]
|
||||
[00:00.365]不生亦不灭
|
||||
[00:06.206]主人啊,求你像这般,赐给我们完全的救恩
|
||||
[00:06.164]不常亦不断
|
||||
[00:11.851]此世已为我之世
|
||||
|
||||
|
||||
[pronunciation: format@LRC, language@romaji]
|
||||
[00:00.365]阿难罗昙 阿耨钵昙
|
||||
[00:06.164]阿耨遮昙 阿刹缚多
|
||||
[00:11.851]天摩诃满 荼揭谛檀
|
||||
```
|
||||
|
||||
The header defines file version. Then blocks follow:
|
||||
|
||||
- `[lyrics: format@Lyricify Syllable]` for word-level lyrics
|
||||
- `[translation: format@LRC]` for translation lines
|
||||
- `[pronunciation: format@LRC, language@romaji]` for pronunciation/transliteration lines
|
||||
|
||||
Translation/pronunciation blocks include only lines that have content. Missing translation/transliteration lines are omitted.
|
||||
|
||||
So Lyricify Quick Export supports word-level timing, background lines, duet lines, translation, and transliteration.
|
||||
|
||||
See also [Lyricify Lyrics Helper](https://github.com/WXRIW/Lyricify-Lyrics-Helper), an MIT-licensed project by the Lyricify developer that includes parsing and generation logic.
|
||||
|
||||
**This package provides [`parseLqe`](/en/reference/lyric/functionparselqe) and [`stringifyLqe`](/en/reference/lyric/functionstringifylqe).**
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Format | Extension | Line timing | Word timing | Native translation/transliteration | Native background/duet |
|
||||
| --------------------- | --------------- | :---------: | :---------: | :--------------------------------: | :--------------------: |
|
||||
| TTML | `.ttml` | Yes | Yes | Yes | Yes |
|
||||
| LRC | `.lrc` | Yes | No | No | No |
|
||||
| LRC A2 | `.lrc`, `.alrc` | Yes | Yes | No | No |
|
||||
| NetEase YRC | `.yrc` | Yes | Yes | No | No |
|
||||
| QQ QRC | `.qrc` | Yes | Yes | No | No |
|
||||
| Lyricify Lines | `.lyl` | Yes | No | No | No |
|
||||
| Lyricify Syllable | `.lys` | Yes | Yes | No | Yes |
|
||||
| Lyricify Quick Export | `.lqe` | Yes | Yes | Yes | Yes |
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Quick Start
|
||||
---
|
||||
|
||||
import PackageInstall from "../../../../../components/PackageInstall.astro";
|
||||
|
||||
AMLL provides two npm packages for lyric format parsing and serialization:
|
||||
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
Parsing and generation for mainstream lyric formats such as LRC, YRC, and LQE. TTML parsing/serialization in this package internally relies on `@applemusic-like-lyrics/ttml`.
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
Dedicated TTML parsing and serialization library with the most detailed TTML feature support.
|
||||
|
||||
## Lyric Package
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
Supported formats in the Lyric package:
|
||||
|
||||
| Format | Extension | Parse | Stringify |
|
||||
| --------------------- | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| TTML | `.ttml` | [`parseTTML`](/en/reference/lyric/functionparsettml) | [`stringifyTTML`](/en/reference/lyric/functionstringifyttml) |
|
||||
| LRC | `.lrc` | [`parseLrc`](/en/reference/lyric/functionparselrc) | [`stringifyLrc`](/en/reference/lyric/functionstringifylrc) |
|
||||
| LRC A2 | `.lrc`, `.alrc` | [`parseLrcA2`](/en/reference/lyric/functionparselrca2) | [`stringifyLrcA2`](/en/reference/lyric/functionstringifylrca2) |
|
||||
| NetEase YRC | `.yrc` | [`parseYrc`](/en/reference/lyric/functionparseyrc) | [`stringifyYrc`](/en/reference/lyric/functionstringifyyrc) |
|
||||
| QQ QRC | `.qrc` | [`parseQrc`](/en/reference/lyric/functionparseqrc) | [`stringifyQrc`](/en/reference/lyric/functionstringifyqrc) |
|
||||
| EsLyric | `.lrc`, `.eslrc` | [`parseEslrc`](/en/reference/lyric/functionparseeslrc) | [`stringifyEslrc`](/en/reference/lyric/functionstringifyeslrc) |
|
||||
| ASS subtitle | `.ass` | Not supported | [`stringifyAss`](/en/reference/lyric/functionstringifyass) |
|
||||
| Lyricify Lines | `.lyl` | [`parseLyl`](/en/reference/lyric/functionparselyl) | [`stringifyLyl`](/en/reference/lyric/functionstringifylyl) |
|
||||
| Lyricify Syllable | `.lys` | [`parseLys`](/en/reference/lyric/functionparselys) | [`stringifyLys`](/en/reference/lyric/functionstringifylys) |
|
||||
| Lyricify Quick Export | `.lqe` | [`parseLqe`](/en/reference/lyric/functionparselqe) | [`stringifyLqe`](/en/reference/lyric/functionstringifylqe) |
|
||||
|
||||
All parse and stringify methods above are synchronous.
|
||||
|
||||
Except TTML, parse functions take a string and return [`LyricLine[]`](/en/reference/lyric/interfacelyricline). Stringify functions take a lyric object array and return a string.
|
||||
|
||||
For TTML, parse output and stringify input are not `LyricLine[]`, but a [`TTMLLyric`](/en/reference/lyric/interfacettmllyric) object with metadata. Also, TTML parse/stringify in the Lyric package is browser-only. For Node.js usage or advanced TTML data, use the dedicated TTML package below.
|
||||
|
||||
The Lyric package also supports decryption/encryption for QQ Music encrypted QRC payloads: [`decryptQrcHex`](/en/reference/lyric/functiondecryptqrchex) and [`encryptQrcHex`](/en/reference/lyric/functionencryptqrchex).
|
||||
|
||||
For format details, see [Lyric Formats](./formats).
|
||||
|
||||
## TTML Package
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/ttml" />
|
||||
|
||||
The TTML package provides TTML parsing and serialization. For format details, see [TTML Format](./ttml).
|
||||
|
||||
It supports two usage modes: class mode and function mode.
|
||||
|
||||
### Class Mode
|
||||
|
||||
The TTML package provides two classes: [`TTMLParser`](/en/reference/ttml/classttmlparser) for parsing and [`TTMLGenerator`](/en/reference/ttml/classttmlgenerator) for generation. Both also provide static shortcuts.
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
const parser = new TTMLParser();
|
||||
const ttmlObject = parser.parse("Some TTML string...");
|
||||
|
||||
const _ttmlObject = TTMLParser.parse("Some TTML string...");
|
||||
|
||||
const generator = new TTMLGenerator();
|
||||
const ttmlString = generator.generate(ttmlObject);
|
||||
|
||||
const _ttmlString = TTMLGenerator.generate(ttmlObject);
|
||||
```
|
||||
|
||||
By default, these classes are browser-oriented. `TTMLParser` uses `DOMParser`, and `TTMLGenerator` uses `XMLSerializer` and `document.implementation`. For Node.js, provide implementations explicitly, for example with `@xmldom/xmldom`:
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
import { DOMParser, DOMImplementation, XMLSerializer } from "@xmldom/xmldom";
|
||||
|
||||
const parser = new TTMLParser({
|
||||
domParser: new DOMParser(),
|
||||
});
|
||||
|
||||
const generator = new TTMLGenerator({
|
||||
domImplementation: new DOMImplementation(),
|
||||
xmlSerializer: new XMLSerializer(),
|
||||
});
|
||||
```
|
||||
|
||||
Static methods are not suitable for Node.js.
|
||||
|
||||
### Function Mode
|
||||
|
||||
Import [`parseTTML`](/en/reference/ttml/functionparsettml) and [`exportTTML`](/en/reference/ttml/functionexportttml) directly.
|
||||
|
||||
```js
|
||||
import { parseTTML, exportTTML } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
const amllObject = parseTTML("Some TTML string...");
|
||||
const ttmlString = exportTTML(amllObject);
|
||||
```
|
||||
|
||||
The parse result type is [`AmllLyricResult`](/en/reference/ttml/interfaceamlllyricresult), which can be directly used in AMLL core.
|
||||
|
||||
Internally, this mode calls static methods of `TTMLParser`/`TTMLGenerator`. For parsing, it additionally calls [`toAmllLyrics`](/en/reference/ttml/functiontoamlllyrics). So this mode is also not suitable for Node.js.
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: TTML Format Overview
|
||||
---
|
||||
|
||||
TTML (Timed Text Markup Language) is a timed text markup standard defined by [W3C](https://www.w3.org/). Since it is XML-based, it is extensible, machine-readable, and suitable for cross-platform exchange.
|
||||
|
||||
TTML is whitespace-sensitive XML. Spaces and other whitespace in inline text are preserved in rendered lyrics. TTML snippets in this document are formatted for readability; in real usage, avoid arbitrary whitespace changes.
|
||||
|
||||
Apple Music uses TTML for word-by-word lyrics, so TTML is also the primary storage and exchange format in the AMLL ecosystem. It supports:
|
||||
|
||||
- Word-level timing
|
||||
- Translation and transliteration
|
||||
- Background vocals (`x-bg`)
|
||||
- Duet/multi-singer metadata (`ttm:agent`)
|
||||
- Song sections (`itunes:song-part`)
|
||||
- Ruby annotations
|
||||
|
||||
For AMLL TTML spec details, see [AMLL TTML DB Wiki - Format Specification](https://github.com/amll-dev/amll-ttml-db/wiki/%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83).
|
||||
|
||||
## Overall Structure
|
||||
|
||||
A minimal usable TTML file usually contains:
|
||||
|
||||
- Root `<tt>`
|
||||
- Metadata section `<head><metadata>...</metadata></head>`
|
||||
- Main lyric section `<body><div><p>...</p></div></body>`
|
||||
|
||||
Namespaces:
|
||||
|
||||
- `xmlns="http://www.w3.org/ns/ttml"`
|
||||
- `xmlns:ttm="http://www.w3.org/ns/ttml#metadata"`
|
||||
- `xmlns:itunes="http://music.apple.com/lyric-ttml-internal"`
|
||||
- `xmlns:amll="http://www.example.com/ns/amll"`
|
||||
- `xmlns:tts="http://www.w3.org/ns/ttml#styling"` (required for Ruby)
|
||||
|
||||
Root attributes:
|
||||
|
||||
- `xml:lang`: primary lyric language (BCP-47, e.g. `ja`, `zh-Hans`, `en-US`)
|
||||
- `itunes:timing`: `Word` (word-by-word) or `Line` (line-by-line)
|
||||
|
||||
## Metadata
|
||||
|
||||
Metadata is mainly in `<head><metadata>`. In AMLL, common types are:
|
||||
|
||||
1. `ttm:*` metadata (TTML standard)
|
||||
2. `amll:meta` metadata (AMLL extension)
|
||||
|
||||
Example:
|
||||
|
||||
```xml
|
||||
<metadata>
|
||||
<ttm:title>Song Title</ttm:title>
|
||||
|
||||
<ttm:agent type="person" xml:id="v1">
|
||||
<ttm:name type="full">Singer A</ttm:name>
|
||||
</ttm:agent>
|
||||
|
||||
<amll:meta key="musicName" value="Song Title" />
|
||||
<amll:meta key="artists" value="Singer A" />
|
||||
<amll:meta key="album" value="Album Name" />
|
||||
<amll:meta key="isrc" value="XX0000000000" />
|
||||
<amll:meta key="appleMusicId" value="1234567890" />
|
||||
</metadata>
|
||||
```
|
||||
|
||||
Common `amll:meta` keys:
|
||||
|
||||
- `musicName`, `artists`, `album`, `isrc`
|
||||
- Platform IDs: `ncmMusicId`, `qqMusicId`, `spotifyId`, `appleMusicId`
|
||||
- Contributors: `ttmlAuthorGithub`, `ttmlAuthorGithubLogin`
|
||||
|
||||
## Timing and Modes
|
||||
|
||||
Timing is typically expressed by `begin` / `end` / `dur`. Supported units:
|
||||
|
||||
- Clock format: `MM:SS.fff`, `HH:MM:SS.fff` (fractional digits 0-3)
|
||||
- Seconds format: `12.3s`
|
||||
|
||||
With `itunes:timing="Word"`:
|
||||
|
||||
- `<p>` is one lyric line
|
||||
- Timestamped inline `<span>` elements represent words/syllables
|
||||
|
||||
With `itunes:timing="Line"`:
|
||||
|
||||
- Primarily uses `<p begin="..." end="...">whole line text</p>`
|
||||
- Inline word timing spans are usually not used
|
||||
|
||||
## Body Structure and Extension Roles
|
||||
|
||||
Typical body structure:
|
||||
|
||||
```xml
|
||||
<body>
|
||||
<div itunes:song-part="Verse">
|
||||
<p begin="10.000" end="12.000" itunes:key="L1" ttm:agent="v1">
|
||||
<span begin="10.000" end="10.500">こ</span>
|
||||
<span begin="10.500" end="11.000">れ</span>
|
||||
<span begin="11.000" end="12.000">は</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
Common attributes and conventions:
|
||||
|
||||
- `itunes:key="L1"`: unique line ID (`L1`, `L2`, ...)
|
||||
- `ttm:agent="v1"`: points to `<ttm:agent xml:id="v1">`
|
||||
- `itunes:song-part`: section info (`Verse`, `Chorus`, etc.)
|
||||
|
||||
Inline assistant content uses `ttm:role`:
|
||||
|
||||
- `x-translation`: translation
|
||||
- `x-roman`: transliteration / romanization
|
||||
- `x-bg`: background vocal
|
||||
|
||||
Example:
|
||||
|
||||
```xml
|
||||
<p begin="20.000" end="25.000" itunes:key="L3" ttm:agent="v1000">
|
||||
<span begin="20.000" end="21.500">コーラス</span>
|
||||
<span begin="21.500" end="22.000">です</span>
|
||||
|
||||
<span ttm:role="x-bg" begin="22.500" end="23.800">
|
||||
<span begin="22.500" end="23.800">(背景)</span>
|
||||
<span ttm:role="x-translation" xml:lang="en">Background</span>
|
||||
<span ttm:role="x-roman" xml:lang="ja-Latn">haikei</span>
|
||||
</span>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Apple Music Style Translation/Transliteration Sidecar
|
||||
|
||||
In addition to inline spans, translation/transliteration can be stored in `<iTunesMetadata>`:
|
||||
|
||||
```xml
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="zh-Hans-CN" type="subtitle">
|
||||
<text for="L1">First line translation</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L1">dai ichi gyou</text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
```
|
||||
|
||||
`for="L1"` links to `itunes:key="L1"` in the body.
|
||||
|
||||
## Ruby Annotation
|
||||
|
||||
AMLL supports TTML Ruby (`tts:ruby`) for furigana, pinyin, etc.:
|
||||
|
||||
- `tts:ruby="container"`: ruby container
|
||||
- `tts:ruby="base"`: base text
|
||||
- `tts:ruby="textContainer"`: ruby text container
|
||||
- `tts:ruby="text"`: ruby text (can carry timing)
|
||||
|
||||
```xml
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">所</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.690" end="00:27.820">しょ</span>
|
||||
</span>
|
||||
</span>
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">詮</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.820" end="00:27.880">せ</span>
|
||||
<span tts:ruby="text" begin="00:27.880" end="00:27.950">ん</span>
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
## Implementation Behavior in This Library
|
||||
|
||||
Current parse/export behavior includes:
|
||||
|
||||
- Accepts both `itunes:songPart` and `itunes:song-part`; export prefers `song-part`
|
||||
- Parses and preserves `amll:obscene` and `amll:empty-beat`
|
||||
- Background vocal text can be with/without parentheses; parser normalizes it
|
||||
- Can merge inline translations with Head Sidecar translations (same language may duplicate; dedupe in business layer)
|
||||
- If word-level timing is absent but line timing exists, it can fallback to placeholder word entries to avoid information loss
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Ecosystem
|
||||
---
|
||||
|
||||
After continuous development, AMLL has built an open-source ecosystem around word-by-word lyrics, including lyric databases, editors, and music players.
|
||||
|
||||
## First-party Ecosystem
|
||||
|
||||
First-party projects are repositories under the [amll-dev](https://github.com/amll-dev/) GitHub organization. Content and docs for each project are maintained independently. Please report issues in the corresponding repository.
|
||||
|
||||
### AMLL TTML Database
|
||||
|
||||
[AMLL TTML Database](https://github.com/amll-dev/amll-ttml-db) is a high-quality open word-by-word lyrics database. Lyrics are community-contributed and reviewed, and published under [CC0-1.0](https://github.com/amll-dev/amll-ttml-db/blob/main/LICENSE).
|
||||
|
||||
If you are building a player, you can use it as your lyric source. You can also contribute lyrics back to the database. For submission and usage details, see its [repository wiki](https://github.com/amll-dev/amll-ttml-db/wiki).
|
||||
|
||||
### AMLL TTML Tool
|
||||
|
||||
[AMLL TTML Tool](https://github.com/amll-dev/amll-ttml-tool) is a React-based word-by-word lyrics editor with lyric editing and timing capabilities. Most lyrics in the database were created using this tool.
|
||||
|
||||

|
||||
|
||||
It is deployed at <https://tool.amll.dev/> and can be used directly.
|
||||
|
||||
### AMLL Editor
|
||||
|
||||
[AMLL Editor](https://github.com/amll-dev/amll-editor) is a next-generation Vue-based word-by-word lyrics editor, currently in early development. Compared with AMLL TTML Tool, it introduces additional conveniences such as find and replace.
|
||||
|
||||
It is deployed at <https://editor.amll.dev/> and can be used directly. Documentation is available in its [repository wiki](https://github.com/amll-dev/amll-editor/wiki).
|
||||
|
||||
### AMLL Player
|
||||
|
||||
[AMLL Player](https://github.com/amll-dev/amll-player) is a music player built on AMLL. It can be used as a local music player, or together with WS protocol to integrate with other music software.
|
||||
|
||||
## Recommended Third-party Projects
|
||||
|
||||
Here are selected third-party applications integrating AMLL. Because of GPL copyleft, these projects are also open-source under GPL and available for free use. We also maintain a [GitHub discussion](https://github.com/orgs/amll-dev/discussions/397).
|
||||
|
||||
### SPlayer
|
||||
|
||||
[SPlayer](https://github.com/imsyy/SPlayer) is a third-party NetEase Cloud Music client built with Vue.
|
||||
|
||||

|
||||
|
||||
## History
|
||||
|
||||
AMLL was born in [December 2022](https://github.com/amll-dev/applemusic-like-lyrics/commit/88a3c1d), initially as a BetterNCM plugin for NetEase Cloud Music PC client to enhance lyric UI.
|
||||
|
||||

|
||||
|
||||
In July 2023, AMLL released its first npm package [@applemusic-like-lyrics/core@0.0.1](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.0.1).
|
||||
|
||||
Due to multiple client limitations and performance issues in NetEase Cloud Music, AMLL Player development started in [August 2023](https://github.com/amll-dev/applemusic-like-lyrics/commit/28d3f6f). It communicates with clients through a WebSocket-based protocol and moved lyric rendering into an independent application.
|
||||
|
||||
In February 2024, the plugin released its final version [v3.1.0](https://github.com/amll-dev/applemusic-like-lyrics/releases/tag/v3.1.0), ending plugin-mode development and maintenance. Later, plugin UI parts were reorganized into reusable component libraries.
|
||||
|
||||
In September 2024, components from the original plugin were released as [@applemusic-like-lyrics/react-full@0.2.0-alpha.0](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full/v/0.2.0-alpha.0).
|
||||
|
||||
In April 2026, AMLL Player was [split out](https://github.com/amll-dev/applemusic-like-lyrics/pull/455) from the main repository into an [independent repository](https://github.com/amll-dev/amll-player), and an automated release workflow was introduced. Through GitHub Actions, the first provenance package [@applemusic-like-lyrics/core@0.3.0](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.3.0) was published.
|
||||
|
||||
AMLL is still under active development. Contributions are welcome. See [Contributing](/en/contribute).
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 995 KiB |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: A quick introduction to AMLL
|
||||
---
|
||||
|
||||
## What is AMLL
|
||||
|
||||
Apple Music Like Lyrics (AMLL) is an open-source frontend library for Apple Music style word-by-word lyric rendering.
|
||||
|
||||
Word-by-word lyrics (also called syllable-level lyrics) means lyric timing is aligned to syllables (Chinese characters, or syllables in alphabetic languages), similar to karaoke style rendering. During playback, text is highlighted progressively in sync with the music. You can see a simple demo on the [home page](/en/). Screenshots are shown below.
|
||||
|
||||

|
||||
|
||||
## Distribution and Usage
|
||||
|
||||
AMLL is distributed as npm packages and provides tools across rendering components, framework bindings, and lyric processing:
|
||||
|
||||
- **Rendering packages** (browser)
|
||||
- [@applemusic-like-lyrics/core](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
AMLL core library with framework-agnostic lyric and background rendering components
|
||||
- [@applemusic-like-lyrics/react](https://www.npmjs.com/package/@applemusic-like-lyrics/react)
|
||||
React bindings for the core library
|
||||
- [@applemusic-like-lyrics/vue](https://www.npmjs.com/package/@applemusic-like-lyrics/vue)
|
||||
Vue bindings for the core library
|
||||
- [@applemusic-like-lyrics/react-full](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full)
|
||||
Ready-to-use full player package with progress bar, cover, lyrics, background, etc. (React only)
|
||||
|
||||
- **Peripheral tools** (browser and Node)
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
Parsing and generation library for TTML word-by-word lyrics
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
Parsing and generation library for popular lyric formats, such as LRC, YRC, and LQE
|
||||
- [@applemusic-like-lyrics/fft](https://www.npmjs.com/package/@applemusic-like-lyrics/fft)
|
||||
Audio visualization module that converts waveform data into spectrum data
|
||||
- [@applemusic-like-lyrics/ws-protocol](https://www.npmjs.com/package/@applemusic-like-lyrics/ws-protocol)
|
||||
Lyrics player protocol library for syncing playback progress and playback information
|
||||
|
||||
AMLL is **open-sourced under [AGPL v3.0 only](https://spdx.org/licenses/AGPL-3.0-only.html)**, with the repository hosted on [GitHub](https://github.com/amll-dev/applemusic-like-lyrics). You can integrate it into your projects under the license terms.
|
||||
|
||||
Thanks to the maturity of frontend technologies, web rendering now has strong consistency across browsers, desktop, and mobile platforms. If you are building a music player, karaoke app, or related product with frontend technologies, AMLL is a strong option.
|
||||
|
||||
## Next Step
|
||||
|
||||
Beyond AMLL itself, there is a growing ecosystem around it, including lyric databases, lyric editors, and first-party players. See [Ecosystem](./eco) for details.
|
||||
|
||||
If you want to start using AMLL in your project, continue with [Quick Start](./quickstart).
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: API Reference
|
||||
description: Browse API documentation by module
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Core"
|
||||
description="Framework-agnostic lyric player"
|
||||
href="/en/reference/core"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React"
|
||||
description="React bindings and components for the core library"
|
||||
href="/en/reference/react"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React Full"
|
||||
description="Ready-to-use full React player package"
|
||||
href="/en/reference/react-full"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Vue"
|
||||
description="Vue bindings and components for the core library"
|
||||
href="/en/reference/vue"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric"
|
||||
description="Lyrics format processing library"
|
||||
href="/en/reference/lyric"
|
||||
/>
|
||||
<LinkCard
|
||||
title="TTML"
|
||||
description="TTML lyrics processing library"
|
||||
href="/en/reference/ttml"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: 动态背景
|
||||
---
|
||||
|
||||
AMLL Core 提供了独立的背景渲染组件 [`BackgroundRender`](/reference/core/classbackgroundrender)。它负责把专辑图或专辑视频渲染成 Apple Music 风格的动态背景;歌词组件仍然只负责歌词视图本身,音频播放、资源加载与图层挂载需要由宿主环境管理。
|
||||
|
||||
本文主要使用原生 Core API 说明背景集成方式,如果你使用 React 或 Vue 绑定,请直接转到 [绑定部分](#react-与-vue-绑定)。
|
||||
|
||||
## 基本结构
|
||||
|
||||
背景组件由两部分组成:
|
||||
|
||||
- [`BackgroundRender`](/reference/core/classbackgroundrender):统一的包装器,提供设置专辑图、帧率、渲染比例、暂停恢复等方法。
|
||||
- 渲染器:目前 Core 提供 [`MeshGradientRenderer`](/reference/core/classmeshgradientrenderer) 和 [`PixiRenderer`](/reference/core/classpixirenderer)。
|
||||
|
||||
创建背景时需要选择其中一个渲染器:
|
||||
|
||||
```ts
|
||||
// 使用 Mesh Gradient
|
||||
import {
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
const meshBackground = BackgroundRender.new(MeshGradientRenderer);
|
||||
|
||||
// 使用 Pixi
|
||||
import { BackgroundRender, PixiRenderer } from "@applemusic-like-lyrics/core";
|
||||
const pixiBackground = BackgroundRender.new(PixiRenderer);
|
||||
```
|
||||
|
||||
## 与歌词组件叠放
|
||||
|
||||
背景元素是一个 `<canvas>`。通常把它和歌词组件放在同一个容器中,并放置在歌词元素之前。
|
||||
|
||||
```ts
|
||||
import {
|
||||
BackgroundRender,
|
||||
DomLyricPlayer,
|
||||
MeshGradientRenderer,
|
||||
} from "@applemusic-like-lyrics/core";
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const background = BackgroundRender.new(MeshGradientRenderer);
|
||||
const lyricPlayer = new DomLyricPlayer();
|
||||
|
||||
function mountPlayer(host: HTMLElement) {
|
||||
const backgroundElement = background.getElement();
|
||||
const lyricElement = lyricPlayer.getElement();
|
||||
|
||||
host.appendChild(backgroundElement);
|
||||
host.appendChild(lyricElement);
|
||||
}
|
||||
|
||||
const host = document.querySelector<HTMLElement>("#player");
|
||||
if (!host) throw new Error("missing #player");
|
||||
|
||||
mountPlayer(host);
|
||||
```
|
||||
|
||||
背景的 canvas 尺寸由 CSS 决定,内部会通过 `ResizeObserver` 按设备像素比和渲染比例调整实际绘制尺寸。因此,宿主容器应有明确宽高。
|
||||
|
||||
AMLL 本身不会定义背景组件与歌词组件的定位与层级样式,这部分样式应由宿主定义。
|
||||
|
||||
## 设置专辑资源
|
||||
|
||||
调用 [`setAlbum`](/reference/core/classbackgroundrender#setalbum) 设置背景来源。它可以接收图片或视频 URL、`HTMLImageElement` 或 `HTMLVideoElement`。
|
||||
|
||||
如果传入字符串 URL 且资源是视频,需要把第二个参数设为 `true`:
|
||||
|
||||
```ts
|
||||
// 图片 URL
|
||||
await background.setAlbum("/album-cover.jpg");
|
||||
|
||||
// 视频 URL
|
||||
await background.setAlbum("/album-video.webm", true);
|
||||
```
|
||||
|
||||
若你已经持有 `File` 或 `Blob` 对象,可以使用 [`URL.createObjectURL`](https://developer.mozilla.org/zh-CN/docs/Web/API/URL/createObjectURL_static) 创建对象 URL 并提供给 `setAlbum`。
|
||||
|
||||
背景渲染会把资源绘制到 canvas / WebGL 纹理中。
|
||||
|
||||
## 同步播放状态
|
||||
|
||||
背景组件拥有自己的动画循环,不需要像 `DomLyricPlayer` 一样手动逐帧调用 `update(delta)`。只需要在播放、暂停时调用 `resume()` 与 `pause()`:
|
||||
|
||||
```ts
|
||||
audio.addEventListener("play", () => {
|
||||
lyricPlayer.resume();
|
||||
background.resume();
|
||||
});
|
||||
|
||||
audio.addEventListener("pause", () => {
|
||||
lyricPlayer.pause();
|
||||
background.pause();
|
||||
});
|
||||
```
|
||||
|
||||
背景动画的播放状态与歌词动画独立,你也可以只控制背景动画。
|
||||
|
||||
```ts
|
||||
function setBackgroundPlaying(playing: boolean) {
|
||||
if (playing) background.resume();
|
||||
else background.pause();
|
||||
}
|
||||
```
|
||||
|
||||
## 应用渲染设置
|
||||
|
||||
常用设置可以在初始化后或用户调整选项时应用,设置后即时生效。
|
||||
|
||||
```ts
|
||||
function applyBackgroundSettings() {
|
||||
background.setFPS(60);
|
||||
background.setRenderScale(1);
|
||||
background.setFlowSpeed(0.2);
|
||||
background.setStaticMode(false);
|
||||
background.setLowFreqVolume(1);
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| [`setFPS(fps)`](/reference/core/classbackgroundrender#setfps) | 设置背景动画帧率 |
|
||||
| [`setRenderScale(scale)`](/reference/core/classbackgroundrender#setrenderscale) | 设置渲染比例,数值越高越清晰,也越消耗性能 |
|
||||
| [`setFlowSpeed(speed)`](/reference/core/classbackgroundrender#setflowspeed) | 设置背景流动速度 |
|
||||
| [`setStaticMode(enable)`](/reference/core/classbackgroundrender#setstaticmode) | 开启后,背景在资源切换动画结束后可以停在静态状态,以节省性能 |
|
||||
| [`setLowFreqVolume(volume)`](/reference/core/classbackgroundrender#setlowfreqvolume) | 传入低频音量提示,部分渲染器可能会据此调整动态效果 |
|
||||
| [`setHasLyric(hasLyric)`](/reference/core/classbackgroundrender#sethaslyric) | 告诉渲染器当前歌曲是否有歌词,部分渲染器可能会据此调整动态效果 |
|
||||
|
||||
`setRenderScale` 的值通常在 `0.5` 到 `1` 之间取舍。移动端或低性能设备可以降低渲染比例和帧率;播放器全屏展示时可以提高渲染比例。
|
||||
|
||||
## 更换渲染器
|
||||
|
||||
`BackgroundRender` 创建后不能替换内部渲染器。如果用户从 `MeshGradientRenderer` 切换到 `PixiRenderer`,应该释放旧实例并创建新实例:
|
||||
|
||||
```ts
|
||||
type PlayerBackground =
|
||||
| BackgroundRender<MeshGradientRenderer>
|
||||
| BackgroundRender<PixiRenderer>;
|
||||
|
||||
function switchToPixiRenderer(
|
||||
host: HTMLElement,
|
||||
lyricPlayer: DomLyricPlayer,
|
||||
currentBackground: PlayerBackground,
|
||||
) {
|
||||
currentBackground.dispose();
|
||||
|
||||
const nextBackground = BackgroundRender.new(PixiRenderer);
|
||||
host.insertBefore(nextBackground.getElement(), lyricPlayer.getElement());
|
||||
return nextBackground;
|
||||
}
|
||||
```
|
||||
|
||||
## 清理
|
||||
|
||||
页面卸载、播放器销毁或永久切换实现时,需要释放背景实例:
|
||||
|
||||
```ts
|
||||
background.dispose();
|
||||
lyricPlayer.dispose();
|
||||
```
|
||||
|
||||
`dispose()` 会释放渲染器内部资源,并移除背景 canvas。若你自己创建了 `ObjectURL`、音频事件监听或其他异步加载状态,也需要在宿主代码中一并清理。
|
||||
|
||||
## React 与 Vue 绑定
|
||||
|
||||
背景组件没有歌词组件那么复杂的中间状态维护,因此组件化轻松许多。
|
||||
|
||||
React 与 Vue 是类似的,均通过 props 设置选项与维护状态。是否播放使用 `playing` 属性指定,专辑图片或视频资源使用 `album` 属性指定。你可以在 API 参考中查看完整的属性列表。
|
||||
|
||||
- React 属性列表参考:[BackgroundRenderProps](/reference/react/interfacebackgroundrenderprops)
|
||||
- Vue 属性列表参考:[BackgroundRender](/reference/vue/classbackgroundrender)
|
||||
|
||||
组件在卸载时会自动释放内部资源。但你自行定义的监听器、Object URL 等仍需你自行释放。
|
||||
|
||||
下面是一个 React 的最小示例:
|
||||
|
||||
```tsx
|
||||
import { LyricPlayer, BackgroundRender } from "@applemusic-like-lyrics/react";
|
||||
|
||||
function app() {
|
||||
const albumUrl = "/album-cover.jpg";
|
||||
return (
|
||||
<>
|
||||
<BackgroundRender album={albumUrl} />
|
||||
<LyricPlayer lyricLines={lyricLines} currentTime={currentTime} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
下面是一个 Vue 的最小示例:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BackgroundRender :album="albumUrl" />
|
||||
<LyricPlayer :lyricLines="lyricLines" :currentTime="currentTime" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackgroundRender, LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const albumUrl = ref("/album-cover.jpg");
|
||||
const currentTime = ref(0);
|
||||
const lyricLines = ref([]);
|
||||
</script>
|
||||
```
|
||||
|
||||
## 检查清单
|
||||
|
||||
- 宿主容器有明确尺寸。
|
||||
- 背景 canvas 插入在歌词元素之前,且歌词层级高于背景层级。
|
||||
- 图片或视频资源允许跨域读取,或与页面同源。
|
||||
- 视频 URL 调用 `setAlbum(source, true)`。
|
||||
- 播放状态同步到 `resume()` / `pause()`。
|
||||
- 切换渲染器时先 `dispose()` 旧背景,再创建新背景。
|
||||
- 卸载时释放背景、歌词组件和宿主代码创建的资源。
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
title: 快速开始
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
import PackageInstall from "#components/PackageInstall.astro";
|
||||
|
||||
下面快速介绍如何将 AMLL 歌词组件集成到你的项目中。请注意,AMLL 不提供 CDN 引入方式,必须使用 bundler。
|
||||
|
||||
有关除组件库之外的其他周围工具包,请直接查阅 [API 参考](/reference)。
|
||||
|
||||
## 依赖
|
||||
|
||||
安装 AMLL 核心库:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/core" />
|
||||
|
||||
此外,AMLL 将一些图形与动画库声明为 peer,这是为了复用项目中可能存在的相关依赖。一些包管理器或配置下可能会自动安装 peer,若没有,需要手动安装。
|
||||
|
||||
<PackageInstall
|
||||
packages={[
|
||||
"@pixi/app",
|
||||
"@pixi/core",
|
||||
"@pixi/display",
|
||||
"@pixi/filter-blur",
|
||||
"@pixi/filter-bulge-pinch",
|
||||
"@pixi/filter-color-matrix",
|
||||
"@pixi/sprite",
|
||||
"jss",
|
||||
"jss-preset-default",
|
||||
]}
|
||||
/>
|
||||
|
||||
下面的示例会用 `@applemusic-like-lyrics/lyric` 解析 TTML 歌词文件。如果你的项目已经能直接提供 `LyricLine[]`,可以跳过这个包。
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
接下来你可以使用原生包,也可以使用 React 或 Vue 绑定。
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="使用原生方式引入"
|
||||
description="不使用前端框架"
|
||||
href="#使用原生方式引入"
|
||||
/>
|
||||
<LinkCard
|
||||
title="使用 React 绑定"
|
||||
description="项目使用 React 框架"
|
||||
href="#使用-react-绑定"
|
||||
/>
|
||||
<LinkCard
|
||||
title="使用 Vue 绑定"
|
||||
description="项目使用 Vue 框架"
|
||||
href="#使用-vue-绑定"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
## 使用原生方式引入
|
||||
|
||||
AMLL 核心库是框架无关的。无论是否使用框架,均可使用此方法引入。
|
||||
|
||||
下面假设:
|
||||
|
||||
- 页面中已有 `<audio id="audio">` 和 `<div id="lyric-player">`,并给歌词容器设置了明确高度
|
||||
- TTML 歌词文件在 `/lyrics/song.ttml` 上提供
|
||||
|
||||
```js
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audio = document.querySelector("#audio");
|
||||
const playerHost = document.querySelector("#lyric-player");
|
||||
const player = new LyricPlayer();
|
||||
|
||||
playerHost.appendChild(player.getElement());
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setLyricLines(parseTTML(ttml).lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
}
|
||||
|
||||
// 播放时持续同步音频进度
|
||||
let lastFrameTime = -1;
|
||||
function onFrame(frameTime) {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
player.update(delta);
|
||||
requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", () => player.resume());
|
||||
audio.addEventListener("pause", () => player.pause());
|
||||
audio.addEventListener("seeked", () => {
|
||||
// 跳转后立即对齐歌词位置
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
});
|
||||
|
||||
loadLyric();
|
||||
requestAnimationFrame(onFrame);
|
||||
```
|
||||
|
||||
有关更详细的时序管理,请转到 [时序与生命周期](./sequence)。
|
||||
|
||||
你可以前往 [API 参考:Core 核心](/reference/core) 获取详细的接口文档。
|
||||
|
||||
## 使用 React 绑定
|
||||
|
||||
确保你已安装 `react` 和 `react-dom` 包。然后安装 AMLL React 绑定包:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/react" />
|
||||
|
||||
React 绑定包拥有具名导出 [`LyricPlayer`](/reference/react/variablelyricplayer) 作为核心组件。下面是一段示例,假设 TTML 歌词文件在 `/lyrics/song.ttml` 上提供、音频文件在 `/music/song.m4a` 上提供。
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/react";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
function App() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [lyricLines, setLyricLines] = useState<LyricLine[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
function syncCurrentTime() {
|
||||
const audio = audioRef.current;
|
||||
if (audio) setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
// parseTTML 返回包含元数据的对象,组件只需要其中的 lines
|
||||
fetch("/lyrics/song.ttml")
|
||||
.then((res) => res.text())
|
||||
.then((ttml) => {
|
||||
if (!canceled) setLyricLines(parseTTML(ttml).lines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0;
|
||||
const onFrame = () => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) {
|
||||
setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
playing={playing}
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onSeeked={syncCurrentTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
你可以前往 [API 参考:React 绑定](/reference/react) 获取详细的接口文档。
|
||||
|
||||
## 使用 Vue 绑定
|
||||
|
||||
确保你已安装 Vue。然后安装 AMLL Vue 绑定包:
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/vue" />
|
||||
|
||||
Vue 绑定包拥有具名导出 [`LyricPlayer`](/reference/vue/classlyricplayer) 作为核心组件。歌词对象、播放进度等均以组件的响应式属性传入。下面是一段示例,假设 TTML 歌词文件在 `/lyrics/song.ttml` 上提供、音频文件在 `/music/song.m4a` 上提供。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LyricPlayer
|
||||
class="lyric-player"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime"
|
||||
:playing="playing"
|
||||
/>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
src="/music/song.m4a"
|
||||
controls
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onPause"
|
||||
@seeked="syncCurrentTime"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import {
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
} from "vue";
|
||||
|
||||
// 一般地,打包器会处理 CSS 导入;如果出现异常,请查阅你使用的打包器文档
|
||||
import "@applemusic-like-lyrics/core/style.css";
|
||||
|
||||
const audioEl = useTemplateRef<HTMLAudioElement>("audioEl");
|
||||
const lyricLines = shallowRef<LyricLine[]>([]);
|
||||
const currentTime = ref(0);
|
||||
const playing = ref(false);
|
||||
let frameId = 0;
|
||||
|
||||
async function loadLyric() {
|
||||
const ttml = await fetch("/lyrics/song.ttml").then((res) => res.text());
|
||||
// parseTTML 返回包含元数据的对象,组件只需要其中的 lines
|
||||
lyricLines.value = parseTTML(ttml).lines;
|
||||
}
|
||||
|
||||
function syncCurrentTime() {
|
||||
if (audioEl.value) {
|
||||
currentTime.value = Math.round(audioEl.value.currentTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function onFrame() {
|
||||
syncCurrentTime();
|
||||
if (playing.value) frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playing.value = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
onFrame();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
playing.value = false;
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
|
||||
onMounted(() => void loadLyric());
|
||||
onBeforeUnmount(() => cancelAnimationFrame(frameId));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-player {
|
||||
height: 420px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
你可以前往 [API 参考:Vue 绑定](/reference/vue) 获取详细的接口文档。
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: 时序与生命周期
|
||||
---
|
||||
|
||||
下面介绍歌词组件的时序与生命周期管理。
|
||||
|
||||
歌词组件只负责歌词视图本身,**不负责音频播放**。因此 **宿主环境(也就是你的代码)需要管理音频播放,并把音频播放状态与 AMLL 的组件状态桥接起来。**
|
||||
|
||||
如果你使用 React 或 Vue 绑定,组件会代管一部分生命周期;如果直接使用原生方式,则需要自己管理完整流程。本文主要介绍原生方式引入的周期管理,并介绍绑定托管的状态。
|
||||
|
||||
## 初始化
|
||||
|
||||
初始化时需要完成:
|
||||
|
||||
1. 创建歌词组件,并把它的元素挂载到一个 **有明确尺寸的** 容器里。
|
||||
2. (可选)设置自定义歌词优化选项。[`setOptimizeOptions`](/reference/core/classlyricplayerbase#setoptimizeoptions) 方法接受 [`OptimizeLyricOptions`](/reference/core/interfaceoptimizelyricoptions)。
|
||||
3. 设置歌词数据。[`setLyricLines`](/reference/core/classlyricplayerbase#setlyriclines) 方法接受 [`LyricLine[]`](/reference/core/interfacelyricline),传入后不应再修改这些对象。
|
||||
4. 用当前播放进度对齐一次歌词位置。
|
||||
|
||||
原生方式的典型顺序如下:
|
||||
|
||||
```ts
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/core";
|
||||
|
||||
const player = new LyricPlayer();
|
||||
host.appendChild(player.getElement());
|
||||
|
||||
const currentTime = Math.round(audio.currentTime * 1000);
|
||||
player.setOptimizeOptions({}); // 可选
|
||||
player.setLyricLines(lines, currentTime);
|
||||
player.setCurrentTime(currentTime, true);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
在设置歌词时会执行歌词优化处理,因此这部分选项如需调整,应在 `setLyricLines` 之前调用 `setOptimizeOptions`。在 `setLyricLines` 之后修改不会自动重新处理已有歌词,需要重新设置歌词。
|
||||
|
||||
另外需要注意其中 `currentTime` 的单位是毫秒,且应为整数。`audio.currentTime` 单位为秒,所以要乘以 `1000`。
|
||||
|
||||
## 播放与暂停
|
||||
|
||||
`pause()` 和 `resume()` 控制歌词组件内部的演出状态,包括逐字动画与辉光、间奏点动画。音频开始播放时调用 `resume()`,音频暂停、结束或被外部中断时调用 `pause()`。
|
||||
|
||||
例如,若使用 `<audio>` 播放音频,可以使用其事件驱动:
|
||||
|
||||
```ts
|
||||
const onPlay = () => {
|
||||
player.resume();
|
||||
};
|
||||
const onPause = () => {
|
||||
player.pause();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
```
|
||||
|
||||
## 播放进度
|
||||
|
||||
### 正常播放
|
||||
|
||||
在播放过程中需要更新歌词组件的时间进度。**AMLL 使用的所有时间,单位均为毫秒**。
|
||||
|
||||
其中有两个容易混淆的时间:
|
||||
|
||||
| 时间类型 | 接收于 | 含义 |
|
||||
| ------------ | ------------------------------------------- | -------------------- |
|
||||
| 当前播放进度 | `setCurrentTime(time)` / `currentTime` 属性 | 歌曲播放的进度 |
|
||||
| 帧间隔 | `update(delta)` | 距离上一帧过去的时间 |
|
||||
|
||||
原生方式下,`setCurrentTime` 会更新歌词时间线,`update` 会推进动画。**二者不是同一个值**。
|
||||
|
||||
```ts
|
||||
let frameId = 0;
|
||||
let lastFrameTime = -1;
|
||||
|
||||
function startFrameLoop() {
|
||||
const onFrame = (frameTime: number) => {
|
||||
const delta = lastFrameTime === -1 ? 0 : frameTime - lastFrameTime;
|
||||
lastFrameTime = frameTime;
|
||||
if (!audio.paused) {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000));
|
||||
}
|
||||
player.update(delta);
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameId = requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
function stopFrameLoop() {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
lastFrameTime = -1;
|
||||
}
|
||||
```
|
||||
|
||||
**不应依赖 `<audio>` 的 `timeupdate` 事件同步歌词**。这是由于浏览器触发 `timeupdate` 的频率较低且不稳定,通常明显低于动画帧频率。播放中应使用 `requestAnimationFrame` 逐帧同步当前进度。
|
||||
|
||||
### 跳转
|
||||
|
||||
在正常播放之外,播放进度有可能产生跳变,常见于:
|
||||
|
||||
- 拖动进度条
|
||||
- 快进快退
|
||||
- 点击某一歌词行跳转
|
||||
- 循环播放时,进度从结尾跳至开头
|
||||
|
||||
播放进度发生跳变时,需要把 `setCurrentTime` 的第二个参数设为 `true`:
|
||||
|
||||
```ts
|
||||
function onSeeked() {
|
||||
player.setCurrentTime(Math.round(audio.currentTime * 1000), true);
|
||||
}
|
||||
audio.addEventListener("seeked", onSeeked);
|
||||
```
|
||||
|
||||
**这个参数表示本次同步是一次 seek。正常播放状态与 seek 状态的布局与动画行为是不同的:**
|
||||
|
||||
- 正常播放时,组件会对视图内的每一行单独执行布局与弹簧动画,实现细腻的视觉效果
|
||||
- 调整进度时,组件会强制对齐歌词位置,对所有歌词行整体执行布局与弹簧动画效果,减小性能消耗且动画更加利落
|
||||
|
||||
如果没有正确标记 seek 状态,可能出现布局异常,例如出现卡顿、歌词行从屏幕一端快速飞到另一端消失等等。你可以在 [issue #429](https://github.com/amll-dev/applemusic-like-lyrics/issues/429) 中看到截图。
|
||||
|
||||
### 歌词行点击事件
|
||||
|
||||
组件提供了 `line-click` 事件,在某一歌词行被点击时触发,其事件类型为 [`LyricLineMouseEvent`](/reference/core/classlyriclinemouseevent)。
|
||||
|
||||
**组件本身不会响应歌词行的点击操作。** 宿主环境需要监听该事件,并作出音频进度跳转等操作。例如:
|
||||
|
||||
```ts
|
||||
import type { LyricLineMouseEvent } from "@applemusic-like-lyrics/core";
|
||||
|
||||
player.addEventListener("line-click", (event) => {
|
||||
const lineEvent = event as LyricLineMouseEvent;
|
||||
audio.currentTime = lineEvent.line.getLine().startTime / 1000;
|
||||
player.setCurrentTime(lineEvent.line.getLine().startTime, true);
|
||||
});
|
||||
```
|
||||
|
||||
值得一提:点击歌词行跳转时也属于 seek。
|
||||
|
||||
## 更换歌词
|
||||
|
||||
更换歌曲或歌词源时,通过 `setLyricLines` 方法再次设置歌词行对象数组即可。如果加载失败,可以传入空数组清空歌词。
|
||||
|
||||
```ts
|
||||
player.setLyricLines([]);
|
||||
player.update(0);
|
||||
```
|
||||
|
||||
## React 与 Vue 绑定
|
||||
|
||||
React 和 Vue 绑定会创建并销毁底层 Core 组件,也会在未禁用时自动调用 `update`。因此使用绑定时,通常不需要自己调用底层 `update`。
|
||||
|
||||
你仍然需要负责这些状态:
|
||||
|
||||
| 状态 | React / Vue 传入方式 | 说明 |
|
||||
| ------------ | -------------------- | ------------------------------------------- |
|
||||
| 歌词数据 | `lyricLines` | 解析后的 `LyricLine[]` |
|
||||
| 当前播放进度 | `currentTime` | 播放中用 `requestAnimationFrame` 从音频同步 |
|
||||
| 播放状态 | `playing` | 控制歌词组件内部演出暂停或恢复 |
|
||||
|
||||
React 绑定额外提供 `isSeeking` 属性,可以在跳转时传入:
|
||||
|
||||
```tsx
|
||||
<LyricPlayer
|
||||
lyricLines={lyricLines}
|
||||
currentTime={currentTime}
|
||||
isSeeking={isSeeking}
|
||||
playing={playing}
|
||||
/>
|
||||
```
|
||||
|
||||
`isSeeking` 不应长期保持为 `true`。通常在用户完成一次跳转时短暂置为 `true`,下一轮同步后再恢复为 `false`。
|
||||
|
||||
Vue 绑定目前功能较为残缺,没有单独的 `isSeeking` 属性。一般场景下同步 `currentTime` 就可以工作。如果需要进一步控制状态,建议直接使用原生方式引入。我们将会在接下来的版本中逐步优化 Vue 绑定的功能与使用体验。
|
||||
|
||||
如果设置了 `disabled`,绑定将不再代管逐帧动画。此时你可以通过组件 ref 取得底层 `lyricPlayer`,并像原生方式一样自己调用 `update`。
|
||||
|
||||
## 清理
|
||||
|
||||
当不再需要歌词播放组件时,原生方式需要清理你自己创建的所有资源:
|
||||
|
||||
```ts
|
||||
// 清除你定义的 requestAnimationFrame 逐帧调用
|
||||
stopFrameLoop();
|
||||
|
||||
// 移除你添加的侦听器
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("seeked", onSeeked);
|
||||
|
||||
// 释放组件资源
|
||||
player.dispose();
|
||||
```
|
||||
|
||||
`dispose()` 会移除组件元素并释放内部监听。
|
||||
|
||||
如果使用 React 或 Vue 绑定,组件卸载时会自动调用底层 `dispose()`;但你自己创建的 `requestAnimationFrame`、音频事件监听、`ObjectURL` 等仍然需要在组件卸载时清理。
|
||||
|
||||
## 检查清单
|
||||
|
||||
- 容器应有明确尺寸,且已经挂载到 DOM。
|
||||
- 歌词通过 `setLyricLines(lines, currentTime)` 或 `lyricLines` 属性传入。
|
||||
- 播放进度用毫秒表示。
|
||||
- 播放时用 `requestAnimationFrame` 同步 `currentTime`。
|
||||
- 原生方式逐帧调用 `update(delta)`。
|
||||
- 暂停、恢复、结束播放时同步 `pause()` / `resume()` 或 `playing`。
|
||||
- 跳转使用 seek 标志对齐。
|
||||
- 卸载时取消动画帧、移除事件监听并释放组件。
|
||||
29
amll-local/packages/docs/src/content/docs/guides/index.mdx
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: 使用文档
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="介绍"
|
||||
description="AMLL 的基本信息"
|
||||
href="/guides/overview/intro"
|
||||
/>
|
||||
<LinkCard
|
||||
title="歌词组件"
|
||||
description="将歌词组件集成到项目中"
|
||||
href="/guides/component/quickstart"
|
||||
/>
|
||||
<LinkCard
|
||||
title="背景组件"
|
||||
description="将背景组件集成到项目中"
|
||||
href="/guides/component/background"
|
||||
/>
|
||||
<LinkCard
|
||||
title="歌词格式"
|
||||
description="歌词格式介绍与库使用说明"
|
||||
href="/guides/lyric/quickstart"
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: 各歌词格式介绍
|
||||
---
|
||||
|
||||
本文介绍 [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric) 库支持的一些歌词文件格式。
|
||||
|
||||
在本文中,「逐字歌词」**泛指时间戳精度高于行级别的歌词**。不同平台、格式实现有所不同,可能对应逐音节或逐词的时间戳。
|
||||
|
||||
## TTML
|
||||
|
||||
TTML 格式是 AMLL 生态的主要歌词存储与交换格式,支持 AMLL 生态的所有能力,包括翻译音译、背景对唱、逐字音译、注音等等。
|
||||
|
||||
有关 TTML 的介绍详见 [TTML](./ttml) 一文。
|
||||
|
||||
**本库提供 [`parseTTML`](/reference/lyric/functionparsettml) 和 [`stringifyTTML`](/reference/lyric/functionstringifyttml) 方法用于正反序列化 TTML 歌词。**
|
||||
|
||||
## LRC
|
||||
|
||||
LRC 是最常见的歌词文件格式,只支持逐行歌词。扩展名为 `.lrc`,是 **l**y**r**i**c**s 的缩写。
|
||||
|
||||
我们这里所说的 LRC 是最基本、未作任何扩展的 LRC 格式,有时也称为简单 LRC。LRC 并不是某个机构公开声明定义的,其最初来源已经不可考,目前已经成为业界的某种约定俗成,因此各种变体繁多,很多细节也没有固定。
|
||||
|
||||
一般地,LRC 格式中每一行对应着一个歌词行。歌词行的开头有时间戳,表示该行的开始时间。常见的格式有
|
||||
|
||||
- `[mm:ss]`
|
||||
- `[mm:ss.xx]`
|
||||
- `[mm:ss.xxx]`
|
||||
|
||||
其中 `mm` 为分钟,`ss.xxx` 为秒,小数点后可能不保留、保留 2 位或保留 3 位。特别地,同一行歌词可以使用多个时间戳,表示在不同时间重复出现。
|
||||
|
||||
LRC 格式还支持在开头添加元数据。元数据的格式为 `[tag:content]`。AMLL Editor 支持 LRC 元数据模板,包含了常见字段,在元数据侧边栏的下拉框中可以选择。此处不再列举。
|
||||
|
||||
一些实现允许使用 `#` 开头的行作为注释。
|
||||
|
||||
这里给出一段 LRC 歌词的示例。
|
||||
|
||||
```lrc
|
||||
[al:崩坏星穹铁道-不虚此行 On the Journey]
|
||||
[ti:不虚此行 On the Journey]
|
||||
[ar:魏晨, Nea]
|
||||
[length: 2:36]
|
||||
|
||||
[00:25.494]We venture through the cosmic sea
|
||||
[00:27.541]A thousand light-years, wild and free
|
||||
[00:30.805]We dance beneath the galaxy
|
||||
[00:32.847]Then will you be with me?
|
||||
# ...
|
||||
[01:45.949]Catching on, our paths unknown
|
||||
[01:50.261]To sink into daylight
|
||||
[01:52.851]Break into the moonlight
|
||||
[01:56.487]Life goes on, through tides of time
|
||||
[02:00.900]Get in the line, to dream alive
|
||||
[02:03.580]In our souls, do we know?
|
||||
[02:05.896][02:08.473][02:11.262]On the journey
|
||||
```
|
||||
|
||||
LRC 不支持为歌词添加翻译与音译。一般的处理方法是,如有翻译与音译,通过额外的独立文件提供,时间戳与原文歌词文件相同,对应时间戳对应行的翻译与音译。
|
||||
|
||||
[维基百科:LRC (file format)](<https://en.wikipedia.org/wiki/LRC_(file_format)>) 上有更多介绍。
|
||||
|
||||
**本库提供 [`parseLrc`](/reference/lyric/functionparselrc) 和 [`stringifyLrc`](/reference/lyric/functionstringifylrc) 方法用于正反序列化 LRC 歌词。**
|
||||
|
||||
## LRC A2
|
||||
|
||||
LRC A2 首先由 A2 Media Player 提出,故名。其是在 LRC 基础上扩展的结果,通过在文中加注尖括号包裹的时间戳 `<mm:ss.xx>` 实现了逐字。时间戳格式与 LRC 类似,可能不保留小数点、或小数点后保留 2 位或 3 位。每个尖括号都表示其**后续**一部分文本片段的开始时间。
|
||||
|
||||
LRC A2 的扩展名为 `.lrc` 或 `.alrc`。
|
||||
|
||||
这里直接给出示例。
|
||||
|
||||
```alrc
|
||||
[ti: Somebody to Love]
|
||||
[ar: Jefferson Airplane]
|
||||
[al: Surrealistic Pillow]
|
||||
[length: 2:58]
|
||||
|
||||
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> Love
|
||||
```
|
||||
|
||||
解析时,时间戳前后的空格应当合并。
|
||||
|
||||
LRC A2 使用尖括号夹入文中,这里涉及到转义的问题:如果歌词中出现了大于号 `>` 或小于号 `<`,应当如何处置?目前没有找到公开的规范说明,业内没有统一的转义方式。在解析时会尽量按时间戳模式匹配 `<mm:ss.xx>`,若歌词中出现 `<` 或 `>` 且不符合时间戳格式,则按普通字符处理。
|
||||
|
||||
LRC A2 也不支持为歌词添加翻译与音译。一般的做法是伴随提供 LRC 格式的翻译或音译。
|
||||
|
||||
**本库提供 [`parseLrcA2`](/reference/lyric/functionparselrca2) 和 [`stringifyLrcA2`](/reference/lyric/functionstringifylrca2) 方法用于正反序列化 LRC 歌词。**
|
||||
|
||||
## 网易云逐字与 QQ 音乐逐字
|
||||
|
||||
二者都是音乐平台私有的逐字歌词格式。网易云音乐使用 `.yrc` 作为扩展名,QQ 音乐使用 `.qrc` 作为扩展名。
|
||||
|
||||
二者都使用开头的方括号标注行时间戳,格式均为 `[lineStart,lineDur]`。`lineStart` 为行起始时间,以毫秒计的整数;`lineDur` 是行持续时间,也是以毫秒计的整数。二者也均**不支持**类似 LRC 的重复行使用多个时间戳。
|
||||
|
||||
在逐字的表现形式上二者有一些不同。
|
||||
|
||||
- 网易云的 YRC 采用 `(sylStart,sylDur,0)text` 格式,先时间戳、后文本,时间戳括号内三个数分别是起始时间、持续时间、`0`。所有 YRC 格式歌词都带有这个 `0`,目前尚未发现其具体含义,可能是为未来扩展预留的字段。
|
||||
- QQ 音乐的 QRC 采用 `text(sylStart,sylDur)` 格式,先文本、后时间戳。并且没有 YRC 的 `0`。
|
||||
|
||||
所有的开始时间均为**绝对时间**,即从音频开始到文本开始所经过的时间。
|
||||
|
||||
下面是同一段歌词在两种格式下的示例:
|
||||
|
||||
```
|
||||
# 网易云音乐 YRC
|
||||
[190871,1984](190871,361,0)For (191232,172,0)the (191404,376,0)first (191780,1075,0)time
|
||||
[193459,4198](193459,412,0)What's (193871,574,0)past (194445,506,0)is (194951,2706,0)past
|
||||
|
||||
# QQ 音乐 QRC
|
||||
[190871,1984]For (190871,361)the (191232,172)first (191404,376)time(191780,1075)
|
||||
[193459,4198]What's (193459,412)past (193871,574)is (194445,506)past(194951,2706)
|
||||
```
|
||||
|
||||
QRC 和 YRC **不具有**类似 LRC A2 的合并相邻空格特性。
|
||||
|
||||
由于在文中夹注半角括号 `()` 的时间戳,那歌词文本中的圆括号应当如何转义?由于 YRC 和 QRC 都是私有文件格式,没有官方资料可查,于是我们查找这两个平台上的官方歌词:
|
||||
|
||||
- 在我们目前处理到的所有 YRC 歌词中,所有的圆括号均为全角 `()`,无论是中文还是其他语言
|
||||
- 在我们目前处理到的所有 QRC 歌词中,圆括号未做转译或替换
|
||||
|
||||
因此根据现有样本推测:
|
||||
|
||||
- YRC 实际上可能不允许在歌词文本中使用半角圆括号,若有需要应使用全角圆括号。本库也遵守这一原则,在导出为 YRC 时若歌词文本中存在半角圆括号,会自动替换为全角。
|
||||
- QRC 在匹配时间戳时会略过非时间戳的圆括号,将其作为歌词文本处理。
|
||||
|
||||
YRC、QRC 也不支持为歌词添加翻译与音译。一般的做法是伴随提供 LRC 格式的翻译或音译。
|
||||
|
||||
另外,本库在解析 YRC 与 QRC 歌词时,若整行被圆括号括起,则会将该行视为背景行并去除括号。
|
||||
|
||||
**本库提供 [`parseYrc`](/reference/lyric/functionparseyrc) 和 [`stringifyYrc`](/reference/lyric/functionstringifyyrc) 方法用于正反序列化 YRC 歌词;提供 [`parseQrc`](/reference/lyric/functionparseqrc) 和 [`stringifyQrc`](/reference/lyric/functionstringifyqrc) 方法用于正反序列化 QRC 歌词。**
|
||||
|
||||
特别地,QQ 音乐在分发 QRC 歌词时使用了一种加密格式。此格式为包含了 QRC 文本的 XML,经一种类 DES 算法加密后使用 base64 编码而成。**本库提供 [`decryptQrcHex`](/reference/lyric/functiondecryptqrchex) 函数用于解密这样的 base64 串为 XML 文本,提供 [`encryptQrcHex`](/reference/lyric/functionencryptqrchex) 函数用于将明文 XML 加密为 base64 串。**
|
||||
|
||||
## Lyricify 系列格式
|
||||
|
||||
[Lyricify](https://lyricify.app) 是一款优秀的逐字歌词展示软件。其定义了 Lyricify Lines、Lyricify Syllable 和 Lyricify 快速导出 三种私有格式。
|
||||
|
||||
其中:
|
||||
|
||||
- Lyricify Lines 为逐行歌词,扩展名 `.lyl`
|
||||
- Lyricify Syllable 为逐字歌词,扩展名 `.lys`,支持设置背景与对唱行
|
||||
|
||||
这两种格式 [官方提供了文档说明](https://github.com/WXRIW/Lyricify-App/blob/main/docs/Lyricify%204/Lyrics.md#lyricify-lines-%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83)。
|
||||
|
||||
**本库提供 [`parseLyl`](/reference/lyric/functionparselyl) 和 [`stringifyLyl`](/reference/lyric/functionstringifylyl) 方法用于正反序列化 Lyricify Lines 歌词;提供 [`parseLys`](/reference/lyric/functionparselys) 和 [`stringifyLys`](/reference/lyric/functionstringifylys) 方法用于正反序列化 Lyricify Syllable 歌词。**
|
||||
|
||||
Lyricify 快速导出格式扩展名为 `.lqe`(**L**yricify **Q**uick **E**xport 的缩写)。官方没有提供文档说明,但其内容比较易懂。以下说明基于对相关软件实际导出文件的分析,并非官方规范,也可能与未来版本存在差异。
|
||||
|
||||
```
|
||||
[Lyricify Quick Export]
|
||||
[version:1.0]
|
||||
|
||||
[lyrics: format@Lyricify Syllable]
|
||||
[4]A(365,350)ni(715,307)ro(1022,312)dham (1334,419)a(3203,337)nut(3540,350)pā(3890,306)dam(4196,382)
|
||||
[5]Qua(6206,312)e(6518,350)so (6868,370)do(7238,338)mi(7576,373)ne (7949,413)nos (8362,736)ple(9098,306)ne (9404,338)sal(9742,237)va (9979,244)tam(10223,350)
|
||||
[4]A(6164,1436)nuc(7600,744)che(8344,724)dam (9068,399)a(9467,293)śā(9760,240)śva(10000,225)tam(10225,893)
|
||||
[4]Hi (11851,812)ma(12663,344)ma (13007,369)ja(13376,263)gad (13639,237)i(13876,212)daṃ(14088,800)
|
||||
|
||||
|
||||
[translation: format@LRC]
|
||||
[00:00.365]不生亦不灭
|
||||
[00:06.206]主人啊,求你像这般,赐给我们完全的救恩
|
||||
[00:06.164]不常亦不断
|
||||
[00:11.851]此世已为我之世
|
||||
|
||||
|
||||
[pronunciation: format@LRC, language@romaji]
|
||||
[00:00.365]阿难罗昙 阿耨钵昙
|
||||
[00:06.164]阿耨遮昙 阿刹缚多
|
||||
[00:11.851]天摩诃满 荼揭谛檀
|
||||
```
|
||||
|
||||
头部定义了文件版本信息。此后内容由几部分构成。`[lyrics: format@Lyricify Syllable]` 后携带 Lyricify Syllable 格式的逐字歌词,此后 `[translation: format@LRC]` 后携带 LRC 格式的翻译歌词、`[pronunciation: format@LRC, language@romaji]` 后携带 LRC 格式的罗马字音译歌词。
|
||||
|
||||
需要注意的是,翻译或音译区块只包含存在内容的行。若某一行没有翻译或音译,则该区块中不会出现对应时间戳。
|
||||
|
||||
可见 Lyricify 快速导出格式支持逐字时间、背景行、对唱行、翻译、音译。
|
||||
|
||||
此外,也可以参考 Lyricify 开发者的项目 [Lyricify Lyrics Helper](https://github.com/WXRIW/Lyricify-Lyrics-Helper),其中有 Lyricify 系列格式的解析与生成逻辑,并以 MIT 开源。
|
||||
|
||||
**本库提供 [`parseLqe`](/reference/lyric/functionparselqe) 和 [`stringifyLqe`](/reference/lyric/functionstringifylqe) 方法用于正反序列化 Lyricify 快速导出。**
|
||||
|
||||
## 总结表格
|
||||
|
||||
| 格式 | 扩展名 | 逐行时间 | 逐字时间 | 原生翻译音译 | 原生背景对唱 |
|
||||
| ----------------- | --------------- | :------: | :------: | :----------: | :----------: |
|
||||
| TTML | `.ttml` | ✓ | ✓ | ✓ | ✓ |
|
||||
| LRC | `.lrc` | ✓ | ✕ | ✕ | ✕ |
|
||||
| LRC A2 扩展 | `.lrc`, `.alrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| 网易云逐字 | `.yrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| QQ 音乐逐字 | `.qrc` | ✓ | ✓ | ✕ | ✕ |
|
||||
| Lyricify Lines | `.lyl` | ✓ | ✕ | ✕ | ✕ |
|
||||
| Lyricify Syllable | `.lys` | ✓ | ✓ | ✕ | ✓ |
|
||||
| Lyricify 快速导出 | `.lqe` | ✓ | ✓ | ✓ | ✓ |
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: 快速开始
|
||||
---
|
||||
|
||||
import PackageInstall from "#components/PackageInstall.astro";
|
||||
|
||||
AMLL 提供两个 npm 包,用于歌词格式正反序列化:
|
||||
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
主流各歌词格式的解析与生成库,例如 LRC、YRC、LQE 等,其中 TTML 正反解实际上依赖 @applemusic-like-lyrics/ttml。
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
TTML 逐字歌词格式正反序列化库。提供最详细的信息,包括逐字音译等特色功能。
|
||||
|
||||
## Lyric 包
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/lyric" />
|
||||
|
||||
Lyric 包支持如下格式:
|
||||
|
||||
| 格式 | 扩展名 | 解析 | 生成 |
|
||||
| ----------------- | ---------------- | --------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| TTML | `.ttml` | [`parseTTML`](/reference/lyric/functionparsettml) | [`stringifyTTML`](/reference/lyric/functionstringifyttml) |
|
||||
| LRC | `.lrc` | [`parseLrc`](/reference/lyric/functionparselrc) | [`stringifyLrc`](/reference/lyric/functionstringifylrc) |
|
||||
| LRC A2 扩展 | `.lrc`, `.alrc` | [`parseLrcA2`](/reference/lyric/functionparselrca2) | [`stringifyLrcA2`](/reference/lyric/functionstringifylrca2) |
|
||||
| 网易云逐字 | `.yrc` | [`parseYrc`](/reference/lyric/functionparseyrc) | [`stringifyYrc`](/reference/lyric/functionstringifyyrc) |
|
||||
| QQ 音乐逐字 | `.qrc` | [`parseQrc`](/reference/lyric/functionparseqrc) | [`stringifyQrc`](/reference/lyric/functionstringifyqrc) |
|
||||
| EsLyric | `.lrc`, `.eslrc` | [`parseEslrc`](/reference/lyric/functionparseeslrc) | [`stringifyEslrc`](/reference/lyric/functionstringifyeslrc) |
|
||||
| ASS 字幕 | `.ass` | 不支持 | [`stringifyAss`](/reference/lyric/functionstringifyass) |
|
||||
| Lyricify Lines | `.lyl` | [`parseLyl`](/reference/lyric/functionparselyl) | [`stringifyLyl`](/reference/lyric/functionstringifylyl) |
|
||||
| Lyricify Syllable | `.lys` | [`parseLys`](/reference/lyric/functionparselys) | [`stringifyLys`](/reference/lyric/functionstringifylys) |
|
||||
| Lyricify 快速导出 | `.lqe` | [`parseLqe`](/reference/lyric/functionparselqe) | [`stringifyLqe`](/reference/lyric/functionstringifylqe) |
|
||||
|
||||
以上解析与生成方法均为同步函数。
|
||||
|
||||
除 TTML 外,解析函数均接受一个字符串作为参数并返回歌词对象数组 [`LyricLine[]`](/reference/lyric/interfacelyricline),生成函数均接受一个歌词对象数组并返回字符串。
|
||||
|
||||
对于 TTML,解析结果和生成输入并非歌词对象数组,而是包含了元数据信息的 [`TTMLLyric`](/reference/lyric/interfacettmllyric) 对象。**并且 lyric 包的 TTML 正反解仅能在浏览器中使用,不支持 Node 环境**。**如果在 Node 中使用,或需要 TTML 中更多的信息或能力,请使用下一节介绍的 TTML 专用包**。
|
||||
|
||||
Lyric 包还支持 QQ 音乐加密逐词格式的加解密。提供 [`decryptQrcHex`](/reference/lyric/functiondecryptqrchex) 方法用于解密,提供 [`encryptQrcHex`](/reference/lyric/functionencryptqrchex) 方法用于加密。
|
||||
|
||||
你可以在 [各歌词格式介绍](./formats) 一文中了解各歌词格式的详细信息。
|
||||
|
||||
## TTML 包
|
||||
|
||||
<PackageInstall packages="@applemusic-like-lyrics/ttml" />
|
||||
|
||||
TTML 包提供 TTML 格式的正反序列化能力。有关 TTML 格式的详细信息,请转到 [TTML 格式介绍](./ttml)。
|
||||
|
||||
TTML 包有两种使用模式:类模式与函数模式。
|
||||
|
||||
### 类模式
|
||||
|
||||
TTML 包提供两个类:用于解析的 [`TTMLParser`](/reference/ttml/classttmlparser) 和用于生成的 [`TTMLGenerator`](/reference/ttml/classttmlgenerator)。特别地,二者还提供了静态的便捷方法,本质是立即新建实例然后操作。
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
// 建立实例并调用
|
||||
const parser = new TTMLParser();
|
||||
const ttmlObject = parser.parse("Some TTML string...");
|
||||
|
||||
// 静态方法,等价于 (new TTMLParser()).parse("Some TTML string...")
|
||||
const _ttmlObject = TTMLParser.parse("Some TTML string...");
|
||||
|
||||
// 建立实例并调用
|
||||
const generator = new TTMLGenerator();
|
||||
const ttmlString = generator.generate(ttmlObject);
|
||||
|
||||
// 静态方法,等价于 (new TTMLGenerator()).generate(ttmlObject)
|
||||
const _ttmlString = TTMLGenerator.generate(ttmlObject);
|
||||
```
|
||||
|
||||
这两个类**默认在浏览器环境下**工作。`TTMLParser` 默认使用 `DOMParser` 类,`TTMLGenerator` 默认使用 `XMLSerializer` 类和 `document.implementation` 。因此,若要在 Node 环境使用,需要在构造时传入这几个工具的实现。例如使用 `@xmldom/xmldom`:
|
||||
|
||||
```js
|
||||
import { TTMLParser, TTMLGenerator } from "@applemusic-like-lyrics/ttml";
|
||||
import { DOMParser, DOMImplementation, XMLSerializer } from "@xmldom/xmldom";
|
||||
|
||||
const parser = new TTMLParser({
|
||||
domParser: new DOMParser(),
|
||||
});
|
||||
|
||||
const generator = new TTMLGenerator({
|
||||
domImplementation: new DOMImplementation(),
|
||||
xmlSerializer: new XMLSerializer(),
|
||||
});
|
||||
```
|
||||
|
||||
静态方法在 Node 下不适用。
|
||||
|
||||
### 函数模式
|
||||
|
||||
直接引入 [`parseTTML`](/reference/ttml/functionparsettml), [`exportTTML`](/reference/ttml/functionexportttml) 函数。
|
||||
|
||||
```js
|
||||
import { parseTTML, exportTTML } from "@applemusic-like-lyrics/ttml";
|
||||
|
||||
const amllObject = parseTTML("Some TTML string...");
|
||||
const ttmlString = exportTTML(amllObject);
|
||||
```
|
||||
|
||||
解析结果以 [`AmllLyricResult`](/reference/ttml/interfaceamlllyricresult) 类型给出,可以直接用于 AMLL 核心库。
|
||||
|
||||
此模式的本质是调用 `TTMLParser` 或 `TTMLGenerator` 的静态方法。对于 `TTMLParser` 还会额外调用 [`toAmllLyrics`](/reference/ttml/functiontoamlllyrics) 将对象降级为 `AmllLyricResult`。因此,此方法在 Node 下不适用。
|
||||
186
amll-local/packages/docs/src/content/docs/guides/lyric/ttml.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: TTML 格式介绍
|
||||
---
|
||||
|
||||
TTML 全称 Timed Text Markup Language,是 [W3C](https://www.w3.org/) 定义的时序文本标记语言标准。它基于 XML 表达结构化文本与时间信息,因此天然具备可扩展、可机器解析、跨平台交换的特点。
|
||||
|
||||
需要注意,TTML 是空格敏感的 XML。行内的空格等空白字符会如实反映在歌词上。为方便阅读,本文中的 TTML 代码进行了格式化。实际使用时不应随意增减空格或换行。
|
||||
|
||||
Apple Music 使用 TTML 作为逐字歌词格式,因此 AMLL 生态也主要采用 TTML 为歌词存储与交换格式。TTML 是 AMLL 生态里能力最完整的歌词格式,支持:
|
||||
|
||||
- 逐字时间
|
||||
- 翻译与音译
|
||||
- 背景人声(`x-bg`)
|
||||
- 对唱/多人演唱者信息(`ttm:agent`)
|
||||
- 歌曲分段(`itunes:song-part`)
|
||||
- Ruby 注音
|
||||
|
||||
AMLL 的 TTML 规范可参考:[AMLL TTML DB Wiki - 格式规范](https://github.com/amll-dev/amll-ttml-db/wiki/%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83)
|
||||
|
||||
## 整体架构
|
||||
|
||||
一个最小可用的 TTML 文件通常包含:
|
||||
|
||||
- 根节点 `<tt>`
|
||||
- `<head><metadata>...</metadata></head>` 元数据区
|
||||
- `<body><div><p>...</p></div></body>` 歌词正文区
|
||||
|
||||
命名空间:
|
||||
|
||||
- `xmlns="http://www.w3.org/ns/ttml"`
|
||||
- `xmlns:ttm="http://www.w3.org/ns/ttml#metadata"`
|
||||
- `xmlns:itunes="http://music.apple.com/lyric-ttml-internal"`
|
||||
- `xmlns:amll="http://www.example.com/ns/amll"`
|
||||
- `xmlns:tts="http://www.w3.org/ns/ttml#styling"`(Ruby 需要)
|
||||
|
||||
根属性:
|
||||
|
||||
- `xml:lang`:歌词主语言(BCP-47,如 `ja`、`zh-Hans`、`en-US`)
|
||||
- `itunes:timing`:`Word`(逐字)或 `Line`(逐行)
|
||||
|
||||
## 元数据
|
||||
|
||||
TTML 元数据主要放在 `<head><metadata>` 中,AMLL 里常见两类:
|
||||
|
||||
1. `ttm:*` 元数据(TTML 标准)
|
||||
2. `amll:meta` 元数据(AMLL 扩展)
|
||||
|
||||
例如:
|
||||
|
||||
```xml
|
||||
<metadata>
|
||||
<ttm:title>Song Title</ttm:title>
|
||||
|
||||
<ttm:agent type="person" xml:id="v1">
|
||||
<ttm:name type="full">Singer A</ttm:name>
|
||||
</ttm:agent>
|
||||
|
||||
<amll:meta key="musicName" value="Song Title" />
|
||||
<amll:meta key="artists" value="Singer A" />
|
||||
<amll:meta key="album" value="Album Name" />
|
||||
<amll:meta key="isrc" value="XX0000000000" />
|
||||
<amll:meta key="appleMusicId" value="1234567890" />
|
||||
</metadata>
|
||||
```
|
||||
|
||||
常见 `amll:meta` 键:
|
||||
|
||||
- `musicName`、`artists`、`album`、`isrc`
|
||||
- 平台 ID:`ncmMusicId`、`qqMusicId`、`spotifyId`、`appleMusicId`
|
||||
- 贡献者:`ttmlAuthorGithub`、`ttmlAuthorGithubLogin`
|
||||
|
||||
## 时间与模式
|
||||
|
||||
时间一般通过 `begin` / `end` / `dur` 表达,单位支持:
|
||||
|
||||
- 时钟格式:`MM:SS.fff`、`HH:MM:SS.fff`,小数点后可保留 1~3 位或不保留
|
||||
- 秒值格式:`12.3s`
|
||||
|
||||
`itunes:timing="Word"`(逐字)时:
|
||||
|
||||
- `<p>` 表示行
|
||||
- 行内多个带时间戳的 `<span>` 表示字词
|
||||
|
||||
`itunes:timing="Line"`(逐行)时:
|
||||
|
||||
- 主要依赖 `<p begin="..." end="...">整行文本</p>`
|
||||
- 内部 `span` 的逐字时间通常不使用
|
||||
|
||||
## 正文结构与扩展角色
|
||||
|
||||
正文结构一般为:
|
||||
|
||||
```xml
|
||||
<body>
|
||||
<div itunes:song-part="Verse">
|
||||
<p begin="10.000" end="12.000" itunes:key="L1" ttm:agent="v1">
|
||||
<span begin="10.000" end="10.500">こ</span>
|
||||
<span begin="10.500" end="11.000">れ</span>
|
||||
<span begin="11.000" end="12.000">は</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
常见属性与约定:
|
||||
|
||||
- `itunes:key="L1"`:行唯一 ID(通常 `L1`、`L2`...)
|
||||
- `ttm:agent="v1"`:对应 `<ttm:agent xml:id="v1">`
|
||||
- `itunes:song-part`:段落信息(如 `Verse`、`Chorus`)
|
||||
|
||||
行内辅助信息通过 `ttm:role` 标记:
|
||||
|
||||
- `x-translation`:翻译
|
||||
- `x-roman`:音译/罗马音
|
||||
- `x-bg`:背景人声
|
||||
|
||||
示例:
|
||||
|
||||
```xml
|
||||
<p begin="20.000" end="25.000" itunes:key="L3" ttm:agent="v1000">
|
||||
<span begin="20.000" end="21.500">コーラス</span>
|
||||
<span begin="21.500" end="22.000">です</span>
|
||||
|
||||
<span ttm:role="x-bg" begin="22.500" end="23.800">
|
||||
<span begin="22.500" end="23.800">(背景)</span>
|
||||
<span ttm:role="x-translation" xml:lang="en">Background</span>
|
||||
<span ttm:role="x-roman" xml:lang="ja-Latn">haikei</span>
|
||||
</span>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Apple Music 风格翻译音译
|
||||
|
||||
除了把翻译/音译写在行内,也可以放在 `<iTunesMetadata>`:
|
||||
|
||||
```xml
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="zh-Hans-CN" type="subtitle">
|
||||
<text for="L1">第一行翻译</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L1">dai ichi gyou</text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
```
|
||||
|
||||
其中 `for="L1"` 会关联到正文中的 `itunes:key="L1"`。
|
||||
|
||||
## Ruby 注音
|
||||
|
||||
AMLL 支持 TTML Ruby 结构(`tts:ruby`),适合日语振假名、拼音等:
|
||||
|
||||
- `tts:ruby="container"`:整个 Ruby 容器
|
||||
- `tts:ruby="base"`:基文本
|
||||
- `tts:ruby="textContainer"`:注音容器
|
||||
- `tts:ruby="text"`:注音文本(可带时间)
|
||||
|
||||
```xml
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">所</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.690" end="00:27.820">しょ</span>
|
||||
</span>
|
||||
</span>
|
||||
<span tts:ruby="container">
|
||||
<span tts:ruby="base">詮</span>
|
||||
<span tts:ruby="textContainer">
|
||||
<span tts:ruby="text" begin="00:27.820" end="00:27.880">せ</span>
|
||||
<span tts:ruby="text" begin="00:27.880" end="00:27.950">ん</span>
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
## 与本库的实现对齐
|
||||
|
||||
本库中的解析/导出实现有如下行为:
|
||||
|
||||
- 同时兼容 `itunes:songPart` 与 `itunes:song-part`,导出时优先 `song-part`
|
||||
- 可解析并保留 `amll:obscene`、`amll:empty-beat`
|
||||
- 背景人声文本可带/不带括号,解析时会做清理
|
||||
- 可同时合并行内翻译与 Head Sidecar 翻译(同语言可能出现重复,需业务侧去重)
|
||||
- 在无逐字但有行时间戳时,可回退为单词级占位词条,避免信息丢失
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: 生态
|
||||
---
|
||||
|
||||
经过一段时间的发展,AMLL 建立起了一个围绕逐字歌词的开源生态,包含逐字歌词库、编辑器、音乐播放器等。
|
||||
|
||||
## 第一方生态
|
||||
|
||||
第一方生态是指在 GitHub 上以 [amll-dev](https://github.com/amll-dev/) 组织下的仓库。以下项目的内容与文档均各自独立维护,问题反馈请至各仓库处理。
|
||||
|
||||
### AMLL TTML Database
|
||||
|
||||
[AMLL TTML Database](https://github.com/amll-dev/amll-ttml-db) 是一个高质量的开放逐字歌词数据库。其中的歌词均由社区贡献并经过审核,以 [CC0-1.0](https://github.com/amll-dev/amll-ttml-db/blob/main/LICENSE) 协议公开。
|
||||
|
||||
如果你正在制作音乐播放器,可以将其作为歌词源。你也可以制作逐字歌词并提交到歌词库中。有关提交与使用的相关说明,请转到其 [仓库 wiki](https://github.com/amll-dev/amll-ttml-db/wiki)。
|
||||
|
||||
### AMLL TTML Tool
|
||||
|
||||
[AMLL TTML Tool](https://github.com/amll-dev/amll-ttml-tool) 是基于 React 编写的逐字歌词编辑器,涵盖歌词内容编辑、打轴等功能。歌词库中大部分逐字歌词均使用此工具制作。
|
||||
|
||||

|
||||
|
||||
其部署在 <https://tool.amll.dev/> 上,可以直接访问并开始使用。
|
||||
|
||||
### AMLL Editor
|
||||
|
||||
[AMLL Editor](https://github.com/amll-dev/amll-editor) 是基于 Vue 编写的下一代逐字歌词编辑器,仍处于早期开发阶段。相比 AMLL TTML Tool 增加了查找替换等更便利的功能。
|
||||
|
||||
其部署在 <https://editor.amll.dev/> 上,可以直接访问并开始使用。使用文档位于其 [仓库 wiki](https://github.com/amll-dev/amll-editor/wiki)。
|
||||
|
||||
### AMLL Player
|
||||
|
||||
[AMLL Player](https://github.com/amll-dev/amll-player) 是基于 AMLL 的音乐播放器。可以作为本地音乐播放器使用,也可以配合 WS protocol 等功能搭配其他音乐软件使用。
|
||||
|
||||
## 第三方生态推荐
|
||||
|
||||
下面列举部分集成了 AMLL 的优秀第三方应用。得益于 GPL 协议的传染性,这些应用均以 GPL 协议开放源代码并免费使用。我们也为此建立了一个 [GitHub discussion](https://github.com/orgs/amll-dev/discussions/397)。
|
||||
|
||||
### SPlayer
|
||||
|
||||
[SPlayer](https://github.com/imsyy/SPlayer) 是一款基于 Vue 构建的第三方网易云音乐客户端。
|
||||
|
||||

|
||||
|
||||
## 沿革
|
||||
|
||||
AMLL 诞生于 [2022 年 12 月](https://github.com/amll-dev/applemusic-like-lyrics/commit/88a3c1d),起初是基于 [BetterNCM](https://std.microblock.cc/betterncm) 框架的网易云音乐 PC 客户端插件,用于美化网易云 UI 中的歌词。
|
||||
|
||||

|
||||
|
||||
2023 年 7 月,AMLL 发布了第一个 npm 包 [@applemusic-like-lyrics/core@0.0.1](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.0.1)。
|
||||
|
||||
由于网易云客户端中的诸多限制与性能问题,[2023 年 8 月](https://github.com/amll-dev/applemusic-like-lyrics/commit/28d3f6f),AMLL Player 开始开发。它通过一个基于 WebSocket 的协议与客户端通信,将歌词展示转变成独立应用,不再受限于网易云客户端。
|
||||
|
||||
2024 年 2 月,插件发布了最终版本 [v3.1.0](https://github.com/amll-dev/applemusic-like-lyrics/releases/tag/v3.1.0),至此结束插件版本的开发与维护。后续一段时间里 AMLL 插件部分的 UI 开始整理为可复用的组件库。
|
||||
|
||||
2024 年 9 月,原插件中的组件发布为 [@applemusic-like-lyrics/react-full@0.2.0-alpha.0](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full/v/0.2.0-alpha.0)。
|
||||
|
||||
2026 年 4 月,AMLL Player 从主仓库 [剥离](https://github.com/amll-dev/applemusic-like-lyrics/pull/455) 到 [独立仓库](https://github.com/amll-dev/amll-player) 进行维护;并建立了自动化版本发布工作流,通过 GitHub Actions 发布了第一个 provenace 包 [@applemusic-like-lyrics/core@0.3.0](https://www.npmjs.com/package/@applemusic-like-lyrics/core/v/0.3.0)。
|
||||
|
||||
AMLL 仍在积极开发中。也期待你的贡献!转到 [贡献指南](/contribute) 以查阅相关文档。
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 995 KiB |
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 介绍
|
||||
description: AMLL 库的快速介绍
|
||||
---
|
||||
|
||||
## 什么是 AMLL
|
||||
|
||||
Apple Music Like Lyrics,缩写为 AMLL,是一个 Apple Music 风格的开源前端逐字歌词展示库。
|
||||
|
||||
逐字歌词也称逐音节歌词,是指歌词的时间轴精确到音节(中文的字,或拼音文字中的音节),类似于卡拉 OK 样式。在展示时,歌词中的文本将随着音乐播放逐字亮起或出现。
|
||||
|
||||
在网站的 [首页](/) 上有一个简单的 demo。我们还在网站上提供一个 [交互式试验场](/playground),你可以在其中导入音频与歌词文件、调节动画参数等,查看渲染效果。
|
||||
|
||||
下面是一些运行截图。
|
||||
|
||||

|
||||
|
||||
## 分发与使用
|
||||
|
||||
AMLL 以 npm 包的形式分发,提供了从展示组件、框架绑定到歌词处理的一系列工具:
|
||||
|
||||
- **组件相关**(浏览器端)
|
||||
- [@applemusic-like-lyrics/core](https://www.npmjs.com/package/@applemusic-like-lyrics/core)
|
||||
AMLL 核心库,框架无关的逐字歌词与背景渲染组件
|
||||
- [@applemusic-like-lyrics/react](https://www.npmjs.com/package/@applemusic-like-lyrics/react)
|
||||
核心库的 React 绑定
|
||||
- [@applemusic-like-lyrics/vue](https://www.npmjs.com/package/@applemusic-like-lyrics/vue)
|
||||
核心库的 Vue 绑定
|
||||
- [@applemusic-like-lyrics/react-full](https://www.npmjs.com/package/@applemusic-like-lyrics/react-full)
|
||||
开箱即用的完整播放器封装,包含进度条、封面、歌词、背景等,仅支持 React
|
||||
|
||||
- **外围工具**(浏览器与 Node 双端)
|
||||
- [@applemusic-like-lyrics/ttml](https://www.npmjs.com/package/@applemusic-like-lyrics/ttml)
|
||||
TTML 逐字歌词格式的解析与生成库
|
||||
- [@applemusic-like-lyrics/lyric](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
主流各歌词格式的解析与生成库,例如 LRC、YRC、LQE 等
|
||||
- [@applemusic-like-lyrics/fft](https://www.npmjs.com/package/@applemusic-like-lyrics/fft) (已分离至 [独立仓库](https://github.com/amll-dev/fft))
|
||||
基于 Rust WASM 的高性能音频可视化模块,将音频波形数据转换成频谱
|
||||
- [@applemusic-like-lyrics/ws-protocol](https://www.npmjs.com/package/@applemusic-like-lyrics/ws-protocol) (已分离至 [独立仓库](https://github.com/amll-dev/ws-protocol))
|
||||
基于 Rust WASM 的高性能歌词播放器协议库,用于同步播放进度和播放信息
|
||||
|
||||
AMLL 系列包 **均以 [AGPL v3 only](https://spdx.org/licenses/AGPL-3.0-only.html) 开放源代码**,仓库位于 [GitHub](https://github.com/amll-dev/applemusic-like-lyrics)。在遵守开源协议的前提下,你可以将其集成到你的项目中。
|
||||
|
||||
得益于前端技术栈的大规模应用与日趋成熟,网页渲染在浏览器、桌面端、移动端等平台上有着出色的一致性。如果你正在开发音乐播放器、卡拉 OK 等相关项目,并正在使用前端技术栈,AMLL 是一个不错的选择。
|
||||
|
||||
## 下一步
|
||||
|
||||
要开始使用 AMLL 歌词组件,请转到 [歌词组件:快速开始](../component/quickstart)。
|
||||
|
||||
要开始使用 AMLL 的歌词解析处理功能,请转到 [歌词处理:快速开始](../lyric/quickstart)。
|
||||
|
||||
除了 AMLL 本身之外,以 AMLL 为中心还有一系列上下游生态项目,例如逐字歌词库、逐字歌词编辑器、第一方播放器等。了解这些内容可能会让你少造一些轮子。你可以在 [生态](./eco) 中了解详情。
|
||||
29322
amll-local/packages/docs/src/content/docs/reference/core.json
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: API 参考
|
||||
description: 按模块浏览 API 文档
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
import { CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Core"
|
||||
description="框架无关的歌词播放器"
|
||||
href="/reference/core"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React"
|
||||
description="核心库的 React 绑定与组件"
|
||||
href="/reference/react"
|
||||
/>
|
||||
<LinkCard
|
||||
title="React Full"
|
||||
description="开箱即用的完整 React 播放器封装"
|
||||
href="/reference/react-full"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Vue"
|
||||
description="核心库的 Vue 绑定与组件"
|
||||
href="/reference/vue"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Lyric"
|
||||
description="歌词格式处理库"
|
||||
href="/reference/lyric"
|
||||
/>
|
||||
<LinkCard
|
||||
title="TTML"
|
||||
description="TTML 歌词格式处理库"
|
||||
href="/reference/ttml"
|
||||
/>
|
||||
</CardGrid>
|
||||
107
amll-local/packages/docs/src/pages/en/index.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
import { Icon } from "@astrojs/starlight/components";
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import { AMLLPreview } from "../../components/AMLLPreview";
|
||||
import "../../styles/index.css";
|
||||
import { amllCoreVersionLabel } from "../../utils/core-version";
|
||||
|
||||
const entryLinks = [
|
||||
{
|
||||
title: "Component Quick Start",
|
||||
description: "Bring word-by-word lyrics into your frontend project.",
|
||||
href: "/en/guides/component/quickstart",
|
||||
icon: "right-arrow",
|
||||
},
|
||||
{
|
||||
title: "Lyric Processing",
|
||||
description:
|
||||
"Parse, generate, and convert formats including LRC, TTML, and more.",
|
||||
href: "/en/guides/lyric/quickstart",
|
||||
icon: "document",
|
||||
},
|
||||
{
|
||||
title: "API Reference",
|
||||
description:
|
||||
"Look up types for Core, React, Vue, and lyric utility packages.",
|
||||
href: "/en/reference",
|
||||
icon: "information",
|
||||
},
|
||||
{
|
||||
title: "Playground",
|
||||
description: "Import audio and lyrics to tune rendering in real time.",
|
||||
href: "/en/playground",
|
||||
icon: "puzzle",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const topicLinks = [
|
||||
{ label: "Overview", href: "/en/guides/overview/intro" },
|
||||
{ label: "Ecosystem", href: "/en/guides/overview/eco" },
|
||||
{ label: "Contributing", href: "/en/contribute" },
|
||||
] as const;
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={({
|
||||
title: "Apple Music-like Lyrics",
|
||||
description: "A lyrics player component library built for the web",
|
||||
template: "splash",
|
||||
})}
|
||||
lang="en"
|
||||
>
|
||||
<div class="home-shell">
|
||||
<section class="home-hero" aria-labelledby="home-title">
|
||||
<div class="home-preview" aria-hidden="true">
|
||||
<AMLLPreview client:only="react" />
|
||||
</div>
|
||||
<div class="home-hero-copy">
|
||||
<p class="home-eyebrow">{amllCoreVersionLabel}</p>
|
||||
<h1 id="home-title">Apple Music-like Lyrics</h1>
|
||||
<p class="home-summary">
|
||||
Apple Music style word-by-word lyric rendering for the web, from UI
|
||||
components to framework bindings and lyric format tooling.
|
||||
</p>
|
||||
<div class="home-actions">
|
||||
<a class="home-button home-button-primary" href="/en/guides">
|
||||
Guides
|
||||
<Icon name="right-arrow" />
|
||||
</a>
|
||||
<a class="home-button" href="/en/reference">
|
||||
API Reference
|
||||
<Icon name="information" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-entry" aria-labelledby="home-entry-title">
|
||||
<div class="home-section-heading">
|
||||
<p class="home-eyebrow">Explore</p>
|
||||
<h2 id="home-entry-title">Ready to get started?</h2>
|
||||
</div>
|
||||
<div class="home-card-grid">
|
||||
{entryLinks.map((link) => (
|
||||
<a class="home-card" href={link.href}>
|
||||
<span class="home-card-icon">
|
||||
<Icon name={link.icon} />
|
||||
</span>
|
||||
<span class="home-card-copy">
|
||||
<strong>{link.title}</strong>
|
||||
<span>{link.description}</span>
|
||||
</span>
|
||||
<Icon name="right-arrow" class="home-card-arrow" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="home-topic-links" aria-label="More documentation links">
|
||||
{topicLinks.map((link) => (
|
||||
<a href={link.href}>
|
||||
{link.label}
|
||||
<Icon name="right-arrow" />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</StarlightPage>
|
||||
105
amll-local/packages/docs/src/pages/index.astro
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import { Icon } from "@astrojs/starlight/components";
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import { AMLLPreview } from "../components/AMLLPreview";
|
||||
import "../styles/index.css";
|
||||
import { amllCoreVersionLabel } from "../utils/core-version";
|
||||
|
||||
const entryLinks = [
|
||||
{
|
||||
title: "组件快速开始",
|
||||
description: "把逐字歌词组件接入你的前端项目。",
|
||||
href: "/guides/component/quickstart",
|
||||
icon: "rocket",
|
||||
},
|
||||
{
|
||||
title: "歌词处理",
|
||||
description: "解析、生成和转换 LRC、TTML 等歌词格式。",
|
||||
href: "/guides/lyric/quickstart",
|
||||
icon: "document",
|
||||
},
|
||||
{
|
||||
title: "API 参考",
|
||||
description: "查看核心及其绑定、歌词工具包的类型文档。",
|
||||
href: "/reference",
|
||||
icon: "information",
|
||||
},
|
||||
{
|
||||
title: "交互试验场",
|
||||
description: "导入音频和歌词,实时调试播放器效果。",
|
||||
href: "/playground",
|
||||
icon: "puzzle",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const topicLinks = [
|
||||
{ label: "项目介绍", href: "/guides/overview/intro" },
|
||||
{ label: "生态项目", href: "/guides/overview/eco" },
|
||||
{ label: "贡献指南", href: "/contribute" },
|
||||
] as const;
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={({
|
||||
title: "类苹果歌词",
|
||||
description: "专为网页开发的歌词播放页面组件库",
|
||||
template: "splash",
|
||||
})}
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div class="home-shell">
|
||||
<section class="home-hero" aria-labelledby="home-title">
|
||||
<div class="home-preview" aria-hidden="true">
|
||||
<AMLLPreview client:only="react" />
|
||||
</div>
|
||||
<div class="home-hero-copy">
|
||||
<p class="home-eyebrow">{amllCoreVersionLabel}</p>
|
||||
<h1 id="home-title">Apple Music-like Lyrics</h1>
|
||||
<p class="home-summary">
|
||||
面向网页的 Apple Music
|
||||
风格逐字歌词组件库,覆盖渲染组件、框架绑定和歌词格式处理。
|
||||
</p>
|
||||
<div class="home-actions">
|
||||
<a class="home-button home-button-primary" href="/guides">
|
||||
使用文档
|
||||
<Icon name="right-arrow" />
|
||||
</a>
|
||||
<a class="home-button" href="/reference">
|
||||
API 参考
|
||||
<Icon name="information" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-entry" aria-labelledby="home-entry-title">
|
||||
<div class="home-section-heading">
|
||||
<p class="home-eyebrow">探索</p>
|
||||
<h2 id="home-entry-title">准备好开始了吗?</h2>
|
||||
</div>
|
||||
<div class="home-card-grid">
|
||||
{entryLinks.map((link) => (
|
||||
<a class="home-card" href={link.href}>
|
||||
<span class="home-card-icon">
|
||||
<Icon name={link.icon} />
|
||||
</span>
|
||||
<span class="home-card-copy">
|
||||
<strong>{link.title}</strong>
|
||||
<span>{link.description}</span>
|
||||
</span>
|
||||
<Icon name="right-arrow" class="home-card-arrow" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="home-topic-links" aria-label="更多文档入口">
|
||||
{topicLinks.map((link) => (
|
||||
<a href={link.href}>
|
||||
{link.label}
|
||||
<Icon name="right-arrow" />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</StarlightPage>
|
||||
358
amll-local/packages/docs/src/scripts/typedoc.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Application, PageEvent, type TypeDocOptions } from "typedoc";
|
||||
import type {
|
||||
PluginOptions as TypeDocPluginOptions,
|
||||
MarkdownApplication,
|
||||
} from "typedoc-plugin-markdown";
|
||||
|
||||
type TypeDocBaseOptions = TypeDocOptions & TypeDocPluginOptions;
|
||||
|
||||
type TypeDocDocTarget = TypeDocBaseOptions & {
|
||||
name: string;
|
||||
packageRoot: string;
|
||||
routeBase: string;
|
||||
out: string;
|
||||
tsconfig: string;
|
||||
};
|
||||
|
||||
type TypeDocCache = {
|
||||
version: number;
|
||||
targetFingerprints: Record<string, string>;
|
||||
};
|
||||
|
||||
type LoggerLike = {
|
||||
info: (message: string) => void;
|
||||
};
|
||||
|
||||
const TYPE_DOC_CONFIG_BASE_OPTIONS: TypeDocBaseOptions = {
|
||||
githubPages: false,
|
||||
hideGenerator: true,
|
||||
plugin: [
|
||||
"typedoc-plugin-markdown",
|
||||
"typedoc-plugin-mark-react-functional-components",
|
||||
"typedoc-plugin-vue",
|
||||
],
|
||||
readme: "none",
|
||||
logLevel: "Warn",
|
||||
parametersFormat: "table",
|
||||
outputFileStrategy: "members",
|
||||
flattenOutputFiles: true,
|
||||
entryFileName: "index.md",
|
||||
hidePageHeader: true,
|
||||
hidePageTitle: true,
|
||||
hideBreadcrumbs: true,
|
||||
useCodeBlocks: true,
|
||||
propertiesFormat: "table",
|
||||
typeDeclarationFormat: "table",
|
||||
useHTMLAnchors: true,
|
||||
};
|
||||
|
||||
const DOCS_ROOT = resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
||||
const TYPEDOC_CACHE_PATH = resolve(
|
||||
DOCS_ROOT,
|
||||
"node_modules/.cache/typedoc-generation-cache.json",
|
||||
);
|
||||
const TYPEDOC_CACHE_VERSION = 1;
|
||||
const TYPEDOC_CONFIG_SEED = "typedoc-config-v1";
|
||||
const TYPEDOC_FINGERPRINT_EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".vue",
|
||||
".json",
|
||||
]);
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFingerprintFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (TYPEDOC_FINGERPRINT_EXTENSIONS.has(extname(entry.name))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(root)) {
|
||||
await walk(root);
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function calculateFilesFingerprint(files: string[]): Promise<string> {
|
||||
const hash = createHash("sha1");
|
||||
for (const filePath of files) {
|
||||
const fileStat = await stat(filePath);
|
||||
const normalizedPath = filePath.replaceAll("\\", "/").toLowerCase();
|
||||
hash.update(normalizedPath);
|
||||
hash.update(":");
|
||||
hash.update(String(fileStat.size));
|
||||
hash.update(":");
|
||||
hash.update(String(fileStat.mtimeMs));
|
||||
hash.update("\n");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
limit: number,
|
||||
items: T[],
|
||||
worker: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const workers = Array.from(
|
||||
{ length: Math.max(1, Math.min(limit, items.length)) },
|
||||
async () => {
|
||||
while (items.length > 0) {
|
||||
const next = items.shift();
|
||||
if (!next) return;
|
||||
await worker(next);
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
function convertTypeDocHrefToRoute(href: string, routeBase: string): string {
|
||||
const trimmedHref = href.trim();
|
||||
if (
|
||||
trimmedHref.startsWith("#") ||
|
||||
trimmedHref.startsWith("http://") ||
|
||||
trimmedHref.startsWith("https://") ||
|
||||
trimmedHref.startsWith("mailto:")
|
||||
) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const [filePartRaw, hashPart] = trimmedHref.split("#", 2);
|
||||
const filePart = filePartRaw.replace(/^\.?\//, "");
|
||||
if (!filePart.toLowerCase().endsWith(".md")) return href;
|
||||
|
||||
const fileName = filePart.split("/").pop();
|
||||
if (!fileName) return href;
|
||||
|
||||
const stem = fileName.replace(/\.md$/i, "");
|
||||
const normalizedBase = routeBase.endsWith("/")
|
||||
? routeBase.slice(0, -1)
|
||||
: routeBase;
|
||||
|
||||
if (stem.toLowerCase() === "index") {
|
||||
return hashPart ? `${normalizedBase}#${hashPart}` : normalizedBase;
|
||||
}
|
||||
|
||||
const slug = stem.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
const route = `${normalizedBase}/${slug}`;
|
||||
return hashPart ? `${route}#${hashPart}` : route;
|
||||
}
|
||||
|
||||
async function generateOneDoc(cfg: TypeDocDocTarget): Promise<void> {
|
||||
const {
|
||||
routeBase,
|
||||
name: _name,
|
||||
packageRoot: _packageRoot,
|
||||
...typedocConfig
|
||||
} = cfg;
|
||||
|
||||
const config: TypeDocBaseOptions = {
|
||||
...TYPE_DOC_CONFIG_BASE_OPTIONS,
|
||||
...typedocConfig,
|
||||
};
|
||||
|
||||
const app = (await Application.bootstrapWithPlugins(
|
||||
config,
|
||||
)) as unknown as MarkdownApplication;
|
||||
|
||||
function generateFrontmatter(evt: PageEvent): void {
|
||||
const content: string[] = ["---"];
|
||||
if (evt.model.name.startsWith("@applemusic-like-lyrics/")) {
|
||||
content.push('title: "索引"');
|
||||
} else {
|
||||
content.push(`title: "${evt.model.name}"`);
|
||||
}
|
||||
content.push(`pageKind: ${evt.pageKind}`);
|
||||
content.push("editUrl: false");
|
||||
content.push("---");
|
||||
content.push("<!-- This file is generated, do not edit directly! -->");
|
||||
|
||||
let docContent = evt.contents ?? "";
|
||||
if (routeBase) {
|
||||
docContent = docContent
|
||||
.replace(/\]\(([^)\n]+)\)/g, (_raw, href) => {
|
||||
const converted = convertTypeDocHrefToRoute(href, routeBase);
|
||||
return `](${converted})`;
|
||||
})
|
||||
.replace(/href="([^"\n]+)"/g, (_raw, href) => {
|
||||
const converted = convertTypeDocHrefToRoute(href, routeBase);
|
||||
return `href="${converted}"`;
|
||||
});
|
||||
}
|
||||
|
||||
content.push(docContent);
|
||||
evt.contents = content.join("\n");
|
||||
}
|
||||
|
||||
app.renderer.on(PageEvent.END, generateFrontmatter);
|
||||
|
||||
const project = await app.convert();
|
||||
if (project) {
|
||||
await app.generateOutputs(project);
|
||||
}
|
||||
}
|
||||
|
||||
function getDocTargets(): TypeDocDocTarget[] {
|
||||
return [
|
||||
{
|
||||
name: "core",
|
||||
packageRoot: "../core",
|
||||
entryPoints: ["../core/src/index.ts"],
|
||||
tsconfig: "../core/tsconfig.json",
|
||||
out: "./src/content/docs/reference/core",
|
||||
routeBase: "/reference/core",
|
||||
},
|
||||
{
|
||||
name: "react",
|
||||
packageRoot: "../react",
|
||||
entryPoints: ["../react/src/index.ts"],
|
||||
tsconfig: "../react/tsconfig.json",
|
||||
out: "./src/content/docs/reference/react",
|
||||
routeBase: "/reference/react",
|
||||
},
|
||||
{
|
||||
name: "vue",
|
||||
packageRoot: "../vue",
|
||||
entryPoints: ["../vue/src/index.ts"],
|
||||
tsconfig: "../vue/tsconfig.json",
|
||||
out: "./src/content/docs/reference/vue",
|
||||
routeBase: "/reference/vue",
|
||||
},
|
||||
{
|
||||
name: "react-full",
|
||||
packageRoot: "../react-full",
|
||||
entryPoints: ["../react-full/src/index.ts"],
|
||||
tsconfig: "../react-full/tsconfig.json",
|
||||
out: "./src/content/docs/reference/react-full",
|
||||
routeBase: "/reference/react-full",
|
||||
},
|
||||
{
|
||||
name: "lyric",
|
||||
packageRoot: "../lyric",
|
||||
entryPoints: ["../lyric/src/index.ts"],
|
||||
tsconfig: "../lyric/tsconfig.json",
|
||||
skipErrorChecking: true,
|
||||
out: "./src/content/docs/reference/lyric",
|
||||
routeBase: "/reference/lyric",
|
||||
},
|
||||
{
|
||||
name: "ttml",
|
||||
packageRoot: "../ttml",
|
||||
entryPoints: ["../ttml/src/index.ts"],
|
||||
tsconfig: "../ttml/tsconfig.json",
|
||||
skipErrorChecking: true,
|
||||
out: "./src/content/docs/reference/ttml",
|
||||
routeBase: "/reference/ttml",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readTypeDocCache(): Promise<TypeDocCache> {
|
||||
if (await pathExists(TYPEDOC_CACHE_PATH)) {
|
||||
try {
|
||||
const raw = await readFile(TYPEDOC_CACHE_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw) as TypeDocCache;
|
||||
if (
|
||||
parsed &&
|
||||
parsed.version === TYPEDOC_CACHE_VERSION &&
|
||||
typeof parsed.targetFingerprints === "object"
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore cache parse errors
|
||||
}
|
||||
}
|
||||
return { version: TYPEDOC_CACHE_VERSION, targetFingerprints: {} };
|
||||
}
|
||||
|
||||
async function writeTypeDocCache(cache: TypeDocCache): Promise<void> {
|
||||
await mkdir(dirname(TYPEDOC_CACHE_PATH), { recursive: true });
|
||||
await writeFile(TYPEDOC_CACHE_PATH, JSON.stringify(cache, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function generateTypedocDocs(logger?: LoggerLike): Promise<void> {
|
||||
const docTargets = getDocTargets();
|
||||
const typedocCache = await readTypeDocCache();
|
||||
|
||||
const optionsFingerprint = createHash("sha1")
|
||||
.update(JSON.stringify(TYPE_DOC_CONFIG_BASE_OPTIONS))
|
||||
.update("\n")
|
||||
.update(TYPEDOC_CONFIG_SEED)
|
||||
.digest("hex");
|
||||
|
||||
const dirtyTargets: TypeDocDocTarget[] = [];
|
||||
for (const target of docTargets) {
|
||||
const packageRootAbs = resolve(DOCS_ROOT, target.packageRoot);
|
||||
const srcFiles = await collectFingerprintFiles(join(packageRootAbs, "src"));
|
||||
const tsconfigAbs = resolve(DOCS_ROOT, target.tsconfig);
|
||||
const inputFiles = [...srcFiles, tsconfigAbs];
|
||||
const sourceFingerprint = await calculateFilesFingerprint(inputFiles);
|
||||
const targetFingerprint = createHash("sha1")
|
||||
.update(optionsFingerprint)
|
||||
.update("\n")
|
||||
.update(sourceFingerprint)
|
||||
.digest("hex");
|
||||
const previousFingerprint = typedocCache.targetFingerprints[target.name];
|
||||
const outIndex = resolve(DOCS_ROOT, target.out, "index.md");
|
||||
const hasOutput = await pathExists(outIndex);
|
||||
|
||||
if (hasOutput && previousFingerprint === targetFingerprint) {
|
||||
logger?.info(`Skipping typedoc (${target.name}): cache hit`);
|
||||
continue;
|
||||
}
|
||||
|
||||
typedocCache.targetFingerprints[target.name] = targetFingerprint;
|
||||
dirtyTargets.push(target);
|
||||
}
|
||||
|
||||
const concurrencyRaw = Number.parseInt(
|
||||
process.env.TYPEDOC_CONCURRENCY ?? "2",
|
||||
10,
|
||||
);
|
||||
const concurrency = Number.isFinite(concurrencyRaw)
|
||||
? Math.max(1, concurrencyRaw)
|
||||
: 2;
|
||||
|
||||
await runWithConcurrency(concurrency, dirtyTargets, async (target) => {
|
||||
logger?.info(`Generating typedoc (${target.name})...`);
|
||||
await generateOneDoc(target);
|
||||
logger?.info(`Finished typedoc (${target.name})`);
|
||||
});
|
||||
|
||||
await writeTypeDocCache(typedocCache);
|
||||
}
|
||||
105
amll-local/packages/docs/src/styles/consts.css
Normal file
@@ -0,0 +1,105 @@
|
||||
:root {
|
||||
--amll-accent: #ff4545;
|
||||
--amll-accent-strong: #d51d1d;
|
||||
--amll-radius: 0.5rem;
|
||||
--amll-nav-border: color-mix(in srgb, var(--sl-color-white) 9%, transparent);
|
||||
--amll-soft-border: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-white) 12%,
|
||||
transparent
|
||||
);
|
||||
--sl-content-width: 48rem;
|
||||
--sl-sidebar-width: 18.25rem;
|
||||
--sl-color-text: var(--sl-color-gray-1);
|
||||
--amll-page-illumination: radial-gradient(
|
||||
49.63% 57.02% at 58.99% -7.2%,
|
||||
#ff60601a 39.4%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--sl-color-accent-high: #8f1010;
|
||||
--sl-color-accent: var(--amll-accent);
|
||||
--sl-color-accent-low: #ffe8e8;
|
||||
--sl-color-text-accent: #cc2d1f;
|
||||
--sl-color-text-invert: #fff7f7;
|
||||
--sl-color-bg: #fffafa;
|
||||
--sl-color-bg-nav: #fffcfc;
|
||||
--sl-color-bg-sidebar: #fdfdfdc7;
|
||||
--sl-color-bg-inline-code: #fff0f0;
|
||||
--sl-color-bg-accent: var(--sl-color-accent);
|
||||
--sl-color-hairline: #2d222014;
|
||||
--sl-color-hairline-light: #2d222014;
|
||||
--sl-color-hairline-shade: #2d21201a;
|
||||
--sl-color-white: #171417;
|
||||
--sl-color-gray-1: #252020;
|
||||
--sl-color-gray-2: #423939;
|
||||
--sl-color-gray-3: #6b6060;
|
||||
--sl-color-gray-4: #998f8f;
|
||||
--sl-color-gray-5: #d8d0d0;
|
||||
--sl-color-gray-6: #f2eded;
|
||||
--sl-color-gray-7: #faf7f7;
|
||||
--sl-color-black: #fffdfd;
|
||||
--amll-page-bg: white;
|
||||
--amll-card-bg: #fffa;
|
||||
--amll-surface: #f0eeee88;
|
||||
--amll-surface-strong: #fffdfd;
|
||||
--amll-surface-hover: #fbeff1;
|
||||
--amll-muted-border: #2d20201f;
|
||||
--amll-shadow-soft: 0 18px 48px #37181814;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--sl-color-accent-high: #ffa19a;
|
||||
--sl-color-accent: #ff5754;
|
||||
--sl-color-accent-low: #2c1515;
|
||||
--sl-color-text-accent: #ff928e;
|
||||
--sl-color-text-invert: #250808;
|
||||
--sl-color-bg: #100d0d;
|
||||
--sl-color-bg-nav: #100e0dd1;
|
||||
--sl-color-bg-sidebar: #130f0fbd;
|
||||
--sl-color-bg-inline-code: #211a18;
|
||||
--sl-color-bg-accent: var(--sl-color-accent-high);
|
||||
--sl-color-hairline: #ffffff14;
|
||||
--sl-color-hairline-light: #ffffff17;
|
||||
--sl-color-hairline-shade: #ffffff14;
|
||||
--sl-color-white: #fff8fa;
|
||||
--sl-color-gray-1: #efe4e4;
|
||||
--sl-color-gray-2: #cbbcbc;
|
||||
--sl-color-gray-3: #9a8d8d;
|
||||
--sl-color-gray-4: #665b5b;
|
||||
--sl-color-gray-5: #332929;
|
||||
--sl-color-gray-6: #1d1717;
|
||||
--sl-color-gray-7: #151111;
|
||||
--sl-color-black: #100d0d;
|
||||
--amll-page-bg: #100d0d;
|
||||
--amll-card-bg: #ffffff0b;
|
||||
--amll-surface: #ffffff0b;
|
||||
--amll-surface-strong: #ffffff11;
|
||||
--amll-surface-hover: #ffffff16;
|
||||
--amll-muted-border: #ffffff1a;
|
||||
--amll-shadow-soft: 0 18px 54px #00000057;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sl-font:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--sl-font-mono:
|
||||
ui-monospace, "Jetbrains Mono", "Iosevka", "Menlo", "Monaco", "Consolas",
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
[lang]:where(:is(:lang(zh), :lang(ja), :lang(ko))) {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
li,
|
||||
p {
|
||||
line-break: strict;
|
||||
}
|
||||
}
|
||||
132
amll-local/packages/docs/src/styles/content.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.content-panel {
|
||||
border-top-color: transparent;
|
||||
|
||||
h1 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sl-markdown-content {
|
||||
--sl-content-gap-y: 1.05rem;
|
||||
|
||||
:is(h1, h2, h3, h4):not(:where(.not-content *)) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a:not(:where(.not-content *)) {
|
||||
border-radius: 0.16rem;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 46%, transparent);
|
||||
text-underline-offset: 0.22em;
|
||||
transition:
|
||||
color 0.12s,
|
||||
text-decoration-color 0.12s;
|
||||
}
|
||||
|
||||
a:hover:not(:where(.not-content *)) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
code:not(:where(.not-content *)) {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--sl-color-text-accent) 14%, transparent);
|
||||
border-radius: 0.28rem;
|
||||
}
|
||||
|
||||
blockquote:not(:where(.not-content *)) {
|
||||
border-inline-start: var(--sl-color-text-accent) solid 2px;
|
||||
color: var(--sl-color-gray-2);
|
||||
}
|
||||
|
||||
.expressive-code figure.frame,
|
||||
starlight-file-tree {
|
||||
background-color: var(--amll-surface);
|
||||
border: 1px solid var(--amll-muted-border);
|
||||
border-radius: 0.55rem;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.expressive-code figure.frame {
|
||||
figcaption.header {
|
||||
background-color: var(--amll-surface);
|
||||
border: none;
|
||||
&::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0;
|
||||
}
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
pre {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
.copy button {
|
||||
transition: none;
|
||||
background: none;
|
||||
&::before {
|
||||
border: none;
|
||||
}
|
||||
&::after {
|
||||
margin: 0.6rem;
|
||||
}
|
||||
div {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.starlight-aside {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--sl-color-asides-text-accent) 35%, transparent);
|
||||
border-inline-start-width: 3px;
|
||||
border-radius: var(--amll-radius);
|
||||
}
|
||||
|
||||
article.card {
|
||||
border-color: var(--amll-muted-border);
|
||||
border-radius: var(--amll-radius);
|
||||
background: var(--amll-card-bg);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sl-link-card {
|
||||
border-color: var(--amll-muted-border);
|
||||
border-radius: var(--amll-radius);
|
||||
background: var(--amll-card-bg);
|
||||
box-shadow: none;
|
||||
&:hover {
|
||||
border-color: var(--amll-muted-border);
|
||||
background: var(--amll-surface-hover);
|
||||
}
|
||||
.description {
|
||||
line-height: var(--sl-line-height-headings);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-links a {
|
||||
border-color: var(--amll-muted-border);
|
||||
border-radius: var(--amll-radius);
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 1.25rem 1rem;
|
||||
span {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
line-height: 1;
|
||||
gap: 0.6rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
font-size: var(--sl-text-sm);
|
||||
.link-title {
|
||||
color: var(--sl-color-text);
|
||||
font-size: var(--sl-text-lg);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--amll-muted-border);
|
||||
background: var(--amll-surface);
|
||||
}
|
||||
}
|
||||
250
amll-local/packages/docs/src/styles/frame.css
Normal file
@@ -0,0 +1,250 @@
|
||||
* {
|
||||
scrollbar-color: var(--amll-muted-border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--amll-page-bg);
|
||||
}
|
||||
|
||||
.main-frame {
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
z-index: -1;
|
||||
background: var(--amll-page-illumination);
|
||||
}
|
||||
}
|
||||
|
||||
header.header,
|
||||
#starlight__mobile-toc {
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
}
|
||||
|
||||
header.header {
|
||||
border-bottom: 1px solid var(--amll-nav-border);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
@media (max-width: 72rem) {
|
||||
background-color: var(--amll-page-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.site-title {
|
||||
gap: 0.65rem;
|
||||
color: var(--sl-color-white);
|
||||
font-size: var(--sl-text-lg);
|
||||
img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.45rem;
|
||||
filter: drop-shadow(0 8px 18px rgba(255, 69, 111, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
starlight-theme-select,
|
||||
starlight-lang-select {
|
||||
label {
|
||||
margin: 0 1em;
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background: transparent;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 -1em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--amll-muted-border);
|
||||
background-color: var(--amll-surface);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
site-search {
|
||||
button[data-open-modal] {
|
||||
border-color: var(--amll-muted-border);
|
||||
border-radius: 999px;
|
||||
background: var(--amll-surface);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-text-accent) 42%,
|
||||
transparent
|
||||
);
|
||||
background: var(--amll-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
button > kbd {
|
||||
border: 1px solid var(--amll-muted-border);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-bg-inline-code) 75%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: none;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button[aria-controls="starlight__sidebar"] {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
color: var(--sl-color-text);
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.sidebar-pane {
|
||||
border-inline-end: 1px solid var(--amll-nav-border);
|
||||
}
|
||||
}
|
||||
|
||||
#starlight__sidebar .sidebar-content {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.starlight-sidebar-topics {
|
||||
background: var(--sl-color-black);
|
||||
border: 1px solid var(--amll-muted-border);
|
||||
padding: 0.2rem;
|
||||
border-radius: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.starlight-sidebar-topics-icon {
|
||||
border-color: var(--amll-muted-border);
|
||||
border-radius: 0.42rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-bg-inline-code) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
a {
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--sl-color-gray-2);
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
|
||||
.starlight-sidebar-topics-icon.starlight-sidebar-topics-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--sl-color-white);
|
||||
background: var(--amll-surface);
|
||||
border-color: var(--amll-muted-border);
|
||||
}
|
||||
|
||||
&.starlight-sidebar-topics-current.starlight-sidebar-topics-current {
|
||||
color: var(--sl-color-text-accent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-accent-low) 50%,
|
||||
var(--amll-surface)
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-text-accent) 10%,
|
||||
var(--amll-muted-border)
|
||||
);
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
content: unset;
|
||||
}
|
||||
}
|
||||
|
||||
#starlight__sidebar sl-sidebar-state-persist {
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
border-bottom: 1px solid var(--sl-color-hairline-light);
|
||||
}
|
||||
|
||||
[aria-current="page"] {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--sl-color-text-accent);
|
||||
font-weight: bold;
|
||||
background: var(--sl-color-accent-low);
|
||||
}
|
||||
}
|
||||
|
||||
summary,
|
||||
a {
|
||||
border-radius: 0.42rem;
|
||||
&:hover {
|
||||
background: var(--amll-surface);
|
||||
}
|
||||
ul.top-level & {
|
||||
padding-block: 0.4em;
|
||||
--sl-sidebar-item-padding-inline: 0.6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar {
|
||||
border-inline-start-color: transparent;
|
||||
}
|
||||
.right-sidebar-panel {
|
||||
padding-top: 3rem;
|
||||
padding-left: 2rem;
|
||||
h2 {
|
||||
font-size: var(--sl-text-sm);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
a {
|
||||
border-inline-start: 1px solid var(--amll-nav-border);
|
||||
border-radius: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline-start: calc(1rem + var(--depth) * 0.7rem);
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: var(--sl-color-text);
|
||||
}
|
||||
&[aria-current="true"] {
|
||||
color: var(--sl-color-text);
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 1.2rem;
|
||||
margin: auto 0;
|
||||
border-radius: 3px;
|
||||
background-color: var(--sl-color-text-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
335
amll-local/packages/docs/src/styles/index.css
Normal file
@@ -0,0 +1,335 @@
|
||||
/** biome-ignore-all lint/style/noDescendingSpecificity: Override starlight */
|
||||
|
||||
.sl-markdown-content {
|
||||
--sl-content-gap-y: 0;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--home-lyric-color: #151316;
|
||||
--home-hero-shade: rgba(255, 250, 252, 0.78);
|
||||
--home-hero-text: #151316;
|
||||
--home-hero-muted: #5e535b;
|
||||
--home-preview-opacity: 0.3;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--home-lyric-color: #fff8fa;
|
||||
--home-hero-shade: rgba(15, 13, 16, 0.72);
|
||||
--home-hero-text: #fff8fa;
|
||||
--home-hero-muted: #d1c4cb;
|
||||
--home-preview-opacity: 0.3;
|
||||
}
|
||||
|
||||
.content-panel:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.amll-lyric-player {
|
||||
pointer-events: none;
|
||||
--amll-lp-font-size: 2.2rem;
|
||||
--amll-lp-color: var(--home-lyric-color);
|
||||
[class*="_lyricMainLine"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
[class*="_emphasizeWrapper"] span {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
}
|
||||
}
|
||||
|
||||
.home-shell {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
width: 100vw;
|
||||
min-height: 36rem;
|
||||
margin-inline: calc(50% - 50vw);
|
||||
padding-block: 4rem 5.5rem;
|
||||
padding-inline: max(var(--sl-content-pad-x), calc((100vw - 68rem) / 2));
|
||||
box-sizing: border-box;
|
||||
isolation: isolate;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 25rem;
|
||||
background: linear-gradient(to bottom, transparent, var(--amll-page-bg));
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.home-preview {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
opacity: var(--home-preview-opacity);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.home-hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-inline: 2rem;
|
||||
color: var(--home-hero-text);
|
||||
}
|
||||
|
||||
h1#home-title {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.home-eyebrow {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--sl-color-text-accent);
|
||||
font-size: var(--sl-text-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
.home-hero & {
|
||||
width: fit-content;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-accent) 15%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.home-hero h1 {
|
||||
margin: 0;
|
||||
color: var(--home-hero-text);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 0.98;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.home-summary {
|
||||
margin: 1.25rem 0 0;
|
||||
color: var(--home-hero-muted);
|
||||
font-size: var(--sl-text-lg);
|
||||
line-height: 1.7;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.home-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.home-button.home-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
box-shadow:
|
||||
0 0 0 1px inset var(--amll-muted-border),
|
||||
var(--amll-shadow-soft);
|
||||
border-radius: 999px;
|
||||
background: var(--amll-surface);
|
||||
color: var(--sl-color-white);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--sl-color-text-accent) 44%,
|
||||
transparent
|
||||
);
|
||||
background: var(--amll-surface-hover);
|
||||
}
|
||||
|
||||
&.home-button-primary {
|
||||
border: none;
|
||||
color: white;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--sl-color-accent),
|
||||
var(--amll-accent-strong)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-entry {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: -2.4rem;
|
||||
padding: 0 var(--sl-content-pad-x) 2rem;
|
||||
}
|
||||
|
||||
.home-section-heading {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.home-section-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--sl-color-white);
|
||||
font-size: var(--sl-text-2xl);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.home-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.home-card.home-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-height: 6.25rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--amll-muted-border);
|
||||
border-radius: var(--amll-radius);
|
||||
background: var(--amll-card-bg);
|
||||
color: var(--sl-color-white);
|
||||
text-decoration: none;
|
||||
box-shadow: var(--amll-shadow-soft);
|
||||
|
||||
&:hover {
|
||||
background: var(--amll-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.home-card-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--sl-color-text-accent) 28%, transparent);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--sl-color-accent-low);
|
||||
color: var(--sl-color-text-accent);
|
||||
svg {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: override starlight */
|
||||
--sl-icon-size: 1.3rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.home-card-copy {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: var(--sl-text-lg);
|
||||
line-height: 1.2;
|
||||
}
|
||||
span {
|
||||
color: var(--sl-color-gray-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.home-card-arrow {
|
||||
color: var(--sl-color-gray-3);
|
||||
transition:
|
||||
color 0.15s,
|
||||
transform 0.15s;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: override starlight */
|
||||
--sl-icon-size: 1.5rem !important;
|
||||
|
||||
.home-card:hover & {
|
||||
color: var(--sl-color-text);
|
||||
transform: translateX(0.15rem);
|
||||
}
|
||||
}
|
||||
|
||||
.home-topic-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
padding: 0 var(--sl-content-pad-x) 4rem;
|
||||
|
||||
a[href] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
color: var(--sl-color-gray-2);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--amll-muted-border);
|
||||
background: var(--amll-surface);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.amll-lyric-player {
|
||||
--amll-lp-font-size: 3rem;
|
||||
}
|
||||
.home-hero {
|
||||
min-height: 39rem;
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
.home-hero h1 {
|
||||
font-size: 4rem;
|
||||
}
|
||||
.home-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
.amll-lyric-player {
|
||||
--amll-lp-font-size: 3.35rem;
|
||||
}
|
||||
.home-hero {
|
||||
min-height: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 36rem) {
|
||||
.home-hero {
|
||||
min-height: 34rem;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
.home-preview {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.home-hero h1 {
|
||||
font-size: 2.35rem;
|
||||
}
|
||||
.home-summary {
|
||||
font-size: var(--sl-text-base);
|
||||
}
|
||||
.home-card.home-card {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.home-card-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
26
amll-local/packages/docs/src/utils/core-version.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const corePackageJsonPath = [
|
||||
resolve(process.cwd(), "../core/package.json"),
|
||||
resolve(process.cwd(), "packages/core/package.json"),
|
||||
].find((path) => existsSync(path));
|
||||
|
||||
if (!corePackageJsonPath) {
|
||||
throw new Error("Unable to locate packages/core/package.json.");
|
||||
}
|
||||
|
||||
const corePackageJson = JSON.parse(
|
||||
readFileSync(corePackageJsonPath, "utf-8"),
|
||||
) as {
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
if (typeof corePackageJson.version !== "string" || !corePackageJson.version) {
|
||||
throw new Error(
|
||||
`Expected ${corePackageJsonPath} to define a package version.`,
|
||||
);
|
||||
}
|
||||
|
||||
export const amllCoreVersion = corePackageJson.version;
|
||||
export const amllCoreVersionLabel = `AMLL Core v${amllCoreVersion}`;
|
||||
9
amll-local/packages/docs/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [".astro/types.d.ts", "src"],
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"declaration": false,
|
||||
"composite": false
|
||||
}
|
||||
}
|
||||