feat: 全屏「正在播放」页与跨平台兜底播放
- NowPlaying:参考 Apple Music tvOS —— 模糊封面铺底、大号逐行歌词(按距离渐隐 / 模糊、逐行跟随)、接下来列表、可拖动进度条,静置后隐藏控件;Esc 收起、←/→ 快退 / 快进 - 播放兜底链:曲库文件 → 原平台 → 另一个平台的同名歌曲,出错时依次切换并保留进度,合并为一条提示 - 新增 /api/:source/stream-alt:按歌名、歌手、时长在另一个平台匹配同一首歌并代理播放 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import NowPlaying from './components/NowPlaying.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 { player } from './lib/player.ts';
|
||||
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
|
||||
import { cycleTheme, themeMode } from './lib/theme.ts';
|
||||
import { navItems } from './router.ts';
|
||||
@@ -85,11 +87,25 @@ const qqLabel = computed(() => {
|
||||
<PlayerBar />
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="np">
|
||||
<NowPlaying v-if="player.full && player.current" />
|
||||
</Transition>
|
||||
<PlaylistPicker />
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.np-enter-active,
|
||||
.np-leave-active {
|
||||
transition: opacity 360ms var(--ease), transform 420ms cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||
}
|
||||
|
||||
.np-enter-from,
|
||||
.np-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
|
||||
@@ -42,6 +42,7 @@ const paths = {
|
||||
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"/>',
|
||||
lyric: '<path d="M5 5.5h14v10h-8.5L6.5 19v-3.5H5z"/><path d="M9 9.5h6M9 12.5h3.5"/>',
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,975 @@
|
||||
<script setup lang="ts">
|
||||
// 全屏「正在播放」:参考 Apple Music tvOS —— 封面取色模糊铺底、大号逐行歌词,静置后隐藏控件
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { coverThumb, formatSeconds } from '../lib/format.ts';
|
||||
import {
|
||||
cycleMode,
|
||||
lyric,
|
||||
lyricIndex,
|
||||
MODE_LABEL,
|
||||
next,
|
||||
player,
|
||||
playAt,
|
||||
prev,
|
||||
queue,
|
||||
seek,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggle,
|
||||
toggleMute,
|
||||
VIA_LABEL,
|
||||
} from '../lib/player.ts';
|
||||
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const cur = computed(() => player.current!);
|
||||
const art = computed(() => coverThumb(cur.value.cover, 800));
|
||||
const backdrop = computed(() => coverThumb(cur.value.cover, 300));
|
||||
const artFailed = ref(false);
|
||||
watch(art, () => (artFailed.value = false));
|
||||
|
||||
const modeIcon = computed(() => ({ list: 'order', loop: 'repeat', one: 'repeatOne', shuffle: 'shuffle' })[player.mode]);
|
||||
const viaNote = computed(() => {
|
||||
const c = cur.value;
|
||||
if (player.via === 'local') return c.source ? '曲库' : '本地';
|
||||
if (c.source && player.via !== c.source) return `${VIA_LABEL[player.via]} · 同名兜底`;
|
||||
return VIA_LABEL[player.via];
|
||||
});
|
||||
|
||||
/** 右侧面板:歌词 / 接下来;没有歌词时默认只显示封面 */
|
||||
const pane = ref<'lyric' | 'queue' | null>('lyric');
|
||||
const showLyric = computed(() => pane.value === 'lyric' && lyric.value.length > 0);
|
||||
const solo = computed(() => !showLyric.value && pane.value !== 'queue');
|
||||
|
||||
function close() {
|
||||
player.full = false;
|
||||
}
|
||||
|
||||
function setPane(p: 'lyric' | 'queue') {
|
||||
pane.value = pane.value === p ? null : p;
|
||||
}
|
||||
|
||||
// ---------- 静置隐藏控件 ----------
|
||||
|
||||
const idle = ref(false);
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function wake() {
|
||||
idle.value = false;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => (idle.value = player.playing && !dragging.value), 3500);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => player.playing,
|
||||
(on) => (on ? wake() : ((idle.value = false), clearTimeout(idleTimer))),
|
||||
);
|
||||
|
||||
// ---------- 进度条(可拖动) ----------
|
||||
|
||||
const dragging = ref(false);
|
||||
const dragRatio = ref(0);
|
||||
const shownRatio = computed(() => (dragging.value ? dragRatio.value : player.duration ? Math.min(1, player.time / player.duration) : 0));
|
||||
const shownTime = computed(() => (dragging.value ? dragRatio.value * player.duration : player.time));
|
||||
|
||||
function ratioAt(e: PointerEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
if (!player.duration) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragging.value = true;
|
||||
dragRatio.value = ratioAt(e);
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (dragging.value) dragRatio.value = ratioAt(e);
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (!dragging.value) return;
|
||||
dragging.value = false;
|
||||
seek(dragRatio.value);
|
||||
wake();
|
||||
}
|
||||
|
||||
// ---------- 歌词滚动:当前行停在视口 32% 处,各行按距离依次跟上 ----------
|
||||
|
||||
const viewport = ref<HTMLElement | null>(null);
|
||||
/** 下标与歌词行一一对应(模板 ref 数组不保证顺序) */
|
||||
const lineEls: Array<HTMLElement | null> = [];
|
||||
const offset = ref(0);
|
||||
/** 用户滚轮浏览时的额外偏移,静止 2.5 秒后回到当前行 */
|
||||
const manual = ref(0);
|
||||
let manualTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function align() {
|
||||
const vp = viewport.value;
|
||||
if (!vp) return;
|
||||
const el = lineEls[Math.max(0, lyricIndex.value)];
|
||||
offset.value = el ? el.offsetTop + el.offsetHeight / 2 - vp.clientHeight * 0.32 : 0;
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
manual.value += e.deltaY;
|
||||
clearTimeout(manualTimer);
|
||||
manualTimer = setTimeout(() => (manual.value = 0), 2500);
|
||||
wake();
|
||||
}
|
||||
|
||||
watch(lyricIndex, () => manual.value === 0 && nextTick(align));
|
||||
watch([lyric, showLyric], () => nextTick(align));
|
||||
|
||||
/** 距当前行越远越淡、越模糊;手动浏览时全部清晰 */
|
||||
function lineStyle(i: number) {
|
||||
const d = Math.abs(i - lyricIndex.value);
|
||||
const ahead = Math.max(0, i - lyricIndex.value);
|
||||
return {
|
||||
transitionDelay: `${Math.min(ahead, 8) * 40}ms`,
|
||||
filter: manual.value || i === lyricIndex.value ? 'none' : `blur(${Math.min(d, 4) * 0.7}px)`,
|
||||
};
|
||||
}
|
||||
|
||||
const intro = computed(() => lyric.value.length > 0 && lyricIndex.value < 0 && (lyric.value[0]?.t ?? 0) > 4);
|
||||
|
||||
// ---------- 接下来 ----------
|
||||
|
||||
const queueEl = ref<HTMLElement | null>(null);
|
||||
watch(
|
||||
() => [pane.value, player.index] as const,
|
||||
() =>
|
||||
pane.value === 'queue' &&
|
||||
nextTick(() => {
|
||||
const el = queueEl.value?.querySelector<HTMLElement>('.cur');
|
||||
el && queueEl.value?.scrollTo({ top: el.offsetTop - 12, behavior: 'smooth' });
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------- 键盘 ----------
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || (e.target as HTMLElement).closest?.('input, textarea, select')) return;
|
||||
wake();
|
||||
if (e.key === 'Escape') close();
|
||||
else if (e.key === 'ArrowLeft') seekTo(player.time - 5);
|
||||
else if (e.key === 'ArrowRight') seekTo(player.time + 5);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('resize', align);
|
||||
wake();
|
||||
nextTick(align);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', align);
|
||||
clearTimeout(idleTimer);
|
||||
clearTimeout(manualTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="np" :class="{ idle, solo, paused: !player.playing, 'has-pane': !solo }" role="dialog" aria-label="正在播放" @pointermove="wake" @pointerdown="wake">
|
||||
<div class="bg" aria-hidden="true">
|
||||
<Transition name="bg">
|
||||
<div :key="backdrop ?? 'none'" class="bg-art">
|
||||
<template v-if="backdrop">
|
||||
<img class="b1" :src="backdrop" alt="" referrerpolicy="no-referrer" />
|
||||
<img class="b2" :src="backdrop" alt="" referrerpolicy="no-referrer" />
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
<i class="veil" />
|
||||
</div>
|
||||
|
||||
<header class="top chrome">
|
||||
<button class="ib" type="button" aria-label="收起" title="收起 (Esc)" @click="close"><Icon name="chevron" :size="22" class="down" /></button>
|
||||
<span class="eyebrow">Now Playing</span>
|
||||
<div class="top-r">
|
||||
<button class="ib" :class="{ on: pane === 'lyric' }" type="button" title="歌词" :disabled="!lyric.length" @click="setPane('lyric')"><Icon name="lyric" :size="19" /></button>
|
||||
<button class="ib" :class="{ on: pane === 'queue' }" type="button" title="接下来" @click="setPane('queue')"><Icon name="queue" :size="19" /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<section class="hero">
|
||||
<div class="art" :class="{ blank: !art || artFailed }">
|
||||
<Transition name="art" mode="out-in">
|
||||
<img v-if="art && !artFailed" :key="art" :src="art" alt="" referrerpolicy="no-referrer" @error="artFailed = true" />
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="info">
|
||||
<h1 class="title">{{ cur.title }}</h1>
|
||||
<p class="artists">{{ cur.artists || '未知歌手' }}</p>
|
||||
<p class="sub">
|
||||
<RouterLink v-if="cur.source && cur.albumId && cur.album" :to="`/album/${cur.source}/${cur.albumId}`" class="album" @click="close">{{ cur.album }}</RouterLink>
|
||||
<span v-else-if="cur.album" class="album">{{ cur.album }}</span>
|
||||
<span class="via" :class="player.via"><i class="dot" />{{ viaNote }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Transition name="pane" mode="out-in">
|
||||
<div v-if="showLyric" key="lyric" ref="viewport" class="lyrics" @wheel.passive="onWheel">
|
||||
<div class="lines" :style="{ transform: `translateY(${-(offset + manual)}px)` }">
|
||||
<p class="dots" :class="{ on: intro }" aria-hidden="true"><i /><i /><i /></p>
|
||||
<p
|
||||
v-for="(l, i) in lyric"
|
||||
:key="i"
|
||||
:ref="(el) => (lineEls[i] = el as HTMLElement | null)"
|
||||
class="line"
|
||||
:class="{ on: i === lyricIndex, past: i < lyricIndex }"
|
||||
:style="lineStyle(i)"
|
||||
@click="seekTo(l.t)"
|
||||
>
|
||||
<span class="txt">{{ l.text }}</span>
|
||||
<span v-if="l.trans" class="trans">{{ l.trans }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pane === 'queue'" key="queue" class="upnext">
|
||||
<h2 class="eyebrow">接下来 · {{ queue.length }} 首</h2>
|
||||
<ol ref="queueEl" class="qlist">
|
||||
<li v-for="(item, i) in queue" :key="`${item.key}-${i}`" class="q" :class="{ cur: i === player.index }" @click="playAt(i)">
|
||||
<img v-if="item.cover" class="q-art" :src="coverThumb(item.cover, 96)" alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
<span v-else class="q-art blank" />
|
||||
<span class="q-text">
|
||||
<span class="q-name">{{ item.title }}</span>
|
||||
<span class="q-sub">{{ item.artists || '未知歌手' }}</span>
|
||||
</span>
|
||||
<span v-if="i === player.index && player.playing" class="eq" aria-label="正在播放"><i /><i /><i /></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<footer class="transport chrome">
|
||||
<div class="bar" @pointerdown="onDown" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp">
|
||||
<div class="rail"><div class="fill" :style="{ width: `${shownRatio * 100}%` }" /></div>
|
||||
</div>
|
||||
<div class="times mono">
|
||||
<span>{{ formatSeconds(shownTime) }}</span>
|
||||
<span>-{{ formatSeconds(Math.max(0, player.duration - shownTime)) }}</span>
|
||||
</div>
|
||||
<div class="ctrls">
|
||||
<div class="side l">
|
||||
<button class="ib" type="button" :title="MODE_LABEL[player.mode]" @click="cycleMode"><Icon :name="modeIcon" :size="19" /></button>
|
||||
<button class="ib" type="button" title="加入歌单" @click="pickPlaylist([inputFromPlayItem(cur)])"><Icon name="listPlus" :size="19" /></button>
|
||||
</div>
|
||||
<div class="main-ctl">
|
||||
<button class="ib lg" type="button" aria-label="上一首" @click="prev"><Icon name="prev" :size="26" /></button>
|
||||
<button class="pp" :class="{ busy: player.loading }" type="button" :aria-label="player.playing ? '暂停' : '播放'" @click="toggle">
|
||||
<Icon :name="player.playing ? 'pause' : 'play'" :size="30" />
|
||||
</button>
|
||||
<button class="ib lg" type="button" aria-label="下一首" :disabled="queue.length < 2" @click="next"><Icon name="next" :size="26" /></button>
|
||||
</div>
|
||||
<div class="side r">
|
||||
<button class="ib" type="button" :title="player.muted ? '取消静音' : '静音'" @click="toggleMute">
|
||||
<Icon :name="player.muted || !player.volume ? 'mute' : 'volume'" :size="18" />
|
||||
</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))" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.np {
|
||||
--fg: var(--ink);
|
||||
--fg-dim: color-mix(in oklab, var(--ink) 38%, transparent);
|
||||
--fg-faint: color-mix(in oklab, var(--ink) 20%, transparent);
|
||||
--glass: color-mix(in oklab, var(--ink) 9%, transparent);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.np.idle {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* ---------- 背景:两层模糊封面反向缓慢旋转,再盖一层纸 / 墨 ---------- */
|
||||
.bg,
|
||||
.bg-art,
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.bg {
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(120% 90% at 20% 30%, var(--accent-soft), transparent 70%);
|
||||
}
|
||||
|
||||
.bg img {
|
||||
position: absolute;
|
||||
width: 90vmax;
|
||||
height: 90vmax;
|
||||
object-fit: cover;
|
||||
filter: blur(70px) saturate(1.5);
|
||||
opacity: 0.9;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.b1 {
|
||||
left: -20vmax;
|
||||
top: -25vmax;
|
||||
animation: drift 48s linear infinite;
|
||||
}
|
||||
|
||||
.b2 {
|
||||
right: -25vmax;
|
||||
bottom: -30vmax;
|
||||
animation: drift 62s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
from {
|
||||
transform: rotate(0deg) scale(1.1);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1.3);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.veil {
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 55%, color-mix(in oklab, var(--bg) 55%, transparent)),
|
||||
color-mix(in oklab, var(--bg) 52%, transparent);
|
||||
}
|
||||
|
||||
.np > :not(.bg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---------- 顶栏 / 控件 ---------- */
|
||||
.chrome {
|
||||
transition: opacity 600ms var(--ease);
|
||||
}
|
||||
|
||||
.idle .chrome {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 18px clamp(16px, 4vw, 56px) 0;
|
||||
}
|
||||
|
||||
.top .eyebrow {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.top-r {
|
||||
display: flex;
|
||||
justify-self: end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ib {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--fg-dim);
|
||||
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.ib:hover:not(:disabled) {
|
||||
color: var(--fg);
|
||||
background: var(--glass);
|
||||
}
|
||||
|
||||
.ib:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ib.on {
|
||||
color: var(--fg);
|
||||
background: var(--glass);
|
||||
}
|
||||
|
||||
.down {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ---------- 主体 ---------- */
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
align-items: center;
|
||||
gap: clamp(24px, 6vw, 96px);
|
||||
min-height: 0;
|
||||
padding: 12px clamp(16px, 6vw, 112px);
|
||||
}
|
||||
|
||||
.solo .stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.solo .hero {
|
||||
justify-self: center;
|
||||
max-width: min(520px, 58vh);
|
||||
}
|
||||
|
||||
.art {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 30px 60px -24px color-mix(in oklab, #000 55%, transparent),
|
||||
0 0 0 1px var(--glass);
|
||||
transition: transform 700ms cubic-bezier(0.3, 1.4, 0.4, 1);
|
||||
}
|
||||
|
||||
.paused .art {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.art img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.art.blank {
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, var(--accent) 0 9%, var(--bg) 9.5% 10.5%, transparent 11%),
|
||||
repeating-radial-gradient(circle, var(--ink) 0 1.6px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.6px 3px);
|
||||
}
|
||||
|
||||
.info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font: 600 clamp(22px, 2.3vw, 34px) / 1.2 var(--font-serif);
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artists {
|
||||
margin: 6px 0 0;
|
||||
font-size: clamp(15px, 1.4vw, 19px);
|
||||
color: color-mix(in oklab, var(--ink) 70%, transparent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.album {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.album:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.via {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--glass);
|
||||
font: 500 11px/18px var(--font-mono);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.via.netease .dot {
|
||||
background: var(--netease);
|
||||
}
|
||||
|
||||
.via.qq .dot {
|
||||
background: var(--qq);
|
||||
}
|
||||
|
||||
/* ---------- 歌词 ---------- */
|
||||
.lyrics {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(transparent, #000 12%, #000 78%, transparent);
|
||||
}
|
||||
|
||||
.lines {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line,
|
||||
.dots {
|
||||
margin: 0;
|
||||
padding: 14px 0;
|
||||
transition:
|
||||
transform 760ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||
color 400ms var(--ease),
|
||||
filter 400ms var(--ease);
|
||||
}
|
||||
|
||||
.line {
|
||||
cursor: pointer;
|
||||
color: var(--fg-dim);
|
||||
transform-origin: left center;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.txt {
|
||||
display: block;
|
||||
font: 700 clamp(24px, 2.9vw, 44px) / 1.28 var(--font-serif);
|
||||
letter-spacing: 0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.trans {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font: 500 clamp(14px, 1.3vw, 19px) / 1.45 var(--font-sans);
|
||||
}
|
||||
|
||||
.line.on {
|
||||
color: var(--fg);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.line.on .trans {
|
||||
color: color-mix(in oklab, var(--ink) 66%, transparent);
|
||||
}
|
||||
|
||||
.line:hover {
|
||||
color: color-mix(in oklab, var(--ink) 70%, transparent);
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dots.on {
|
||||
height: auto;
|
||||
padding: 22px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dots i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg);
|
||||
animation: breathe-dot 1.8s var(--ease) infinite;
|
||||
}
|
||||
|
||||
.dots i:nth-child(2) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.dots i:nth-child(3) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes breathe-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 接下来 ---------- */
|
||||
.upnext {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
padding-top: 4vh;
|
||||
}
|
||||
|
||||
.upnext .eyebrow {
|
||||
margin: 0 0 12px 10px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.qlist {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0 0 6vh;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(#000 85%, transparent);
|
||||
}
|
||||
|
||||
.q {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 64px;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.q:hover,
|
||||
.q.cur {
|
||||
background: var(--glass);
|
||||
}
|
||||
|
||||
.q-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.q-art.blank {
|
||||
background: var(--glass);
|
||||
}
|
||||
|
||||
.q-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.q-name,
|
||||
.q-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.q-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.q.cur .q-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.q-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.eq {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.eq i {
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
transform-origin: bottom;
|
||||
animation: eq 900ms var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 底部播放控制 ---------- */
|
||||
.transport {
|
||||
padding: 0 clamp(16px, 6vw, 112px) clamp(18px, 3.5vh, 36px);
|
||||
}
|
||||
|
||||
.bar {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--fg-faint);
|
||||
overflow: hidden;
|
||||
transition: height var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.bar:hover .rail {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--fg);
|
||||
}
|
||||
|
||||
.bar:hover .fill {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: -2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.ctrls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.side.r {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.side input {
|
||||
width: 110px;
|
||||
accent-color: var(--ink);
|
||||
}
|
||||
|
||||
.main-ctl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ib.lg {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.pp {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 10px 30px -12px color-mix(in oklab, #000 60%, transparent);
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.pp:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pp.busy {
|
||||
animation: breathe 1.2s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 过渡 ---------- */
|
||||
.bg-enter-active,
|
||||
.bg-leave-active {
|
||||
transition: opacity 1.2s var(--ease);
|
||||
}
|
||||
|
||||
.bg-enter-from,
|
||||
.bg-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.art-enter-active,
|
||||
.art-leave-active {
|
||||
transition: opacity 320ms var(--ease);
|
||||
}
|
||||
|
||||
.art-enter-from,
|
||||
.art-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pane-enter-active,
|
||||
.pane-leave-active {
|
||||
transition: opacity 260ms var(--ease), transform 260ms var(--ease);
|
||||
}
|
||||
|
||||
.pane-enter-from,
|
||||
.pane-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg img,
|
||||
.dots i,
|
||||
.eq i {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.line,
|
||||
.art {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 窄屏:封面与面板二选一 ---------- */
|
||||
@media (max-width: 760px) {
|
||||
.stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
.solo .stage {
|
||||
grid-template-rows: auto;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.hero {
|
||||
justify-self: stretch;
|
||||
max-width: none;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.has-pane .hero {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.has-pane .art {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.has-pane .title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.has-pane .artists {
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.has-pane .sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.txt {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.side input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-ctl {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pp {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { formatSeconds } from '../lib/format.ts';
|
||||
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute } from '../lib/player.ts';
|
||||
import { currentLyric, cycleMode, MODE_LABEL, next, player, prev, queue, seek, setVolume, stop, toggle, toggleMute, VIA_LABEL } from '../lib/player.ts';
|
||||
import { inputFromPlayItem, pickPlaylist } from '../lib/playlists.ts';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Icon from './Icon.vue';
|
||||
@@ -16,6 +16,20 @@ function onSeek(e: MouseEvent) {
|
||||
seek((e.clientX - rect.left) / rect.width);
|
||||
}
|
||||
|
||||
/** 正在播放的不是曲目本来的平台:曲库文件,或另一个平台的同名歌曲 */
|
||||
const viaTag = computed(() => {
|
||||
const c = player.current;
|
||||
if (!c?.source || player.via === c.source) return null;
|
||||
return player.via === 'local'
|
||||
? { text: '曲库', title: '曲库里有这首歌,正在播放本地文件' }
|
||||
: { text: VIA_LABEL[player.via], title: `原平台没有音源,正在播放${VIA_LABEL[player.via]}的同名歌曲` };
|
||||
});
|
||||
|
||||
function openFull() {
|
||||
player.panel = null;
|
||||
player.full = true;
|
||||
}
|
||||
|
||||
function openPanel(tab: 'queue' | 'lyric') {
|
||||
player.panel = player.panel === tab ? null : tab;
|
||||
}
|
||||
@@ -33,7 +47,7 @@ function addCurrent() {
|
||||
</Transition>
|
||||
<div class="player">
|
||||
<i class="mini-progress" :style="{ width: `${pct}%` }" />
|
||||
<button class="now" type="button" title="歌词" @click="openPanel('lyric')">
|
||||
<button class="now" type="button" title="正在播放" @click="openFull">
|
||||
<span class="disc" :class="{ spin: player.playing }">
|
||||
<CoverArt :src="player.current.cover" :size="46" round />
|
||||
<i class="hole" />
|
||||
@@ -41,7 +55,7 @@ function addCurrent() {
|
||||
<span class="meta">
|
||||
<span class="t">
|
||||
{{ player.current.title }}
|
||||
<span v-if="player.current.local && player.current.source" class="tag" title="曲库里有这首歌,正在播放本地文件">曲库</span>
|
||||
<span v-if="viaTag" class="tag" :title="viaTag.title">{{ viaTag.text }}</span>
|
||||
</span>
|
||||
<span class="a muted">
|
||||
<Transition name="fade" mode="out-in">
|
||||
|
||||
@@ -368,6 +368,9 @@ export const api = {
|
||||
artist: (source: Source, id: string) => request<ArtistDetail>(`/${source}/artist/${encodeURIComponent(id)}`),
|
||||
lyric: (source: Source, id: string) => request<{ lrc?: string; tlyric?: string }>(`/${source}/lyric/${encodeURIComponent(id)}`),
|
||||
streamUrl: (source: Source, id: string) => `/api/${source}/stream/${encodeURIComponent(id)}`,
|
||||
/** 到另一个平台找同一首歌播放;source 是原曲目的平台 */
|
||||
altStreamUrl: (source: Source, t: { name: string; artists: string; duration?: number }) =>
|
||||
`/api/${source}/stream-alt?${qs({ name: t.name, artists: t.artists, duration: t.duration || undefined })}`,
|
||||
|
||||
tasks: (scope: 'active' | 'finished' | 'all' = 'all') =>
|
||||
request<{ tasks: Task[]; progress: TaskProgress[]; summary: QueueSummary }>(`/tasks?scope=${scope}`),
|
||||
|
||||
+52
-5
@@ -23,8 +23,15 @@ export interface PlayItem {
|
||||
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'];
|
||||
@@ -48,6 +55,10 @@ export const player = reactive({
|
||||
panel: null as null | 'queue' | 'lyric',
|
||||
/** 当前队列的来源,如 playlist:3,用于在来源页面上显示播放状态 */
|
||||
context: '',
|
||||
/** 当前实际播放的音源(兜底后可能与曲目来源不同) */
|
||||
via: 'local' as Via,
|
||||
/** 全屏「正在播放」 */
|
||||
full: false,
|
||||
});
|
||||
|
||||
/** 播放队列可能有上千首:用 shallowRef,整体替换触发更新 */
|
||||
@@ -60,6 +71,10 @@ let attached = false;
|
||||
/** 恢复上次进度:元数据加载后跳转 */
|
||||
let resumeAt = 0;
|
||||
let errorStreak = 0;
|
||||
/** 当前曲目已用掉的兜底音源数 */
|
||||
let attempt = 0;
|
||||
/** 本首已失败的音源,换源后真正出声时合并成一条提示 */
|
||||
let failedVias: Via[] = [];
|
||||
let prefetched = '';
|
||||
/** 播放顺序 → 队列下标;随机模式下是洗牌后的顺序 */
|
||||
let order: number[] = [];
|
||||
@@ -128,8 +143,13 @@ function loadLyric(item: PlayItem) {
|
||||
|
||||
// ---------- 曲目转换 ----------
|
||||
|
||||
/** 曲库优先(默认):曲库文件 → 原平台 → 另一个平台;关闭时原平台排在曲库文件前 */
|
||||
export function fromTrack(t: TrackSummary): PlayItem {
|
||||
const local = settings.value?.preferLocal !== false ? t.localFile : undefined;
|
||||
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,
|
||||
@@ -140,8 +160,9 @@ export function fromTrack(t: TrackSummary): PlayItem {
|
||||
id: t.id,
|
||||
albumId: t.albumId,
|
||||
duration: t.duration,
|
||||
src: local ? api.libraryFileUrl(local) : api.streamUrl(t.source, t.id),
|
||||
local: !!local,
|
||||
src: first!.src,
|
||||
local: first!.via === 'local',
|
||||
alt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +227,7 @@ function restore() {
|
||||
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;
|
||||
@@ -288,6 +310,12 @@ function ensureAudio(): HTMLAudioElement {
|
||||
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);
|
||||
@@ -297,11 +325,26 @@ function ensureAudio(): HTMLAudioElement {
|
||||
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;
|
||||
const item = player.current;
|
||||
if (!item || !attached) return;
|
||||
errorStreak++;
|
||||
// 连续失败时自动跳过,但不无限循环
|
||||
if (queue.value.length > 1 && errorStreak < Math.min(queue.value.length, 5)) {
|
||||
@@ -330,6 +373,9 @@ function load(i: number, autoplay = true, keepResume = false) {
|
||||
const a = ensureAudio();
|
||||
if (!keepResume) resumeAt = 0;
|
||||
prefetched = '';
|
||||
attempt = 0;
|
||||
failedVias = [];
|
||||
player.via = primaryVia(item);
|
||||
player.index = i;
|
||||
player.current = item;
|
||||
player.time = resumeAt;
|
||||
@@ -527,6 +573,7 @@ export function stop() {
|
||||
player.playing = false;
|
||||
player.loading = false;
|
||||
player.panel = null;
|
||||
player.full = false;
|
||||
player.context = '';
|
||||
lyric.value = [];
|
||||
if (media) media.metadata = null;
|
||||
|
||||
Reference in New Issue
Block a user