feat: P7 在线播放(播放队列、媒体控制)与自建歌单
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage - 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件 - 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,12 @@
|
||||
- `core/providers/{netease,qq}/` 实现 `MusicProvider`(`core/types.ts`),在 `providers/index.ts` 注册;`resolveLink` 处理分享短链
|
||||
- QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源
|
||||
- `queue.ts` — 任务存 SQLite `tasks` 表,进度只在内存,经 `events.ts` 的 bus 推送;`routes/api.ts` 的 `/api/events` 是 SSE
|
||||
- `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(试听代理)
|
||||
- `db.ts` — `node:sqlite`;history / tasks / settings 表
|
||||
- `playlists.ts` — 自建歌单(`playlists` / `playlist_items` 表);条目要么是在线曲目(source + track_id)要么是曲库文件(path),`item_key` 去重。读取时给在线曲目补 `localFile`、给文件补 `missing`
|
||||
- `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(在线播放代理,音质取设置 `playQuality`)
|
||||
- `db.ts` — `node:sqlite`;history / tasks / settings / playlists / playlist_items 表
|
||||
- `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库
|
||||
- `lib/store.ts` 全局状态 + SSE;`lib/player.ts` 试听;`lib/api.ts` 类型与请求
|
||||
- `lib/store.ts` 全局状态 + SSE;`lib/api.ts` 类型与请求
|
||||
- `lib/player.ts` 播放器:单个 `<audio>` + 队列(`shallowRef`,整体替换)。曲目统一转成 `PlayItem`(`fromTrack` / `fromLocal` / `fromPlaylistItem`),key 为 `source:id` 或 `file:path`;队列与进度存 localStorage。`lib/playlists.ts` 是歌单缓存与全局「加入歌单」弹窗(`pickPlaylist`)
|
||||
- `components/TrackList.vue` 是所有曲目列表(搜索 / 专辑 / 歌单 / 歌手)的核心
|
||||
- 样式只用 `styles/tokens.css` 的 CSS 变量;`base.css` 有 `.page` `.btn` `.seg` `.badge` 等通用类
|
||||
|
||||
@@ -38,6 +40,8 @@
|
||||
- 服务端永远不把 Cookie 原文返回前端;`relFile` 等路径一律用 `/`
|
||||
- 传给 TrackList 的数组必须稳定(用 computed),否则每次渲染都会清空选择
|
||||
- 前端 `ref<null>` 的接口要返回对象包一层(Koa 对 `null` body 返回 204)
|
||||
- 子组件根元素会继承父组件的 scoped 样式作用域:PlayerBar 根元素不能用 App.vue 里已有的类名(如 `.dock`)
|
||||
- 列表行用 `content-visibility: auto` + `contain-intrinsic-size`,上千首也只渲染视口附近的行
|
||||
- QQ 登录在 `qq/login.ts`:扫码 = ptlogin2 → graph.qq.com OAuth → `QQConnectLogin.QQLogin`;续期 = `music.login.LoginServer.Login`(网页 Cookie 没有 refresh_key 也能续期,旧 key 不会失效)。`accounts.ts` 的 keeper 每 3 小时检查,过半寿命(key 有效 3 天)即续期。有效性必须用 `GetLoginUserInfo` 判断 —— `vip_login_base` 对失效 key 仍返回 code 0
|
||||
- NAS 部署:宿主机文件是 0700,镜像里必须 `chmod -R a+rX /app`;境外请求靠 `NODE_USE_ENV_PROXY` + `HTTP(S)_PROXY`,`NO_PROXY` 覆盖 QQ / 网易云域名
|
||||
- TypeScript 固定 6.x:TS 7 没有 JS API,vue-tsc 无法使用
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 声匣 · Sound Case
|
||||
|
||||
自托管的音乐下载 Web 服务:搜索、试听、批量下载网易云音乐与 QQ 音乐(VIP Cookie),自动写入歌词、封面与完整标签,按「歌手 / 专辑 / 序号. 歌名」归档,可直接被 Navidrome 等播放器识别。
|
||||
自托管的音乐下载 Web 服务:搜索、在线播放、批量下载网易云音乐与 QQ 音乐(VIP Cookie),自动写入歌词、封面与完整标签,按「歌手 / 专辑 / 序号. 歌名」归档,可直接被 Navidrome 等播放器识别。
|
||||
|
||||
> 由 `163-music-dl` CLI 重构而来,不再保留命令行模式。仅供个人对已购买 / 已订阅内容的备份使用。
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
- **下载队列**:并发、全局暂停、单曲暂停 / 继续 / 取消 / 重试,SSE 实时进度;重启后自动恢复
|
||||
- **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级
|
||||
- QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持
|
||||
- **试听**:服务端代理 320K 音源,底部播放条带滚动歌词;曲库与队列中的本地文件也可直接播放
|
||||
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
|
||||
- **歌单**:自建歌单可混排在线曲目与本地文件;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目
|
||||
- **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词
|
||||
- **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON
|
||||
- **设置**:QQ 账号(扫码登录 / 导入 Cookie、VIP 校验、musickey 自动续期)、下载默认值、并发与间隔、访问密码、浅色 / 深色
|
||||
@@ -78,3 +79,4 @@ docker compose up -d --build # http://NAS:8066
|
||||
- [x] P4 完整前端
|
||||
- [x] P5 QQ 扫码登录与 musickey 自动续期
|
||||
- [x] P6 NAS 部署(飞牛 `/vol1/1000/dockercompose/shengxia`)
|
||||
- [x] P7 在线播放(播放队列、媒体控制)与自建歌单
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ export function createApp(): Koa {
|
||||
});
|
||||
|
||||
app.use(authGuard);
|
||||
app.use(bodyParser());
|
||||
// 歌单 / 播放队列一次可能提交上千首
|
||||
app.use(bodyParser({ jsonLimit: '8mb' }));
|
||||
app.use(api.routes()).use(api.allowedMethods());
|
||||
|
||||
// 前端静态资源 + SPA 回退
|
||||
|
||||
@@ -50,6 +50,31 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, id);
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
source TEXT,
|
||||
track_id TEXT,
|
||||
path TEXT,
|
||||
name TEXT NOT NULL,
|
||||
artists TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
album_id TEXT,
|
||||
cover TEXT,
|
||||
duration INTEGER,
|
||||
added_at TEXT NOT NULL,
|
||||
UNIQUE (playlist_id, item_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_items_pos ON playlist_items(playlist_id, position);
|
||||
`);
|
||||
|
||||
export interface HistoryEntry {
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface LibraryEntry {
|
||||
size: number;
|
||||
mtime: number;
|
||||
hasLyric?: boolean;
|
||||
/** 音频文件由路径推断的歌名 / 歌手 / 专辑 */
|
||||
title?: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
/** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */
|
||||
cover?: string;
|
||||
/** 没有文件夹图片时,取前几个子文件夹(专辑)的封面拼图 */
|
||||
@@ -94,6 +98,7 @@ export async function listDir(rel: string | undefined) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const isAudio = AUDIO_EXTS.has(ext);
|
||||
const base = name.slice(0, -ext.length);
|
||||
const guess = isAudio ? describeLocal(abs) : undefined;
|
||||
entries.push({
|
||||
name,
|
||||
path: toRel(abs),
|
||||
@@ -101,6 +106,9 @@ export async function listDir(rel: string | undefined) {
|
||||
size: s.size,
|
||||
mtime: s.mtimeMs,
|
||||
hasLyric: isAudio ? nameSet.has(`${base}.lrc`) : undefined,
|
||||
title: guess?.name,
|
||||
artists: guess?.artists,
|
||||
album: guess?.album,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -367,3 +375,49 @@ export async function startArtistImages(): Promise<ArtistImageJob> {
|
||||
})();
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---------- 播放 / 歌单用的本地曲目 ----------
|
||||
|
||||
export interface LocalTrack {
|
||||
path: string;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
/** 所在文件夹的封面(相对音乐目录) */
|
||||
cover?: string;
|
||||
/** 同名 .lrc 歌词 */
|
||||
lrc: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const MAX_LOCAL_TRACKS = 5000;
|
||||
|
||||
/** 由路径推断显示信息;"歌手/歌名.mp3" 这种两层结构也把上层目录当歌手 */
|
||||
export function describeLocal(abs: string): Pick<LocalTrack, 'name' | 'artists' | 'album'> {
|
||||
const g = guessFromPath(abs);
|
||||
const parent = path.dirname(abs);
|
||||
const artist = g.artist ?? (path.resolve(parent) !== path.resolve(config.musicDir) ? path.basename(parent) : '');
|
||||
return { name: g.title, artists: artist, album: g.album ?? '' };
|
||||
}
|
||||
|
||||
/** 文件或目录下的全部音频(递归),最多 MAX_LOCAL_TRACKS 首 */
|
||||
export async function listLocalTracks(rel: string): Promise<{ items: LocalTrack[]; truncated: boolean }> {
|
||||
const target = resolveInLibrary(rel);
|
||||
if (!(await fs.promises.stat(target).catch(() => null))) throw httpError(404, '路径不存在');
|
||||
const files = await collectAudio(target);
|
||||
const dirs = new Map<string, { names: Set<string>; cover?: string }>();
|
||||
const items: LocalTrack[] = [];
|
||||
for (const abs of files.slice(0, MAX_LOCAL_TRACKS)) {
|
||||
const dir = path.dirname(abs);
|
||||
let info = dirs.get(dir);
|
||||
if (!info) {
|
||||
const image = dirImage(dir);
|
||||
info = { names: new Set(await fs.promises.readdir(dir).catch(() => [])), cover: image ? toRel(image) : undefined };
|
||||
dirs.set(dir, info);
|
||||
}
|
||||
const base = path.basename(abs, path.extname(abs));
|
||||
const size = (await fs.promises.stat(abs).catch(() => null))?.size ?? 0;
|
||||
items.push({ path: toRel(abs), ...describeLocal(abs), cover: info.cover, lrc: info.names.has(`${base}.lrc`), size });
|
||||
}
|
||||
return { items, truncated: files.length > MAX_LOCAL_TRACKS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里
|
||||
import fs from 'node:fs';
|
||||
import type { Source } from './core/types.ts';
|
||||
import { db } from './db.ts';
|
||||
import { bus } from './events.ts';
|
||||
import { resolveInLibrary } from './library.ts';
|
||||
import { findInLibrary } from './libraryIndex.ts';
|
||||
|
||||
const MAX_ITEMS = 5000;
|
||||
|
||||
export interface PlaylistItemInput {
|
||||
source?: Source | null;
|
||||
id?: string | null;
|
||||
path?: string | null;
|
||||
name: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
albumId?: string | null;
|
||||
cover?: string | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
key: string;
|
||||
source: Source | null;
|
||||
trackId: string | null;
|
||||
path: string | null;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumId: string | null;
|
||||
cover: string | null;
|
||||
duration: number | null;
|
||||
addedAt: string;
|
||||
/** 在线曲目在曲库中的同名文件 */
|
||||
localFile?: string;
|
||||
/** 本地文件已被移动或删除 */
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
covers: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function httpError(status: number, message: string): Error {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
function tx<T>(fn: () => T): T {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const out = fn();
|
||||
db.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function changed(id: number) {
|
||||
db.prepare('UPDATE playlists SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
||||
bus.emit('playlists', { id });
|
||||
}
|
||||
|
||||
function text(value: unknown, max: number): string {
|
||||
return String(value ?? '').trim().slice(0, max);
|
||||
}
|
||||
|
||||
/** 校验并规范化前端传来的曲目;无效项丢弃 */
|
||||
function normalize(raw: unknown): (PlaylistItemInput & { key: string }) | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const name = text(r.name, 300);
|
||||
if (!name) return null;
|
||||
const base = {
|
||||
name,
|
||||
artists: text(r.artists, 300),
|
||||
album: text(r.album, 300),
|
||||
albumId: r.albumId ? String(r.albumId) : null,
|
||||
cover: r.cover ? text(r.cover, 1000) : null,
|
||||
duration: Number.isFinite(Number(r.duration)) && Number(r.duration) > 0 ? Math.round(Number(r.duration)) : null,
|
||||
};
|
||||
if (r.source === 'netease' || r.source === 'qq') {
|
||||
const id = text(r.id, 100);
|
||||
if (!id) return null;
|
||||
return { ...base, source: r.source, id, path: null, key: `${r.source}:${id}` };
|
||||
}
|
||||
if (typeof r.path === 'string' && r.path) {
|
||||
resolveInLibrary(r.path); // 越界路径直接报错
|
||||
const path = r.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return { ...base, source: null, id: null, path, key: `file:${path}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
const ITEM_COLUMNS = `id, item_key AS key, source, track_id AS trackId, path, name, artists, album, album_id AS albumId,
|
||||
cover, duration, added_at AS addedAt`;
|
||||
|
||||
function querySummaries(id?: number): PlaylistSummary[] {
|
||||
const one = id !== undefined;
|
||||
const args = one ? [id] : [];
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.description, p.created_at AS createdAt, p.updated_at AS updatedAt,
|
||||
COUNT(i.id) AS count, COALESCE(SUM(i.duration), 0) AS duration
|
||||
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
${one ? 'WHERE p.id = ?' : ''}
|
||||
GROUP BY p.id ORDER BY p.updated_at DESC, p.id DESC`,
|
||||
)
|
||||
.all(...args) as unknown as Array<Omit<PlaylistSummary, 'covers'>>;
|
||||
// 每个歌单取前 4 张不同的封面做拼图
|
||||
const coverRows = db
|
||||
.prepare(
|
||||
`SELECT pid, cover FROM (
|
||||
SELECT playlist_id AS pid, cover, ROW_NUMBER() OVER (PARTITION BY playlist_id ORDER BY MIN(position)) AS rn
|
||||
FROM playlist_items WHERE cover IS NOT NULL ${one ? 'AND playlist_id = ?' : ''} GROUP BY playlist_id, cover
|
||||
) WHERE rn <= 4`,
|
||||
)
|
||||
.all(...args) as Array<{ pid: number; cover: string }>;
|
||||
const covers = new Map<number, string[]>();
|
||||
for (const r of coverRows) covers.set(r.pid, [...(covers.get(r.pid) ?? []), r.cover]);
|
||||
return rows.map((r) => ({ ...r, covers: covers.get(r.id) ?? [] }));
|
||||
}
|
||||
|
||||
export function listPlaylists(): PlaylistSummary[] {
|
||||
return querySummaries();
|
||||
}
|
||||
|
||||
function summary(id: number): PlaylistSummary {
|
||||
const s = querySummaries(id)[0];
|
||||
if (!s) throw httpError(404, '歌单不存在');
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function getPlaylist(id: number): Promise<PlaylistSummary & { items: PlaylistItem[] }> {
|
||||
const head = summary(id);
|
||||
const items = db
|
||||
.prepare(`SELECT ${ITEM_COLUMNS} FROM playlist_items WHERE playlist_id = ? ORDER BY position, id`)
|
||||
.all(id) as unknown as PlaylistItem[];
|
||||
await Promise.all(
|
||||
items.map(async (it) => {
|
||||
if (it.path) {
|
||||
it.missing = !(await fs.promises.stat(resolveInLibrary(it.path)).catch(() => null))?.isFile();
|
||||
} else {
|
||||
it.localFile = findInLibrary(it);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...head, items };
|
||||
}
|
||||
|
||||
// ---------- 修改 ----------
|
||||
|
||||
export function createPlaylist(name: unknown, description: unknown, items: unknown): PlaylistSummary {
|
||||
const title = text(name, 100);
|
||||
if (!title) throw httpError(400, '请输入歌单名称');
|
||||
const now = new Date().toISOString();
|
||||
const id = tx(() => {
|
||||
const res = db
|
||||
.prepare('INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)')
|
||||
.run(title, text(description, 2000), now, now);
|
||||
const pid = Number(res.lastInsertRowid);
|
||||
if (Array.isArray(items)) insertItems(pid, items);
|
||||
return pid;
|
||||
});
|
||||
bus.emit('playlists', { id });
|
||||
return summary(id);
|
||||
}
|
||||
|
||||
export function updatePlaylist(id: number, patch: { name?: unknown; description?: unknown }): PlaylistSummary {
|
||||
summary(id);
|
||||
if (patch.name !== undefined) {
|
||||
const title = text(patch.name, 100);
|
||||
if (!title) throw httpError(400, '请输入歌单名称');
|
||||
db.prepare('UPDATE playlists SET name = ? WHERE id = ?').run(title, id);
|
||||
}
|
||||
if (patch.description !== undefined) db.prepare('UPDATE playlists SET description = ? WHERE id = ?').run(text(patch.description, 2000), id);
|
||||
changed(id);
|
||||
return summary(id);
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: number): void {
|
||||
tx(() => {
|
||||
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(id);
|
||||
db.prepare('DELETE FROM playlists WHERE id = ?').run(id);
|
||||
});
|
||||
bus.emit('playlists', { id });
|
||||
}
|
||||
|
||||
function insertItems(id: number, raw: unknown[]): { added: number; duplicates: number } {
|
||||
const { count, maxPos } = db
|
||||
.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(position), -1) AS maxPos FROM playlist_items WHERE playlist_id = ?')
|
||||
.get(id) as { count: number; maxPos: number };
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO playlist_items
|
||||
(playlist_id, position, item_key, source, track_id, path, name, artists, album, album_id, cover, duration, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
let pos = maxPos + 1;
|
||||
let added = 0;
|
||||
let valid = 0;
|
||||
for (const r of raw) {
|
||||
const it = normalize(r);
|
||||
if (!it) continue;
|
||||
valid++;
|
||||
if (count + added >= MAX_ITEMS) throw httpError(400, `歌单最多 ${MAX_ITEMS} 首`);
|
||||
const res = insert.run(id, pos, it.key, it.source ?? null, it.id ?? null, it.path ?? null, it.name, it.artists ?? '', it.album ?? '', it.albumId ?? null, it.cover ?? null, it.duration ?? null, now);
|
||||
if (res.changes) {
|
||||
added++;
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
return { added, duplicates: valid - added };
|
||||
}
|
||||
|
||||
export function addItems(id: number, items: unknown): { added: number; duplicates: number } {
|
||||
summary(id);
|
||||
if (!Array.isArray(items) || !items.length) throw httpError(400, '没有要添加的歌曲');
|
||||
const res = tx(() => insertItems(id, items));
|
||||
changed(id);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function removeItems(id: number, itemIds: unknown): { removed: number } {
|
||||
if (!Array.isArray(itemIds) || !itemIds.length) return { removed: 0 };
|
||||
const ids = itemIds.map(Number).filter(Number.isInteger);
|
||||
const res = db
|
||||
.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND id IN (${ids.map(() => '?').join(',')})`)
|
||||
.run(id, ...ids);
|
||||
changed(id);
|
||||
return { removed: Number(res.changes) };
|
||||
}
|
||||
|
||||
/** 按传入的条目 id 顺序重排;未列出的条目保持原相对顺序排在最后 */
|
||||
export function reorderItems(id: number, itemIds: unknown): void {
|
||||
if (!Array.isArray(itemIds)) throw httpError(400, '参数错误');
|
||||
const existing = (db.prepare('SELECT id FROM playlist_items WHERE playlist_id = ? ORDER BY position, id').all(id) as Array<{ id: number }>).map((r) => r.id);
|
||||
const known = new Set(existing);
|
||||
const ordered = [...new Set(itemIds.map(Number).filter((n) => known.has(n)))];
|
||||
const seen = new Set(ordered);
|
||||
for (const n of existing) if (!seen.has(n)) ordered.push(n);
|
||||
const update = db.prepare('UPDATE playlist_items SET position = ? WHERE id = ?');
|
||||
tx(() => ordered.forEach((itemId, i) => update.run(i, itemId)));
|
||||
changed(id);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
currentRetagJob,
|
||||
emptyTrash,
|
||||
listDir,
|
||||
listLocalTracks,
|
||||
moveToTrash,
|
||||
resolveInLibrary,
|
||||
startArtistImages,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
zipStream,
|
||||
} from '../library.ts';
|
||||
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts';
|
||||
import { addItems, createPlaylist, deletePlaylist, getPlaylist, listPlaylists, removeItems, reorderItems, updatePlaylist } from '../playlists.ts';
|
||||
import { queue, type EnqueueItem } from '../queue.ts';
|
||||
import { getSettings, updateSettings } from '../settings.ts';
|
||||
import { proxyStream } from '../stream.ts';
|
||||
@@ -47,15 +49,18 @@ function body(ctx: Context): Record<string, any> {
|
||||
return (ctx.request as any).body || {};
|
||||
}
|
||||
|
||||
/** 给列表中的歌曲标记是否已下载过 */
|
||||
/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */
|
||||
/**
|
||||
* 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary)。
|
||||
* localFile 不论来源都给出曲库中的文件,播放时可直接用本地文件
|
||||
*/
|
||||
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
|
||||
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
|
||||
return {
|
||||
...detail,
|
||||
tracks: detail.tracks.map((t) => {
|
||||
const downloaded = owned.has(t.id);
|
||||
return { ...t, downloaded, inLibrary: downloaded ? undefined : findInLibrary(t) };
|
||||
const localFile = findInLibrary(t);
|
||||
return { ...t, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -156,7 +161,8 @@ api.get('/:source/track/:id', async (ctx) => {
|
||||
const provider = getProvider(ctx.params.source!);
|
||||
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
|
||||
const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id);
|
||||
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) };
|
||||
const localFile = findInLibrary(meta);
|
||||
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
|
||||
});
|
||||
|
||||
api.get('/:source/album/:id', async (ctx) => {
|
||||
@@ -264,6 +270,7 @@ api.get('/events', (ctx) => {
|
||||
history: () => send('history', {}),
|
||||
retag: (d) => send('retag', d),
|
||||
'artist-images': (d) => send('artist-images', d),
|
||||
playlists: (d) => send('playlists', d),
|
||||
};
|
||||
for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn);
|
||||
send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() });
|
||||
@@ -277,6 +284,50 @@ api.get('/events', (ctx) => {
|
||||
stream.on('error', cleanup);
|
||||
});
|
||||
|
||||
// ---------- 自建歌单 ----------
|
||||
|
||||
function playlistId(ctx: Context): number {
|
||||
const id = Number(ctx.params.id);
|
||||
if (!Number.isInteger(id)) ctx.throw(404, '歌单不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
api.get('/playlists', (ctx) => {
|
||||
ctx.body = { items: listPlaylists() };
|
||||
});
|
||||
|
||||
api.post('/playlists', (ctx) => {
|
||||
const { name, description, items } = body(ctx);
|
||||
ctx.body = createPlaylist(name, description, items);
|
||||
});
|
||||
|
||||
api.get('/playlists/:id', async (ctx) => {
|
||||
ctx.body = await getPlaylist(playlistId(ctx));
|
||||
});
|
||||
|
||||
api.patch('/playlists/:id', (ctx) => {
|
||||
const { name, description } = body(ctx);
|
||||
ctx.body = updatePlaylist(playlistId(ctx), { name, description });
|
||||
});
|
||||
|
||||
api.delete('/playlists/:id', (ctx) => {
|
||||
deletePlaylist(playlistId(ctx));
|
||||
ctx.body = { ok: true };
|
||||
});
|
||||
|
||||
api.post('/playlists/:id/items', (ctx) => {
|
||||
ctx.body = addItems(playlistId(ctx), body(ctx).items);
|
||||
});
|
||||
|
||||
api.post('/playlists/:id/items/remove', (ctx) => {
|
||||
ctx.body = removeItems(playlistId(ctx), body(ctx).ids);
|
||||
});
|
||||
|
||||
api.put('/playlists/:id/order', (ctx) => {
|
||||
reorderItems(playlistId(ctx), body(ctx).ids);
|
||||
ctx.body = { ok: true };
|
||||
});
|
||||
|
||||
// ---------- 历史 ----------
|
||||
|
||||
api.get('/history', (ctx) => {
|
||||
@@ -317,7 +368,7 @@ api.get('/library/file', async (ctx) => {
|
||||
const stat = await fs.promises.stat(abs).catch(() => null);
|
||||
if (!stat?.isFile()) throw Object.assign(new Error('文件不存在'), { status: 404 });
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
|
||||
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.aac': 'audio/aac', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
|
||||
ctx.type = types[ext] || 'application/octet-stream';
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
const range = ctx.get('range').match(/bytes=(\d*)-(\d*)/);
|
||||
@@ -366,6 +417,10 @@ api.post('/library/index', async (ctx) => {
|
||||
ctx.body = libraryIndexInfo();
|
||||
});
|
||||
|
||||
api.get('/library/tracks', async (ctx) => {
|
||||
ctx.body = await listLocalTracks(String(ctx.query.path ?? ''));
|
||||
});
|
||||
|
||||
api.get('/library/trash', async (ctx) => {
|
||||
ctx.body = await trashInfo();
|
||||
});
|
||||
|
||||
+12
-3
@@ -1,16 +1,24 @@
|
||||
// 运行时设置:config.json 提供默认值,网页上修改后存入 SQLite
|
||||
import { config, type DownloadDefaults } from './config.ts';
|
||||
import { isQualityKey } from './core/quality.ts';
|
||||
import { isQualityKey, type QualityKey } from './core/quality.ts';
|
||||
import { getSetting, setSetting } from './db.ts';
|
||||
import { bus } from './events.ts';
|
||||
|
||||
/** 在线播放可选的音质(更高音质文件过大,不适合边下边播) */
|
||||
export const PLAY_QUALITIES = ['standard', 'high', 'lossless'] as const satisfies readonly QualityKey[];
|
||||
export type PlayQuality = (typeof PLAY_QUALITIES)[number];
|
||||
|
||||
export interface Settings extends DownloadDefaults {
|
||||
concurrency: number;
|
||||
/** 批量任务之间的间隔,避免请求过快 */
|
||||
gapMs: number;
|
||||
/** 在线播放音质 */
|
||||
playQuality: PlayQuality;
|
||||
/** 曲库里已有同名歌曲时直接播放本地文件 */
|
||||
preferLocal: boolean;
|
||||
}
|
||||
|
||||
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400 };
|
||||
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400, playQuality: 'high', preferLocal: true };
|
||||
|
||||
let current: Settings = { ...BASE, ...getSetting<Partial<Settings>>('settings', {}) };
|
||||
|
||||
@@ -30,7 +38,8 @@ export function updateSettings(patch: Partial<Record<keyof Settings, unknown>>):
|
||||
if (patch.retries !== undefined) next.retries = clampInt(patch.retries, 1, 10, next.retries);
|
||||
if (patch.concurrency !== undefined) next.concurrency = clampInt(patch.concurrency, 1, 6, next.concurrency);
|
||||
if (patch.gapMs !== undefined) next.gapMs = clampInt(patch.gapMs, 0, 5000, next.gapMs);
|
||||
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover'] as const) {
|
||||
if (PLAY_QUALITIES.includes(patch.playQuality as PlayQuality)) next.playQuality = patch.playQuality as PlayQuality;
|
||||
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover', 'preferLocal'] as const) {
|
||||
if (typeof patch[key] === 'boolean') next[key] = patch[key] as boolean;
|
||||
}
|
||||
current = next;
|
||||
|
||||
+14
-6
@@ -1,19 +1,26 @@
|
||||
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
|
||||
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 Range),绕过防盗链与混合内容
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Context } from 'koa';
|
||||
import { UA } from './core/net/http.ts';
|
||||
import { getProvider } from './core/providers/index.ts';
|
||||
import type { QualityKey } from './core/quality.ts';
|
||||
import { TtlCache } from './core/util.ts';
|
||||
import { getSettings } from './settings.ts';
|
||||
|
||||
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
|
||||
// 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 Range 请求都会命中缓存
|
||||
const urlCache = new TtlCache<{ url: string; ext: string; quality: QualityKey }>(3 * 60 * 1000, 200);
|
||||
const MIME: Record<string, string> = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' };
|
||||
|
||||
export async function proxyStream(ctx: Context, source: string, id: string) {
|
||||
const provider = getProvider(source);
|
||||
const audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
|
||||
const r = await provider.resolveAudio(id, 'high');
|
||||
return { url: r.url, ext: r.ext };
|
||||
});
|
||||
const quality = getSettings().playQuality;
|
||||
const audio = await urlCache
|
||||
.getOrSet(`${source}:${id}:${quality}`, async () => {
|
||||
const r = await provider.resolveAudio(id, quality);
|
||||
return { url: r.url, ext: r.ext, quality: r.quality };
|
||||
})
|
||||
// 版权下架等情况很常见,播放器会自动跳过,不必按服务端错误记日志
|
||||
.catch((e: Error) => ctx.throw(404, e.message));
|
||||
|
||||
const controller = new AbortController();
|
||||
ctx.req.on('close', () => controller.abort());
|
||||
@@ -26,6 +33,7 @@ export async function proxyStream(ctx: Context, source: string, id: string) {
|
||||
ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream');
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Cache-Control', 'no-store');
|
||||
ctx.set('X-Audio-Quality', audio.quality);
|
||||
for (const h of ['content-length', 'content-range']) {
|
||||
const v = upstream.headers.get(h);
|
||||
if (v) ctx.set(h, v);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import PlaylistPicker from './components/PlaylistPicker.vue';
|
||||
import ToastHost from './components/ToastHost.vue';
|
||||
import { formatSpeed } from './lib/format.ts';
|
||||
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
|
||||
@@ -84,6 +85,7 @@ const qqLabel = computed(() => {
|
||||
<PlayerBar />
|
||||
</div>
|
||||
</div>
|
||||
<PlaylistPicker />
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 歌单封面:4 张以上不同封面拼成 2×2,否则取第一张;填满父元素
|
||||
import { computed, ref } from 'vue';
|
||||
import { coverThumb } from '../lib/format.ts';
|
||||
|
||||
const props = defineProps<{ covers: Array<string | null | undefined>; px?: number }>();
|
||||
const failed = ref(new Set<string>());
|
||||
const list = computed(() => props.covers.filter((c): c is string => !!c && !failed.value.has(c)));
|
||||
const shown = computed(() => (list.value.length >= 4 ? list.value.slice(0, 4) : list.value.slice(0, 1)));
|
||||
|
||||
function onError(c: string) {
|
||||
failed.value = new Set(failed.value).add(c);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="mosaic" :class="{ quad: shown.length === 4 }">
|
||||
<img
|
||||
v-for="c in shown"
|
||||
:key="c"
|
||||
:src="coverThumb(c, shown.length === 4 ? (px ?? 300) / 2 : (px ?? 300))"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onError(c)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mosaic {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, var(--line-strong) 0 7%, transparent 7.5%),
|
||||
repeating-radial-gradient(circle, var(--surface-2) 0 2px, color-mix(in oklab, var(--surface-2) 70%, var(--line)) 2px 3px);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.mosaic.quad {
|
||||
grid-template: 1fr 1fr / 1fr 1fr;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,8 @@ import { SOURCE_LABEL } from '../lib/format.ts';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
|
||||
defineProps<{
|
||||
source: Source;
|
||||
/** 不传表示自建歌单等不属于某个平台的内容 */
|
||||
source?: Source;
|
||||
kind: string;
|
||||
title: string;
|
||||
cover?: string;
|
||||
@@ -20,11 +21,11 @@ const expanded = ref(false);
|
||||
<template>
|
||||
<header class="dh" :class="source">
|
||||
<div class="art" :class="{ round }">
|
||||
<CoverArt :src="cover" :size="196" :px="500" :round="round" />
|
||||
<slot name="art"><CoverArt :src="cover" :size="196" :px="500" :round="round" /></slot>
|
||||
<span v-if="!round" class="vinyl" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="eyebrow kicker"><i class="dot" />{{ kind }} · {{ SOURCE_LABEL[source] }}</span>
|
||||
<span class="eyebrow kicker"><i class="dot" />{{ kind }}<template v-if="source"> · {{ SOURCE_LABEL[source] }}</template></span>
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="by"><slot name="by" /></div>
|
||||
<div v-if="meta?.some(Boolean)" class="meta mono faint">
|
||||
@@ -54,10 +55,15 @@ const expanded = ref(false);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.art :deep(.cover) {
|
||||
.art :deep(.cover),
|
||||
.art :deep(.mosaic) {
|
||||
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.art :deep(.mosaic) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vinyl {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -95,6 +101,10 @@ const expanded = ref(false);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.netease .dot {
|
||||
background: var(--netease);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ const paths = {
|
||||
export: '<path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5.5h14V14"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
logout: '<path d="M14 4.5H6.5v15H14M10.5 12H20m0 0-3.5-3.5M20 12l-3.5 3.5"/>',
|
||||
prev: '<path d="M6.5 5.5v13"/><path d="M18.5 6.2v11.6L9.6 12z" fill="currentColor" stroke="none"/>',
|
||||
next: '<path d="M17.5 5.5v13"/><path d="M5.5 6.2v11.6l8.9-5.8z" fill="currentColor" stroke="none"/>',
|
||||
repeat: '<path d="M4.5 11.5v-1a3.5 3.5 0 0 1 3.5-3.5h11.5m0 0-3-3m3 3-3 3"/><path d="M19.5 12.5v1a3.5 3.5 0 0 1-3.5 3.5H4.5m0 0 3 3m-3-3 3-3"/>',
|
||||
repeatOne: '<path d="M4.5 11.5v-1a3.5 3.5 0 0 1 3.5-3.5h11.5m0 0-3-3m3 3-3 3"/><path d="M19.5 12.5v1a3.5 3.5 0 0 1-3.5 3.5H4.5m0 0 3 3m-3-3 3-3"/><path d="m11 10.6 1.4-.9v4.6" stroke-width="1.4"/>',
|
||||
shuffle: '<path d="M4 7h2.8c2 0 3.2 1 4.2 2.7l2.2 4.6c1 1.7 2.2 2.7 4.2 2.7H20m0 0-2.5-2.5M20 17l-2.5 2.5"/><path d="M4 17h2.8c1.3 0 2.2-.4 3-1.2m3.4-7.6c.8-.8 1.7-1.2 3-1.2H20m0 0-2.5-2.5M20 7l-2.5 2.5"/>',
|
||||
order: '<path d="M4.5 7h10M4.5 12h10M4.5 17h6"/><path d="M18.5 8.5v10m0 0-2.5-2.5m2.5 2.5 2.5-2.5"/>',
|
||||
queue: '<path d="M4 6.5h12M4 11h12M4 15.5h7"/><path d="M18.5 12.5v6.6"/><circle cx="16.6" cy="19.1" r="1.9"/><path d="M18.5 12.5l2.4 1.2"/>',
|
||||
listPlus: '<path d="M4 6.5h12M4 11h12M4 15.5h7"/><path d="M17.5 13.5v7M14 17h7"/>',
|
||||
playNext: '<path d="M4 6.5h12M4 11h9M4 15.5h7"/><path d="M15 13.6v6.8l5.4-3.4z" fill="currentColor" stroke="none"/>',
|
||||
grip: '<circle cx="9.5" cy="7" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="7" r="1.15" fill="currentColor" stroke="none"/><circle cx="9.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="9.5" cy="17" r="1.15" fill="currentColor" stroke="none"/><circle cx="14.5" cy="17" r="1.15" fill="currentColor" stroke="none"/>',
|
||||
edit: '<path d="M5 19l.9-3.9L15.6 5.4a1.8 1.8 0 0 1 2.6 0l.4.4a1.8 1.8 0 0 1 0 2.6L8.9 18.1z"/><path d="m13.8 7.2 3 3"/>',
|
||||
mute: '<path d="M4.5 9.5h3l4.5-4v13l-4.5-4h-3z"/><path d="m15.5 9.5 5 5m0-5-5 5"/>',
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
// 播放器上方的抽屉:播放队列 / 滚动歌词
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { formatDateTime, SOURCE_LABEL } from '../lib/format.ts';
|
||||
import { lyric, lyricIndex, player, playAt, queue, removeAt, seekTo, stop, type PlayItem } from '../lib/player.ts';
|
||||
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
const lyricEl = ref<HTMLElement | null>(null);
|
||||
/** 用户手动滚动歌词后暂停自动跟随 */
|
||||
let freeScrollUntil = 0;
|
||||
|
||||
const label = (i: PlayItem) => (i.source ? SOURCE_LABEL[i.source] : '本地');
|
||||
|
||||
function center(container: HTMLElement | null, selector: string, smooth: boolean) {
|
||||
const el = container?.querySelector<HTMLElement>(selector);
|
||||
if (!container || !el) return;
|
||||
container.scrollTo({ top: el.offsetTop - container.clientHeight / 2 + el.offsetHeight / 2, behavior: smooth ? 'smooth' : 'auto' });
|
||||
}
|
||||
|
||||
function syncScroll(smooth: boolean) {
|
||||
nextTick(() => {
|
||||
if (player.panel === 'queue') center(listEl.value, '.cur', smooth);
|
||||
else if (Date.now() > freeScrollUntil) center(lyricEl.value, '.on', smooth);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => syncScroll(false));
|
||||
watch(() => player.panel, () => syncScroll(false));
|
||||
watch(lyricIndex, () => player.panel === 'lyric' && syncScroll(true));
|
||||
watch(lyric, () => player.panel === 'lyric' && syncScroll(false));
|
||||
|
||||
function saveQueue() {
|
||||
pickPlaylist(queue.value.map(inputFromPlayItem), `播放队列 ${formatDateTime(new Date().toISOString())}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" role="dialog" aria-label="播放队列与歌词">
|
||||
<header class="head">
|
||||
<div class="seg" role="tablist">
|
||||
<button type="button" role="tab" :aria-selected="player.panel === 'queue'" @click="player.panel = 'queue'">
|
||||
播放队列 <span class="mono">{{ queue.length }}</span>
|
||||
</button>
|
||||
<button type="button" role="tab" :aria-selected="player.panel === 'lyric'" @click="player.panel = 'lyric'">歌词</button>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<template v-if="player.panel === 'queue'">
|
||||
<button class="btn sm ghost" type="button" title="保存为歌单" @click="saveQueue"><Icon name="listPlus" :size="15" /> 存为歌单</button>
|
||||
<button class="btn sm ghost danger" type="button" title="清空队列并停止" @click="stop"><Icon name="trash" :size="15" /></button>
|
||||
</template>
|
||||
<button class="icon-btn" type="button" aria-label="收起" @click="player.panel = null"><Icon name="x" :size="16" /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol v-if="player.panel === 'queue'" ref="listEl" class="queue">
|
||||
<li v-for="(item, i) in queue" :key="`${item.key}-${i}`" class="q" :class="[item.source ?? 'local', { cur: i === player.index }]" @click="playAt(i)">
|
||||
<span class="q-no mono">
|
||||
<span v-if="i === player.index && player.playing" class="eq" aria-label="正在播放"><i /><i /><i /></span>
|
||||
<template v-else>{{ String(i + 1).padStart(2, '0') }}</template>
|
||||
</span>
|
||||
<span class="q-title">
|
||||
<span class="q-name">{{ item.title }}</span>
|
||||
<span class="q-sub faint">{{ item.artists || '未知歌手' }}</span>
|
||||
</span>
|
||||
<span class="q-src faint"><i class="dot" />{{ item.local && item.source ? '曲库' : label(item) }}</span>
|
||||
<button class="icon-btn rm" type="button" :aria-label="`从队列移除 ${item.title}`" @click.stop="removeAt(i)"><Icon name="x" :size="14" /></button>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-else ref="lyricEl" class="lyrics" @wheel.passive="freeScrollUntil = Date.now() + 3000" @touchmove.passive="freeScrollUntil = Date.now() + 3000">
|
||||
<template v-if="lyric.length">
|
||||
<p v-for="(l, i) in lyric" :key="i" class="line" :class="{ on: i === lyricIndex }" @click="seekTo(l.t)">
|
||||
{{ l.text }}
|
||||
<span v-if="l.trans" class="trans">{{ l.trans }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="none faint">暂无歌词</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: calc(100% + 10px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 400px;
|
||||
height: min(560px, calc(100vh - 140px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.seg .mono {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ---- 队列 ---- */
|
||||
.queue {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 6px 6px 10px;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.q {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto 28px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 4px 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 48px;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.q:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.q-no {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.q-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.q-name,
|
||||
.q-sub {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.q-name {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.q-sub {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.q.cur .q-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.q-src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
|
||||
.netease .dot {
|
||||
background: var(--netease);
|
||||
}
|
||||
|
||||
.qq .dot {
|
||||
background: var(--qq);
|
||||
}
|
||||
|
||||
.rm {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.q:hover .rm,
|
||||
.rm:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.eq {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.eq i {
|
||||
width: 2.5px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
animation: eq 900ms var(--ease) infinite alternate;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.eq i:nth-child(2) {
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
|
||||
.eq i:nth-child(3) {
|
||||
animation-delay: -600ms;
|
||||
}
|
||||
|
||||
@keyframes eq {
|
||||
from {
|
||||
transform: scaleY(0.25);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 歌词 ---- */
|
||||
.lyrics {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 40% 22px;
|
||||
text-align: center;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
|
||||
}
|
||||
|
||||
.lyrics::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0;
|
||||
padding: 7px 0;
|
||||
font: 15px/1.55 var(--font-serif);
|
||||
color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
transition: color 240ms var(--ease), transform 240ms var(--ease);
|
||||
}
|
||||
|
||||
.line:hover {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.line.on {
|
||||
color: var(--ink);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.trans {
|
||||
display: block;
|
||||
font: 12.5px/1.5 var(--font-sans);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.none {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
height: min(70vh, 560px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { formatSeconds } from '../lib/format.ts';
|
||||
import { currentLyric, player, seek, setVolume, stop, toggle } from '../lib/player.ts';
|
||||
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute } from '../lib/player.ts';
|
||||
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Icon from './Icon.vue';
|
||||
import PlayPanel from './PlayPanel.vue';
|
||||
|
||||
const pct = computed(() => (player.duration ? (player.time / player.duration) * 100 : 0));
|
||||
const pct = computed(() => (player.duration ? Math.min(100, (player.time / player.duration) * 100) : 0));
|
||||
const modeIcon = computed(() => ({ list: 'order', loop: 'repeat', one: 'repeatOne', shuffle: 'shuffle' })[player.mode]);
|
||||
|
||||
function onSeek(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
seek((e.clientX - rect.left) / rect.width);
|
||||
}
|
||||
|
||||
function openPanel(tab: 'queue' | 'lyric') {
|
||||
player.panel = player.panel === tab ? null : tab;
|
||||
}
|
||||
|
||||
function addCurrent() {
|
||||
if (player.current) pickPlaylist([inputFromPlayItem(player.current)]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div v-if="player.current" class="player">
|
||||
<div class="disc" :class="{ spin: player.playing }">
|
||||
<CoverArt :src="player.current.cover" :size="46" round />
|
||||
<i class="hole" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="t">{{ player.current.title }}</div>
|
||||
<div class="a muted">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
|
||||
<span v-else>{{ player.current.artists }}</span>
|
||||
</Transition>
|
||||
<div v-if="player.current" class="player-dock">
|
||||
<Transition name="panel">
|
||||
<PlayPanel v-if="player.panel" />
|
||||
</Transition>
|
||||
<div class="player">
|
||||
<i class="mini-progress" :style="{ width: `${pct}%` }" />
|
||||
<button class="now" type="button" title="歌词" @click="openPanel('lyric')">
|
||||
<span class="disc" :class="{ spin: player.playing }">
|
||||
<CoverArt :src="player.current.cover" :size="46" round />
|
||||
<i class="hole" />
|
||||
</span>
|
||||
<span class="meta">
|
||||
<span class="t">
|
||||
{{ player.current.title }}
|
||||
<span v-if="player.current.local && player.current.source" class="tag" title="曲库里有这首歌,正在播放本地文件">曲库</span>
|
||||
</span>
|
||||
<span class="a muted">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
|
||||
<span v-else>{{ player.current.artists }}</span>
|
||||
</Transition>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="controls">
|
||||
<button class="ctl mode" type="button" :title="MODE_LABEL[player.mode]" :aria-label="MODE_LABEL[player.mode]" @click="cycleMode">
|
||||
<Icon :name="modeIcon" :size="18" />
|
||||
</button>
|
||||
<button class="ctl prev" type="button" aria-label="上一首" :disabled="queue.length < 2 && !player.time" @click="prev"><Icon name="prev" :size="18" /></button>
|
||||
<button class="pp" :class="{ busy: player.loading }" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
|
||||
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
|
||||
</button>
|
||||
<button class="ctl" type="button" aria-label="下一首" :disabled="queue.length < 2" @click="next"><Icon name="next" :size="18" /></button>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
|
||||
<div class="track" @click="onSeek">
|
||||
<div class="fill" :style="{ width: `${pct}%` }" />
|
||||
<div class="knob" :style="{ left: `${pct}%` }" />
|
||||
</div>
|
||||
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="extras">
|
||||
<button class="ctl add" type="button" title="加入歌单" @click="addCurrent"><Icon name="listPlus" :size="18" /></button>
|
||||
<button class="ctl" :class="{ on: player.panel === 'queue' }" type="button" title="播放队列" @click="openPanel('queue')">
|
||||
<Icon name="queue" :size="18" />
|
||||
<span v-if="queue.length > 1" class="n mono">{{ queue.length > 999 ? '999+' : queue.length }}</span>
|
||||
</button>
|
||||
<span class="vol">
|
||||
<button class="ctl" type="button" :title="player.muted ? '取消静音' : '静音'" @click="toggleMute">
|
||||
<Icon :name="player.muted || !player.volume ? 'mute' : 'volume'" :size="17" />
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.01" aria-label="音量" :value="player.muted ? 0 : player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
|
||||
</span>
|
||||
<button class="ctl close" type="button" title="停止并清空队列" @click="stop"><Icon name="x" :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pp" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
|
||||
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
|
||||
</button>
|
||||
<div class="timeline">
|
||||
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
|
||||
<div class="track" @click="onSeek">
|
||||
<div class="fill" :style="{ width: `${pct}%` }" />
|
||||
<div class="knob" :style="{ left: `${pct}%` }" />
|
||||
</div>
|
||||
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
|
||||
</div>
|
||||
<label class="vol" title="音量">
|
||||
<Icon name="volume" :size="17" />
|
||||
<input type="range" min="0" max="1" step="0.01" :value="player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
|
||||
</label>
|
||||
<button class="close" type="button" @click="stop" aria-label="关闭"><Icon name="x" :size="16" /></button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 不能叫 .dock:根元素会继承 App.vue 的作用域,撞上侧栏的 .dock 样式 */
|
||||
.player-dock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(120px, 240px) auto 1fr auto auto;
|
||||
grid-template-columns: minmax(160px, 260px) auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 10px 20px 10px 16px;
|
||||
gap: 20px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--surface) 94%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.mini-progress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.disc {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px var(--ink), var(--shadow);
|
||||
}
|
||||
@@ -87,21 +150,38 @@ function onSeek(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.t,
|
||||
.a {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.t {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.now:hover .t {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 4px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 3px;
|
||||
font: 600 9px/14px var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.a {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lyric {
|
||||
@@ -109,11 +189,63 @@ function onSeek(e: MouseEvent) {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.controls,
|
||||
.extras {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ctl {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-2);
|
||||
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.ctl:hover:not(:disabled) {
|
||||
color: var(--ink);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.ctl:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctl.on {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mode {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.n {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: -2px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
font-size: 9.5px;
|
||||
line-height: 15px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.pp {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 4px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
@@ -125,6 +257,17 @@ function onSeek(e: MouseEvent) {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.pp.busy {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 1.2s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -179,31 +322,18 @@ function onSeek(e: MouseEvent) {
|
||||
.vol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-3);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.vol input {
|
||||
width: 80px;
|
||||
width: 76px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 220ms var(--ease), opacity 220ms var(--ease);
|
||||
@@ -225,16 +355,55 @@ function onSeek(e: MouseEvent) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.player {
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: opacity 180ms var(--ease), transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
.timeline,
|
||||
.vol {
|
||||
.panel-enter-from,
|
||||
.panel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.vol input,
|
||||
.mode {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.player {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 8px 12px;
|
||||
}
|
||||
|
||||
.mini-progress {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.timeline,
|
||||
.vol,
|
||||
.prev,
|
||||
.add,
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.disc :deep(.cover) {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.hole {
|
||||
inset: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { addToMyPlaylist, createMyPlaylist, myPlaylists, picker } from '../lib/playlists.ts';
|
||||
import CoverMosaic from './CoverMosaic.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const creating = ref(false);
|
||||
const name = ref('');
|
||||
const busy = ref(false);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(
|
||||
() => picker.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
creating.value = false;
|
||||
name.value = picker.suggestedName;
|
||||
},
|
||||
);
|
||||
|
||||
function close() {
|
||||
picker.open = false;
|
||||
picker.items = [];
|
||||
}
|
||||
|
||||
async function choose(id: number) {
|
||||
busy.value = true;
|
||||
const ok = await addToMyPlaylist(id, picker.items);
|
||||
busy.value = false;
|
||||
if (ok) close();
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const title = name.value.trim();
|
||||
if (!title) return;
|
||||
busy.value = true;
|
||||
const created = await createMyPlaylist(title, picker.items);
|
||||
busy.value = false;
|
||||
if (created) close();
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
creating.value = true;
|
||||
nextTick(() => input.value?.select());
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && picker.open) close();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="pop">
|
||||
<div v-if="picker.open" class="backdrop" @click.self="close">
|
||||
<div class="dialog" role="dialog" aria-label="加入歌单">
|
||||
<button class="x" type="button" aria-label="关闭" @click="close"><Icon name="x" :size="16" /></button>
|
||||
<span class="eyebrow">Add to playlist</span>
|
||||
<h3>加入歌单 <span class="count">{{ picker.items.length }} 首</span></h3>
|
||||
|
||||
<form v-if="creating" class="create" @submit.prevent="create">
|
||||
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="新歌单名称" />
|
||||
<button class="btn sm primary" type="submit" :disabled="busy || !name.trim()">创建</button>
|
||||
<button class="btn sm ghost" type="button" @click="creating = false">取消</button>
|
||||
</form>
|
||||
<button v-else class="row new" type="button" @click="startCreate">
|
||||
<span class="art plus"><Icon name="plus" :size="18" /></span>
|
||||
<span class="name">新建歌单</span>
|
||||
</button>
|
||||
|
||||
<ul v-if="myPlaylists?.length" class="list">
|
||||
<li v-for="p in myPlaylists" :key="p.id">
|
||||
<button class="row" type="button" :disabled="busy" @click="choose(p.id)">
|
||||
<span class="art"><CoverMosaic :covers="p.covers" :px="88" /></span>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span class="mono faint n">{{ p.count }} 首</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="myPlaylists" class="faint empty">还没有歌单,新建一个吧</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: color-mix(in oklab, var(--ink) 35%, transparent);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
max-height: min(560px, calc(100vh - 32px));
|
||||
padding: 22px 14px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
h3 {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.x:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.row:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.art.plus {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px dashed var(--line-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.row.new:hover .plus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.n {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.create .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.create .btn {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 4px 10px 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.pop-enter-active,
|
||||
.pop-leave-active {
|
||||
transition: opacity 160ms var(--ease);
|
||||
}
|
||||
|
||||
.pop-enter-active .dialog,
|
||||
.pop-leave-active .dialog {
|
||||
transition: transform 160ms var(--ease);
|
||||
}
|
||||
|
||||
.pop-enter-from,
|
||||
.pop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pop-enter-from .dialog,
|
||||
.pop-leave-to .dialog {
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { QualityKey, TrackSummary } from '../lib/api.ts';
|
||||
import { bestQuality, formatDuration, isGold, QUALITY_BADGE, STAGE_LABEL } from '../lib/format.ts';
|
||||
import { isPlaying, player, playOnline } from '../lib/player.ts';
|
||||
import { isPlaying, player, playTracks } from '../lib/player.ts';
|
||||
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
|
||||
import { activeByTrack, defaultQualityFor, download, isDownloaded, progress, trackKey } from '../lib/store.ts';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Icon from './Icon.vue';
|
||||
@@ -70,6 +71,17 @@ function downloadAll() {
|
||||
download(props.tracks, quality.value, props.group);
|
||||
}
|
||||
|
||||
const picked = () => (selected.value.size ? props.tracks.filter((t) => selected.value.has(t.id)) : props.tracks);
|
||||
|
||||
function playSelected() {
|
||||
playTracks(picked());
|
||||
}
|
||||
|
||||
/** group 形如「专辑 · 名称」,取名称作为新歌单的默认名 */
|
||||
function addSelected() {
|
||||
pickPlaylist(picked().map(inputFromTrack), props.group?.split(' · ').slice(1).join(' · ') ?? '');
|
||||
}
|
||||
|
||||
interface RowState {
|
||||
kind: string;
|
||||
text: string;
|
||||
@@ -115,6 +127,12 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
</template>
|
||||
</span>
|
||||
<div class="spacer" />
|
||||
<button class="btn" type="button" :disabled="!tracks.length" :title="selected.size ? '播放所选' : '播放全部'" @click="playSelected">
|
||||
<Icon name="play" :size="14" /> {{ selected.size ? '播放所选' : '播放全部' }}
|
||||
</button>
|
||||
<button class="btn" type="button" :disabled="!tracks.length" :title="selected.size ? '所选加入歌单' : '全部加入歌单'" @click="addSelected">
|
||||
<Icon name="listPlus" :size="16" /> 加入歌单
|
||||
</button>
|
||||
<QualityPicker v-model="quality" :source="source" />
|
||||
<button v-if="selected.size" class="btn primary" type="button" @click="downloadSelected">
|
||||
<Icon name="download" :size="16" /> 下载所选
|
||||
@@ -130,18 +148,18 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
:key="`${t.source}-${t.id}-${i}`"
|
||||
class="row"
|
||||
:class="[t.source, { selected: selected.has(t.id), current: player.current?.key === `${t.source}:${t.id}` }]"
|
||||
@dblclick="playOnline(t)"
|
||||
@dblclick="playTracks(tracks, i)"
|
||||
>
|
||||
<button type="button" class="check" :class="{ on: selected.has(t.id) }" @click="toggleRow(i, $event)" :aria-label="`选择 ${t.name}`">
|
||||
<Icon v-if="selected.has(t.id)" name="check" :size="13" />
|
||||
</button>
|
||||
<span class="num">
|
||||
<span class="mono faint idx">{{ number(t, i) }}</span>
|
||||
<button type="button" class="play" @click="playOnline(t)" :aria-label="`试听 ${t.name}`">
|
||||
<button type="button" class="play" @click="playTracks(tracks, i)" :aria-label="`播放 ${t.name}`">
|
||||
<Icon :name="isPlaying(`${t.source}:${t.id}`) ? 'pause' : 'play'" :size="16" />
|
||||
</button>
|
||||
</span>
|
||||
<CoverArt v-if="showCover" :src="t.cover" :size="40" />
|
||||
<button v-if="showCover" type="button" class="cover-btn" tabindex="-1" @click="playTracks(tracks, i)"><CoverArt :src="t.cover" :size="40" /></button>
|
||||
<div class="title">
|
||||
<div class="name">
|
||||
{{ t.name }}
|
||||
@@ -175,9 +193,14 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
</template>
|
||||
</span>
|
||||
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
|
||||
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
|
||||
<Icon name="download" :size="17" />
|
||||
</button>
|
||||
<span class="acts">
|
||||
<button type="button" class="dl add" title="加入歌单" @click="pickPlaylist([inputFromTrack(t)])">
|
||||
<Icon name="listPlus" :size="17" />
|
||||
</button>
|
||||
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
|
||||
<Icon name="download" :size="17" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -254,11 +277,14 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 32px;
|
||||
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 64px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 10px 9px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* 长列表只渲染视口附近的行 */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 59px;
|
||||
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
@@ -295,7 +321,13 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
}
|
||||
|
||||
.no-cover .row {
|
||||
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 32px;
|
||||
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 64px;
|
||||
}
|
||||
|
||||
.cover-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.num {
|
||||
@@ -451,6 +483,21 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add {
|
||||
opacity: 0;
|
||||
transition: color var(--dur) var(--ease), background var(--dur) var(--ease), opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover .add,
|
||||
.add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
@@ -467,7 +514,8 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
|
||||
.num,
|
||||
.badges,
|
||||
.dur {
|
||||
.dur,
|
||||
.add {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface TrackSummary {
|
||||
downloaded?: boolean;
|
||||
/** 曲库中同名文件(相对路径),不是通过本服务下载的 */
|
||||
inLibrary?: string;
|
||||
/** 曲库中的同名文件(不论是否经本服务下载),播放时可直接使用 */
|
||||
localFile?: string;
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
@@ -87,6 +89,7 @@ export interface TrackMeta {
|
||||
publisher?: string;
|
||||
downloaded?: boolean;
|
||||
inLibrary?: string;
|
||||
localFile?: string;
|
||||
}
|
||||
|
||||
export interface QualityOption {
|
||||
@@ -112,8 +115,12 @@ export interface Settings {
|
||||
albumCover: boolean;
|
||||
concurrency: number;
|
||||
gapMs: number;
|
||||
playQuality: PlayQuality;
|
||||
preferLocal: boolean;
|
||||
}
|
||||
|
||||
export type PlayQuality = 'standard' | 'high' | 'lossless';
|
||||
|
||||
export interface Health {
|
||||
ok: boolean;
|
||||
ffmpeg: boolean;
|
||||
@@ -214,10 +221,69 @@ export interface LibraryEntry {
|
||||
size: number;
|
||||
mtime: number;
|
||||
hasLyric?: boolean;
|
||||
/** 音频文件由路径推断的歌名 / 歌手 / 专辑 */
|
||||
title?: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
covers?: string[];
|
||||
}
|
||||
|
||||
export interface LocalTrack {
|
||||
path: string;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
cover?: string;
|
||||
lrc: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 自建歌单中的一首:在线曲目有 source + trackId,本地文件有 path */
|
||||
export interface PlaylistItemInput {
|
||||
source?: Source | null;
|
||||
id?: string | null;
|
||||
path?: string | null;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumId?: string | null;
|
||||
cover?: string | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
key: string;
|
||||
source: Source | null;
|
||||
trackId: string | null;
|
||||
path: string | null;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumId: string | null;
|
||||
cover: string | null;
|
||||
duration: number | null;
|
||||
addedAt: string;
|
||||
localFile?: string;
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
export interface MyPlaylistSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
covers: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MyPlaylist extends MyPlaylistSummary {
|
||||
items: PlaylistItem[];
|
||||
}
|
||||
|
||||
export interface LibraryListing {
|
||||
path: string;
|
||||
hasCover: boolean;
|
||||
@@ -332,8 +398,20 @@ export const api = {
|
||||
rebuildLibraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index', json('POST')),
|
||||
artistImages: () => request<{ job: ArtistImageJob | null; missing: string[] }>('/library/artist-images'),
|
||||
startArtistImages: () => request<ArtistImageJob>('/library/artist-images', json('POST')),
|
||||
libraryTracks: (path: string) => request<{ items: LocalTrack[]; truncated: boolean }>(`/library/tracks?${qs({ path })}`),
|
||||
trashInfo: () => request<{ count: number; size: number }>('/library/trash'),
|
||||
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
|
||||
myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items),
|
||||
myPlaylist: (id: number) => request<MyPlaylist>(`/playlists/${id}`),
|
||||
createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) =>
|
||||
request<MyPlaylistSummary>('/playlists', json('POST', { name, description, items })),
|
||||
updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request<MyPlaylistSummary>(`/playlists/${id}`, json('PATCH', patch)),
|
||||
deletePlaylist: (id: number) => request(`/playlists/${id}`, { method: 'DELETE' }),
|
||||
addToPlaylist: (id: number, items: PlaylistItemInput[]) =>
|
||||
request<{ added: number; duplicates: number }>(`/playlists/${id}/items`, json('POST', { items })),
|
||||
removeFromPlaylist: (id: number, ids: number[]) => request<{ removed: number }>(`/playlists/${id}/items/remove`, json('POST', { ids })),
|
||||
reorderPlaylist: (id: number, ids: number[]) => request(`/playlists/${id}/order`, json('PUT', { ids })),
|
||||
|
||||
retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job),
|
||||
retag: (path: string, source: Source, onlyMissingLyric: boolean) =>
|
||||
request<RetagJob>('/library/retag', json('POST', { path, source, onlyMissingLyric })),
|
||||
|
||||
@@ -40,6 +40,13 @@ export function formatDuration(ms?: number | null): string {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 总时长:1 小时 12 分 / 38 分钟 */
|
||||
export function formatTotal(ms?: number | null): string {
|
||||
if (!ms) return '';
|
||||
const min = Math.round(ms / 60000);
|
||||
return min >= 60 ? `${Math.floor(min / 60)} 小时 ${min % 60} 分` : `${Math.max(1, min)} 分钟`;
|
||||
}
|
||||
|
||||
export function formatSeconds(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
|
||||
+482
-56
@@ -1,37 +1,72 @@
|
||||
// 试听播放器:单个 <audio>,支持在线音源(服务端代理)与本地曲库文件,并同步滚动歌词
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { api, type Source } from './api.ts';
|
||||
import { toastError } from './store.ts';
|
||||
// 播放器:单个 <audio> + 播放队列。在线曲目经服务端代理,曲库文件直接读取;
|
||||
// 同步滚动歌词,接入系统媒体控制(锁屏 / 耳机按键),队列与进度保存在本机
|
||||
import { computed, reactive, shallowRef } from 'vue';
|
||||
import { api, type PlaylistItem, type Source, type TrackSummary } from './api.ts';
|
||||
import { coverThumb } from './format.ts';
|
||||
import { settings, toast, toastError } from './store.ts';
|
||||
|
||||
export interface PlayItem {
|
||||
/** netease:123 / qq:xxx / file:相对路径 */
|
||||
key: string;
|
||||
title: string;
|
||||
artists: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
src: string;
|
||||
/** 在线曲目用于加载歌词 */
|
||||
/** 在线曲目:加载歌词、跳转专辑 */
|
||||
source?: Source;
|
||||
id?: string;
|
||||
albumId?: string;
|
||||
/** 本地文件旁的 .lrc */
|
||||
lrc?: string;
|
||||
/** 实际播放的是曲库文件 */
|
||||
local?: boolean;
|
||||
/** 毫秒,加载前先显示 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface LrcLine {
|
||||
export type PlayMode = 'list' | 'loop' | 'one' | 'shuffle';
|
||||
export const MODE_LABEL: Record<PlayMode, string> = { list: '顺序播放', loop: '列表循环', one: '单曲循环', shuffle: '随机播放' };
|
||||
const MODES: PlayMode[] = ['loop', 'one', 'shuffle', 'list'];
|
||||
|
||||
export interface LrcLine {
|
||||
t: number;
|
||||
text: string;
|
||||
trans?: string;
|
||||
}
|
||||
|
||||
export const player = reactive({
|
||||
current: null as PlayItem | null,
|
||||
index: -1,
|
||||
mode: 'loop' as PlayMode,
|
||||
playing: false,
|
||||
loading: false,
|
||||
time: 0,
|
||||
duration: 0,
|
||||
volume: 0.85,
|
||||
muted: false,
|
||||
panel: null as null | 'queue' | 'lyric',
|
||||
/** 当前队列的来源,如 playlist:3,用于在来源页面上显示播放状态 */
|
||||
context: '',
|
||||
});
|
||||
|
||||
const lyric = ref<LrcLine[]>([]);
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
/** 播放队列可能有上千首:用 shallowRef,整体替换触发更新 */
|
||||
export const queue = shallowRef<PlayItem[]>([]);
|
||||
export const lyric = shallowRef<LrcLine[]>([]);
|
||||
|
||||
function parseLrc(text: string | undefined): LrcLine[] {
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
/** 当前曲目已经交给 <audio>(恢复的队列在点播放前不加载) */
|
||||
let attached = false;
|
||||
/** 恢复上次进度:元数据加载后跳转 */
|
||||
let resumeAt = 0;
|
||||
let errorStreak = 0;
|
||||
let prefetched = '';
|
||||
/** 播放顺序 → 队列下标;随机模式下是洗牌后的顺序 */
|
||||
let order: number[] = [];
|
||||
|
||||
// ---------- 歌词 ----------
|
||||
|
||||
function parseLines(text: string | undefined): LrcLine[] {
|
||||
if (!text) return [];
|
||||
const out: LrcLine[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
@@ -43,86 +78,477 @@ function parseLrc(text: string | undefined): LrcLine[] {
|
||||
return out.sort((a, b) => a.t - b.t);
|
||||
}
|
||||
|
||||
export const currentLyric = computed(() => {
|
||||
const lines = lyric.value;
|
||||
let found = '';
|
||||
function parseLrc(lrc: string | undefined, tlyric?: string): LrcLine[] {
|
||||
const lines = parseLines(lrc);
|
||||
if (!tlyric) return lines;
|
||||
const trans = new Map(parseLines(tlyric).map((l) => [l.t.toFixed(2), l.text]));
|
||||
for (const l of lines) {
|
||||
if (l.t > player.time + 0.2) break;
|
||||
found = l.text;
|
||||
const t = trans.get(l.t.toFixed(2));
|
||||
if (t && t !== '//' && t !== l.text) l.trans = t;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 二分查找当前行,timeupdate 每秒触发数次,长歌词也不必整表扫描 */
|
||||
export const lyricIndex = computed(() => {
|
||||
const lines = lyric.value;
|
||||
const t = player.time + 0.2;
|
||||
let lo = 0;
|
||||
let hi = lines.length - 1;
|
||||
let found = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (lines[mid]!.t <= t) {
|
||||
found = mid;
|
||||
lo = mid + 1;
|
||||
} else hi = mid - 1;
|
||||
}
|
||||
return found;
|
||||
});
|
||||
|
||||
function ensureAudio(): HTMLAudioElement {
|
||||
if (audio) return audio;
|
||||
audio = new Audio();
|
||||
audio.preload = 'auto';
|
||||
audio.volume = player.volume;
|
||||
audio.addEventListener('timeupdate', () => (player.time = audio!.currentTime));
|
||||
audio.addEventListener('durationchange', () => (player.duration = audio!.duration || 0));
|
||||
audio.addEventListener('play', () => (player.playing = true));
|
||||
audio.addEventListener('pause', () => (player.playing = false));
|
||||
audio.addEventListener('waiting', () => (player.loading = true));
|
||||
audio.addEventListener('playing', () => (player.loading = false));
|
||||
audio.addEventListener('ended', () => (player.playing = false));
|
||||
audio.addEventListener('error', () => {
|
||||
player.loading = false;
|
||||
player.playing = false;
|
||||
if (player.current) toastError(new Error('试听失败:音源不可用'));
|
||||
});
|
||||
return audio;
|
||||
}
|
||||
export const currentLyric = computed(() => lyric.value[lyricIndex.value]?.text ?? '');
|
||||
|
||||
export function play(item: PlayItem) {
|
||||
const a = ensureAudio();
|
||||
if (player.current?.key === item.key) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
player.current = item;
|
||||
player.time = 0;
|
||||
player.duration = 0;
|
||||
player.loading = true;
|
||||
function loadLyric(item: PlayItem) {
|
||||
lyric.value = [];
|
||||
a.src = item.src;
|
||||
a.play().catch(() => {});
|
||||
const apply = (lines: LrcLine[]) => {
|
||||
if (player.current?.key === item.key) lyric.value = lines;
|
||||
};
|
||||
if (item.source && item.id) {
|
||||
api
|
||||
.lyric(item.source, item.id)
|
||||
.then((l) => {
|
||||
if (player.current?.key === item.key) lyric.value = parseLrc(l.lrc);
|
||||
})
|
||||
.then((l) => apply(parseLrc(l.lrc, l.tlyric)))
|
||||
.catch(() => {});
|
||||
} else if (item.lrc) {
|
||||
fetch(item.lrc)
|
||||
.then((r) => (r.ok ? r.text() : ''))
|
||||
.then((text) => apply(parseLrc(text)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function playOnline(t: { source: Source; id: string; name: string; artists: string; cover?: string }) {
|
||||
play({ key: `${t.source}:${t.id}`, title: t.name, artists: t.artists, cover: t.cover, src: api.streamUrl(t.source, t.id), source: t.source, id: t.id });
|
||||
// ---------- 曲目转换 ----------
|
||||
|
||||
export function fromTrack(t: TrackSummary): PlayItem {
|
||||
const local = settings.value?.preferLocal !== false ? t.localFile : undefined;
|
||||
return {
|
||||
key: `${t.source}:${t.id}`,
|
||||
title: t.name,
|
||||
artists: t.artists,
|
||||
album: t.album,
|
||||
cover: t.cover,
|
||||
source: t.source,
|
||||
id: t.id,
|
||||
albumId: t.albumId,
|
||||
duration: t.duration,
|
||||
src: local ? api.libraryFileUrl(local) : api.streamUrl(t.source, t.id),
|
||||
local: !!local,
|
||||
};
|
||||
}
|
||||
|
||||
/** lrc:是否有同名 .lrc;未知(歌单里的文件)时也去尝试读取 */
|
||||
export function fromLocal(t: { path: string; name: string; artists: string; album?: string; cover?: string; lrc?: boolean }): PlayItem {
|
||||
return {
|
||||
key: `file:${t.path}`,
|
||||
title: t.name,
|
||||
artists: t.artists,
|
||||
album: t.album,
|
||||
cover: t.cover,
|
||||
src: api.libraryFileUrl(t.path),
|
||||
lrc: t.lrc === false ? undefined : api.libraryFileUrl(t.path.replace(/\.[^./]+$/, '.lrc')),
|
||||
local: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromPlaylistItem(i: PlaylistItem): PlayItem {
|
||||
if (i.path) return { ...fromLocal({ path: i.path, name: i.name, artists: i.artists, album: i.album, cover: i.cover ?? undefined }), duration: i.duration ?? undefined };
|
||||
return fromTrack({
|
||||
source: i.source!,
|
||||
id: i.trackId!,
|
||||
name: i.name,
|
||||
artists: i.artists,
|
||||
album: i.album,
|
||||
albumId: i.albumId ?? undefined,
|
||||
cover: i.cover ?? undefined,
|
||||
duration: i.duration ?? undefined,
|
||||
localFile: i.localFile,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 本机保存 ----------
|
||||
|
||||
const STORE_KEY = 'sx-player';
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function persistNow() {
|
||||
clearTimeout(persistTimer);
|
||||
try {
|
||||
if (!queue.value.length) localStorage.removeItem(STORE_KEY);
|
||||
else
|
||||
localStorage.setItem(
|
||||
STORE_KEY,
|
||||
JSON.stringify({ queue: queue.value, index: player.index, mode: player.mode, volume: player.volume, time: player.time }),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(persistNow, 400);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
|
||||
if (!s) return;
|
||||
if (typeof s.volume === 'number') player.volume = Math.min(1, Math.max(0, s.volume));
|
||||
if (MODES.includes(s.mode)) player.mode = s.mode;
|
||||
if (Array.isArray(s.queue) && s.queue.length) {
|
||||
queue.value = s.queue.filter((i: PlayItem) => i && typeof i.key === 'string' && typeof i.src === 'string');
|
||||
player.index = Math.min(Math.max(0, Number(s.index) || 0), queue.value.length - 1);
|
||||
player.current = queue.value[player.index] ?? null;
|
||||
resumeAt = Number(s.time) || 0;
|
||||
player.time = resumeAt;
|
||||
player.duration = (player.current?.duration ?? 0) / 1000;
|
||||
rebuildOrder();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------- 系统媒体控制 ----------
|
||||
|
||||
const media = typeof navigator !== 'undefined' && 'mediaSession' in navigator ? navigator.mediaSession : null;
|
||||
|
||||
function updateMetadata(item: PlayItem) {
|
||||
if (!media) return;
|
||||
const art = coverThumb(item.cover, 512);
|
||||
media.metadata = new MediaMetadata({
|
||||
title: item.title,
|
||||
artist: item.artists,
|
||||
album: item.album ?? '',
|
||||
artwork: art ? [{ src: new URL(art, location.href).href, sizes: '512x512' }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!media || !audio || !Number.isFinite(audio.duration) || !audio.duration) return;
|
||||
try {
|
||||
media.setPositionState({ duration: audio.duration, position: Math.min(audio.currentTime, audio.duration), playbackRate: audio.playbackRate });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setupMediaSession() {
|
||||
if (!media) return;
|
||||
const handlers: Array<[MediaSessionAction, MediaSessionActionHandler]> = [
|
||||
['play', () => resume()],
|
||||
['pause', () => audio?.pause()],
|
||||
['previoustrack', () => prev()],
|
||||
['nexttrack', () => next()],
|
||||
['seekto', (d) => d.seekTime !== undefined && seekTo(d.seekTime)],
|
||||
['seekbackward', (d) => seekTo(player.time - (d.seekOffset ?? 10))],
|
||||
['seekforward', (d) => seekTo(player.time + (d.seekOffset ?? 10))],
|
||||
];
|
||||
for (const [action, fn] of handlers) {
|
||||
try {
|
||||
media.setActionHandler(action, fn);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- <audio> ----------
|
||||
|
||||
function ensureAudio(): HTMLAudioElement {
|
||||
if (audio) return audio;
|
||||
const a = new Audio();
|
||||
audio = a;
|
||||
a.preload = 'auto';
|
||||
a.volume = player.volume;
|
||||
a.muted = player.muted;
|
||||
a.addEventListener('timeupdate', () => {
|
||||
player.time = a.currentTime;
|
||||
prefetchNext();
|
||||
});
|
||||
a.addEventListener('durationchange', () => {
|
||||
if (Number.isFinite(a.duration) && a.duration) player.duration = a.duration;
|
||||
updatePosition();
|
||||
});
|
||||
a.addEventListener('loadedmetadata', () => {
|
||||
if (resumeAt && resumeAt < a.duration - 1) a.currentTime = resumeAt;
|
||||
resumeAt = 0;
|
||||
});
|
||||
a.addEventListener('play', () => {
|
||||
player.playing = true;
|
||||
if (media) media.playbackState = 'playing';
|
||||
});
|
||||
a.addEventListener('pause', () => {
|
||||
player.playing = false;
|
||||
if (media) media.playbackState = 'paused';
|
||||
persistNow();
|
||||
});
|
||||
a.addEventListener('waiting', () => (player.loading = true));
|
||||
a.addEventListener('playing', () => {
|
||||
player.loading = false;
|
||||
errorStreak = 0;
|
||||
});
|
||||
a.addEventListener('canplay', () => (player.loading = false));
|
||||
a.addEventListener('seeked', updatePosition);
|
||||
a.addEventListener('ended', () => step(1, true));
|
||||
a.addEventListener('error', onError);
|
||||
setupMediaSession();
|
||||
return a;
|
||||
}
|
||||
|
||||
function onError() {
|
||||
player.loading = false;
|
||||
player.playing = false;
|
||||
const item = player.current;
|
||||
if (!item || !attached) return;
|
||||
errorStreak++;
|
||||
// 连续失败时自动跳过,但不无限循环
|
||||
if (queue.value.length > 1 && errorStreak < Math.min(queue.value.length, 5)) {
|
||||
toast(`无法播放「${item.title}」,已跳到下一首`, 'error');
|
||||
step(1, false);
|
||||
} else {
|
||||
toastError(new Error(`播放失败:「${item.title}」音源不可用`));
|
||||
}
|
||||
}
|
||||
|
||||
/** 快播完时预先请求下一首在线曲目的第一个字节,让服务端提前解析好音源直链 */
|
||||
function prefetchNext() {
|
||||
if (!audio || !player.duration || player.duration - player.time > 25) return;
|
||||
const nextItem = peekNext();
|
||||
if (!nextItem || nextItem.local || prefetched === nextItem.key) return;
|
||||
prefetched = nextItem.key;
|
||||
fetch(nextItem.src, { headers: { Range: 'bytes=0-0' } })
|
||||
.then((r) => r.body?.cancel())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** keepResume:继续上次保存的进度(仅恢复的队列首次播放时) */
|
||||
function load(i: number, autoplay = true, keepResume = false) {
|
||||
const item = queue.value[i];
|
||||
if (!item) return;
|
||||
const a = ensureAudio();
|
||||
if (!keepResume) resumeAt = 0;
|
||||
prefetched = '';
|
||||
player.index = i;
|
||||
player.current = item;
|
||||
player.time = resumeAt;
|
||||
player.duration = (item.duration ?? 0) / 1000;
|
||||
player.loading = autoplay;
|
||||
attached = true;
|
||||
a.src = item.src;
|
||||
if (autoplay) {
|
||||
a.play().catch((e: DOMException) => {
|
||||
// 浏览器拦截自动播放时保持暂停状态,等用户点击
|
||||
if (e.name === 'NotAllowedError') player.loading = false;
|
||||
});
|
||||
}
|
||||
loadLyric(item);
|
||||
updateMetadata(item);
|
||||
persist();
|
||||
}
|
||||
|
||||
// ---------- 队列与顺序 ----------
|
||||
|
||||
function rebuildOrder() {
|
||||
const n = queue.value.length;
|
||||
order = Array.from({ length: n }, (_, i) => i);
|
||||
if (player.mode !== 'shuffle' || n < 2) return;
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[order[i], order[j]] = [order[j]!, order[i]!];
|
||||
}
|
||||
// 当前曲目排在最前,接下来的才是随机的
|
||||
const at = order.indexOf(player.index);
|
||||
if (at > 0) [order[0], order[at]] = [order[at]!, order[0]!];
|
||||
}
|
||||
|
||||
function peekNext(): PlayItem | undefined {
|
||||
const n = queue.value.length;
|
||||
if (!n || player.mode === 'one') return undefined;
|
||||
let pos = order.indexOf(player.index) + 1;
|
||||
if (pos >= n) {
|
||||
if (player.mode === 'list') return undefined;
|
||||
pos = 0;
|
||||
}
|
||||
return queue.value[order[pos]!];
|
||||
}
|
||||
|
||||
function step(dir: 1 | -1, auto: boolean) {
|
||||
const n = queue.value.length;
|
||||
if (!n) return;
|
||||
if (auto && player.mode === 'one') {
|
||||
seekTo(0);
|
||||
audio?.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
let pos = Math.max(0, order.indexOf(player.index)) + dir;
|
||||
if (pos >= n || pos < 0) {
|
||||
if (auto && player.mode === 'list') return; // 顺序播放到底就停
|
||||
if (player.mode === 'shuffle' && pos >= n) {
|
||||
rebuildOrder();
|
||||
pos = n > 1 ? 1 : 0;
|
||||
} else pos = (pos + n) % n;
|
||||
}
|
||||
load(order[pos]!);
|
||||
}
|
||||
|
||||
/** 用一组曲目替换播放队列并从 start 开始播放;点的是正在播放的那首则暂停 / 继续 */
|
||||
export function playQueue(items: PlayItem[], start = 0, context = '') {
|
||||
const target = items[start];
|
||||
if (!target) return;
|
||||
const same = player.current?.key === target.key && attached;
|
||||
player.context = context;
|
||||
queue.value = items.slice();
|
||||
player.index = start;
|
||||
rebuildOrder();
|
||||
if (same) {
|
||||
persist();
|
||||
toggle();
|
||||
} else load(start);
|
||||
}
|
||||
|
||||
export function play(item: PlayItem) {
|
||||
playQueue([item]);
|
||||
}
|
||||
|
||||
export function playTracks(tracks: TrackSummary[], start = 0) {
|
||||
playQueue(tracks.map(fromTrack), start);
|
||||
}
|
||||
|
||||
/** 随机播放整组 */
|
||||
export function shuffleQueue(items: PlayItem[], context = '') {
|
||||
if (!items.length) return;
|
||||
player.mode = 'shuffle';
|
||||
playQueue(items, Math.floor(Math.random() * items.length), context);
|
||||
}
|
||||
|
||||
/** 加到「下一首播放」;队列为空时直接播放 */
|
||||
export function playNext(items: PlayItem[]) {
|
||||
if (!items.length) return;
|
||||
if (!player.current) return playQueue(items);
|
||||
const add = items.filter((i) => i.key !== player.current!.key);
|
||||
const keys = new Set(add.map((i) => i.key));
|
||||
// 已在队列中的挪到当前曲目之后;at 是当前曲目在新队列中的位置
|
||||
let at = 0;
|
||||
const rest = queue.value.filter((it, idx) => {
|
||||
const keep = idx === player.index || !keys.has(it.key);
|
||||
if (keep && idx < player.index) at++;
|
||||
return keep;
|
||||
});
|
||||
queue.value = [...rest.slice(0, at + 1), ...add, ...rest.slice(at + 1)];
|
||||
player.index = at;
|
||||
rebuildOrder();
|
||||
persist();
|
||||
toast(`已添加 ${items.length} 首到下一首播放`, 'ok');
|
||||
}
|
||||
|
||||
export function playAt(i: number) {
|
||||
if (i === player.index && attached) toggle();
|
||||
else load(i);
|
||||
}
|
||||
|
||||
export function removeAt(i: number) {
|
||||
const list = queue.value.slice();
|
||||
if (!list[i]) return;
|
||||
list.splice(i, 1);
|
||||
if (!list.length) return stop();
|
||||
const wasCurrent = i === player.index;
|
||||
queue.value = list;
|
||||
if (i < player.index) player.index--;
|
||||
rebuildOrder();
|
||||
if (wasCurrent) load(Math.min(i, list.length - 1), player.playing);
|
||||
else persist();
|
||||
}
|
||||
|
||||
export function next() {
|
||||
step(1, false);
|
||||
}
|
||||
|
||||
export function prev() {
|
||||
if (player.time > 3) seekTo(0);
|
||||
else step(-1, false);
|
||||
}
|
||||
|
||||
export function cycleMode() {
|
||||
player.mode = MODES[(MODES.indexOf(player.mode) + 1) % MODES.length]!;
|
||||
rebuildOrder();
|
||||
persist();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!player.current) return;
|
||||
if (!attached) load(player.index, true, true);
|
||||
else audio?.play().catch(() => {});
|
||||
}
|
||||
|
||||
export function toggle() {
|
||||
if (!audio || !player.current) return;
|
||||
if (audio.paused) audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
if (!player.current) return;
|
||||
if (!attached || audio?.paused) resume();
|
||||
else audio?.pause();
|
||||
}
|
||||
|
||||
export function seekTo(sec: number) {
|
||||
const t = Math.max(0, Math.min(sec, player.duration || sec));
|
||||
if (audio && attached) audio.currentTime = t;
|
||||
else resumeAt = t;
|
||||
player.time = t;
|
||||
}
|
||||
|
||||
export function seek(ratio: number) {
|
||||
if (audio && player.duration) audio.currentTime = Math.max(0, Math.min(1, ratio)) * player.duration;
|
||||
if (player.duration) seekTo(Math.max(0, Math.min(1, ratio)) * player.duration);
|
||||
}
|
||||
|
||||
export function setVolume(v: number) {
|
||||
player.volume = v;
|
||||
if (audio) audio.volume = v;
|
||||
player.muted = false;
|
||||
if (audio) {
|
||||
audio.volume = v;
|
||||
audio.muted = false;
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
player.muted = !player.muted;
|
||||
if (audio) audio.muted = player.muted;
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
audio?.pause();
|
||||
if (audio) audio.removeAttribute('src');
|
||||
audio?.removeAttribute('src');
|
||||
audio?.load();
|
||||
attached = false;
|
||||
resumeAt = 0;
|
||||
queue.value = [];
|
||||
order = [];
|
||||
player.current = null;
|
||||
player.index = -1;
|
||||
player.playing = false;
|
||||
player.loading = false;
|
||||
player.panel = null;
|
||||
player.context = '';
|
||||
lyric.value = [];
|
||||
if (media) media.metadata = null;
|
||||
persistNow();
|
||||
}
|
||||
|
||||
export function isPlaying(key: string): boolean {
|
||||
return player.current?.key === key && player.playing;
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
|
||||
restore();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pagehide', persistNow);
|
||||
document.addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && persistNow());
|
||||
// 空格键播放 / 暂停(输入框、按钮等聚焦时不拦截)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code !== 'Space' || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || !player.current) return;
|
||||
if ((e.target as HTMLElement).closest?.('input, textarea, select, button, a, [contenteditable]')) return;
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 自建歌单:列表缓存、「加入歌单」弹窗状态与曲目转换
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { api, type LocalTrack, type MyPlaylistSummary, type PlaylistItem, type PlaylistItemInput, type TrackSummary } from './api.ts';
|
||||
import type { PlayItem } from './player.ts';
|
||||
import { playlistTick, toast, toastError } from './store.ts';
|
||||
|
||||
export const myPlaylists = ref<MyPlaylistSummary[] | null>(null);
|
||||
let loading: Promise<void> | null = null;
|
||||
|
||||
export function loadMyPlaylists(force = false): Promise<void> {
|
||||
if (myPlaylists.value && !force) return Promise.resolve();
|
||||
loading ??= api
|
||||
.myPlaylists()
|
||||
.then((list) => void (myPlaylists.value = list))
|
||||
.catch(toastError)
|
||||
.finally(() => (loading = null));
|
||||
return loading;
|
||||
}
|
||||
|
||||
// 其他页面 / 设备改动后,已加载过的列表跟着刷新
|
||||
watch(playlistTick, () => myPlaylists.value && loadMyPlaylists(true));
|
||||
|
||||
// ---------- 转换 ----------
|
||||
|
||||
export function inputFromTrack(t: TrackSummary): PlaylistItemInput {
|
||||
return { source: t.source, id: t.id, name: t.name, artists: t.artists, album: t.album, albumId: t.albumId, cover: t.cover, duration: t.duration };
|
||||
}
|
||||
|
||||
export function inputFromLocal(t: Pick<LocalTrack, 'path' | 'name' | 'artists' | 'album'> & { cover?: string }): PlaylistItemInput {
|
||||
return { path: t.path, name: t.name, artists: t.artists, album: t.album, cover: t.cover };
|
||||
}
|
||||
|
||||
export function inputFromPlayItem(p: PlayItem): PlaylistItemInput {
|
||||
if (p.source && p.id) return { source: p.source, id: p.id, name: p.title, artists: p.artists, album: p.album ?? '', albumId: p.albumId, cover: p.cover, duration: p.duration };
|
||||
return { path: p.key.slice('file:'.length), name: p.title, artists: p.artists, album: p.album ?? '', cover: p.cover, duration: p.duration };
|
||||
}
|
||||
|
||||
export function inputFromItem(i: PlaylistItem): PlaylistItemInput {
|
||||
return { source: i.source, id: i.trackId, path: i.path, name: i.name, artists: i.artists, album: i.album, albumId: i.albumId, cover: i.cover, duration: i.duration };
|
||||
}
|
||||
|
||||
// ---------- 「加入歌单」弹窗 ----------
|
||||
|
||||
export const picker = reactive({ open: false, items: [] as PlaylistItemInput[], suggestedName: '' });
|
||||
|
||||
export function pickPlaylist(items: PlaylistItemInput[], suggestedName = '') {
|
||||
if (!items.length) return;
|
||||
picker.items = items;
|
||||
picker.suggestedName = suggestedName;
|
||||
picker.open = true;
|
||||
loadMyPlaylists();
|
||||
}
|
||||
|
||||
export async function addToMyPlaylist(id: number, items: PlaylistItemInput[]): Promise<boolean> {
|
||||
try {
|
||||
const { added, duplicates } = await api.addToPlaylist(id, items);
|
||||
const name = myPlaylists.value?.find((p) => p.id === id)?.name ?? '歌单';
|
||||
toast(added ? `已加入「${name}」${added} 首${duplicates ? `,${duplicates} 首已在歌单中` : ''}` : `全部已在「${name}」中`, added ? 'ok' : 'info');
|
||||
return true;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMyPlaylist(name: string, items: PlaylistItemInput[] = []): Promise<MyPlaylistSummary | null> {
|
||||
try {
|
||||
const created = await api.createPlaylist(name, items);
|
||||
toast(`已创建歌单「${created.name}」${items.length ? `,共 ${created.count} 首` : ''}`, 'ok');
|
||||
loadMyPlaylists(true);
|
||||
return created;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export const retagJob = ref<RetagJob | null>(null);
|
||||
export const artistImageJob = ref<ArtistImageJob | null>(null);
|
||||
/** 每次有新的下载历史时递增,供页面刷新 */
|
||||
export const historyTick = ref(0);
|
||||
/** 自建歌单有变动时递增(含其他标签页 / 设备的修改) */
|
||||
export const playlistTick = ref(0);
|
||||
/** 本次会话内完成下载的曲目 */
|
||||
export const downloadedNow = reactive(new Set<string>());
|
||||
export const connected = ref(false);
|
||||
@@ -112,6 +114,7 @@ function connectEvents() {
|
||||
on('settings', (s: Settings) => (settings.value = s));
|
||||
on('account', () => refreshAccounts(true));
|
||||
on('history', () => historyTick.value++);
|
||||
on('playlists', () => playlistTick.value++);
|
||||
on('retag', (j: RetagJob) => (retagJob.value = j));
|
||||
on('artist-images', (j: ArtistImageJob) => (artistImageJob.value = j));
|
||||
source.onerror = () => (connected.value = false);
|
||||
|
||||
@@ -5,6 +5,7 @@ export const navItems = [
|
||||
{ path: '/', name: 'search', label: '搜索' },
|
||||
{ path: '/queue', name: 'queue', label: '队列' },
|
||||
{ path: '/library', name: 'library', label: '曲库' },
|
||||
{ path: '/playlists', name: 'playlists', label: '歌单' },
|
||||
{ path: '/history', name: 'history', label: '历史' },
|
||||
{ path: '/settings', name: 'settings', label: '设置' },
|
||||
] as const;
|
||||
@@ -18,6 +19,8 @@ export const router = createRouter({
|
||||
{ path: '/artist/:source/:id', name: 'artist', component: () => import('./views/ArtistView.vue'), props: true },
|
||||
{ path: '/queue', name: 'queue', component: () => import('./views/QueueView.vue') },
|
||||
{ path: '/library', name: 'library', component: () => import('./views/LibraryView.vue') },
|
||||
{ path: '/playlists', name: 'playlists', component: () => import('./views/PlaylistsView.vue') },
|
||||
{ path: '/playlists/:id', name: 'my-playlist', component: () => import('./views/MyPlaylistView.vue'), props: true },
|
||||
{ path: '/history', name: 'history', component: () => import('./views/HistoryView.vue') },
|
||||
{ path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
|
||||
@@ -5,7 +5,8 @@ import Icon from '../components/Icon.vue';
|
||||
import TrackList from '../components/TrackList.vue';
|
||||
import { api, type AlbumDetail, type Source } from '../lib/api.ts';
|
||||
import { formatDate } from '../lib/format.ts';
|
||||
import { playOnline } from '../lib/player.ts';
|
||||
import { playTracks } from '../lib/player.ts';
|
||||
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
|
||||
|
||||
const props = defineProps<{ source: Source; id: string }>();
|
||||
const album = ref<AlbumDetail | null>(null);
|
||||
@@ -21,10 +22,7 @@ onMounted(async () => {
|
||||
|
||||
const tracks = computed(() => (album.value?.tracks ?? []).map((t) => ({ ...t, cover: t.cover || album.value?.cover })));
|
||||
|
||||
function playFirst() {
|
||||
const t = album.value?.tracks[0];
|
||||
if (t) playOnline({ ...t, cover: t.cover || album.value?.cover });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,7 +48,8 @@ function playFirst() {
|
||||
<span v-else>{{ album.artist }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button class="btn" type="button" @click="playFirst"><Icon name="play" :size="14" /> 试听</button>
|
||||
<button class="btn primary" type="button" :disabled="!tracks.length" @click="playTracks(tracks)"><Icon name="play" :size="14" /> 播放全部</button>
|
||||
<button class="btn" type="button" :disabled="!tracks.length" @click="pickPlaylist(tracks.map(inputFromTrack), album.name)"><Icon name="listPlus" :size="16" /> 加入歌单</button>
|
||||
</template>
|
||||
</DetailHeader>
|
||||
<TrackList :tracks="tracks" :show-cover="false" :show-album="false" numbering="track" :group="`专辑 · ${album.name}`" />
|
||||
|
||||
@@ -81,7 +81,7 @@ const qualities = computed(() => {
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<span class="eyebrow">04 · History</span>
|
||||
<span class="eyebrow">05 · History</span>
|
||||
<div class="row-between">
|
||||
<h2>下载历史</h2>
|
||||
<div class="exports">
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts';
|
||||
import { formatSize } from '../lib/format.ts';
|
||||
import { isPlaying, play } from '../lib/player.ts';
|
||||
import { fromLocal, isPlaying, player, playQueue } from '../lib/player.ts';
|
||||
import { inputFromLocal, pickPlaylist } from '../lib/playlists.ts';
|
||||
import { artistImageJob, historyTick, retagJob, toast, toastError } from '../lib/store.ts';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -71,14 +72,53 @@ function stripExt(name: string) {
|
||||
return name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
function playFile(e: LibraryEntry) {
|
||||
play({
|
||||
key: `file:${e.path}`,
|
||||
title: stripExt(e.name).replace(/^\d+\.\s*/, ''),
|
||||
artists: artistName.value || folderName.value,
|
||||
cover: listing.value?.hasCover ? api.libraryFileUrl(`${path.value}/cover.jpg`) : undefined,
|
||||
src: api.libraryFileUrl(e.path),
|
||||
});
|
||||
/** 当前文件夹的封面图 */
|
||||
const dirCover = computed(() => {
|
||||
const img = other.value.find((o) => /^(cover|folder)\.(jpe?g|png)$/i.test(o.name));
|
||||
return img ? api.libraryFileUrl(img.path) : undefined;
|
||||
});
|
||||
|
||||
function localOf(e: LibraryEntry) {
|
||||
return {
|
||||
path: e.path,
|
||||
name: e.title || stripExt(e.name).replace(/^\d+\.\s*/, ''),
|
||||
artists: e.artists || artistName.value || folderName.value,
|
||||
album: e.album ?? '',
|
||||
cover: dirCover.value,
|
||||
lrc: !!e.hasLyric,
|
||||
};
|
||||
}
|
||||
|
||||
/** 播放当前文件夹,从点击的那首开始 */
|
||||
function playFile(i: number) {
|
||||
playQueue(audio.value.map((e) => fromLocal(localOf(e))), i, `library:${path.value}`);
|
||||
}
|
||||
|
||||
/** 当前目录(含子文件夹)的全部音频 */
|
||||
async function folderTracks() {
|
||||
const { items, truncated } = await api.libraryTracks(path.value);
|
||||
if (truncated) toast(`文件过多,只取前 ${items.length} 首`);
|
||||
return items.map((t) => ({ ...t, cover: t.cover ? api.libraryFileUrl(t.cover) : undefined }));
|
||||
}
|
||||
|
||||
async function playFolder() {
|
||||
try {
|
||||
const items = await folderTracks();
|
||||
if (!items.length) return toast('文件夹中没有音频文件');
|
||||
playQueue(items.map(fromLocal), 0, `library:${path.value}`);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function addFolder() {
|
||||
try {
|
||||
const items = await folderTracks();
|
||||
if (!items.length) return toast('文件夹中没有音频文件');
|
||||
pickPlaylist(items.map(inputFromLocal), folderName.value);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function m3u() {
|
||||
@@ -161,6 +201,8 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
|
||||
<div class="tools">
|
||||
<button class="btn sm" type="button" title="补全标签与歌词" @click="retagOpen = !retagOpen"><Icon name="tag" :size="15" /> 补全标签</button>
|
||||
<template v-if="path">
|
||||
<button class="btn sm" type="button" title="播放此文件夹(含子文件夹)" @click="playFolder"><Icon name="play" :size="13" /> 播放</button>
|
||||
<button class="btn sm" type="button" title="把此文件夹(含子文件夹)加入歌单" @click="addFolder"><Icon name="listPlus" :size="15" /> 加入歌单</button>
|
||||
<button class="btn sm" type="button" title="生成 M3U 播放列表" @click="m3u"><Icon name="list" :size="15" /> M3U</button>
|
||||
<a class="btn sm" :href="api.libraryZipUrl(path)" title="打包下载"><Icon name="zip" :size="15" /> ZIP</a>
|
||||
<button class="btn sm danger" type="button" title="移入回收站" @click="trashPath(path, folderName)"><Icon name="trash" :size="15" /></button>
|
||||
@@ -245,14 +287,15 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
|
||||
</div>
|
||||
|
||||
<ol v-if="audio.length" class="files">
|
||||
<li v-for="f in audio" :key="f.path" class="file" :class="{ current: isPlaying(`file:${f.path}`) }">
|
||||
<button type="button" class="play" @click="playFile(f)" :aria-label="`播放 ${f.name}`">
|
||||
<li v-for="(f, i) in audio" :key="f.path" class="file" :class="{ current: player.current?.key === `file:${f.path}` }" @dblclick="playFile(i)">
|
||||
<button type="button" class="play" @click="playFile(i)" :aria-label="`播放 ${f.name}`">
|
||||
<Icon :name="isPlaying(`file:${f.path}`) ? 'pause' : 'play'" :size="14" />
|
||||
</button>
|
||||
<span class="fname">{{ stripExt(f.name) }}</span>
|
||||
<span class="ext mono">{{ f.name.split('.').pop()?.toUpperCase() }}</span>
|
||||
<span class="lrc mono" :class="{ on: f.hasLyric }" :title="f.hasLyric ? '有歌词' : '缺少歌词'">LRC</span>
|
||||
<span class="size mono faint">{{ formatSize(f.size) }}</span>
|
||||
<button type="button" class="del add" title="加入歌单" @click="pickPlaylist([inputFromLocal(localOf(f))])"><Icon name="listPlus" :size="16" /></button>
|
||||
<button type="button" class="del" title="移入回收站" @click="trashPath(f.path, f.name)"><Icon name="trash" :size="15" /></button>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -517,11 +560,13 @@ details li.bad {
|
||||
|
||||
.file {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px 30px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 47px;
|
||||
}
|
||||
|
||||
.file:hover {
|
||||
@@ -599,6 +644,15 @@ details li.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.del.add {
|
||||
margin-right: -10px;
|
||||
}
|
||||
|
||||
.del.add:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.other {
|
||||
margin-top: 16px;
|
||||
font-size: 12.5px;
|
||||
@@ -639,7 +693,7 @@ details li.bad {
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.file {
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto 30px;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto 30px 30px;
|
||||
}
|
||||
|
||||
.ext,
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import CoverMosaic from '../components/CoverMosaic.vue';
|
||||
import DetailHeader from '../components/DetailHeader.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import { api, type MyPlaylist, type PlaylistItem, type Source, type TrackSummary } from '../lib/api.ts';
|
||||
import { formatDate, formatDuration, formatTotal, SOURCE_LABEL } from '../lib/format.ts';
|
||||
import { fromPlaylistItem, isPlaying, player, playNext, playQueue, shuffleQueue } from '../lib/player.ts';
|
||||
import { inputFromItem, loadMyPlaylists, pickPlaylist } from '../lib/playlists.ts';
|
||||
import { defaultQualityFor, download, playlistTick, toast, toastError } from '../lib/store.ts';
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const pid = computed(() => Number(props.id));
|
||||
const playlist = ref<MyPlaylist | null>(null);
|
||||
const error = ref('');
|
||||
const filter = ref('');
|
||||
const editing = ref(false);
|
||||
const form = ref({ name: '', description: '' });
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
playlist.value = await api.myPlaylist(pid.value);
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// 其他标签页改动后同步;自己发起的改动已经乐观更新,忽略紧随其后的那次通知
|
||||
let selfChange = 0;
|
||||
watch(playlistTick, () => {
|
||||
if (Date.now() - selfChange > 1500) load();
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
const list = playlist.value?.items ?? [];
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
return q ? list.filter((t) => `${t.name} ${t.artists} ${t.album}`.toLowerCase().includes(q)) : list;
|
||||
});
|
||||
const playable = computed(() => items.value.filter((i) => !i.missing));
|
||||
const context = computed(() => `playlist:${pid.value}`);
|
||||
const covers = computed(() => playlist.value?.covers ?? []);
|
||||
const localCount = computed(() => playlist.value?.items.filter((i) => i.path).length ?? 0);
|
||||
|
||||
const keyOf = (i: PlaylistItem) => (i.path ? `file:${i.path}` : `${i.source}:${i.trackId}`);
|
||||
const sourceLabel = (i: PlaylistItem) => (i.source ? SOURCE_LABEL[i.source] : '本地');
|
||||
|
||||
function playFrom(item: PlaylistItem) {
|
||||
if (item.missing) return;
|
||||
const list = playable.value;
|
||||
playQueue(list.map(fromPlaylistItem), Math.max(0, list.indexOf(item)), context.value);
|
||||
}
|
||||
|
||||
// ---------- 编辑 ----------
|
||||
|
||||
function startEdit() {
|
||||
if (!playlist.value) return;
|
||||
form.value = { name: playlist.value.name, description: playlist.value.description };
|
||||
editing.value = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
try {
|
||||
selfChange = Date.now();
|
||||
const s = await api.updatePlaylist(pid.value, form.value);
|
||||
if (playlist.value) Object.assign(playlist.value, { name: s.name, description: s.description });
|
||||
editing.value = false;
|
||||
loadMyPlaylists(true);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!playlist.value || !confirm(`删除歌单「${playlist.value.name}」?\n(只删除歌单,不会删除任何音乐文件)`)) return;
|
||||
try {
|
||||
await api.deletePlaylist(pid.value);
|
||||
toast('歌单已删除');
|
||||
loadMyPlaylists(true);
|
||||
router.replace('/playlists');
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: PlaylistItem) {
|
||||
if (!playlist.value) return;
|
||||
const before = playlist.value.items;
|
||||
playlist.value.items = before.filter((i) => i !== item);
|
||||
playlist.value.count--;
|
||||
selfChange = Date.now();
|
||||
try {
|
||||
await api.removeFromPlaylist(pid.value, [item.id]);
|
||||
} catch (e) {
|
||||
playlist.value.items = before;
|
||||
playlist.value.count++;
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载歌单中曲库里还没有的在线曲目,按平台分别使用默认音质 */
|
||||
const toDownload = computed(() => (playlist.value?.items ?? []).filter((i) => i.source && !i.localFile));
|
||||
function downloadMissing() {
|
||||
const bySource = new Map<Source, TrackSummary[]>();
|
||||
for (const i of toDownload.value) {
|
||||
const t: TrackSummary = { source: i.source!, id: i.trackId!, name: i.name, artists: i.artists, album: i.album, cover: i.cover ?? undefined };
|
||||
bySource.set(t.source, [...(bySource.get(t.source) ?? []), t]);
|
||||
}
|
||||
for (const [source, tracks] of bySource) download(tracks, defaultQualityFor(source), `歌单 · ${playlist.value?.name}`);
|
||||
}
|
||||
|
||||
// ---------- 拖动排序 ----------
|
||||
|
||||
const dragFrom = ref(-1);
|
||||
const dragOver = ref(-1);
|
||||
const canDrag = computed(() => !filter.value.trim());
|
||||
|
||||
function onDragStart(i: number, e: DragEvent) {
|
||||
dragFrom.value = i;
|
||||
e.dataTransfer?.setData('text/plain', String(i));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function onDragOver(i: number) {
|
||||
if (dragFrom.value >= 0) dragOver.value = i;
|
||||
}
|
||||
|
||||
async function onDrop() {
|
||||
const from = dragFrom.value;
|
||||
const to = dragOver.value;
|
||||
dragFrom.value = dragOver.value = -1;
|
||||
if (!playlist.value || from < 0 || to < 0 || from === to) return;
|
||||
const list = playlist.value.items.slice();
|
||||
const [moved] = list.splice(from, 1);
|
||||
list.splice(to, 0, moved!);
|
||||
const before = playlist.value.items;
|
||||
playlist.value.items = list;
|
||||
selfChange = Date.now();
|
||||
try {
|
||||
await api.reorderPlaylist(pid.value, list.map((i) => i.id));
|
||||
} catch (e) {
|
||||
playlist.value.items = before;
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragFrom.value = dragOver.value = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
|
||||
<p v-if="error" class="state-msg">{{ error }}</p>
|
||||
<template v-else-if="playlist">
|
||||
<DetailHeader
|
||||
kind="自建歌单"
|
||||
:title="playlist.name"
|
||||
:meta="[`${playlist.count} 首`, formatTotal(playlist.duration), localCount > 0 && `本地 ${localCount} 首`, `创建于 ${formatDate(playlist.createdAt)}`]"
|
||||
:description="playlist.description"
|
||||
>
|
||||
<template #art><CoverMosaic :covers="covers" :px="500" /></template>
|
||||
<template #actions>
|
||||
<button class="btn primary" type="button" :disabled="!playable.length" @click="playQueue(playable.map(fromPlaylistItem), 0, context)">
|
||||
<Icon name="play" :size="14" /> 播放全部
|
||||
</button>
|
||||
<button class="btn" type="button" :disabled="!playable.length" @click="shuffleQueue(playable.map(fromPlaylistItem), context)">
|
||||
<Icon name="shuffle" :size="15" /> 随机
|
||||
</button>
|
||||
<button class="btn" type="button" title="编辑名称与简介" @click="startEdit"><Icon name="edit" :size="15" /> 编辑</button>
|
||||
<button class="btn ghost danger" type="button" title="删除歌单" @click="remove"><Icon name="trash" :size="15" /></button>
|
||||
</template>
|
||||
</DetailHeader>
|
||||
|
||||
<Transition name="fold">
|
||||
<form v-if="editing" class="panel" @submit.prevent="saveEdit">
|
||||
<label class="fld">
|
||||
<span class="faint">名称</span>
|
||||
<input v-model="form.name" class="input" maxlength="100" required />
|
||||
</label>
|
||||
<label class="fld">
|
||||
<span class="faint">简介</span>
|
||||
<textarea v-model="form.description" class="input area" rows="3" maxlength="2000" placeholder="可选" />
|
||||
</label>
|
||||
<div class="panel-row">
|
||||
<div class="spacer" />
|
||||
<button class="btn sm ghost" type="button" @click="editing = false">取消</button>
|
||||
<button class="btn sm primary" type="submit" :disabled="!form.name.trim()">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</Transition>
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="muted small">
|
||||
<template v-if="filter.trim()">筛选出 <b class="mono">{{ items.length }}</b> 首</template>
|
||||
<template v-else>共 <b class="mono">{{ playlist.items.length }}</b> 首<span v-if="playlist.items.length > 1" class="faint"> · 拖动可排序</span></template>
|
||||
</span>
|
||||
<div class="spacer" />
|
||||
<input v-model="filter" class="input filter" type="search" placeholder="在歌单中筛选…" />
|
||||
<button class="btn sm" type="button" :disabled="!items.length" title="添加到其他歌单" @click="pickPlaylist(items.map(inputFromItem))">
|
||||
<Icon name="listPlus" :size="15" /> 加入歌单
|
||||
</button>
|
||||
<button v-if="toDownload.length" class="btn sm" type="button" title="下载曲库里还没有的在线曲目" @click="downloadMissing">
|
||||
<Icon name="download" :size="15" /> 下载缺少的 {{ toDownload.length }} 首
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol v-if="items.length" class="rows" @dragover.prevent @drop.prevent="onDrop">
|
||||
<li
|
||||
v-for="(t, i) in items"
|
||||
:key="t.id"
|
||||
class="row"
|
||||
:class="[
|
||||
t.source ?? 'local',
|
||||
{
|
||||
current: player.current?.key === keyOf(t),
|
||||
missing: t.missing,
|
||||
dragging: dragFrom === i,
|
||||
'drop-before': dragOver === i && dragFrom > i,
|
||||
'drop-after': dragOver === i && dragFrom < i,
|
||||
},
|
||||
]"
|
||||
:draggable="canDrag"
|
||||
@dragstart="onDragStart(i, $event)"
|
||||
@dragenter.prevent="onDragOver(i)"
|
||||
@dragend="onDragEnd"
|
||||
@dblclick="playFrom(t)"
|
||||
>
|
||||
<span class="grip" :class="{ off: !canDrag }" aria-hidden="true"><Icon name="grip" :size="14" /></span>
|
||||
<span class="num">
|
||||
<span class="mono faint idx">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<button type="button" class="play" :disabled="t.missing" :aria-label="`播放 ${t.name}`" @click="playFrom(t)">
|
||||
<Icon :name="isPlaying(keyOf(t)) ? 'pause' : 'play'" :size="16" />
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="cover-btn" tabindex="-1" :disabled="t.missing" @click="playFrom(t)"><CoverArt :src="t.cover" :size="40" /></button>
|
||||
<div class="title">
|
||||
<div class="name">{{ t.name }}</div>
|
||||
<div class="sub muted">
|
||||
{{ t.artists || '未知歌手' }}
|
||||
<template v-if="t.album">
|
||||
<span class="faint"> · </span>
|
||||
<RouterLink v-if="t.source && t.albumId" :to="`/album/${t.source}/${t.albumId}`" class="ln faint">{{ t.album }}</RouterLink>
|
||||
<span v-else class="faint">{{ t.album }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tags">
|
||||
<span v-if="t.missing" class="st bad" :title="t.path ?? ''">文件缺失</span>
|
||||
<span v-else-if="t.localFile" class="st" :title="`曲库中有:${t.localFile}`"><Icon name="check" :size="13" /> 曲库</span>
|
||||
<span class="src"><i class="dot" />{{ sourceLabel(t) }}</span>
|
||||
</span>
|
||||
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
|
||||
<span class="acts">
|
||||
<button type="button" class="act" title="下一首播放" :disabled="t.missing" @click="playNext([fromPlaylistItem(t)])"><Icon name="playNext" :size="16" /></button>
|
||||
<button type="button" class="act rm" title="从歌单移除" @click="removeItem(t)"><Icon name="x" :size="15" /></button>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else-if="filter.trim()" class="state-msg">没有匹配的歌曲</p>
|
||||
<div v-else class="empty">
|
||||
<p class="muted">歌单还是空的</p>
|
||||
<p class="faint small">在搜索、专辑、歌手页勾选歌曲,或在曲库里点文件旁的 <Icon name="listPlus" :size="13" class="inline" /> 加进来。</p>
|
||||
<RouterLink to="/" class="btn sm"><Icon name="search" :size="14" /> 去搜索</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.back {
|
||||
margin: -16px 0 14px -10px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fld {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fld .faint {
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.area {
|
||||
height: auto;
|
||||
padding: 8px 12px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fold-enter-active,
|
||||
.fold-leave-active {
|
||||
transition: opacity 160ms var(--ease), transform 160ms var(--ease);
|
||||
}
|
||||
|
||||
.fold-enter-from,
|
||||
.fold-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 10px 12px 12px;
|
||||
background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1.5px solid var(--ink);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter {
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 14px 30px auto minmax(0, 1fr) auto 44px 64px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 10px 9px 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 59px;
|
||||
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 13px;
|
||||
bottom: 13px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--ink-3);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row.netease::before {
|
||||
background: var(--netease);
|
||||
}
|
||||
|
||||
.row.qq::before {
|
||||
background: var(--qq);
|
||||
}
|
||||
|
||||
.row:hover::before,
|
||||
.row.current::before {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
border-bottom-color: var(--line-strong);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.row.missing {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.row.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.row.drop-before {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.row.drop-after {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.grip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-3);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover .grip:not(.off) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.grip.off {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.num {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.idx {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover .play:not(:disabled),
|
||||
.row.current .play,
|
||||
.play:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.row.current .play {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.cover-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row.current .name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ln:hover {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.st {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-mono);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.st.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 52px;
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
|
||||
.netease .dot {
|
||||
background: var(--netease);
|
||||
}
|
||||
|
||||
.qq .dot {
|
||||
background: var(--qq);
|
||||
}
|
||||
|
||||
.dur {
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
opacity: 0;
|
||||
transition: color var(--dur) var(--ease), background var(--dur) var(--ease), opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover .act,
|
||||
.act:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.act:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.act.rm:hover {
|
||||
color: var(--danger);
|
||||
background: color-mix(in oklab, var(--danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 64px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty .small {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter {
|
||||
width: 100%;
|
||||
order: 9;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto 32px;
|
||||
gap: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.grip,
|
||||
.num,
|
||||
.dur,
|
||||
.st,
|
||||
.act:not(.rm) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.acts,
|
||||
.act {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,8 @@ import DetailHeader from '../components/DetailHeader.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import TrackList from '../components/TrackList.vue';
|
||||
import { api, type PlaylistDetail, type Source } from '../lib/api.ts';
|
||||
import { playTracks } from '../lib/player.ts';
|
||||
import { inputFromTrack, pickPlaylist } from '../lib/playlists.ts';
|
||||
|
||||
const props = defineProps<{ source: Source; id: string }>();
|
||||
const playlist = ref<PlaylistDetail | null>(null);
|
||||
@@ -42,6 +44,10 @@ const tracks = computed(() => {
|
||||
<span v-if="playlist.creator">by {{ playlist.creator }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button class="btn primary" type="button" :disabled="!playlist.tracks.length" @click="playTracks(playlist.tracks)"><Icon name="play" :size="14" /> 播放全部</button>
|
||||
<button class="btn" type="button" :disabled="!playlist.tracks.length" title="另存为自建歌单,或加入已有歌单" @click="pickPlaylist(playlist.tracks.map(inputFromTrack), playlist.name)">
|
||||
<Icon name="listPlus" :size="16" /> 收藏到歌单
|
||||
</button>
|
||||
<input v-model="filter" class="input" type="search" placeholder="在歌单中筛选…" />
|
||||
</template>
|
||||
</DetailHeader>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import CoverMosaic from '../components/CoverMosaic.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import { api, type MyPlaylistSummary } from '../lib/api.ts';
|
||||
import { formatTotal } from '../lib/format.ts';
|
||||
import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.ts';
|
||||
import { createMyPlaylist, loadMyPlaylists, myPlaylists } from '../lib/playlists.ts';
|
||||
import { toastError } from '../lib/store.ts';
|
||||
|
||||
const router = useRouter();
|
||||
const creating = ref(false);
|
||||
const name = ref('');
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => loadMyPlaylists(true));
|
||||
|
||||
function startCreate() {
|
||||
creating.value = true;
|
||||
name.value = '';
|
||||
nextTick(() => input.value?.focus());
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const title = name.value.trim();
|
||||
if (!title) return;
|
||||
const created = await createMyPlaylist(title);
|
||||
if (created) {
|
||||
creating.value = false;
|
||||
router.push(`/playlists/${created.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const playingHere = (p: MyPlaylistSummary) => player.context === `playlist:${p.id}` && player.playing;
|
||||
|
||||
async function playAll(p: MyPlaylistSummary) {
|
||||
if (player.context === `playlist:${p.id}` && player.current) return toggle();
|
||||
try {
|
||||
const detail = await api.myPlaylist(p.id);
|
||||
playQueue(detail.items.filter((i) => !i.missing).map(fromPlaylistItem), 0, `playlist:${p.id}`);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<span class="eyebrow">04 · Playlists</span>
|
||||
<div class="row-between">
|
||||
<h2>
|
||||
歌单
|
||||
<span v-if="myPlaylists?.length" class="count">{{ myPlaylists.length }} 个</span>
|
||||
</h2>
|
||||
<form v-if="creating" class="create" @submit.prevent="create">
|
||||
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="creating = false" />
|
||||
<button class="btn sm primary" type="submit" :disabled="!name.trim()">创建</button>
|
||||
<button class="btn sm ghost" type="button" @click="creating = false">取消</button>
|
||||
</form>
|
||||
<button v-else class="btn sm" type="button" @click="startCreate"><Icon name="plus" :size="15" /> 新建歌单</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="myPlaylists?.length" class="grid">
|
||||
<article v-for="p in myPlaylists" :key="p.id" class="tile">
|
||||
<div class="art-wrap">
|
||||
<RouterLink :to="`/playlists/${p.id}`" class="art">
|
||||
<CoverMosaic :covers="p.covers" :px="320" />
|
||||
</RouterLink>
|
||||
<button v-if="p.count" class="play" :class="{ on: playingHere(p) }" type="button" :aria-label="`播放 ${p.name}`" @click="playAll(p)">
|
||||
<Icon :name="playingHere(p) ? 'pause' : 'play'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<RouterLink :to="`/playlists/${p.id}`" class="tile-name">{{ p.name }}</RouterLink>
|
||||
<span class="tile-meta mono faint">{{ p.count }} 首<template v-if="p.duration"> · {{ formatTotal(p.duration) }}</template></span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="myPlaylists" class="empty">
|
||||
<span class="empty-art"><Icon name="queue" :size="30" /></span>
|
||||
<p class="muted">还没有歌单</p>
|
||||
<p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单。</p>
|
||||
<button class="btn primary" type="button" @click="startCreate"><Icon name="plus" :size="16" /> 新建歌单</button>
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="i in 5" :key="i" class="tile"><span class="art skeleton" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.create .input {
|
||||
width: 220px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 26px 20px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.art-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.art {
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.art.skeleton {
|
||||
height: auto;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tile:hover .art {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.tile:hover .play,
|
||||
.play.on,
|
||||
.play:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 14.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tile-name:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 72px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-art {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 10px;
|
||||
border: 1.5px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.small {
|
||||
max-width: 380px;
|
||||
margin-bottom: 14px !important;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 20px 14px;
|
||||
}
|
||||
|
||||
.play {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import QRLoginDialog from '../components/QRLoginDialog.vue';
|
||||
import { api, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
|
||||
import { api, type PlayQuality, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
|
||||
import { formatDate, QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
|
||||
import { auth, ffmpeg, meta, qqStatus, refreshAccounts, settings, toast, toastError } from '../lib/store.ts';
|
||||
import { themeMode, type ThemeMode } from '../lib/theme.ts';
|
||||
@@ -81,6 +81,7 @@ async function save(patch: Partial<Settings>) {
|
||||
}
|
||||
|
||||
const qualityOptions = computed(() => [...(meta.value?.qualities ?? [])].reverse());
|
||||
const playQualities: PlayQuality[] = ['standard', 'high', 'lossless'];
|
||||
|
||||
// ---- 访问密码 ----
|
||||
const pwCurrent = ref('');
|
||||
@@ -125,7 +126,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<section class="page narrow">
|
||||
<header class="page-head">
|
||||
<span class="eyebrow">05 · Settings</span>
|
||||
<span class="eyebrow">06 · Settings</span>
|
||||
<h2>设置</h2>
|
||||
</header>
|
||||
|
||||
@@ -248,6 +249,26 @@ onMounted(() => {
|
||||
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
|
||||
</section>
|
||||
|
||||
<!-- 播放 -->
|
||||
<section v-if="settings" class="group">
|
||||
<h3>播放</h3>
|
||||
<div class="field">
|
||||
<div class="label">在线播放音质<span class="faint">无损约 30 MB / 首,网络较慢时建议「极高」</span></div>
|
||||
<div class="seg" role="tablist">
|
||||
<button v-for="q in playQualities" :key="q" type="button" role="tab" :aria-selected="settings.playQuality === q" @click="save({ playQuality: q })">
|
||||
{{ QUALITY_LABEL[q] }} · {{ QUALITY_BADGE[q] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="settings.preferLocal" @change="save({ preferLocal: ($event.target as HTMLInputElement).checked })" />
|
||||
<span class="sw" />
|
||||
曲库里已有的歌曲直接播放本地文件
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 访问密码 -->
|
||||
<section class="group">
|
||||
<h3>访问密码 <span class="faint state">{{ auth.required ? '已开启' : '未开启' }}</span></h3>
|
||||
|
||||
Reference in New Issue
Block a user