Files
PC-official/player.html
DESKTOP-RQ919RC\Pc 0a90e17d59 no message
2025-12-10 19:29:23 +08:00

2072 lines
64 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>朴见潮音</title>
<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>
/* --- 1. 核心变量与重置 --- */
:root {
--theme-color: #d4af37;
--text-main: #ffffff;
--text-sub: rgba(255, 255, 255, 0.65);
--glass-bg: rgba(20, 20, 20, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
outline: none;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Roboto, sans-serif;
/* background: #1a82ea; */
color: var(--text-main);
height: 100dvh;
min-height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* --- 2. 氛围背景与粒子特效 --- */
.bg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
background: #1a82ea;
}
.bg-image {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: blur(60px) brightness(0.6);
transform: scale(1.1);
transition: transform 10s ease;
opacity: 0.3;
}
/* 播放时的背景呼吸效果 */
body.is-playing .bg-image {
animation: breathe 20s infinite alternate ease-in-out;
}
@keyframes breathe {
from {
transform: scale(1.1);
}
to {
transform: scale(1.3);
}
}
.bg-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.9));
}
.particles span {
position: absolute;
bottom: -50px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.8), transparent);
box-shadow: 0 0 15px 2px rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: floatUp 15s linear infinite;
opacity: 0;
}
@keyframes floatUp {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
20% {
opacity: 0.4;
}
80% {
opacity: 0.2;
}
100% {
transform: translateY(-100vh) scale(1.5);
opacity: 0;
}
}
/* --- 3. 主布局 --- */
.main-container {
order: 1;
position: relative;
z-index: 10;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10vh;
background: #1a82ea;
}
/* 顶部区域 (歌名) */
.header-info {
order: 2;
text-align: center;
z-index: 25;
opacity: 0.9;
margin-bottom: 8px;
}
.header-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
letter-spacing: 0.5px;
text-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
}
.header-artist {
font-size: 13px;
color: var(--text-sub);
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
backdrop-filter: blur(4px);
}
/* --- 4. 核心视图切换区 --- */
.view-wrapper {
order: 1;
position: relative;
width: 100%;
flex: 1;
display: flex;
justify-content: center;
transition: opacity 0.4s ease;
}
/* A. 唱片模式 */
.disc-mode {
position: absolute;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
transition: opacity 0.5s, transform 0.5s;
}
.disc-mode.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.95);
}
/* 唱臂组件 */
.needle {
position: absolute;
top: -50px;
left: 50%;
width: 80px;
height: 140px;
z-index: 20;
transform-origin: 40px 20px;
/* 修正旋转中心 */
transform: translateX(-10px) rotate(-35deg);
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
filter: drop-shadow(4px 8px 10px rgba(0, 0, 0, 0.5));
/* 增加立体投影 */
}
.needle.playing {
transform: translateX(-10px) rotate(-5deg);
/* 修正角度 */
}
.needle-pivot {
position: absolute;
top: 0;
left: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #e0e0e0, #555);
border: 1px solid #444;
z-index: 3;
}
.needle-pivot::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: #222;
border-radius: 50%;
}
.needle-rod {
position: absolute;
top: 30px;
left: 36px;
width: 8px;
height: 100px;
background: linear-gradient(90deg, #555, #ccc, #555);
z-index: 2;
}
.needle-head {
position: absolute;
bottom: 0;
left: 28px;
width: 24px;
height: 38px;
background: #1a1a1a;
border-radius: 4px;
z-index: 2;
border-top: 2px solid #555;
}
/* 唱片本体容器 */
.disc-container {
width: 55vw;
height: 55vw;
max-width: 300px;
max-height: 300px;
margin-top: 35px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
/* --- 增强特效:动态光波 (Ripple) --- */
.ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
opacity: 0;
z-index: 0;
pointer-events: none;
}
/* 播放时触发 ripple 动画 */
.disc-mode.playing .ripple:nth-child(1) {
animation: rippleAnim 2.5s linear infinite;
animation-delay: 0s;
}
.disc-mode.playing .ripple:nth-child(2) {
animation: rippleAnim 2.5s linear infinite;
animation-delay: 0.8s;
}
.disc-mode.playing .ripple:nth-child(3) {
animation: rippleAnim 2.5s linear infinite;
animation-delay: 1.6s;
}
@keyframes rippleAnim {
0% {
width: 100%;
height: 100%;
opacity: 0.4;
border-width: 4px;
}
100% {
width: 160%;
height: 160%;
opacity: 0;
border-width: 0px;
}
}
/* 唱片主体 */
.disc {
width: 100%;
height: 100%;
border-radius: 50%;
background:
radial-gradient(circle, #111 0%, #1a1a1a 100%);
/* 纹理 */
background-image: repeating-radial-gradient(#111 0, #111 2px, #222 3px, #252525 4px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
animation: rotate 20s linear infinite;
animation-play-state: paused;
position: relative;
z-index: 2;
border: 6px solid #080808;
}
/* 唱片光泽反射层 (Gloss) */
.disc::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.08) 50%, transparent 60%);
z-index: 4;
pointer-events: none;
}
.disc.playing {
animation-play-state: running;
}
.album-cover {
width: 65%;
height: 65%;
border-radius: 50%;
overflow: hidden;
z-index: 3;
border: 4px solid #000;
position: relative;
}
.album-cover img {
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
/* 封面跳动效果 */
.disc.playing .album-cover img {
animation: beat 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite alternate;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes beat {
from {
transform: scale(1);
filter: brightness(1);
}
to {
transform: scale(1.03);
filter: brightness(1.05);
}
}
.hint-text {
margin-top: 40px;
font-size: 12px;
color: var(--text-sub);
background: rgba(0, 0, 0, 0.3);
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 1px;
}
/* B. 歌词模式 */
.lyrics-mode {
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
pointer-events: none;
transform: translateY(30px);
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 50;
}
.lyrics-mode.active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.lyrics-header {
margin-top: 20px;
margin-bottom: 20px;
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
}
.lyrics-scroll {
width: 85%;
height: 70%;
overflow-y: auto;
scroll-behavior: smooth;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
overflow-x: hidden;
}
.lyrics-scroll::-webkit-scrollbar {
width: 4px;
background: transparent;
}
.lyrics-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.lyric-line {
padding: 12px 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.4);
transition: all 0.4s ease;
text-align: center;
line-height: 1.5;
}
.lyric-line.active {
color: var(--theme-color);
font-size: 22px;
font-weight: bold;
text-shadow: 0 0 15px rgba(212, 175, 55, 0.4);
transform: scale(1.05);
}
.mini-lyrics {
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, black 40%, black 60%, rgba(0, 0, 0, 0.2) 100%);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, black 40%, black 60%, rgba(0, 0, 0, 0.2) 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
display: none;
}
.mini-line {
height: 22px;
line-height: 22px;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-line.active {
color: var(--theme-color);
font-size: 18px;
font-weight: 700;
}
.mini-line.dim {
opacity: 0.5;
filter: blur(0.4px);
}
/* --- 5. 底部控制区 (Glassmorphism) --- */
.controls-area {
order: 3;
width: 100%;
padding: 20px 25px 40px;
padding-bottom: calc(40px + env(safe-area-inset-bottom));
padding-bottom: calc(40px + constant(safe-area-inset-bottom));
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid var(--glass-border);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
z-index: 100;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
}
/* 音乐频谱波形 (模拟) */
.music-waves {
display: flex;
justify-content: center;
align-items: flex-end;
height: 16px;
gap: 3px;
margin-bottom: 15px;
opacity: 0.6;
}
.wave-bar {
width: 3px;
background: #fff;
border-radius: 2px;
height: 4px;
transition: height 0.1s;
}
/* 播放时的波形动画 */
body.is-playing .wave-bar {
animation: waveBounce 0.6s infinite ease-in-out alternate;
}
body.is-playing .wave-bar:nth-child(1) {
animation-delay: 0.1s;
animation-duration: 0.5s;
}
body.is-playing .wave-bar:nth-child(2) {
animation-delay: 0.3s;
animation-duration: 0.7s;
}
body.is-playing .wave-bar:nth-child(3) {
animation-delay: 0.0s;
animation-duration: 0.6s;
}
body.is-playing .wave-bar:nth-child(4) {
animation-delay: 0.4s;
animation-duration: 0.5s;
}
body.is-playing .wave-bar:nth-child(5) {
animation-delay: 0.2s;
animation-duration: 0.8s;
}
@keyframes waveBounce {
0% {
height: 4px;
opacity: 0.3;
}
100% {
height: 16px;
opacity: 1;
background: var(--theme-color);
}
}
/* 进度条 */
.progress-container {
display: flex;
align-items: center;
margin-bottom: 25px;
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
font-variant-numeric: tabular-nums;
}
.progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
margin: 0 12px;
position: relative;
cursor: pointer;
overflow: visible;
/* 允许圆点超出 */
}
/* 增加触摸区域 */
.progress-bar::before {
content: '';
position: absolute;
top: -10px;
bottom: -10px;
left: 0;
right: 0;
z-index: 10;
}
.progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #fff, var(--theme-color));
border-radius: 4px;
position: relative;
}
.progress-dot {
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
position: absolute;
right: -6px;
top: -4px;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
transform: scale(0);
transition: transform 0.2s;
}
.progress-bar:active .progress-dot,
.is-playing .progress-dot {
transform: scale(1);
}
/* 按钮组 */
.btn-group {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
}
.btn {
background: none;
border: none;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.btn:active {
transform: scale(0.9);
opacity: 1;
}
.btn-play {
width: 65px;
height: 65px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
opacity: 1;
}
.btn-play svg {
fill: #fff;
}
.btn-side {
width: 40px;
height: 40px;
}
/* Loading */
.loader {
position: fixed;
inset: 0;
background: #000;
z-index: 200;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 0.6s;
}
.loader-text {
margin-top: 15px;
font-size: 12px;
letter-spacing: 3px;
color: rgba(255, 255, 255, 0.5);
animation: blink 1.5s infinite;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--theme-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes blink {
50% {
opacity: 0.3;
}
}
@keyframes recBlink {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 1;
}
}
</style>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script src="/static/js/ffmpeg.min.js"></script>
<script src="/static/js/html2canvas.min.js"></script>
<script src="/static/js/html-to-image.js"></script>
<script src="https://unpkg.com/vconsole@3.15.1/dist/vconsole.min.js"></script>
<script>
// VConsole will be exported to `window.VConsole` by default.
var vConsole = new window.VConsole();
</script>
</head>
<body>
<div class="loader" id="loader">
<div class="spinner"></div>
<div class="loader-text">LOADING...</div>
</div>
<div class="main-container">
<!-- 背景层 -->
<div class="bg-layer">
<div class="bg-image" id="bgImg"></div>
<div class="bg-mask"></div>
<div class="particles" id="particles"></div>
</div>
<!-- 歌名信息 -->
<div class="header-info">
<div class="header-title" id="songTitle">...</div>
<div class="mini-lyrics" id="miniLyrics">
<div class="mini-line dim"></div>
<div class="mini-line active"></div>
<div class="mini-line dim"></div>
</div>
</div>
<!-- 中间视图切换 -->
<div class="view-wrapper" onclick="toggleView()">
<!-- 唱片模式 -->
<div class="disc-mode" id="discMode">
<div class="needle" id="needle">
<div class="needle-pivot"></div>
<div class="needle-rod"></div>
<div class="needle-head"></div>
</div>
<div class="disc-container">
<!-- 动效:光波 -->
<!-- <div class="ripple"></div>
<div class="ripple"></div>
<div class="ripple"></div> -->
<div class="disc" id="disc">
<div class="album-cover">
<img src="" id="coverImg" alt="cover">
</div>
</div>
</div>
</div>
<!-- 歌词模式 -->
<div class="lyrics-mode" id="lyricsMode">
<div class="lyrics-header">歌词 LYRICS</div>
<div class="lyrics-scroll" id="lyricsBox">
<div class="lyric-line">暂无歌词</div>
</div>
</div>
</div>
<!-- 底部控制 -->
<div class="controls-area">
<!-- 模拟音频频谱波形 -->
<div class="music-waves">
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
</div>
<div class="progress-container">
<span id="currTime">00:00</span>
<div class="progress-bar" id="progressBar">
<div class="progress-fill" id="progressFill">
<div class="progress-dot"></div>
</div>
</div>
<span id="totalTime">00:00</span>
</div>
<div class="btn-group">
<!-- 下载 -->
<button class="btn btn-side" onclick="downloadMusic()" title="下载">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<!-- 上一首 -->
<button class="btn btn-side" onclick="msg('这是单曲展示')">
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<!-- 播放/暂停 -->
<button class="btn btn-play" id="playBtn">
<svg id="iconPlay" width="30" height="30" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
<svg id="iconPause" width="30" height="30" viewBox="0 0 24 24" fill="currentColor" style="display:none">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
</button>
<!-- 下一首 -->
<button class="btn btn-side" onclick="msg('这是单曲展示')">
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
</svg>
</button>
<!-- 歌词切换按钮 -->
<button class="btn btn-side" onclick="toggleView(event)">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z" />
</svg>
</button>
<button class="btn btn-side" id="recToggleBtn" onclick="toggleRecording()" title="录制开关">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="6" />
</svg>
</button>
</div>
</div>
</div>
<div id="recIndicator" style="position:fixed;top:12px;right:12px;z-index:999;display:none;align-items:center;gap:6px;background:rgba(0,0,0,0.4);padding:6px 10px;border-radius:16px;color:#fff;backdrop-filter:blur(6px)">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ff3b30;box-shadow:0 0 10px #ff3b30;animation:recBlink 1s infinite"></span>
<span style="font-size:12px;letter-spacing:1px">REC</span>
</div>
<audio id="audio" crossorigin="anonymous"></audio>
<video id="tabCaptureVideo" playsinline muted style="display:none"></video>
<script>
// 获取参数
const urlParams = new URLSearchParams(window.location.search);
const musicId = urlParams.get('id');
const lrcParam = urlParams.get('lrc');
const API_URL = `https://pujianchaoyin.com/api/getMusicDetail?id=${musicId}`;
let musicData = {};
let isPlaying = false;
let isLyricView = false;
let lyricLines = [];
const els = {
body: document.body,
loader: document.getElementById('loader'),
bgImg: document.getElementById('bgImg'),
coverImg: document.getElementById('coverImg'),
songTitle: document.getElementById('songTitle'),
audio: document.getElementById('audio'),
playBtn: document.getElementById('playBtn'),
iconPlay: document.getElementById('iconPlay'),
iconPause: document.getElementById('iconPause'),
disc: document.getElementById('disc'),
discMode: document.getElementById('discMode'),
needle: document.getElementById('needle'),
progressBar: document.getElementById('progressBar'),
progressFill: document.getElementById('progressFill'),
currTime: document.getElementById('currTime'),
totalTime: document.getElementById('totalTime'),
lyricsMode: document.getElementById('lyricsMode'),
lyricsBox: document.getElementById('lyricsBox'),
miniLyrics: document.getElementById('miniLyrics'),
recIndicator: document.getElementById('recIndicator'),
recToggleBtn: document.getElementById('recToggleBtn'),
tabVideo: document.getElementById('tabCaptureVideo')
};
// 初始化
async function init() {
createParticles();
try {
const res = await fetch(API_URL);
const json = await res.json();
if (json.code === 200) {
musicData = json.data;
renderUI(musicData);
const lrcText = musicData.lrc || '';
parseLyrics(lrcText);
if (lrcParam && lrcText.trim()) {
isLyricView = true;
els.discMode.classList.add('hidden');
els.lyricsMode.classList.add('active');
// if (els.miniLyrics) els.miniLyrics.style.display = 'none';
syncLyrics(els.audio.currentTime || 0);
}
} else {
els.songTitle.innerText = "获取失败";
closeLoader();
}
} catch (e) {
console.error(e);
els.songTitle.innerText = "网络错误";
closeLoader();
}
}
function closeLoader() {
els.loader.style.opacity = 0;
setTimeout(() => els.loader.remove(), 600);
}
async function renderUI(data) {
els.songTitle.innerText = data.title;
// 预加载图片并转换为 Blob URL (解决跨域和缓存问题)
try {
const imgRes = await fetch(data.img);
const imgBlob = await imgRes.blob();
const imgUrl = URL.createObjectURL(imgBlob);
els.coverImg.src = imgUrl;
els.bgImg.style.backgroundImage = `url('${imgUrl}')`;
} catch (e) {
console.warn('图片缓存失败,使用原始链接', e);
els.coverImg.src = data.img;
els.bgImg.style.backgroundImage = `url('${data.img}')`;
}
els.audio.src = data.playurl;
// 图片加载后显示
els.coverImg.onload = () => {
closeLoader();
// 尝试自动播放
els.audio.play().catch(err => {
console.warn('自动播放被浏览器阻止', err);
});
};
// 防止图片加载失败导致一直loading
setTimeout(closeLoader, 3000);
document.title = data.title;
document.getElementById('ogUrl').content = window.location.href;
document.getElementById('ogTitle').content = data.title;
document.getElementById('ogDesc').content = data.desc;
document.getElementById('ogImage').content = data.img;
// 分享到微信
initWeixinShare(data);
}
// 播放控制
els.playBtn.addEventListener('click', () => {
if (isPlaying) els.audio.pause();
else els.audio.play();
});
els.audio.addEventListener('play', () => {
isPlaying = true;
updatePlayState();
if (recordEnabled) startSilentMV();
});
els.audio.addEventListener('pause', () => {
isPlaying = false;
updatePlayState();
});
els.audio.addEventListener('ended', () => {
stopSilentMV();
});
function downloadFile(name, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function transcodeToMp4(webmBlob, title) {
try {
if (!window.FFmpeg || !FFmpeg.createFFmpeg) {
downloadFile(`${title}.webm`, webmBlob);
msg('已保存 WebM');
return;
}
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: false, corePath: 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/ffmpeg-core.js' });
msg('正在转码为 MP4');
await ffmpeg.load();
ffmpeg.FS('writeFile', 'input.webm', await fetchFile(webmBlob));
await ffmpeg.run('-i', 'input.webm', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', 'output.mp4');
const data = ffmpeg.FS('readFile', 'output.mp4');
const mp4Blob = new Blob([data.buffer], { type: 'video/mp4' });
downloadFile(`${title}.mp4`, mp4Blob);
msg('MP4 已生成');
} catch (e) {
console.error(e);
downloadFile(`${title}.webm`, webmBlob);
msg('转码失败,已保存 WebM');
}
}
function updatePlayState() {
if (isPlaying) {
els.body.classList.add('is-playing');
els.needle.classList.add('playing');
els.disc.classList.add('playing');
els.discMode.classList.add('playing'); // 触发 Ripple
els.iconPlay.style.display = 'none';
els.iconPause.style.display = 'block';
} else {
els.body.classList.remove('is-playing');
els.needle.classList.remove('playing');
els.disc.classList.remove('playing');
els.discMode.classList.remove('playing');
els.iconPlay.style.display = 'block';
els.iconPause.style.display = 'none';
}
}
// 进度与歌词
els.audio.addEventListener('timeupdate', () => {
const { currentTime, duration } = els.audio;
if (!duration) return;
const percent = (currentTime / duration) * 100;
els.progressFill.style.width = `${percent}%`;
els.currTime.innerText = formatTime(currentTime);
syncLyrics(currentTime);
});
els.audio.addEventListener('loadedmetadata', () => {
els.totalTime.innerText = formatTime(els.audio.duration);
});
els.progressBar.addEventListener('click', (e) => {
const rect = els.progressBar.getBoundingClientRect();
let p = (e.clientX - rect.left) / rect.width;
p = Math.max(0, Math.min(1, p));
const newTime = p * (els.audio.duration || 0);
els.audio.currentTime = newTime;
syncLyrics(newTime);
if (!isPlaying) els.audio.play();
});
let seeking = false;
function seekByClientX(x) {
const rect = els.progressBar.getBoundingClientRect();
let p = (x - rect.left) / rect.width;
p = Math.max(0, Math.min(1, p));
els.progressFill.style.width = `${p * 100}%`;
const dur = els.audio.duration || 0;
const newTime = p * dur;
if (dur) {
els.audio.currentTime = newTime;
}
syncLyrics(newTime);
}
els.progressBar.addEventListener('mousedown', (e) => {
seeking = true;
seekByClientX(e.clientX);
});
document.addEventListener('mousemove', (e) => {
if (!seeking) return;
seekByClientX(e.clientX);
});
document.addEventListener('mouseup', () => {
if (!seeking) return;
seeking = false;
if (!isPlaying) els.audio.play();
});
els.progressBar.addEventListener('touchstart', (e) => {
if (!e.touches || !e.touches.length) return;
seeking = true;
seekByClientX(e.touches[0].clientX);
e.preventDefault();
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (!seeking || !e.touches || !e.touches.length) return;
seekByClientX(e.touches[0].clientX);
e.preventDefault();
}, { passive: false });
document.addEventListener('touchend', () => {
if (!seeking) return;
seeking = false;
if (!isPlaying) els.audio.play();
});
// 歌词解析
function parseLyrics(lrc) {
lyricLines = [];
if (!lrc) {
if (els.miniLyrics) els.miniLyrics.style.display = 'none';
els.lyricsBox.innerHTML = '<div class="lyric-line">纯音乐 / 暂无歌词</div>';
return;
}
lrc = lrc.replace(/\r\n/g, '\n').replace(/\\n/g, '\n').replace(/<br\s*\/>?/gi, '\n');
const lines = lrc.split('\n');
const timeReg = /\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?\]/;
els.lyricsBox.innerHTML = '';
lines.forEach(line => {
const match = timeReg.exec(line);
if (match) {
const min = parseInt(match[1]);
const sec = parseInt(match[2]);
const ms = match[3] ? parseFloat("0." + String(match[3]).padEnd(3, '0')) : 0;
const time = min * 60 + sec + ms;
const text = line.replace(/\[.*?\]/g, '').trim();
if (text) {
const div = document.createElement('div');
div.className = 'lyric-line';
div.innerText = text;
div.dataset.time = time;
els.lyricsBox.appendChild(div);
lyricLines.push({ time, el: div });
}
}
});
lyricLines.sort((a, b) => a.time - b.time);
// 移除额外留白,保持精准滚动定位
if (isLyricView && lyricLines.length > 0) {
syncLyrics(els.audio.currentTime || 0);
}
}
// 歌词同步
function syncLyrics(time) {
if (lyricLines.length === 0) return;
let nextIdx = lyricLines.findIndex(l => time < l.time);
let activeIdx = nextIdx === -1 ? lyricLines.length - 1 : Math.max(0, nextIdx - 1);
document.querySelectorAll('.lyric-line.active').forEach(el => el.classList.remove('active'));
const activeEl = lyricLines[activeIdx].el;
activeEl.classList.add('active');
const box = els.lyricsBox;
const offset = activeEl.offsetTop - box.clientHeight / 2 + activeEl.clientHeight / 2;
box.scrollTo({ top: Math.max(0, offset), behavior: seeking ? 'auto' : 'smooth' });
updateMiniLyrics(activeIdx);
}
function updateMiniLyrics(idx) {
if (!els.miniLyrics) return;
if (els.miniLyrics) els.miniLyrics.style.display = isLyricView ? 'none' : 'block';
const lines = els.miniLyrics.querySelectorAll('.mini-line');
const getText = (i) => (lyricLines[i] ? lyricLines[i].el.innerText : '');
if (lines.length >= 3) {
lines[0].className = 'mini-line dim';
lines[1].className = 'mini-line active';
lines[2].className = 'mini-line dim';
lines[0].innerText = getText(Math.max(0, idx - 1));
lines[1].innerText = getText(idx);
lines[2].innerText = getText(Math.min(lyricLines.length - 1, idx + 1));
}
}
// 视图切换
window.toggleView = function (e) {
if (e) e.stopPropagation();
isLyricView = !isLyricView;
if (isLyricView) {
els.discMode.classList.add('hidden');
els.lyricsMode.classList.add('active');
if (els.miniLyrics) els.miniLyrics.style.display = 'none';
syncLyrics(els.audio.currentTime);
} else {
els.discMode.classList.remove('hidden');
els.lyricsMode.classList.remove('active');
if (els.miniLyrics) els.miniLyrics.style.display = 'block';
}
}
window.downloadMusic = async function () {
if (!musicData.playurl) return;
msg('正在准备下载,请稍候...');
try {
const response = await fetch(musicData.playurl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${musicData.title || 'audio'}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
msg('下载已开始');
} catch (error) {
console.error('下载失败:', error);
msg('下载失败,请检查网络或稍后重试');
}
}
// --- 微信分享配置 ---
async function initWeixinShare(shareData) {
// 判断是否在微信环境中
if (!/micromessenger/i.test(navigator.userAgent)) {
return;
}
try {
// 1. 从您的后端获取签名
const signatureData = await getWeixinSignature();
// 2. 配置JS-SDK
wx.config({
debug: false, // 调试时可开启
appId: signatureData.appId,
timestamp: signatureData.timestamp,
nonceStr: signatureData.nonceStr,
signature: signatureData.signature,
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData']
});
// 3. 配置分享内容
wx.ready(() => {
const sharePayload = {
title: shareData.title,
desc: '立即收听:' + shareData.title,
link: window.location.href,
imgUrl: shareData.img,
success: () => msg('分享成功'),
cancel: () => msg('取消分享')
};
wx.updateAppMessageShareData(sharePayload); // 分享给朋友
wx.updateTimelineShareData(sharePayload); // 分享到朋友圈
});
wx.error(err => {
console.error('微信JS-SDK配置失败:', err);
// msg('微信分享配置失败');
});
} catch (error) {
console.error('初始化微信分享失败:', error);
// msg('初始化微信分享失败');
}
}
/**
* @description 从后端获取微信JS-SDK签名
* @returns {Promise<object>} 包含 appId, timestamp, nonceStr, signature 的对象
*
* 后端实现参考 (Node.js):
* 1. 安装 `axios` 和 `sha1` 库: `npm install axios sha1`
* 2. 后端代码需要缓存 access_token 和 jsapi_ticket避免频繁请求微信接口
* 3. 签名算法请严格参考微信官方文档
*/
async function getWeixinSignature() {
// 在这里,您需要向您的后端服务发起一个请求
// 后端需要根据当前的 URL 生成一个有效的签名
// 例如: const res = await fetch('https://your-backend.com/api/weixin-signature?url=' + encodeURIComponent(location.href.split('#')[0]));
// return await res.json();
// --- 以下为模拟数据,请务必替换为真实后端请求 ---
console.warn('正在使用模拟的微信签名数据,请替换为您的后端实现');
return new Promise(resolve => {
resolve({
appId: "YOUR_APP_ID", // 替换为您的公众号AppID
timestamp: "1678886400", // 替换为后端生成的时间戳
nonceStr: "YOUR_NONCE_STR", // 替换为后端生成的随机字符串
signature: "YOUR_SIGNATURE", // 替换为后端生成的签名
});
});
}
function createParticles() {
const container = document.getElementById('particles');
for (let i = 0; i < 20; i++) {
const span = document.createElement('span');
const size = Math.random() * 4 + 2;
span.style.width = `${size}px`;
span.style.height = `${size}px`;
span.style.left = `${Math.random() * 100}%`;
span.style.animationDelay = `${Math.random() * 5}s`;
span.style.animationDuration = `${Math.random() * 10 + 10}s`;
container.appendChild(span);
}
}
function formatTime(s) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
function msg(txt) {
// 简单提示
const div = document.createElement('div');
div.innerText = txt;
div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.7);color:#fff;padding:10px 20px;border-radius:20px;z-index:999;font-size:14px;';
document.body.appendChild(div);
setTimeout(() => div.remove(), 2000);
}
let recordEnabled = false;
let mvCanvas = null;
let mvCtx = null;
let mvRecorder = null;
let mvChunks = [];
let mvRenderTimer = null;
let isMVRecording = false;
let mvStream = null;
let mvAudioTrack = null;
let mvRafId = 0;
els.viewWrapper = document.querySelector('.view-wrapper');
els.captureTarget = document.querySelector('.view-wrapper');
const isMobile = /Mobile|Android|iP(hone|od|ad)|IEMobile|BlackBerry|Opera Mini/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 1);
function buildSVGFromElement(el, w, h) {
const styles = Array.from(document.querySelectorAll('style')).map(s => s.textContent).join('\n');
const html = el.outerHTML;
const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${w}\" height=\"${h}\"><foreignObject width=\"100%\" height=\"100%\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><style>${styles}</style>${html}</div></foreignObject></svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
// 手动绘制 Canvas 帧(高性能)
function drawCanvasFrame(containerRect, dpr) {
if (!mvCtx || !mvCanvas) return;
const w = mvCanvas.width;
const h = mvCanvas.height;
const ctx = mvCtx;
dpr = dpr || window.devicePixelRatio || 1;
// 确保 containerRect 存在
if (!containerRect) {
const main = document.querySelector('.main-container');
if (main) containerRect = main.getBoundingClientRect();
}
// 清空画布
ctx.clearRect(0, 0, w, h);
// --- 1. 绘制背景 ---
// A. Base Color (#1a82ea)
// 强制使用 #1a82ea 以确保背景色可见
ctx.fillStyle = '#1a82ea';
ctx.fillRect(0, 0, w, h);
// B. Background Image (Opacity 0.3 + Breathe)
// 模拟 .bg-image 的呼吸效果
ctx.save();
const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1; // 呼吸动画
// 绘制背景图
// 使用渐变替代背景图片绘制
// if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
// const imgW = w * scale;
// const imgH = h * scale;
// const x = (w - imgW) / 2;
// const y = (h - imgH) / 2;
// ctx.save();
// // 增加 brightness(0.6) 滤镜以匹配 CSS防止图片过亮遮盖背景色
// ctx.filter = 'brightness(0.6)';
// ctx.globalAlpha = 0.3; // Match CSS opacity
// ctx.drawImage(els.coverImg, x, y, imgW, imgH);
// ctx.restore();
// }
// 绘制渐变背景 #1a82ea -> #000
const bgGradient = ctx.createLinearGradient(0, 0, 0, h);
bgGradient.addColorStop(0, '#1a82ea');
bgGradient.addColorStop(1, '#000000');
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, w, h);
// 必须在这里调用一次 restore 以结束 scale(1.1) 的上下文
ctx.restore();
// --- 1.1 绘制粒子 (Particles) ---
// 模拟 20 个粒子,从下往上浮动
// ctx.save(); // Removed redundant save
const now = Date.now();
for (let i = 0; i < 20; i++) {
// 使用伪随机数生成固定的粒子属性
const seed = i * 1337;
const speed = 10000 + (seed % 5000); // 10-15s
const delay = (seed % 5000);
const time = (now + delay) % speed;
const p = time / speed; // 0 -> 1
// floatUp keyframes:
// 0%: y=0, op=0, s=0.5
// 20%: op=0.4
// 80%: op=0.2
// 100%: y=-h, op=0, s=1.5
const y = h - (p * (h + 100)); // 从底向上
const x = ((seed * 7) % 100) / 100 * w;
const size = (2 + (seed % 4)) * dpr * (0.5 + p); // 变大
let opacity = 0;
if (p < 0.2) opacity = (p / 0.2) * 0.4;
else if (p < 0.8) opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2; // 0.4 -> 0.2
else opacity = 0.2 * (1 - (p - 0.8) / 0.2); // 0.2 -> 0
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.fill();
}
// ctx.restore(); // Removed redundant restore (moved up)
// ctx.restore(); // Removed redundant restore (moved up)
// --- 2. 绘制标题 ---
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 10 * dpr;
ctx.shadowOffsetY = 4 * dpr;
ctx.font = `bold ${22 * dpr}px sans-serif`;
// 顶部留出空间,大约 15% 高度处
ctx.fillText(els.songTitle.innerText, w / 2, h * 0.15);
// 绘制 Mini Lyrics (如果不在歌词模式)
if (!isLyricView) {
// 简单绘制一行当前歌词
const activeLine = document.querySelector('.mini-line.active');
if (activeLine) {
ctx.font = `bold ${16 * dpr}px sans-serif`;
ctx.fillStyle = '#d4af37'; // var(--theme-color)
ctx.fillText(activeLine.innerText, w / 2, h * 0.22);
}
}
ctx.restore();
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
// 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
if (!isLyricView) {
// === 唱片模式 (.disc-mode) ===
// 核心思路:分别获取 .disc-container, .disc, .album-cover, .needle 等元素的 rect
// 1. 光波 (Ripple) - 位于 .disc-container 中心
const discContainer = document.querySelector('.disc-container');
if (discContainer) {
// Ripples (Disabled)
}
// 2. 唱臂 (.needle) - 必须放在唱片上面CSS z-index: 20在唱片(z-index: 2)之上
// 我们先画唱片,再画唱臂
// 3. 唱片 (.disc)
const disc = document.querySelector('.disc');
if (disc && discContainer) {
// Fix: Use discContainer for geometry to avoid wobbling caused by CSS rotation of .disc
const dr = discContainer.getBoundingClientRect();
const dx = (dr.left - containerRect.left + dr.width / 2) * dpr;
const dy = (dr.top - containerRect.top + dr.height / 2) * dpr;
// Use the smaller dimension to ensure it fits, similar to CSS containment
const radius = (Math.min(dr.width, dr.height) / 2) * dpr;
// Adjust radius slightly if needed to match .disc size (it might have margins? no, it's 100%)
// CSS: .disc { width: 100%; height: 100%; ... border: 6px solid ... }
// The border is usually part of the width/height in border-box.
ctx.save();
ctx.translate(dx, dy);
// 旋转:读取 computed style 的 transform 可能会比较麻烦matrix
// 简单起见,如果正在播放,我们手动模拟旋转角度,或者尝试解析 matrix
// 为了平滑录制,手动模拟旋转是最佳实践,因为 captureStream 可能会丢帧导致 matrix 跳变
if (isPlaying) {
const angle = (Date.now() / 20000) * 360 % 360; // 20s per round
ctx.rotate(angle * Math.PI / 180);
}
// 唱片本体背景
// background: radial-gradient(circle, #111 0%, #1a1a1a 100%);
const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
bgGrad.addColorStop(0, '#111');
bgGrad.addColorStop(1, '#1a1a1a');
ctx.fillStyle = bgGrad;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.fill();
// 纹理 (Fine Vinyl Grooves - Dark Style)
// 纯黑背景下的深色纹路
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
ctx.lineWidth = 0.5 * dpr;
// 增加纹理密度
for (let r = radius * 0.66; r < radius * 0.96; r += 1.5 * dpr) {
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.stroke();
}
// 边框 border: 6px solid #080808;
ctx.strokeStyle = '#080808';
ctx.lineWidth = 6 * dpr;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.stroke();
// 封面 (.album-cover)
const coverRadius = radius * 0.65;
ctx.save();
// 封面边框
ctx.beginPath();
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
ctx.fillStyle = '#000';
ctx.fill();
// Image
if (els.coverImg.complete) {
ctx.beginPath();
ctx.arc(0, 0, coverRadius - 2 * dpr, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
}
ctx.restore(); // end cover
// [已移除] 光泽/反光
// 用户要求:不要反光,背景全黑
// ctx.save(); ... ctx.restore();
ctx.restore(); // end disc rotate
}
// 4. 唱臂 (.needle)
const needle = document.getElementById('needle');
if (needle) {
// CSS: top: -50px; left: 50%; transform-origin: 40px 20px;
// transform: translateX(-10px) rotate(...);
const parentRect = document.querySelector('.disc-mode').getBoundingClientRect();
// Calculate base position (before transform) relative to container
const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr;
const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr;
ctx.save();
ctx.translate(needleBaseX, needleBaseY);
// Apply transforms
const originX = 40 * dpr;
const originY = 20 * dpr;
const angle = isPlaying ? -5 : -35;
ctx.translate(originX, originY);
ctx.translate(-10 * dpr, 0);
ctx.rotate(angle * Math.PI / 180);
ctx.translate(-originX, -originY);
// Draw Parts (Flat Matte Style - No Reflections)
// 1. Pivot
ctx.save();
ctx.translate(20 * dpr, 0);
ctx.beginPath();
ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2);
// 纯色哑光深灰,无渐变
ctx.fillStyle = '#333';
ctx.fill();
ctx.strokeStyle = '#222';
ctx.lineWidth = 1 * dpr;
ctx.stroke();
// Center screw
ctx.beginPath();
ctx.arc(20 * dpr, 20 * dpr, 6 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.restore();
// 2. Rod
// 纯色哑光中灰,无高光
ctx.fillStyle = '#444';
ctx.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
// 3. Head
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 38 * dpr);
// Top border
ctx.fillStyle = '#333';
ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 2 * dpr);
ctx.restore();
}
} else {
// === 歌词模式 ===
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const boxH = h * 0.6;
const startY = (h - boxH) / 2;
// 简单的歌词绘制逻辑:绘制当前句及前后几句
// 找到 active 的歌词
let activeIndex = lyricLines.findIndex(l => Math.abs(l.time - els.audio.currentTime) < 0.5);
// 如果没找到精确匹配,找最近的一个过去的时间
if (activeIndex === -1) {
activeIndex = lyricLines.filter(l => l.time <= els.audio.currentTime).length - 1;
}
if (activeIndex === -1) activeIndex = 0;
const lineHeight = 40 * dpr;
const maxLines = 7; // 显示行数
for (let i = -3; i <= 3; i++) {
const idx = activeIndex + i;
if (idx >= 0 && idx < lyricLines.length) {
const line = lyricLines[idx];
const y = (h - boxH) / 2 + boxH / 2 + i * lineHeight; // Center vertically
if (i === 0) {
// 当前句
ctx.font = `bold ${22 * dpr}px sans-serif`;
ctx.fillStyle = '#d4af37';
ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
ctx.shadowBlur = 10;
} else {
// 其他句
ctx.font = `${16 * dpr}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.shadowBlur = 0;
}
ctx.fillText(line.el.innerText, w / 2, y);
}
}
ctx.restore();
}
// --- 3.5 绘制 Controls Area 背景 (优化细节) ---
const controlsEl = document.querySelector('.controls-area');
if (controlsEl) {
const cr = controlsEl.getBoundingClientRect();
// 相对于 main-container
const cx = (cr.left - containerRect.left) * dpr;
const cy = (cr.top - containerRect.top) * dpr;
const cw = cr.width * dpr;
const ch = cr.height * dpr;
ctx.save();
// 阴影
ctx.shadowColor = 'rgba(0, 0, 0, 0.6)';
ctx.shadowBlur = 50 * dpr;
ctx.shadowOffsetY = -15 * dpr;
// 背景 (加深不透明度,提升对比度)
ctx.fillStyle = 'rgba(20, 20, 20, 0.8)';
// 绘制路径 (左上右上圆角)
const radius = 24 * dpr;
ctx.beginPath();
ctx.moveTo(cx, cy + ch); // 左下
ctx.lineTo(cx, cy + radius); // 左上起始
ctx.arcTo(cx, cy, cx + radius, cy, radius); // 左上圆角
ctx.lineTo(cx + cw - radius, cy); // 顶边
ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius); // 右上圆角
ctx.lineTo(cx + cw, cy + ch); // 右下
ctx.closePath();
ctx.fill();
// 边框 (Top border enhancement - 更亮更粗)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
// 只描绘顶部和圆角,延伸一点下来
ctx.moveTo(cx, cy + radius + 20 * dpr);
ctx.lineTo(cx, cy + radius);
ctx.arcTo(cx, cy, cx + radius, cy, radius);
ctx.lineTo(cx + cw - radius, cy);
ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius);
ctx.lineTo(cx + cw, cy + radius + 20 * dpr);
ctx.stroke();
ctx.restore();
}
// --- 4. 绘制底部进度条 ---
const barY = h * 0.85;
const barWidth = w * 0.8;
const barX = (w - barWidth) / 2;
// 总进度条背景
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.beginPath();
ctx.roundRect(barX, barY, barWidth, 4 * dpr, 2 * dpr);
ctx.fill();
// 当前进度
const duration = els.audio.duration || 1;
const progress = els.audio.currentTime / duration;
const currentBarWidth = barWidth * progress;
// 进度填充
const gradBar = ctx.createLinearGradient(barX, 0, barX + currentBarWidth, 0);
gradBar.addColorStop(0, '#fff');
gradBar.addColorStop(1, '#d4af37');
ctx.fillStyle = gradBar;
ctx.beginPath();
ctx.roundRect(barX, barY, currentBarWidth, 4 * dpr, 2 * dpr);
ctx.fill();
// 进度点
ctx.beginPath();
ctx.arc(barX + currentBarWidth, barY + 2 * dpr, 6 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
ctx.shadowBlur = 10 * dpr;
ctx.fill();
// 时间文字
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = `${11 * dpr}px monospace`;
ctx.textAlign = 'left';
ctx.fillText(els.currTime.innerText, barX, barY - 15 * dpr);
ctx.textAlign = 'right';
ctx.fillText(els.totalTime.innerText, barX + barWidth, barY - 15 * dpr);
// --- 5. 绘制音频波形 (精准还原 CSS 动画) ---
const wavesEl = document.querySelector('.music-waves');
if (wavesEl) {
const rect = wavesEl.getBoundingClientRect();
// 如果元素不可见(例如被隐藏),则不绘制
if (rect.width > 0 && rect.height > 0) {
const wx = (rect.left - containerRect.left) * dpr;
const wy = (rect.top - containerRect.top) * dpr;
const ww = rect.width * dpr;
const wh = rect.height * dpr;
const bottomY = wy + wh;
// 动画参数配置 (对应 CSS)
// 0: dur 0.5s, del 0.1s
// 1: dur 0.7s, del 0.3s
// 2: dur 0.6s, del 0.0s
// 3: dur 0.5s, del 0.4s
// 4: dur 0.8s, del 0.2s
const bars = [
{ dur: 0.5, delay: 0.1 },
{ dur: 0.7, delay: 0.3 },
{ dur: 0.6, delay: 0.0 },
{ dur: 0.5, delay: 0.4 },
{ dur: 0.8, delay: 0.2 }
];
const barW = 3 * dpr;
const gap = 3 * dpr;
const totalContentW = 5 * barW + 4 * gap;
// 居中计算
let currentX = wx + (ww - totalContentW) / 2;
const now = Date.now() / 1000;
for (let i = 0; i < 5; i++) {
let p = 0; // 0.0 ~ 1.0
if (isPlaying) {
const { dur, delay } = bars[i];
const cycle = dur * 2; // 往返周期
const t = (now + delay) % cycle;
const rawP = t / dur;
// Alternate direction
const linearP = rawP <= 1 ? rawP : (2 - rawP);
// Ease-in-out approximation (Cosine)
p = 0.5 - 0.5 * Math.cos(linearP * Math.PI);
}
// Interpolate Properties
// Height: 4px -> 16px
const hVal = 4 + (16 - 4) * p;
const hBar = hVal * dpr;
// Opacity: 0.3 -> 1
const opVal = 0.3 + (1 - 0.3) * p;
// Color: #ffffff -> #d4af37 (212, 175, 55)
// CSS var(--theme-color) is #d4af37
const r = 255 + (212 - 255) * p;
const g = 255 + (175 - 255) * p;
const b = 255 + ( 55 - 255) * p;
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${opVal})`;
// Draw
ctx.beginPath();
// align bottom
ctx.roundRect(currentX, bottomY - hBar, barW, hBar, 2 * dpr);
ctx.fill();
currentX += barW + gap;
}
}
}
// --- 6. 绘制按钮组 (SVG) ---
// 位于底部
// 已移除手动绘制,统一使用 DOM 绘制逻辑
// Buttons
const btns = document.querySelectorAll('.btn');
btns.forEach(btn => {
drawDomElement(ctx, btn, dpr, containerRect, (c, cw, ch) => {
// If it's play button, draw circle bg
if (btn.classList.contains('btn-play')) {
c.beginPath();
c.arc(cw/2, ch/2, cw/2, 0, Math.PI * 2);
c.fillStyle = 'rgba(255, 255, 255, 0.1)';
c.fill();
c.strokeStyle = 'rgba(255, 255, 255, 0.2)';
c.lineWidth = 1 * dpr;
c.stroke();
}
// Handle Play Button Explicitly
if (btn.id === 'playBtn') {
c.save();
c.translate(cw/2, ch/2);
c.scale(dpr, dpr);
c.translate(-12, -12);
c.fillStyle = '#fff';
if (isPlaying) {
c.beginPath(); c.rect(6, 5, 4, 14); c.rect(14, 5, 4, 14); c.fill();
} else {
c.beginPath(); c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath(); c.fill();
}
c.restore();
return;
}
// Draw Icon (SVG)
const svg = btn.querySelector('svg');
if (svg && svg.style.display !== 'none') {
c.translate(cw/2, ch/2);
c.scale(dpr, dpr);
c.translate(-12, -12); // Assuming 24x24 base
c.fillStyle = '#fff';
c.strokeStyle = '#fff';
c.lineWidth = 2;
c.lineCap = 'round';
c.lineJoin = 'round';
// Fallback: Since we know the buttons:
if (btn.onclick && btn.onclick.toString().includes('downloadMusic')) {
// Download
c.beginPath(); c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15); c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10); c.moveTo(12, 15); c.lineTo(12, 3); c.stroke();
} else if (btn.id === 'recToggleBtn') {
c.beginPath(); c.arc(12, 12, 6, 0, Math.PI * 2);
if (recordEnabled) { c.fillStyle = '#ff3b30'; c.fill(); } else { c.fillStyle = '#fff'; c.fill(); }
} else {
// Prev/Next/Mode
const pathD = svg.querySelector('path').getAttribute('d');
if (pathD.startsWith('M11 18')) { // Prev
c.beginPath(); c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath(); c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath(); c.fill();
} else if (pathD.startsWith('M4 18')) { // Next
c.beginPath(); c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath(); c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath(); c.fill();
} else if (pathD.startsWith('M15 15')) { // Mode
const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z"); c.fill(p);
}
}
}
});
});
}
async function startSilentMV() {
if (!recordEnabled || isMVRecording) return;
const dpr = window.devicePixelRatio || 1;
const mainContainer = document.querySelector('.main-container');
const rect = mainContainer.getBoundingClientRect();
// 创建 Canvas
mvCanvas = document.createElement('canvas');
mvCtx = mvCanvas.getContext('2d');
mvCanvas.width = rect.width * dpr;
mvCanvas.height = rect.height * dpr;
// 120 FPS
const targetFPS = 120;
async function renderLoop() {
if (!isMVRecording) return;
try {
drawCanvasFrame(rect, dpr);
} catch (e) {
console.error('Render error:', e);
}
if (isMVRecording) {
mvRafId = requestAnimationFrame(renderLoop);
}
}
mvRafId = requestAnimationFrame(renderLoop);
mvStream = mvCanvas.captureStream(targetFPS);
// 添加音频
const audioEl = document.getElementById('audio');
if (audioEl) {
try {
let audioStream;
if (audioEl.captureStream) audioStream = audioEl.captureStream();
else if (audioEl.mozCaptureStream) audioStream = audioEl.mozCaptureStream();
if (audioStream) {
const audioTrack = audioStream.getAudioTracks()[0];
if (audioTrack) mvStream.addTrack(audioTrack);
}
} catch (e) { console.warn(e); }
}
mvChunks = [];
const optsList = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2' },
{ mimeType: 'video/mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus' },
{ mimeType: 'video/webm' }
];
let opts = {};
for (const o of optsList) {
if (MediaRecorder.isTypeSupported(o.mimeType)) {
opts = { ...o, videoBitsPerSecond: 25000000 };
break;
}
}
mvRecorder = new MediaRecorder(mvStream, opts);
mvRecorder.ondataavailable = e => { if (e.data && e.data.size) mvChunks.push(e.data); };
mvRecorder.onstop = async () => {
const title = (musicData.title || 'mv').replace(/\s+/g, '_');
const blob = new Blob(mvChunks, { type: mvRecorder.mimeType || 'video/webm' });
const type = mvRecorder.mimeType || 'video/webm';
if (/mp4/i.test(type)) downloadFile(`${title}.mp4`, blob);
else await transcodeToMp4(blob, title);
// Stop tracks
if (mvStream) mvStream.getTracks().forEach(t => t.stop());
};
mvRecorder.start();
isMVRecording = true;
if (els.recIndicator) els.recIndicator.style.display = 'flex';
if (els.recToggleBtn) els.recToggleBtn.style.color = '#ff3b30';
}
// 辅助函数:绘制 DOM 元素到 Canvas
function drawDomElement(ctx, el, dpr, containerRect, drawFn) {
if (!el || el.style.display === 'none' || el.style.opacity === '0') return;
const r = el.getBoundingClientRect();
// 计算相对于 main-container 的坐标
const x = (r.left - containerRect.left) * dpr;
const y = (r.top - containerRect.top) * dpr;
const w = r.width * dpr;
const h = r.height * dpr;
ctx.save();
ctx.translate(x, y);
drawFn(ctx, w, h, r); // Pass raw rect too if needed
ctx.restore();
}
function stopSilentMV() {
if (!isMVRecording) return;
try { mvRecorder && mvRecorder.stop(); } catch (_) { }
try { if (mvStream) mvStream.getTracks().forEach(t => t.stop()); } catch (_) { }
if (mvRafId) cancelAnimationFrame(mvRafId);
mvRafId = 0;
clearInterval(mvRenderTimer);
mvRenderTimer = null;
isMVRecording = false;
if (els.recIndicator) els.recIndicator.style.display = 'none';
if (els.recToggleBtn) els.recToggleBtn.style.color = '';
}
function toggleRecording() {
recordEnabled = !recordEnabled;
if (recordEnabled && isPlaying) startSilentMV();
if (!recordEnabled && isMVRecording) stopSilentMV();
}
init();
</script>
</body>
</html>