fork(fix): Clone AMLL 并修复 BUG

- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork)
- 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
lqtmcstudio
2026-06-07 00:02:14 +08:00
parent 783d2c3dee
commit 72f4510dc8
458 changed files with 86075 additions and 1665 deletions

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>AMLL Core Test</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="src/test.ts" type="module" defer></script>
<style>
:root {
font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
body {
max-width: 100vw;
width: 100vw;
max-height: 100vh;
height: 100vh;
overflow: hidden;
background: #222;
margin: 0;
}
#player {
max-height: 100vh;
height: 100vh;
max-width: 100vw;
width: 100vw;
overflow: hidden;
}
</style>
</head>
<body>
<div id="player"></div>
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<title>AMLL Core Test</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="src/mg-test.ts" type="module" defer></script>
<style>
:root {
font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
body {
max-width: 100vw;
width: 100vw;
max-height: 100vh;
height: 100vh;
overflow: hidden;
background: #222;
margin: 0;
}
#bg {
max-height: 100vh;
height: 100vh;
max-width: 100vw;
width: 100vw;
overflow: hidden;
}
#result {
position: absolute;
right: 20px;
bottom: 20px;
width: 50em;
height: 20em;
border: solid 1px #3337;
background-color: #1117;
resize: both;
color: white;
font-family:
"Fira Code", "SF Pro Display", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
#result::placeholder {
color: #888;
}
.dragger {
position: absolute;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border-radius: 50%;
border: solid 3px #8888;
cursor: move;
user-select: none;
}
.dragger-handle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #4af;
border: solid 2px #fff;
cursor: pointer;
transform: translate(-50%, -50%);
z-index: 10;
}
.dragger-line {
position: absolute;
height: 2px;
background-color: #4af8;
transform-origin: left center;
pointer-events: none;
}
.active {
border-color: #f44;
}
</style>
</head>
<body>
<canvas id="bg"></canvas>
<textarea id="result" placeholder="控制点代码将会在这里显示"></textarea>
</body>
</html>

View File

@@ -0,0 +1,32 @@
{
"name": "@applemusic-like-lyrics/playground-core-legacy",
"private": true,
"version": "0.0.0",
"type": "module",
"nx": {
"tags": [
"playground"
]
},
"scripts": {
"dev": "vite dev"
},
"devDependencies": {
"@applemusic-like-lyrics/core": "workspace:^",
"@applemusic-like-lyrics/lyric": "workspace:^",
"@applemusic-like-lyrics/ttml": "workspace:^",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
"@pixi/filter-blur": "^7.4.3",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"lil-gui": "^0.21.0",
"stats.js": "^0.17.0",
"vite": "catalog:"
},
"dependencies": {
"@types/stats.js": "^0.17.4"
}
}

View File

@@ -0,0 +1,504 @@
/**
* @fileoverview
* 调试 MG 渲染器的测试脚本
*
* @author SteveXMH
*/
import { MeshGradientRenderer } from "@applemusic-like-lyrics/core";
import GUI from "lil-gui";
import Stats from "stats.js";
const debugValues = {
image: new URL(location.href).searchParams.get("image") || "",
controlPointSize: 4,
subdivideDepth: 15,
wireFrame: false,
};
const canvas = document.getElementById("bg") as HTMLCanvasElement;
const mgRenderer = new MeshGradientRenderer(canvas);
mgRenderer.setManualControl(true);
mgRenderer.setRenderScale(1);
mgRenderer.setFPS(Number.POSITIVE_INFINITY);
function updateControlPointDraggers() {
for (const el of document.querySelectorAll(".dragger")) {
const x = Number.parseInt(el.getAttribute("x") ?? "", 10);
const y = Number.parseInt(el.getAttribute("y") ?? "", 10);
const point = mgRenderer.getControlPoint(x, y);
if (point === undefined) return;
const px = (point.location.x + 1) * 50;
const py = (1 - point.location.y) * 50;
(el as HTMLElement).style.left = `${px}%`;
(el as HTMLElement).style.top = `${py}%`;
// Update handles
const uHandle = el.querySelector(".u-handle") as HTMLElement;
const uLine = el.querySelector(".u-line") as HTMLElement;
if (uHandle && uLine) {
const uLen = point.uScale * 50;
const uAngle = -point.uRot;
uHandle.style.left = `${10 + Math.cos(uAngle) * uLen}px`;
uHandle.style.top = `${10 + Math.sin(uAngle) * uLen}px`;
uLine.style.width = `${uLen}px`;
uLine.style.transform = `rotate(${uAngle}rad)`;
uLine.style.left = "10px";
uLine.style.top = "10px";
}
const vHandle = el.querySelector(".v-handle") as HTMLElement;
const vLine = el.querySelector(".v-line") as HTMLElement;
if (vHandle && vLine) {
const vLen = point.vScale * 50;
// vRot is relative to vertical axis (PI/2) in WebGL
const vAngle = -(point.vRot + Math.PI / 2);
vHandle.style.left = `${10 + Math.cos(vAngle) * vLen}px`;
vHandle.style.top = `${10 + Math.sin(vAngle) * vLen}px`;
vLine.style.width = `${vLen}px`;
vLine.style.transform = `rotate(${vAngle}rad)`;
vLine.style.left = "10px";
vLine.style.top = "10px";
}
}
}
let draggerGui: GUI | undefined;
function setActiveDragger(x: number, y: number) {
if (draggerGui) {
draggerGui.destroy();
draggerGui = undefined;
}
const point = mgRenderer.getControlPoint(x, y);
if (point) {
draggerGui = gui.addFolder(`控制点 (${x}, ${y})`);
const obj = {
uAngle: (point.uRot * 180) / Math.PI,
vAngle: (point.vRot * 180) / Math.PI,
uScale: point.uScale,
vScale: point.vScale,
};
draggerGui
.add(obj, "uAngle", -180, 180)
.name("横向扭曲角度")
.onChange((v: number) => {
point.uRot = (v * Math.PI) / 180;
updateControlPointDraggers();
updateResult();
});
draggerGui
.add(obj, "vAngle", -180, 180)
.name("纵向扭曲角度")
.onChange((v: number) => {
point.vRot = (v * Math.PI) / 180;
updateControlPointDraggers();
updateResult();
});
draggerGui
.add(obj, "uScale", 0.1, 10)
.name("横向缩放")
.onChange((v: number) => {
point.uScale = v;
updateControlPointDraggers();
updateResult();
});
draggerGui
.add(obj, "vScale", 0.1, 10)
.name("纵向缩放")
.onChange((v: number) => {
point.vScale = v;
updateControlPointDraggers();
updateResult();
});
}
}
window.addEventListener("resize", updateControlPointDraggers);
const resultTextArea = document.getElementById("result") as HTMLTextAreaElement;
resultTextArea.value = "// 控制点的设置代码将会在这里显示";
function updateResult() {
const result = [
`preset(${debugValues.controlPointSize}, ${debugValues.controlPointSize}, [`,
];
for (let y = 0; y < debugValues.controlPointSize; y++) {
for (let x = 0; x < debugValues.controlPointSize; x++) {
const point = mgRenderer.getControlPoint(x, y);
if (point === undefined) continue;
const px = Number(point.location.x.toFixed(4));
const py = Number(point.location.y.toFixed(4));
const ur = Number(point.uRot.toFixed(4));
const vr = Number(point.vRot.toFixed(4));
const up = Number(point.uScale.toFixed(4));
const vp = Number(point.vScale.toFixed(4));
let pStr = ` p(${x}, ${y}, ${px}, ${py}`;
if (ur !== 0 || vr !== 0 || up !== 1 || vp !== 1) {
pStr += `, ${ur}, ${vr}`;
if (up !== 1 || vp !== 1) {
pStr += `, ${up}, ${vp}`;
}
}
pStr += `),`;
result.push(pStr);
}
}
result.push("]),");
resultTextArea.value = result.join("\n");
}
function resizeControlPoint() {
document.querySelectorAll(".dragger").forEach((el) => {
el.parentElement?.removeChild(el);
});
mgRenderer.resizeControlPoints(
debugValues.controlPointSize,
debugValues.controlPointSize,
);
mgRenderer.resetSubdivition(debugValues.subdivideDepth);
for (let y = 0; y < debugValues.controlPointSize; y++) {
for (let x = 0; x < debugValues.controlPointSize; x++) {
const point = mgRenderer.getControlPoint(x, y);
if (point === undefined) continue;
const dragger = document.createElement("div");
const draggerInput = document.createElement("input");
draggerInput.type = "color";
draggerInput.style.position = "absolute";
draggerInput.style.visibility = "hidden";
dragger.appendChild(draggerInput);
dragger.setAttribute("x", `${x}`);
dragger.setAttribute("y", `${y}`);
dragger.className = "dragger";
dragger.style.left = `${(x * 100) / (debugValues.controlPointSize - 1)}%`;
dragger.style.top = `${
((debugValues.controlPointSize - y - 1) * 100) /
(debugValues.controlPointSize - 1)
}%`;
// Add U/V handles
const uLine = document.createElement("div");
uLine.className = "dragger-line u-line";
const uHandle = document.createElement("div");
uHandle.className = "dragger-handle u-handle";
uHandle.style.backgroundColor = "#f44";
const vLine = document.createElement("div");
vLine.className = "dragger-line v-line";
const vHandle = document.createElement("div");
vHandle.className = "dragger-handle v-handle";
vHandle.style.backgroundColor = "#4f4";
dragger.appendChild(uLine);
dragger.appendChild(uHandle);
dragger.appendChild(vLine);
dragger.appendChild(vHandle);
// Handle U dragging
uHandle.addEventListener("mousedown", (evt) => {
if (point === undefined) return;
evt.stopPropagation();
const rect = dragger.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
function onMouseMove(e: MouseEvent) {
if (point === undefined) return;
const dx = e.clientX - centerX;
const dy = e.clientY - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
const isCorner =
(x === 0 || x === debugValues.controlPointSize - 1) &&
(y === 0 || y === debugValues.controlPointSize - 1);
const isEdge =
x === 0 ||
x === debugValues.controlPointSize - 1 ||
y === 0 ||
y === debugValues.controlPointSize - 1;
if (!isCorner) {
if (isEdge) {
// For edge points, only allow scaling, keep rotation at 0
point.uRot = 0;
} else {
point.uRot = Math.atan2(-dy, dx);
}
}
point.uScale = Math.max(0.1, dist / 50);
updateControlPointDraggers();
updateResult();
if (draggerGui) {
draggerGui.controllersRecursive().forEach((c) => {
c.updateDisplay();
});
}
}
function onMouseUp() {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
// Handle V dragging
vHandle.addEventListener("mousedown", (evt) => {
if (point === undefined) return;
evt.stopPropagation();
const rect = dragger.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
function onMouseMove(e: MouseEvent) {
if (point === undefined) return;
const dx = e.clientX - centerX;
const dy = e.clientY - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
const isCorner =
(x === 0 || x === debugValues.controlPointSize - 1) &&
(y === 0 || y === debugValues.controlPointSize - 1);
const isEdge =
x === 0 ||
x === debugValues.controlPointSize - 1 ||
y === 0 ||
y === debugValues.controlPointSize - 1;
if (!isCorner) {
if (isEdge) {
// For edge points, only allow scaling, keep rotation at 0
point.vRot = 0;
} else {
// vRot is relative to vertical axis
point.vRot = Math.atan2(-dy, dx) - Math.PI / 2;
}
}
point.vScale = Math.max(0.1, dist / 50);
updateControlPointDraggers();
updateResult();
if (draggerGui) {
draggerGui.controllersRecursive().forEach((c) => {
c.updateDisplay();
});
}
}
function onMouseUp() {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
draggerInput.addEventListener("input", () => {
// mgRenderer.getControlPoint(x, y).color = dragger.value;
const c = draggerInput.value;
console.log(c);
dragger.style.backgroundColor = c;
const color = c.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
if (color) {
point.color.r = Number.parseInt(color[1], 16) / 255;
point.color.g = Number.parseInt(color[2], 16) / 255;
point.color.b = Number.parseInt(color[3], 16) / 255;
dragger.setAttribute("r", `${point.color.r}`);
dragger.setAttribute("g", `${point.color.g}`);
dragger.setAttribute("b", `${point.color.b}`);
updateResult();
}
});
let dragging = false;
dragger.addEventListener("mousedown", (evt) => {
evt.stopPropagation();
const isCorner =
(x === 0 || x === debugValues.controlPointSize - 1) &&
(y === 0 || y === debugValues.controlPointSize - 1);
function onMouseMove(evt: MouseEvent) {
if (!isCorner) {
dragger.style.left = `${Math.min(
window.innerWidth,
Math.max(0, evt.clientX),
)}px`;
dragger.style.top = `${Math.min(
window.innerHeight,
Math.max(0, evt.clientY),
)}px`;
if (point) {
point.location.x = Math.max(
-1,
Math.min(1, (evt.clientX / window.innerWidth) * 2 - 1),
);
point.location.y = Math.max(
-1,
Math.min(1, -((evt.clientY / window.innerHeight) * 2 - 1)),
);
}
}
dragging = true;
updateControlPointDraggers();
updateResult();
evt.stopPropagation();
}
function onMouseUp(evt: MouseEvent) {
if (dragging) {
dragging = false;
} else if (dragger.classList.contains("active")) {
draggerInput.click();
} else {
for (const el of document.querySelectorAll(".dragger.active")) {
el.classList.remove("active");
}
dragger.classList.add("active");
setActiveDragger(x, y);
}
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
evt.stopPropagation();
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
document.body.appendChild(dragger);
}
}
updateResult();
}
function subdivide() {
mgRenderer.resetSubdivition(debugValues.subdivideDepth);
}
function reloadImage() {
mgRenderer
.setAlbum(debugValues.image)
.catch(() =>
mgRenderer.setAlbum(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAADUExURf///6fEG8gAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAKSURBVBjTY2AAAAACAAGYY2zXAAAAAElFTkSuQmCC",
),
)
.finally(() => {
resizeControlPoint();
updateControlPointDraggers();
subdivide();
});
}
reloadImage();
const gui = new GUI();
gui.close();
gui.title("MG Renderer 调试页面");
gui.add(debugValues, "image").name("图片 URL").onFinishChange(reloadImage);
gui
.add(debugValues, "controlPointSize", 3, 10, 1)
.name("控制点矩阵大小")
.onFinishChange(resizeControlPoint);
gui
.add(debugValues, "subdivideDepth", 2, 50, 1)
.name("细分深度")
.onChange(subdivide);
gui
.add(debugValues, "wireFrame")
.name("线框模式")
.onChange((v: boolean) => mgRenderer.setWireFrame(v));
/** @internal */
export interface ControlPointConf {
cx: number;
cy: number;
x: number;
y: number;
ur: number;
vr: number;
up: number;
vp: number;
}
/** @internal */
export interface ControlPointPreset {
width: number;
height: number;
conf: ControlPointConf[];
}
const actions = {
copyCode: () => {
navigator.clipboard.writeText(resultTextArea.value).then(() => {
alert("代码已复制到剪贴板");
});
},
loadCode: () => {
try {
const code = resultTextArea.value;
const preset = (
width: number,
height: number,
conf: ControlPointConf[],
): ControlPointPreset => {
return { width, height, conf };
};
const p = (
cx: number,
cy: number,
x: number,
y: number,
ur = 0,
vr = 0,
up = 1,
vp = 1,
): ControlPointConf => ({ cx, cy, x, y, ur, vr, up, vp });
// Remove trailing comma if exists
const cleanCode = code.trim().replace(/,$/, "");
const fn = new Function("preset", "p", `return ${cleanCode}`);
const loadedPreset = fn(preset, p) as ControlPointPreset | undefined;
if (loadedPreset) {
debugValues.controlPointSize = loadedPreset.width;
gui.controllersRecursive().forEach((c) => {
c.updateDisplay();
});
resizeControlPoint();
for (const conf of loadedPreset.conf) {
const point = mgRenderer.getControlPoint(conf.cx, conf.cy);
if (point) {
point.location.x = conf.x;
point.location.y = conf.y;
point.uRot = conf.ur;
point.vRot = conf.vr;
point.uScale = conf.up;
point.vScale = conf.vp;
}
}
updateControlPointDraggers();
updateResult();
alert("预设加载成功");
}
} catch (e) {
alert(`加载失败,请检查代码格式是否正确\n${e}`);
}
},
};
gui.add(actions, "copyCode").name("复制预设代码");
gui.add(actions, "loadCode").name("从文本框加载预设");
const stats = new Stats();
stats.showPanel(0);
stats.dom.style.left = "50px";
document.body.appendChild(stats.dom);
const frame = () => {
stats.end();
stats.begin();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);

View File

@@ -0,0 +1,449 @@
/**
* @fileoverview
* 此处是一个简易的组件加载测试脚本,用来调试歌词
*
* @author SteveXMH
*/
import type { LyricLine } from "@applemusic-like-lyrics/core";
import {
BackgroundRender,
DomLyricPlayer,
type LyricLineMouseEvent,
MeshGradientRenderer,
PixiRenderer,
} from "@applemusic-like-lyrics/core";
import * as lyrics from "@applemusic-like-lyrics/lyric";
import {
parseLrc,
parseLys,
parseQrc,
parseYrc,
type LyricLine as RawLyricLine,
} from "@applemusic-like-lyrics/lyric";
import { parseTTML } from "@applemusic-like-lyrics/ttml";
import GUI from "lil-gui";
import Stats from "stats.js";
export interface SpringParams {
mass: number; // = 1.0
damping: number; // = 10.0
stiffness: number; // = 100.0
soft: boolean; // = false
}
window.lyrics = lyrics;
const audio = document.createElement("audio");
audio.volume = 0.5;
audio.preload = "auto";
audio.addEventListener("play", () => lyricPlayer.resume());
audio.addEventListener("pause", () => lyricPlayer.pause());
const debugValues = {
lyric: new URL(location.href).searchParams.get("lyric") || "",
music: new URL(location.href).searchParams.get("music") || "",
album: new URL(location.href).searchParams.get("album") || "",
enableSpring: true,
bgFPS: 60,
bgMode: new URL(location.href).searchParams.get("bg") || "mg",
bgScale: 1,
bgFlowSpeed: 0.2,
bgPlaying: true,
bgStaticMode: false,
currentTime: 0,
enableBlur: true,
playing: false,
async mockPlay() {
this.playing = true;
const startTime = Date.now();
const baseTime = this.currentTime * 1000;
while (this.playing && this.currentTime < 300) {
const time = Date.now() - startTime;
this.currentTime = (baseTime + time) / 1000;
progress.updateDisplay();
lyricPlayer.setCurrentTime(baseTime + time);
await waitFrame();
}
},
forceUpdateAlbum() {
window.globalBackground.setAlbum(debugValues.album);
},
forceUpdateLyric() {
loadLyric();
},
play() {
this.playing = true;
audio.load();
audio.play();
},
pause() {
this.playing = false;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
},
fadeWidth: 0.5,
lineSprings: {
posX: {
mass: 1,
damping: 10,
stiffness: 100,
soft: false,
} as SpringParams,
posY: {
mass: 1,
damping: 15,
stiffness: 100,
soft: false,
} as SpringParams,
scale: {
mass: 1,
damping: 20,
stiffness: 100,
soft: false,
} as SpringParams,
},
};
function recreateBGRenderer(mode: string) {
window.globalBackground?.dispose();
if (mode === "pixi") {
window.globalBackground = BackgroundRender.new(PixiRenderer);
} else if (mode === "mg") {
window.globalBackground = BackgroundRender.new(MeshGradientRenderer);
} else {
throw new Error("Unknown renderer mode");
}
const bg = window.globalBackground;
bg.setFPS(debugValues.bgFPS);
bg.setRenderScale(debugValues.bgScale);
bg.setStaticMode(debugValues.bgStaticMode);
bg.setFlowSpeed(debugValues.bgFlowSpeed);
bg.getElement().style.position = "absolute";
bg.getElement().style.top = "0";
bg.getElement().style.left = "0";
bg.getElement().style.width = "100%";
bg.getElement().style.height = "100%";
bg.setAlbum(debugValues.album);
}
audio.src = debugValues.music;
audio.load();
const gui = new GUI();
gui.close();
gui.title("AMLL 歌词测试页面");
const lyricController = gui
.add(debugValues, "lyric")
.name("歌词文件")
.onFinishChange(async (url: string) => {
lyricPlayer.setLyricLines(parseTTML(await (await fetch(url)).text()).lines);
});
const localFileApi = {
openLocalLyricFile() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".ttml,.lrc,.yrc,.lys,.qrc";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
localLyricExt = file.name;
if (localLyricUrl) {
URL.revokeObjectURL(localLyricUrl);
}
localLyricUrl = URL.createObjectURL(file);
debugValues.lyric = localLyricUrl;
lyricController.updateDisplay();
await loadLyric();
};
input.click();
},
openLocalMusicFile() {
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
if (localMusicUrl) {
URL.revokeObjectURL(localMusicUrl);
}
localMusicUrl = URL.createObjectURL(file);
debugValues.music = localMusicUrl;
audio.src = localMusicUrl;
audio.load();
musicController.updateDisplay();
};
input.click();
},
};
gui.add(localFileApi, "openLocalLyricFile").name("打开本地歌词");
gui.add(localFileApi, "openLocalMusicFile").name("打开本地歌曲");
const musicController = gui
.add(debugValues, "music")
.name("歌曲")
.onFinishChange((v: string) => {
audio.src = v;
});
gui
.add(debugValues, "album")
.name("专辑图片")
.onFinishChange((v: string) => {
window.globalBackground.setAlbum(v);
});
gui.add(debugValues, "forceUpdateAlbum").name("强制更新专辑图片");
gui.add(debugValues, "forceUpdateLyric").name("强制更新歌词");
const bgGui = gui.addFolder("背景");
bgGui
.add(debugValues, "bgPlaying")
.name("播放")
.onFinishChange((v: boolean) => {
if (v) {
window.globalBackground.resume();
} else {
window.globalBackground.pause();
}
});
bgGui
.add(debugValues, "bgMode", ["pixi", "mg"])
.name("背景渲染器")
.onFinishChange((v: string) => {
recreateBGRenderer(v);
});
bgGui
.add(debugValues, "bgScale", 0.01, 1, 0.01)
.name("分辨率比率")
.onChange((v: number) => {
window.globalBackground.setRenderScale(v);
});
bgGui
.add(debugValues, "bgFPS", 1, 1000, 1)
.name("帧率")
.onFinishChange((v: number) => {
window.globalBackground.setFPS(v);
});
bgGui
.add(debugValues, "bgFlowSpeed", 0, 10, 0.1)
.name("流动速度")
.onFinishChange((v: number) => {
window.globalBackground.setFlowSpeed(v);
});
bgGui
.add(debugValues, "bgStaticMode")
.name("静态模式")
.onFinishChange((v: boolean) => {
window.globalBackground.setStaticMode(v);
});
{
const animation = gui.addFolder("歌词行动画/效果");
animation
.add(debugValues, "fadeWidth", 0, 10, 0.01)
.name("歌词渐变宽度")
.onChange((v: number) => {
lyricPlayer.setWordFadeWidth(v);
});
animation
.add(debugValues, "enableBlur")
.name("启用歌词模糊")
.onChange((v: boolean) => {
lyricPlayer.setEnableBlur(v);
});
animation
.add(debugValues, "enableSpring")
.name("使用弹簧动画")
.onChange((v: boolean) => {
lyricPlayer.setEnableSpring(v);
});
function addSpringDbg(name: string, obj: SpringParams, onChange: () => void) {
const x = animation.addFolder(name);
x.close();
x.add(obj, "mass").name("质量").onFinishChange(onChange);
x.add(obj, "damping").name("阻力").onFinishChange(onChange);
x.add(obj, "stiffness").name("弹性").onFinishChange(onChange);
x.add(obj, "soft")
.name("强制软弹簧(当阻力小于 1 时有用)")
.onFinishChange(onChange);
}
addSpringDbg("水平位移弹簧", debugValues.lineSprings.posX, () => {
lyricPlayer.setLinePosXSpringParams(debugValues.lineSprings.posX);
});
addSpringDbg("垂直位移弹簧", debugValues.lineSprings.posY, () => {
lyricPlayer.setLinePosYSpringParams(debugValues.lineSprings.posY);
});
addSpringDbg("缩放弹簧", debugValues.lineSprings.scale, () => {
lyricPlayer.setLineScaleSpringParams(debugValues.lineSprings.scale);
});
}
const playerGui = gui.addFolder("音乐播放器");
const progress = playerGui
.add(debugValues, "currentTime")
.min(0)
.step(1)
.name("当前进度")
.onChange((v: number) => {
audio.currentTime = v;
lyricPlayer.setCurrentTime(v * 1000, true);
});
playerGui.add(debugValues, "play").name("加载/播放");
playerGui.add(debugValues, "pause").name("暂停/继续");
const lyricPlayer = new DomLyricPlayer();
lyricPlayer.addEventListener("line-click", (evt) => {
const e = evt as LyricLineMouseEvent;
evt.preventDefault();
evt.stopImmediatePropagation();
evt.stopPropagation();
console.log(e.line, e.lineIndex);
const time = e.line.getLine().startTime;
lyricPlayer.setCurrentTime(time, true);
audio.currentTime = time / 1000;
});
const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);
let lastTime = -1;
const frame = (time: number) => {
stats.end();
if (lastTime === -1) {
lastTime = time;
}
if (!audio.paused) {
const time = (audio.currentTime * 1000) | 0;
debugValues.currentTime = (time / 1000) | 0;
progress.max(audio.duration | 0);
progress.updateDisplay();
lyricPlayer.setCurrentTime(time);
}
lyricPlayer.update(time - lastTime);
lastTime = time;
stats.begin();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
declare global {
interface Window {
globalLyricPlayer: DomLyricPlayer;
globalBackground:
| BackgroundRender<PixiRenderer>
| BackgroundRender<MeshGradientRenderer>;
lyrics: typeof lyrics;
}
}
window.globalLyricPlayer = lyricPlayer;
const waitFrame = (): Promise<number> =>
new Promise((resolve) => requestAnimationFrame(resolve));
let localLyricUrl: string | null = null;
let localLyricExt: string | null = null;
let localMusicUrl: string | null = null;
const mapLyric = (
line: RawLyricLine,
_i: number,
_lines: RawLyricLine[],
): LyricLine => ({
words: line.words.map((word) => ({
...word,
obscene: false,
romanWord: word.romanWord ?? "",
})),
startTime: line.words[0]?.startTime ?? 0,
endTime:
line.words[line.words.length - 1]?.endTime ?? Number.POSITIVE_INFINITY,
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
});
async function loadLyric() {
const lyricFile = debugValues.lyric;
const content = await (await fetch(lyricFile)).text();
const lyricSource = (localLyricExt ?? lyricFile).toLowerCase();
if (lyricSource.endsWith(".ttml")) {
lyricPlayer.setLyricLines(parseTTML(content).lines);
} else if (lyricSource.endsWith(".lrc")) {
lyricPlayer.setLyricLines(parseLrc(content).map(mapLyric));
} else if (lyricSource.endsWith(".yrc")) {
lyricPlayer.setLyricLines(parseYrc(content).map(mapLyric));
} else if (lyricSource.endsWith(".lys")) {
lyricPlayer.setLyricLines(parseLys(content).map(mapLyric));
} else if (lyricSource.endsWith(".qrc")) {
lyricPlayer.setLyricLines(parseQrc(content).map(mapLyric));
} else if (lyricFile === "bug") {
const buildLyricLines = (
lyric: string,
startTime = 1000,
otherParams: Partial<LyricLine> = {},
): LyricLine => {
let curTime = startTime;
const words = [];
for (const word of lyric.split("|")) {
const [text, duration] = word.split(",");
const endTime = curTime + Number.parseInt(duration, 10);
words.push({
word: text,
romanWord: "",
startTime: curTime,
endTime,
obscene: false,
});
curTime = endTime;
}
return {
startTime,
endTime: curTime + 3000,
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
words,
...otherParams,
};
};
const DEMO_LYRIC: LyricLine[] = [
buildLyricLines(
"Apple ,750|Music ,500|Like ,500|Ly,400|ri,500|cs ,250",
1000,
),
buildLyricLines("BG ,750|Lyrics ,1000", 2000, {
isBG: true,
}),
buildLyricLines("Next ,1000|Lyrics,1000", 2500, {
// isDuet: true,
}),
];
lyricPlayer.setLyricLines(DEMO_LYRIC);
}
}
(async () => {
recreateBGRenderer(debugValues.bgMode);
audio.style.display = "none";
// lyricPlayer.getBottomLineElement().innerHTML = "Test Bottom Line";
const player = document.getElementById("player");
if (player) {
player.appendChild(audio);
player.appendChild(window.globalBackground.getElement());
player.appendChild(lyricPlayer.getElement());
}
if (!debugValues.enableSpring) {
lyricPlayer.setEnableSpring(false);
}
await loadLyric();
// debugValues.play();
// debugValues.currentTime = 34;
// debugValues.mockPlay();
})();

View File

@@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"]
}

View File

@@ -0,0 +1,20 @@
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: {
"@applemusic-like-lyrics/core": path.resolve(__dirname, "../../core/src"),
"@applemusic-like-lyrics/core/style.css": path.resolve(
__dirname,
"../../core/src/styles/index.css",
),
"@applemusic-like-lyrics/lyric": path.resolve(
__dirname,
"../../lyric/src",
),
"@applemusic-like-lyrics/ttml": path.resolve(__dirname, "../../ttml/src"),
"@amll-core-src": path.resolve(__dirname, "../../core/src"),
},
},
});

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-vega",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/index.tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AMLL Playground</title>
<style>
:root {
background-color: light-dark(#fafafa, #18181b);
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
{
"name": "@applemusic-like-lyrics/playground-core",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"typecheck": "vue-tsc --noEmit",
"build-only": "vite build",
"build": "run-p typecheck \"build-only {@}\" --"
},
"dependencies": {
"@applemusic-like-lyrics/core": "workspace:^",
"@applemusic-like-lyrics/lyric": "workspace:^",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
"@pixi/filter-blur": "^7.4.3",
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"lucide-vue-next": "^1.0.0",
"music-metadata": "^11.12.3",
"pinia": "^3.0.4",
"reka-ui": "^2.9.7",
"shadcn-vue": "^2.7.3",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.34"
},
"devDependencies": {
"@iconify-json/radix-icons": "^1.2.6",
"@iconify/vue": "^5.0.1",
"@tailwindcss/vite": "^4.3.0",
"@tsconfig/node22": "^22.0.5",
"@types/node": "^25.8.0",
"@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.13",
"vue-tsc": "^3.2.9"
}
}

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import SidebarShell from "@/components/SidebarShell.vue";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import LyricPlayer from "./components/MainPlayer.vue";
</script>
<template>
<SidebarProvider>
<SidebarShell variant="inset" />
<SidebarInset class="relative overflow-hidden">
<LyricPlayer />
</SidebarInset>
</SidebarProvider>
</template>
<style>
html,
body,
#app {
margin: 0;
min-height: 100%;
overflow: hidden;
}
:root {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
</style>

View 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

View File

@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "shadcn-vue/tailwind.css";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, "PingFang SC",
system-ui, sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.629 0.215 25.963);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.582 0.193 26.976);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.281 0 271.152);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.218 0 271.152);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply font-sans;
}
}
::selection {
background-color: color-mix(in oklab, var(--primary) 40%, transparent);
}

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import {
ImageIcon,
MusicIcon,
PauseIcon,
PlayIcon,
TextAlignStartIcon,
} from "lucide-vue-next";
import { computed } from "vue";
import { extractCoverBlob } from "@/lib/extract-cover";
import { usePlayerStore } from "@/stores/player";
import ModeToggle from "./ModeToggle.vue";
import Button from "./ui/button/Button.vue";
import ButtonGroup from "./ui/button-group/ButtonGroup.vue";
import SidebarFooter from "./ui/sidebar/SidebarFooter.vue";
import Slider from "./ui/slider/Slider.vue";
const player = usePlayerStore();
const currentTime = computed({
get: () => [player.audio.currentTime],
set: (value) => player.seek(value[0] ?? 0),
});
const maxTime = computed(() =>
Math.max(1, player.audio.duration, player.audio.currentTime),
);
const currentTimeLabel = computed(() => formatTime(player.audio.currentTime));
const durationLabel = computed(() => formatTime(player.audio.duration));
function formatTime(time: number): string {
const normalizedTime = Math.max(0, Number.isFinite(time) ? time : 0);
const minutes = Math.floor(normalizedTime / 60);
const seconds = Math.floor(normalizedTime % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
function openFile(accept: string, onFile: (file: File) => void): void {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = () => {
const file = input.files?.[0];
if (file) onFile(file);
};
input.click();
}
async function openLocalMusicFile(file: File): Promise<void> {
player.setLocalMusicFile(file);
try {
const cover = await extractCoverBlob(file);
if (cover) {
player.setExtractedAlbumBlob(cover, `${file.name} cover`);
} else {
player.clearExtractedAlbum();
}
} catch {
player.clearExtractedAlbum();
}
}
</script>
<template>
<SidebarFooter class="border-t border-sidebar-border bg-sidebar p-3 gap-2">
<div
class="flex items-center justify-between px-1 text-xs text-muted-foreground"
>
<span>{{ currentTimeLabel }}</span>
<span>{{ durationLabel }}</span>
</div>
<Slider
v-model="currentTime"
class="mt-1 mb-2"
:max="maxTime"
:step="1"
:disabled="!player.source.musicUrl"
/>
<div class="flex gap-2 justify-between">
<div class="flex gap-2">
<Button
size="icon"
:aria-label="player.audio.playing ? '暂停' : '播放'"
@click="player.togglePlayback"
:disabled="!player.source.musicUrl"
>
<PauseIcon v-if="player.audio.playing" />
<PlayIcon v-else />
</Button>
<ButtonGroup>
<Button
variant="outline"
size="icon"
aria-label="打开歌词"
@click="openFile('.ttml,.lrc,.alrc,.yrc,.lys,.lyl,.lqe,.qrc,.eslrc', player.setLocalLyricFile)"
>
<TextAlignStartIcon />
</Button>
<Button
variant="outline"
size="icon"
aria-label="打开歌曲"
@click="openFile('audio/*', openLocalMusicFile)"
>
<MusicIcon />
</Button>
<Button
variant="outline"
size="icon"
aria-label="打开专辑图"
@click="openFile('image/*,video/*', player.setLocalAlbumFile)"
>
<ImageIcon />
</Button>
</ButtonGroup>
</div>
<ModeToggle />
</div>
</SidebarFooter>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { BrushIcon, MonitorPlayIcon } from "lucide-vue-next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { usePlayerStore } from "@/stores/player";
import ControllerSlider from "./ControllerSlider.vue";
import ControllerSliderGroup from "./ControllerSliderGroup.vue";
import ControllerSwitch from "./ControllerSwitch.vue";
const player = usePlayerStore();
</script>
<template>
<div class="space-y-4 py-1">
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<MonitorPlayIcon :size="16" />
播放状态
</h3>
<ControllerSwitch
v-model="player.background.playing"
title="播放"
description="暂停或恢复背景动画"
/>
<ControllerSwitch
v-model="player.background.staticMode"
title="静态模式"
description="固定背景流动状态"
/>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<BrushIcon :size="16" />
渲染选项
</h3>
<Select v-model="player.background.renderer">
<SelectTrigger class="w-full">
<SelectValue placeholder="选择渲染器" />
</SelectTrigger>
<SelectContent>
<SelectItem value="mg">Mesh Gradient 渲染器</SelectItem>
<SelectItem value="pixi">Pixi 渲染器</SelectItem>
</SelectContent>
</Select>
<ControllerSliderGroup>
<ControllerSlider
v-model="player.background.scale"
title="分辨率比率"
:min="0.01"
:max="1"
:step="0.01"
:precision="2"
/>
<ControllerSlider
v-model="player.background.fps"
title="帧率"
:min="1"
:max="240"
:step="1"
suffix="FPS"
/>
<ControllerSlider
v-model="player.background.flowSpeed"
title="流动速度"
:min="0"
:max="5"
:step="0.1"
:precision="1"
/>
</ControllerSliderGroup>
</section>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from "vue";
import { Slider } from "@/components/ui/slider";
const props = withDefaults(
defineProps<{
modelValue: number;
title: string;
min: number;
max: number;
step: number;
precision?: number;
suffix?: string;
}>(),
{
precision: undefined,
suffix: "",
},
);
const emit = defineEmits<{
"update:modelValue": [value: number];
}>();
const value = computed({
get: () => [props.modelValue],
set: (nextValue) => emit("update:modelValue", nextValue[0] ?? props.min),
});
const displayValue = computed(() => {
const currentValue = props.modelValue;
const formattedValue =
props.precision === undefined
? String(currentValue)
: currentValue.toFixed(props.precision);
return props.suffix ? `${formattedValue} ${props.suffix}` : formattedValue;
});
</script>
<template>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-muted-foreground">{{ title }}</span>
<span>{{ displayValue }}</span>
</div>
<Slider class="my-1" v-model="value" :min="min" :max="max" :step="step" />
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div class="rounded-md border p-2 flex flex-col gap-2.5"><slot></slot></div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
import { Switch } from "@/components/ui/switch";
const props = defineProps<{
modelValue: boolean;
title: string;
description: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const value = computed({
get: () => props.modelValue,
set: (nextValue) => emit("update:modelValue", nextValue),
});
</script>
<template>
<div class="flex items-center justify-between gap-3 rounded-md border p-2">
<div>
<div class="text-sm">{{ title }}</div>
<div class="text-xs text-muted-foreground">{{ description }}</div>
</div>
<Switch v-model="value" />
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import {
ArrowDownUpIcon,
ExpandIcon,
TypeIcon,
WandIcon,
} from "lucide-vue-next";
import { Separator } from "@/components/ui/separator";
import { usePlayerStore } from "@/stores/player";
import ControllerSlider from "./ControllerSlider.vue";
import ControllerSliderGroup from "./ControllerSliderGroup.vue";
import ControllerSwitch from "./ControllerSwitch.vue";
import Input from "./ui/input/Input.vue";
const player = usePlayerStore();
const springFields = [
{ key: "mass", label: "质量", min: 0.1, max: 5, step: 0.1 },
{ key: "damping", label: "阻力", min: 0, max: 40, step: 0.5 },
{ key: "stiffness", label: "弹性", min: 1, max: 300, step: 1 },
] as const;
</script>
<template>
<div class="space-y-4 py-1">
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<TypeIcon :size="16" />
歌词字体
</h3>
<Input
id="font-family"
v-model="player.lyric.fontFamily"
placeholder="Inter, sans-serif"
/>
<ControllerSliderGroup>
<ControllerSlider
v-model="player.lyric.fontWeight"
title="字重"
:min="100"
:max="900"
:step="100"
:precision="0"
/>
</ControllerSliderGroup>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<WandIcon :size="16" />
歌词行效果
</h3>
<ControllerSliderGroup>
<ControllerSlider
v-model="player.lyric.fadeWidth"
title="歌词渐变宽度"
:min="0"
:max="3"
:step="0.01"
:precision="2"
/>
</ControllerSliderGroup>
<ControllerSwitch
v-model="player.lyric.enableBlur"
title="歌词模糊"
description="为非焦点行启用模糊效果"
/>
<ControllerSwitch
v-model="player.lyric.enableSpring"
title="使用弹簧动画"
description="使用物理弹簧替代 CSS transition"
/>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<ArrowDownUpIcon :size="16" />
垂直位移弹簧
</h3>
<ControllerSliderGroup>
<ControllerSlider
v-for="field in springFields"
:key="field.key"
:title="field.label"
:min="field.min"
:max="field.max"
:step="field.step"
v-model="player.lyric.verticalSpring[field.key]"
/>
</ControllerSliderGroup>
<ControllerSwitch
v-model="player.lyric.verticalSpring.soft"
title="强制软弹簧"
description="阻力小于 1 时可用"
/>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<ExpandIcon :size="16" />
缩放弹簧
</h3>
<ControllerSliderGroup>
<ControllerSlider
v-for="field in springFields"
:key="field.key"
:title="field.label"
:min="field.min"
:max="field.max"
:step="field.step"
v-model="player.lyric.scaleSpring[field.key]"
/>
</ControllerSliderGroup>
<ControllerSwitch
v-model="player.lyric.scaleSpring.soft"
title="强制软弹簧"
description="阻力小于 1 时可用"
/>
</section>
</div>
</template>

View File

@@ -0,0 +1,360 @@
<script setup lang="ts">
import type { LyricLine } from "@applemusic-like-lyrics/core";
import {
DomLyricPlayer,
type LyricLineMouseEvent,
} from "@applemusic-like-lyrics/core";
import {
parseEslrc,
parseLqe,
parseLrc,
parseLrcA2,
parseLyl,
parseLys,
parseQrc,
parseTTML,
parseYrc,
} from "@applemusic-like-lyrics/lyric";
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import { audioRuntime } from "@/runtime/audio";
import { backgroundRuntime } from "@/runtime/background";
import { usePlayerStore } from "@/stores/player";
import { SidebarTrigger } from "./ui/sidebar";
const player = usePlayerStore();
const playerEl = ref<HTMLElement | null>(null);
const lyricPlayerRef = shallowRef<DomLyricPlayer>();
let frameId = 0;
let lastFrameTime = -1;
let lyricLoadRevision = 0;
function applyLyricSettings(): void {
const lyricPlayer = lyricPlayerRef.value;
if (!lyricPlayer) return;
lyricPlayer.setWordFadeWidth(player.lyric.fadeWidth);
lyricPlayer.setEnableBlur(player.lyric.enableBlur);
lyricPlayer.setEnableSpring(player.lyric.enableSpring);
lyricPlayer.setLinePosYSpringParams({ ...player.lyric.verticalSpring });
lyricPlayer.setLineScaleSpringParams({ ...player.lyric.scaleSpring });
}
function mountBackground(): void {
const host = playerEl.value;
if (!host) return;
const lyricElement = lyricPlayerRef.value?.getElement() ?? null;
backgroundRuntime.mount(host, player.background.renderer, lyricElement);
player.setBackgroundError("");
backgroundRuntime.applySettings(player);
void backgroundRuntime.loadAlbum(player);
}
function getSourceName(source: string, fallbackName: string): string {
const rawName = fallbackName || source;
const withoutHash = rawName.split("#", 1)[0] ?? rawName;
return (withoutHash.split("?", 1)[0] ?? withoutHash).toLowerCase();
}
function hasLrcA2Timestamps(content: string): boolean {
return /<(?:(?:\d+:)*\d+(?:\.\d+)?)>/.test(content);
}
function buildDemoLyricLine(
lyric: string,
startTime = 1000,
otherParams: Partial<LyricLine> = {},
): LyricLine {
let currentTime = startTime;
const words: LyricLine["words"] = [];
for (const word of lyric.split("|")) {
const [text = "", duration = "0"] = word.split(",");
const endTime = currentTime + Number.parseInt(duration, 10);
words.push({
word: text,
romanWord: "",
startTime: currentTime,
endTime,
obscene: false,
});
currentTime = endTime;
}
return {
words,
startTime,
endTime: currentTime + 3000,
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
...otherParams,
};
}
async function parseLyricSource(source: string): Promise<LyricLine[]> {
const trimmedSource = source.trim();
if (!trimmedSource) return [];
if (trimmedSource === "bug") {
return [
buildDemoLyricLine(
"Apple ,750|Music ,500|Like ,500|Ly,400|ri,500|cs ,250",
1000,
),
buildDemoLyricLine("BG ,750|Lyrics ,1000", 2000, { isBG: true }),
buildDemoLyricLine("Next ,1000|Lyrics,1000", 2500),
];
}
const response = await fetch(trimmedSource);
if (!response.ok) {
throw new Error(`歌词加载失败:${response.status} ${response.statusText}`);
}
const content = await response.text();
const sourceName = getSourceName(trimmedSource, player.source.lyricName);
if (sourceName.endsWith(".ttml")) return parseTTML(content).lines;
if (sourceName.endsWith(".alrc")) return parseLrcA2(content);
if (sourceName.endsWith(".lrc")) {
return hasLrcA2Timestamps(content)
? parseLrcA2(content)
: parseLrc(content);
}
if (sourceName.endsWith(".yrc")) return parseYrc(content);
if (sourceName.endsWith(".lys")) return parseLys(content);
if (sourceName.endsWith(".lyl")) return parseLyl(content);
if (sourceName.endsWith(".lqe")) return parseLqe(content);
if (sourceName.endsWith(".qrc")) return parseQrc(content);
if (sourceName.endsWith(".eslrc")) return parseEslrc(content);
throw new Error("不支持的歌词格式");
}
async function loadLyric(): Promise<void> {
const lyricPlayer = lyricPlayerRef.value;
if (!lyricPlayer) return;
const revision = ++lyricLoadRevision;
player.setLyricLoading(true);
player.setLyricError("");
try {
const lines = await parseLyricSource(player.source.lyricUrl);
if (revision !== lyricLoadRevision) return;
const currentTime = Math.round(player.audio.currentTime * 1000);
lyricPlayer.setLyricLines(lines, currentTime);
lyricPlayer.setCurrentTime(currentTime, true);
backgroundRuntime.setHasLyric(lines.length > 0);
applyLyricSettings();
} catch (error) {
if (revision !== lyricLoadRevision) return;
lyricPlayer.setLyricLines([]);
backgroundRuntime.setHasLyric(false);
player.setLyricError(
error instanceof Error ? error.message : String(error),
);
} finally {
if (revision === lyricLoadRevision) player.setLyricLoading(false);
}
}
function applyMusicSource(): void {
audioRuntime.setSource(player.source.musicUrl);
}
function applyPlayback(playing: boolean): void {
const lyricPlayer = lyricPlayerRef.value;
if (!playing) {
lyricPlayer?.pause();
void audioRuntime.setPlaying(false);
return;
}
lyricPlayer?.resume();
void audioRuntime.setPlaying(true);
}
function seekCoreToStoreTime(): void {
const currentTime = player.audio.currentTime;
audioRuntime.seek(currentTime);
lyricPlayerRef.value?.setCurrentTime(Math.round(currentTime * 1000), true);
}
function startFrameLoop(): void {
const onFrame = (time: number) => {
if (lastFrameTime === -1) lastFrameTime = time;
const delta = time - lastFrameTime;
const lyricPlayer = lyricPlayerRef.value;
if (!audioRuntime.isPaused) {
const currentTime = audioRuntime.currentTime;
player.syncCurrentTime(currentTime);
lyricPlayer?.setCurrentTime(Math.round(currentTime * 1000));
}
lyricPlayer?.update(delta);
lastFrameTime = time;
frameId = requestAnimationFrame(onFrame);
};
frameId = requestAnimationFrame(onFrame);
}
function stopFrameLoop(): void {
if (frameId) cancelAnimationFrame(frameId);
frameId = 0;
lastFrameTime = -1;
}
function onLineClick(event: Event): void {
const lineEvent = event as LyricLineMouseEvent;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
player.seek(lineEvent.line.getLine().startTime / 1000);
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select" ||
target.isContentEditable
);
}
function onGlobalKeyDown(event: KeyboardEvent): void {
if (event.defaultPrevented || isEditableTarget(event.target)) return;
if (event.code === "Space") {
event.preventDefault();
player.togglePlayback();
return;
}
if (event.code === "ArrowLeft") {
event.preventDefault();
player.seek(player.audio.currentTime - 5);
return;
}
if (event.code === "ArrowRight") {
event.preventDefault();
player.seek(player.audio.currentTime + 5);
}
}
onMounted(() => {
const host = playerEl.value;
if (!host) return;
audioRuntime.attachStore(player);
audioRuntime.mount(host);
const lyricPlayer = new DomLyricPlayer();
lyricPlayer.addEventListener("line-click", onLineClick);
host.appendChild(lyricPlayer.getElement());
lyricPlayerRef.value = lyricPlayer;
mountBackground();
applyLyricSettings();
applyMusicSource();
applyPlayback(player.audio.playing);
void loadLyric();
startFrameLoop();
window.addEventListener("keydown", onGlobalKeyDown);
});
onBeforeUnmount(() => {
stopFrameLoop();
window.removeEventListener("keydown", onGlobalKeyDown);
lyricPlayerRef.value?.removeEventListener("line-click", onLineClick);
lyricPlayerRef.value?.dispose();
});
watch(
() => player.source.musicUrl,
() => applyMusicSource(),
);
watch(
() => [
player.source.lyricUrl,
player.source.lyricName,
player.source.lyricRevision,
],
() => void loadLyric(),
);
watch(
() => [
player.source.albumUrl,
player.source.albumName,
player.source.albumRevision,
],
() => void backgroundRuntime.loadAlbum(player),
);
watch(
() => player.audio.playing,
(playing) => applyPlayback(playing),
);
watch(
() => player.audio.seekRevision,
() => seekCoreToStoreTime(),
);
watch(
() => player.background.renderer,
() => mountBackground(),
);
watch(
() => [
player.background.fps,
player.background.scale,
player.background.flowSpeed,
player.background.staticMode,
player.background.playing,
],
() => backgroundRuntime.applySettings(player),
);
watch(
() => [
player.lyric.fadeWidth,
player.lyric.enableBlur,
player.lyric.enableSpring,
player.lyric.verticalSpring.mass,
player.lyric.verticalSpring.damping,
player.lyric.verticalSpring.stiffness,
player.lyric.verticalSpring.soft,
player.lyric.scaleSpring.mass,
player.lyric.scaleSpring.damping,
player.lyric.scaleSpring.stiffness,
player.lyric.scaleSpring.soft,
],
() => applyLyricSettings(),
);
</script>
<template>
<SidebarTrigger
class="z-1 absolute m-3.5 text-white hover:bg-white/25! hover:text-white"
/>
<main
ref="playerEl"
id="player"
class="absolute top-0 right-0 bottom-0 left-0 overflow-hidden bg-black text-white"
:style="{
fontFamily: player.lyric.fontFamily || undefined,
fontWeight: player.lyric.fontWeight
}"
/>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useColorMode } from "@vueuse/core";
import { ContrastIcon, MoonIcon, SunIcon } from "lucide-vue-next";
import { computed } from "vue";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const { store } = useColorMode();
const switchToLight = () => (store.value = "light");
const switchToDark = () => (store.value = "dark");
const switchToSystem = () => (store.value = "auto");
const isLight = computed(() => store.value === "light");
const isDark = computed(() => store.value === "dark");
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline">
<SunIcon v-if="isLight" />
<MoonIcon v-else-if="isDark" />
<ContrastIcon v-else />
<span class="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="switchToLight"> 亮色 </DropdownMenuItem>
<DropdownMenuItem @click="switchToDark"> 暗色 </DropdownMenuItem>
<DropdownMenuItem @click="switchToSystem"> 跟随系统 </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import amllLogoSvg from "@/assets/amll-logo.svg";
import {
Sidebar,
SidebarContent,
SidebarHeader,
type SidebarProps,
} from "@/components/ui/sidebar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import AudioPlayer from "./AudioController.vue";
import BackgroundController from "./BackgroundController.vue";
import LyricController from "./LyricController.vue";
import SourceController from "./SourceController.vue";
const props = withDefaults(defineProps<SidebarProps>(), {});
const coreVersion = __AMLL_CORE_VERSION__;
</script>
<template>
<Sidebar v-bind="props">
<SidebarHeader class="border-b border-sidebar-border p-3">
<div class="flex gap-2 items-center">
<div
class="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-sidebar-primary-foreground overflow-hidden"
>
<img :src="amllLogoSvg">
</div>
<div class="flex flex-col gap-1 leading-none">
<span class="font-medium"> AMLL Playground </span>
<span class="text-xs/3 opacity-75"> Core v{{ coreVersion }} </span>
</div>
</div>
</SidebarHeader>
<SidebarContent class="p-3">
<Tabs default-value="source">
<TabsList class="flex w-[unset]">
<TabsTrigger value="source"> </TabsTrigger>
<TabsTrigger value="lyric"> 歌词 </TabsTrigger>
<TabsTrigger value="background"> 背景 </TabsTrigger>
</TabsList>
<TabsContent value="source"> <SourceController /> </TabsContent>
<TabsContent value="background"> <BackgroundController /> </TabsContent>
<TabsContent value="lyric"> <LyricController /> </TabsContent>
</Tabs>
</SidebarContent>
<AudioPlayer />
</Sidebar>
</template>
<style>
[data-slot="sidebar-container"],
[data-slot="sidebar-gap"] {
transition: none;
}
[data-slot="sidebar"][data-collapsible="offcanvas"]
+ [data-slot="sidebar-inset"] {
margin: 0;
padding: calc(var(--spacing) * 2);
border-radius: 0;
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import {
FileAudioIcon,
FileTextIcon,
ImageIcon,
MusicIcon,
RefreshCwIcon,
TextAlignStartIcon,
} from "lucide-vue-next";
import { computed } from "vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { extractCoverBlob } from "@/lib/extract-cover";
import { usePlayerStore } from "@/stores/player";
const player = usePlayerStore();
const lyric = computed({
get: () => player.source.lyricUrl,
set: (value) => player.setLyricUrl(String(value)),
});
const music = computed({
get: () => player.source.musicUrl,
set: (value) => player.setMusicUrl(String(value)),
});
const album = computed({
get: () => player.source.albumUrl,
set: (value) => player.setAlbumUrl(String(value)),
});
function openFile(accept: string, onFile: (file: File) => void): void {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = () => {
const file = input.files?.[0];
if (file) onFile(file);
};
input.click();
}
async function openLocalMusicFile(file: File): Promise<void> {
player.setLocalMusicFile(file);
try {
const cover = await extractCoverBlob(file);
if (cover) {
player.setExtractedAlbumBlob(cover, `${file.name} cover`);
} else {
player.clearExtractedAlbum();
}
} catch {
player.clearExtractedAlbum();
}
}
</script>
<template>
<div class="space-y-4 py-1">
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<TextAlignStartIcon :size="16" />
歌词
</h3>
<Input id="lyric-url" v-model="lyric" placeholder="歌词文件 URI" />
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
@click="openFile('.ttml,.lrc,.alrc,.yrc,.lys,.lyl,.lqe,.qrc,.eslrc', player.setLocalLyricFile)"
>
<FileTextIcon />
本地歌词
</Button>
<Button variant="outline" size="sm" @click="player.reloadLyric">
<RefreshCwIcon />
刷新歌词
</Button>
</div>
<p v-if="player.lyric.error" class="text-xs text-destructive">
{{ player.lyric.error }}
</p>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<MusicIcon :size="16" />
音频
</h3>
<Input id="music-url" v-model="music" placeholder="音频文件 URI" />
<Button
class="w-full"
variant="outline"
size="sm"
@click="openFile('audio/*', openLocalMusicFile)"
>
<FileAudioIcon />
打开本地歌曲
</Button>
<p v-if="player.audio.error" class="text-xs text-destructive">
{{ player.audio.error }}
</p>
</section>
<Separator />
<section class="space-y-2.5">
<h3 class="text-sm font-bold flex items-center gap-1">
<ImageIcon :size="16" />
专辑图
</h3>
<Input id="album-url" v-model="album" placeholder="专辑图片 URI" />
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
@click="openFile('image/*,video/*', player.setLocalAlbumFile)"
>
<ImageIcon />
本地图片
</Button>
<Button variant="outline" size="sm" @click="player.reloadAlbum">
<RefreshCwIcon />
刷新图片
</Button>
</div>
<p v-if="player.background.error" class="text-xs text-destructive">
{{ player.background.error }}
</p>
</section>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonGroupVariants } from '.'
import { cn } from '@/lib/utils'
import { buttonGroupVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
orientation?: ButtonGroupVariants['orientation']
}>()
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="cn(buttonGroupVariants({ orientation: props.orientation }), props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'vertical',
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="cn(
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
props.class,
)"
/>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonGroupVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
interface Props extends PrimitiveProps {
class?: HTMLAttributes['class']
orientation?: ButtonGroupVariants['orientation']
}
const props = withDefaults(defineProps<Props>(), {
as: 'div',
})
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="cn('bg-muted gap-2 rounded-md border px-2.5 text-sm font-medium shadow-xs [&_svg:not([class*=size-])]:size-4 flex items-center [&_svg]:pointer-events-none', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,25 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as ButtonGroup } from './ButtonGroup.vue'
export { default as ButtonGroupSeparator } from './ButtonGroupSeparator.vue'
export { default as ButtonGroupText } from './ButtonGroupText.vue'
export const buttonGroupVariants = cva(
'has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*=\'w-\'])]:w-fit [&>input]:flex-1',
{
variants: {
orientation: {
horizontal:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
)
export type ButtonGroupVariants = VariantProps<typeof buttonGroupVariants>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
'lg': 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'icon': 'size-9',
'icon-xs': 'size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'start',
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from 'reka-ui'
import { DropdownMenuGroup } from 'reka-ui'
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}>(), {
variant: 'default',
})
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSeparator,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
import {
DropdownMenuSub,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import {
DropdownMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,16 @@
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { DropdownMenuPortal } from 'reka-ui'

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<NumberFieldRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NumberFieldRoot v-slot="slotProps" v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
<slot v-bind="slotProps" />
</NumberFieldRoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NumberFieldDecrementProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { MinusIcon } from 'lucide-vue-next'
import { NumberFieldDecrement, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<NumberFieldDecrement data-slot="decrement" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)">
<slot>
<MinusIcon class="h-4 w-4" />
</slot>
</NumberFieldDecrement>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NumberFieldIncrementProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PlusIcon } from 'lucide-vue-next'
import { NumberFieldIncrement, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<NumberFieldIncrement data-slot="increment" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)">
<slot>
<PlusIcon class="h-4 w-4" />
</slot>
</NumberFieldIncrement>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { NumberFieldInput } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<NumberFieldInput
data-slot="input"
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</template>

View File

@@ -0,0 +1,5 @@
export { default as NumberField } from './NumberField.vue'
export { default as NumberFieldContent } from './NumberFieldContent.vue'
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue'
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue'
export { default as NumberFieldInput } from './NumberFieldInput.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-4 rounded-md p-4 text-sm shadow-md ring-1 duration-100 z-50 w-72 origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="popover-description"
:class="cn('text-muted-foreground', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-header"
:class="cn('flex flex-col gap-1 text-sm', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-title"
:class="cn('font-medium cn-font-heading', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverAnchor } from './PopoverAnchor.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverDescription } from './PopoverDescription.vue'
export { default as PopoverHeader } from './PopoverHeader.vue'
export { default as PopoverTitle } from './PopoverTitle.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'item-aligned',
align: 'center',
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
:data-align-trigger="position === 'item-aligned'"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="cn(
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
)"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1 p-1', props.class)"
>
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)
"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
>
<slot>
<ChevronDownIcon />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUpIcon } from 'lucide-vue-next'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
>
<slot>
<ChevronUpIcon />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
/>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
{ size: 'default' },
)
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border bg-transparent py-2 pr-2 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes['class'] }
>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="sheet"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="sheet-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import SheetOverlay from './SheetOverlay.vue'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: 'right',
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:data-side="side"
:class="cn('bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="sheet-close"
as-child
>
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm">
<XIcon />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="sheet-footer"
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('gap-1.5 p-4 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground font-medium cn-font-heading', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="sheet-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,8 @@
export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'
export { default as SheetContent } from './SheetContent.vue'
export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetFooter } from './SheetFooter.vue'
export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { SidebarProps } from '.'
import { cn } from '@/lib/utils'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SidebarProps>(), {
side: 'left',
variant: 'sidebar',
collapsible: 'offcanvas',
})
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
</script>
<template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
:class="cn(
'transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)"
/>
<div
data-slot="sidebar-container"
:data-side="side"
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-content"
data-sidebar="content"
:class="cn('no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-footer"
data-sidebar="footer"
:class="cn('gap-2 p-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-group"
data-sidebar="group"
:class="cn('p-2 relative flex w-full min-w-0 flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Primitive
data-slot="sidebar-group-action"
data-sidebar="group-action"
:as="as"
:as-child="asChild"
:class="cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0',
props.class,
)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
:class="cn('text-sm w-full', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Primitive
data-slot="sidebar-group-label"
data-sidebar="group-label"
:as="as"
:as-child="asChild"
:class="cn(
'text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0',
props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-header"
data-sidebar="header"
:class="cn('gap-2 p-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Input
data-slot="sidebar-input"
data-sidebar="input"
:class="cn('bg-background h-8 w-full shadow-none', props.class)"
>
<slot />
</Input>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<main
data-slot="sidebar-inset"
:class="cn(
'bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col',
props.class,
)"
>
<slot />
</main>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
:class="cn('gap-1 flex w-full min-w-0 flex-col', props.class)"
>
<slot />
</ul>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & {
showOnHover?: boolean
class?: HTMLAttributes['class']
}>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
:class="cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0',
showOnHover
&& 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
props.class,
)"
:as="as"
:as-child="asChild"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
:class="cn(
'text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 h-5 min-w-5 rounded-md px-1 text-xs font-medium peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 flex items-center justify-center tabular-nums select-none group-data-[collapsible=icon]:hidden',
props.class,
)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { Component } from 'vue'
import type { SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
import { reactiveOmit } from '@vueuse/core'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarMenuButtonChild from './SidebarMenuButtonChild.vue'
import { useSidebar } from './utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
tooltip?: string | Component
}>(), {
as: 'button',
variant: 'default',
size: 'default',
})
const { isMobile, state } = useSidebar()
const delegatedProps = reactiveOmit(props, 'tooltip')
</script>
<template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template>

Some files were not shown because too many files have changed in this diff Show More