c1ad24aace
- NowPlaying:参考 Apple Music tvOS —— 模糊封面铺底、大号逐行歌词(按距离渐隐 / 模糊、逐行跟随)、接下来列表、可拖动进度条,静置后隐藏控件;Esc 收起、←/→ 快退 / 快进 - 播放兜底链:曲库文件 → 原平台 → 另一个平台的同名歌曲,出错时依次切换并保留进度,合并为一条提示 - 新增 /api/:source/stream-alt:按歌名、歌手、时长在另一个平台匹配同一首歌并代理播放 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
602 lines
19 KiB
TypeScript
602 lines
19 KiB
TypeScript
// 播放器:单个 <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;
|
|
/** src 播放失败时依次尝试:曲库文件 / 原平台 / 另一个平台的同名歌曲 */
|
|
alt?: Array<{ src: string; via: Via }>;
|
|
}
|
|
|
|
/** 实际出声的音源 */
|
|
export type Via = 'local' | Source;
|
|
export const VIA_LABEL: Record<Via, string> = { local: '曲库文件', netease: '网易云', qq: 'QQ 音乐' };
|
|
const OTHER: Record<Source, Source> = { netease: 'qq', qq: 'netease' };
|
|
|
|
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: '',
|
|
/** 当前实际播放的音源(兜底后可能与曲目来源不同) */
|
|
via: 'local' as Via,
|
|
/** 全屏「正在播放」 */
|
|
full: false,
|
|
});
|
|
|
|
/** 播放队列可能有上千首:用 shallowRef,整体替换触发更新 */
|
|
export const queue = shallowRef<PlayItem[]>([]);
|
|
export const lyric = shallowRef<LrcLine[]>([]);
|
|
|
|
let audio: HTMLAudioElement | null = null;
|
|
/** 当前曲目已经交给 <audio>(恢复的队列在点播放前不加载) */
|
|
let attached = false;
|
|
/** 恢复上次进度:元数据加载后跳转 */
|
|
let resumeAt = 0;
|
|
let errorStreak = 0;
|
|
/** 当前曲目已用掉的兜底音源数 */
|
|
let attempt = 0;
|
|
/** 本首已失败的音源,换源后真正出声时合并成一条提示 */
|
|
let failedVias: Via[] = [];
|
|
let prefetched = '';
|
|
/** 播放顺序 → 队列下标;随机模式下是洗牌后的顺序 */
|
|
let order: number[] = [];
|
|
|
|
// ---------- 歌词 ----------
|
|
|
|
function parseLines(text: string | undefined): LrcLine[] {
|
|
if (!text) return [];
|
|
const out: LrcLine[] = [];
|
|
for (const line of text.split('\n')) {
|
|
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
|
|
const content = line.replace(/\[[^\]]*\]/g, '').trim();
|
|
if (!stamps.length || !content) continue;
|
|
for (const m of stamps) out.push({ t: Number(m[1]) * 60 + Number(m[2]), text: content });
|
|
}
|
|
return out.sort((a, b) => a.t - b.t);
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
});
|
|
|
|
export const currentLyric = computed(() => lyric.value[lyricIndex.value]?.text ?? '');
|
|
|
|
function loadLyric(item: PlayItem) {
|
|
lyric.value = [];
|
|
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) => 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 fromTrack(t: TrackSummary): PlayItem {
|
|
const online = { src: api.streamUrl(t.source, t.id), via: t.source as Via };
|
|
const file = t.localFile ? { src: api.libraryFileUrl(t.localFile), via: 'local' as Via } : null;
|
|
const chain = (settings.value?.preferLocal !== false ? [file, online] : [online, file]).filter((x) => !!x);
|
|
chain.push({ src: api.altStreamUrl(t.source, { name: t.name, artists: t.artists, duration: t.duration }), via: OTHER[t.source] });
|
|
const [first, ...alt] = chain;
|
|
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: first!.src,
|
|
local: first!.via === 'local',
|
|
alt,
|
|
};
|
|
}
|
|
|
|
/** 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;
|
|
if (player.current) player.via = primaryVia(player.current);
|
|
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;
|
|
const item = player.current;
|
|
if (failedVias.length && item) {
|
|
const other = player.via !== 'local' && player.via !== item.source;
|
|
toast(`${failedVias.map((v) => VIA_LABEL[v]).join('、')}无法播放,改用${VIA_LABEL[player.via]}${other ? '的同名歌曲' : ''}`);
|
|
failedVias = [];
|
|
}
|
|
});
|
|
a.addEventListener('canplay', () => (player.loading = false));
|
|
a.addEventListener('seeked', updatePosition);
|
|
a.addEventListener('ended', () => step(1, true));
|
|
a.addEventListener('error', onError);
|
|
setupMediaSession();
|
|
return a;
|
|
}
|
|
|
|
const primaryVia = (item: PlayItem): Via => (item.local || !item.source ? 'local' : item.source);
|
|
|
|
function onError() {
|
|
const item = player.current;
|
|
if (!item || !attached || !audio) return;
|
|
// 还有兜底音源:从出错的位置接着放
|
|
const fallback = item.alt?.[attempt];
|
|
if (fallback) {
|
|
attempt++;
|
|
failedVias.push(player.via);
|
|
const at = audio.currentTime || player.time;
|
|
resumeAt = at > 1 ? at : 0;
|
|
player.via = fallback.via;
|
|
player.loading = true;
|
|
audio.src = fallback.src;
|
|
audio.play().catch(() => {});
|
|
return;
|
|
}
|
|
player.loading = false;
|
|
player.playing = false;
|
|
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 = '';
|
|
attempt = 0;
|
|
failedVias = [];
|
|
player.via = primaryVia(item);
|
|
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 (!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 (player.duration) seekTo(Math.max(0, Math.min(1, ratio)) * player.duration);
|
|
}
|
|
|
|
export function setVolume(v: number) {
|
|
player.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();
|
|
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.full = false;
|
|
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();
|
|
});
|
|
}
|