feat(player): 添加音乐播放器页面及核心功能实现

实现音乐播放器页面,包含以下核心功能:
1. 响应式唱片旋转动画和播放控制
2. 歌词同步显示和进度条拖动
3. 自动播放处理及移动端适配优化
4. 粒子效果和视觉优化
5. 元数据动态更新和错误处理
This commit is contained in:
DESKTOP-RQ919RC\Pc
2025-12-09 15:19:48 +08:00
parent c057fb2f1d
commit f692fbcc05

495
player.html Normal file
View File

@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- 优化 viewport禁止缩放适配刘海屏 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>道口艺金 - 音乐播放器</title>
<meta property="og:type" content="music.song" />
<meta property="og:url" content="" id="ogUrl" />
<meta property="og:title" content="" id="ogTitle" />
<meta property="og:description" content="" id="ogDesc" />
<meta property="og:image" content="" id="ogImage" />
<style>
:root {
--gold: #d4af37;
--gold-light: #f3e5ab;
--bg-dark: #050505;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, "PingFang SC", sans-serif;
background-color: var(--bg-dark);
color: #fff;
/* 核心修复:使用 dvh 适配移动端地址栏fallback 到 vh */
height: 100vh;
height: 100dvh;
width: 100vw;
overflow: hidden; /* 保持单页应用风格 */
display: flex;
flex-direction: column;
user-select: none;
}
/* --- 背景 --- */
.backdrop {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
}
.backdrop-img {
width: 100%; height: 100%;
background-size: cover; background-position: center;
filter: blur(35px) brightness(0.4); /* 性能平衡点 */
transform: scale(1.1);
will-change: transform;
}
.particle-system {
position: absolute; inset: 0; z-index: 1;
}
.particle {
position: absolute;
background: radial-gradient(circle, var(--gold-light) 0%, transparent 70%);
border-radius: 50%; opacity: 0;
animation: float-up linear forwards;
}
/* --- 核心布局修复:舞台区 --- */
.stage {
/* 关键min-height: 0 允许 flex 项目压缩到比内容更小,防止顶出底部按钮 */
flex: 1;
min-height: 0;
position: relative;
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 20px 0; /* 给上下留点呼吸空间 */
}
/* 唱片容器:极度响应式 */
.turntable-wrapper {
/* 逻辑尽量大但不超过宽度的80%不超过高度的70%(留给歌词和按钮) */
width: min(320px, 80vw);
height: min(320px, 80vw);
/* 如果高度太小,强制缩小唱片 */
max-height: 50vh;
aspect-ratio: 1/1;
position: relative;
display: flex; justify-content: center; align-items: center;
}
.wave-ring {
position: absolute; width: 100%; height: 100%; border-radius: 50%;
border: 2px solid rgba(212, 175, 55, 0.3); opacity: 0; z-index: -1;
}
.is-playing .wave-ring { animation: ripple 2.5s infinite linear; }
.is-playing .wave-ring:nth-child(2) { animation-delay: 0.8s; }
.is-playing .wave-ring:nth-child(3) { animation-delay: 1.6s; }
.disc-group {
width: 96%; height: 96%; border-radius: 50%;
position: relative; z-index: 2;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.is-playing .disc-group { animation: shadow-breath 2s infinite alternate; }
.disc-rotate {
width: 100%; height: 100%; border-radius: 50%;
position: relative;
animation: rotate-vinyl 8s linear infinite;
animation-play-state: paused;
will-change: transform;
}
.vinyl-texture {
position: absolute; inset: 0; border-radius: 50%;
background: repeating-radial-gradient(#111 0, #111 2px, #222 3px, #151515 4px);
border: 2px solid rgba(255,255,255,0.05);
}
.album-art {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 65%; height: 65%; border-radius: 50%;
background-color: #333; background-size: cover; background-position: center;
border: 3px solid #0e0e0e;
}
.arm-wrapper {
position: absolute; top: -15%; right: -10%;
width: 40%; height: 60%; z-index: 20;
transform-origin: 55% 12%; transform: rotate(-35deg);
transition: transform 0.6s cubic-bezier(0.5, 0.1, 0.3, 1.2);
filter: drop-shadow(3px 5px 5px rgba(0,0,0,0.5)); pointer-events: none;
}
.is-playing .disc-rotate { animation-play-state: running; }
.is-playing .arm-wrapper { transform: rotate(20deg); }
/* --- 底部控制区 (Fixed priority) --- */
.control-deck {
position: relative; z-index: 20;
/* 增加底部 padding 以适配 iPhone Home 条 */
padding: 0 25px max(20px, env(safe-area-inset-bottom));
background: linear-gradient(to top, rgba(0,0,0,0.95) 30%, transparent);
text-align: center;
flex-shrink: 0; /* 禁止被压缩,确保按钮永远可见 */
display: flex; flex-direction: column;
}
.track-title {
font-size: 20px; font-weight: 700; color: #fff; margin-bottom: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.track-artist {
font-size: 13px; color: var(--gold); margin-bottom: 10px;
min-height: 18px;
}
/* 歌词区 */
.lrc-container {
height: 60px; /* 进一步减小高度 */
margin-bottom: 15px;
overflow: hidden; position: relative;
mask-image: linear-gradient(to bottom, transparent 0%, #000 25%, #000 75%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 25%, #000 75%, transparent 100%);
}
.lrc-list {
transition: transform 0.3s ease-out;
transform: translateY(22px); will-change: transform;
}
.lrc-line {
height: 24px; line-height: 24px; font-size: 14px;
color: rgba(255,255,255,0.4); text-align: center;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding: 0 5px;
}
.lrc-line.active { color: #fff; font-size: 15px; font-weight: bold; }
/* 进度条 */
.progress-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: #999; margin-bottom: 20px; font-family: monospace;
}
.progress-hitbox {
flex: 1; height: 30px; margin: 0 10px;
display: flex; align-items: center; cursor: pointer;
-webkit-user-select: none; /* 防止长按选中 */
}
.bar-bg {
width: 100%; height: 3px; background: rgba(255,255,255,0.2);
border-radius: 4px; position: relative;
}
.bar-fg {
width: 0%; height: 100%; background: var(--gold);
border-radius: 4px; position: relative;
}
.bar-thumb {
position: absolute; right: -6px; top: 50%; margin-top: -6px;
width: 12px; height: 12px; background: #fff; border-radius: 50%;
box-shadow: 0 0 5px rgba(0,0,0,0.5); transform: scale(0); transition: transform 0.1s;
}
.progress-hitbox:active .bar-thumb, .is-playing .bar-thumb { transform: scale(1); }
/* 播放按钮 (小尺寸) */
.play-btn {
width: 60px; height: 60px; margin: 0 auto;
border-radius: 50%;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
/* 解决点击响应慢:移除 scale 动画让反馈更直接,或加速 */
transition: transform 0.05s;
-webkit-tap-highlight-color: transparent;
}
.play-btn:active { transform: scale(0.92); background: rgba(255,255,255,0.15); }
.is-playing .play-btn { border-color: var(--gold); }
@keyframes rotate-vinyl { to { transform: rotate(360deg); } }
@keyframes ripple { 0% { transform: scale(1); opacity: 0.6; border-width: 4px; } 100% { transform: scale(1.6); opacity: 0; border-width: 0px; } }
@keyframes shadow-breath { from { box-shadow: 0 10px 30px rgba(212, 175, 55, 0.1); } to { box-shadow: 0 10px 40px rgba(212, 175, 55, 0.2); } }
@keyframes float-up { 0% { transform: translateY(100%) scale(0); opacity: 0; } 50% { opacity: 0.8; } 100% { transform: translateY(-120%) scale(1.5); opacity: 0; } }
#loading { position: fixed; inset: 0; background: #000; z-index: 999; display: flex; justify-content: center; align-items: center; color: var(--gold); }
</style>
</head>
<body>
<div id="loading">LOADING...</div>
<div class="backdrop">
<div class="backdrop-img" id="bgImg"></div>
<div class="particle-system" id="particles"></div>
</div>
<div class="stage" id="stage">
<div class="turntable-wrapper">
<div class="wave-ring"></div><div class="wave-ring"></div><div class="wave-ring"></div>
<div class="arm-wrapper">
<svg width="100%" height="100%" viewBox="0 0 120 200" fill="none">
<circle cx="60" cy="20" r="18" fill="#d0d0d0" stroke="#666" stroke-width="2"/>
<circle cx="60" cy="20" r="5" fill="#222"/>
<path d="M 60 20 Q 55 60 85 110 T 95 170" stroke="#e0e0e0" stroke-width="7" stroke-linecap="round"/>
<g transform="translate(88, 162) rotate(25)">
<rect x="-6" y="0" width="16" height="26" rx="2" fill="#fff" stroke="#aaa"/>
<rect x="0" y="26" width="4" height="4" fill="#333"/>
</g>
</svg>
</div>
<div class="disc-group">
<div class="vinyl-sheen"></div>
<div class="disc-rotate">
<div class="vinyl-texture"></div>
<div class="album-art" id="coverImg"></div>
</div>
</div>
</div>
</div>
<div class="control-deck">
<div class="track-title" id="songTitle"></div>
<div class="track-artist" id="descInfo"></div>
<div class="lrc-container">
<div class="lrc-list" id="lrcList"></div>
</div>
<div class="progress-row">
<span id="currTime">00:00</span>
<div class="progress-hitbox" id="progBox">
<div class="bar-bg">
<div class="bar-fg" id="barFg">
<div class="bar-thumb"></div>
</div>
</div>
</div>
<span id="totalTime">00:00</span>
</div>
<!-- 播放按钮 -->
<div class="play-btn" id="playBtn">
<svg id="iconPlay" width="30" height="30" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
<svg id="iconPause" width="30" height="30" viewBox="0 0 24 24" fill="#d4af37" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</div>
</div>
<audio id="audio" playsinline></audio>
<script>
const els = {
stage: document.getElementById('stage'),
bg: document.getElementById('bgImg'),
cover: document.getElementById('coverImg'),
title: document.getElementById('songTitle'),
desc: document.getElementById('descInfo'),
playBtn: document.getElementById('playBtn'),
iconPlay: document.getElementById('iconPlay'),
iconPause: document.getElementById('iconPause'),
progBox: document.getElementById('progBox'),
barFg: document.getElementById('barFg'),
currTime: document.getElementById('currTime'),
totalTime: document.getElementById('totalTime'),
audio: document.getElementById('audio'),
loader: document.getElementById('loading'),
particleContainer: document.getElementById('particles'),
lrcList: document.getElementById('lrcList'),
ogUrl: document.getElementById('ogUrl'),
ogTitle: document.getElementById('ogTitle'),
ogDesc: document.getElementById('ogDesc'),
ogImage: document.getElementById('ogImage')
};
let lyrics = [];
let lrcLineHeight = 24;
let particleInterval;
let isDragging = false;
let hasInteracted = false; // 标记用户是否交互过
async function init() {
const id = new URLSearchParams(window.location.search).get('id');
if (!id) return els.loader.innerText = "Missing ID";
try {
const res = await fetch(`https://pujianchaoyin.com/api/getMusicDetail?id=${id}`);
const json = await res.json();
if (json.code === 200 && json.data) {
render(json.data);
setTimeout(() => {
els.loader.style.opacity = '0';
setTimeout(() => els.loader.remove(), 500);
}, 500);
// 初始化尝试播放
attemptAutoPlay();
} else { els.loader.innerText = "Error Data"; }
} catch (e) { console.error(e); els.loader.innerText = "Network Error"; }
}
function render(data) {
const imgUrl = `url('${data.img}')`;
els.bg.style.backgroundImage = imgUrl;
els.cover.style.backgroundImage = imgUrl;
els.title.innerText = data.title || "未知";
els.audio.src = data.playurl;
document.title = data.title;
if (data.desc && data.desc.trim()) {
els.desc.innerText = data.desc;
els.desc.style.display = 'block';
} else { els.desc.style.display = 'none'; }
if (data.lrc) parseLyrics(data.lrc);
else els.lrcList.innerHTML = '<div class="lrc-line active">纯音乐 / 暂无歌词</div>';
// Meta
els.ogUrl.content = window.location.href;
els.ogTitle.content = data.title;
els.ogImage.content = data.img;
els.ogDesc.content = data.desc || "精选音乐";
}
function parseLyrics(lrcText) {
lyrics = [];
const lines = lrcText.split('\n');
const timeReg = /\[(\d{2}):(\d{2})(\.\d*)?\]/;
lines.forEach(line => {
const match = timeReg.exec(line);
if (match) {
const t = parseInt(match[1]) * 60 + parseInt(match[2]) + (match[3] ? parseFloat(match[3]) : 0);
lyrics.push({ time: t, text: line.replace(timeReg, '').trim() });
}
});
if (lyrics.length) els.lrcList.innerHTML = lyrics.map(i => `<div class="lrc-line">${i.text}</div>`).join('');
}
// --- 核心播放控制修复 ---
// 1. 尝试自动播放
function attemptAutoPlay() {
const promise = els.audio.play();
if (promise !== undefined) {
promise.catch(error => {
console.log("Autoplay blocked. Waiting for user interaction.");
// 添加一个全局的一次性点击事件来解锁音频
const unlockAudio = () => {
if (els.audio.paused) els.audio.play();
document.removeEventListener('click', unlockAudio);
document.removeEventListener('touchstart', unlockAudio);
};
document.addEventListener('click', unlockAudio);
document.addEventListener('touchstart', unlockAudio);
});
}
}
// 2. UI状态完全绑定到音频事件而不是点击事件
// 这样可以解决“点两次”的问题确保UI永远真实反映播放状态
els.audio.addEventListener('play', () => {
els.stage.classList.add('is-playing');
els.iconPlay.style.display = 'none';
els.iconPause.style.display = 'block';
startParticles();
});
els.audio.addEventListener('pause', () => {
els.stage.classList.remove('is-playing');
els.iconPlay.style.display = 'block';
els.iconPause.style.display = 'none';
stopParticles();
});
els.audio.addEventListener('ended', () => {
els.barFg.style.width = '0%';
els.lrcList.style.transform = `translateY(22px)`;
syncLyricUI(-1);
});
// 3. 播放按钮点击逻辑简化
els.playBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 防止冒泡触发上面的全局 unlock
if (els.audio.paused) {
els.audio.play();
} else {
els.audio.pause();
}
});
// --- 进度条与歌词 ---
els.audio.addEventListener('timeupdate', () => {
if (!isDragging) updateUI(els.audio.currentTime);
});
function updateUI(ct) {
els.currTime.innerText = formatTime(ct);
if (els.audio.duration) els.barFg.style.width = `${(ct / els.audio.duration) * 100}%`;
if (lyrics.length) {
let idx = lyrics.findIndex(l => l.time > ct) - 1;
if (idx < 0) idx = (ct >= lyrics[0]?.time) ? 0 : -1;
syncLyricUI(idx);
}
}
function syncLyricUI(idx) {
const lines = els.lrcList.children;
for (let i = 0; i < lines.length; i++) {
if (i === idx) lines[i].classList.add('active');
else lines[i].classList.remove('active');
}
if(idx >= 0) els.lrcList.style.transform = `translateY(${-idx * lrcLineHeight + 22}px)`;
}
// --- 拖动逻辑 ---
function handleSeek(e) {
if (!els.audio.duration) return;
const rect = els.progBox.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
let pct = (clientX - rect.left) / rect.width;
pct = Math.max(0, Math.min(1, pct));
const newTime = pct * els.audio.duration;
els.audio.currentTime = newTime;
updateUI(newTime);
}
// 仅在进度条上阻止默认行为,允许页面其他部分正常交互(如果需要)
els.progBox.addEventListener('touchstart', (e) => { isDragging = true; handleSeek(e); }, {passive: false});
els.progBox.addEventListener('touchmove', (e) => {
if (isDragging) { e.preventDefault(); handleSeek(e); }
}, {passive: false});
els.progBox.addEventListener('touchend', () => { isDragging = false; });
els.progBox.addEventListener('mousedown', (e) => {
isDragging = true; handleSeek(e);
const move = (ev) => { if(isDragging) handleSeek(ev); };
const up = () => { isDragging = false; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
});
function formatTime(s) {
if(isNaN(s)) return '00:00';
const m = Math.floor(s/60).toString().padStart(2,'0');
const sec = Math.floor(s%60).toString().padStart(2,'0');
return `${m}:${sec}`;
}
function startParticles() {
if (particleInterval) return;
particleInterval = setInterval(() => {
const p = document.createElement('div');
p.classList.add('particle');
const size = Math.random() * 5 + 2;
p.style.width = p.style.height = `${size}px`;
p.style.left = `${Math.random() * 100}%`;
p.style.bottom = `-20px`;
p.style.animationDuration = `${Math.random() * 3 + 4}s`;
els.particleContainer.appendChild(p);
setTimeout(() => p.remove(), 7000);
}, 500);
}
function stopParticles() { clearInterval(particleInterval); particleInterval = null; }
init();
</script>
</body>
</html>