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

495 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>