feat: P7 在线播放(播放队列、媒体控制)与自建歌单
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage - 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件 - 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user