Files
PC-official/player.html
DESKTOP-RQ919RC\Pc 577c9d967d no message
2025-12-10 18:17:00 +08:00

2448 lines
78 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();
}
// 绘制蒙版 .bg-mask
// const gradient = ctx.createLinearGradient(0, 0, 0, h);
// // gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
// // gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
// // gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
// // ctx.fillStyle = gradient;
// ctx.fillRect(0, 0, w, h);
// --- 1.1 绘制粒子 (Particles) ---
// 模拟 20 个粒子,从下往上浮动
ctx.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();
ctx.restore();
// --- 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) - 已移除
// 2. 唱臂 (.needle) - 必须放在唱片上面CSS z-index: 20在唱片(z-index: 2)之上
// 我们先画唱片,再画唱臂
// 3. 唱片 (.disc)
const disc = document.querySelector('.disc');
if (disc) {
const dr = disc.getBoundingClientRect();
const dx = (dr.left - containerRect.left + dr.width / 2) * dpr;
const dy = (dr.top - containerRect.top + dr.height / 2) * dpr;
const radius = (dr.width / 2) * dpr;
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();
// 纹理 (repeating-radial-gradient) - Canvas 模拟比较耗时,画几个圈代替
ctx.strokeStyle = '#222';
ctx.lineWidth = 2 * dpr;
for(let r = radius * 0.68; r < radius * 0.95; r += 6 * 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; // border is usually inside or outside? CSS border is outside if content-box, inside if border-box. usually inside visual boundary.
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.stroke();
// 封面 (.album-cover)
// width: 65%; height: 65%;
const coverRadius = radius * 0.65;
ctx.save();
// 封面跳动
if (isPlaying) {
const beatScale = 1 + Math.sin(Date.now() / 200) * 0.005; // 微弱跳动
ctx.scale(beatScale, beatScale);
}
// 封面边框 border: 4px solid #000;
ctx.beginPath();
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
ctx.fillStyle = '#000';
ctx.fill(); // fill black behind image
// Image
if (els.coverImg.complete) {
ctx.beginPath();
ctx.arc(0, 0, coverRadius - 2 * dpr, 0, Math.PI * 2); // minus border
ctx.clip();
ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
}
ctx.restore(); // end cover
// 光泽 (.disc::before) - 简单画个半透明渐变
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
const glossGrad = ctx.createLinearGradient(-radius, -radius, radius, radius);
glossGrad.addColorStop(0, 'transparent');
glossGrad.addColorStop(0.45, 'transparent');
glossGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)');
glossGrad.addColorStop(0.55, 'transparent');
glossGrad.addColorStop(1, 'transparent');
ctx.fillStyle = glossGrad;
ctx.fill();
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
// parentRect.width/2 corresponds to left: 50%
// -50 corresponds to top: -50px
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: translateX(-10px) then rotate
// And respect transform-origin: 40px 20px
const originX = 40 * dpr;
const originY = 20 * dpr;
const angle = isPlaying ? -5 : -35;
// Matrix order for: transform-origin(ox, oy) + translateX(tx) + rotate(r)
// M = T(ox, oy) * T(tx, 0) * R(r) * T(-ox, -oy)
ctx.translate(originX, originY);
ctx.translate(-10 * dpr, 0);
ctx.rotate(angle * Math.PI / 180);
ctx.translate(-originX, -originY);
// Draw Parts
// 1. Pivot (top: 0, left: 20px, w: 40px, h: 40px)
ctx.save();
ctx.translate(20 * dpr, 0);
ctx.beginPath();
ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2);
const pivotGrad = ctx.createRadialGradient(12 * dpr, 12 * dpr, 0, 20 * dpr, 20 * dpr, 20 * dpr);
pivotGrad.addColorStop(0, '#e0e0e0');
pivotGrad.addColorStop(1, '#555');
ctx.fillStyle = pivotGrad;
ctx.fill();
ctx.strokeStyle = '#444';
ctx.lineWidth = 1 * dpr;
ctx.stroke();
// Center screw
ctx.beginPath();
ctx.arc(20 * dpr, 20 * dpr, 6 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#222';
ctx.fill();
ctx.restore();
// 2. Rod (top: 30px, left: 36px, w: 8px, h: 100px)
// Gradient: linear-gradient(90deg, #555, #ccc, #555)
const rodGrad = ctx.createLinearGradient(36 * dpr, 0, 44 * dpr, 0);
rodGrad.addColorStop(0, '#555');
rodGrad.addColorStop(0.5, '#ccc');
rodGrad.addColorStop(1, '#555');
ctx.fillStyle = rodGrad;
ctx.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
// 3. Head (bottom: 0 -> top: 102px, left: 28px, w: 24px, h: 38px)
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 38 * dpr);
// Top border
ctx.fillStyle = '#555';
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 = centerY + i * lineHeight;
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, centerX, y);
}
}
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. 绘制音频波形 (可选) ---
// 简单模拟
if (isPlaying) {
ctx.save();
ctx.translate(centerX, barY - 40 * dpr);
ctx.fillStyle = '#fff';
for (let i = -2; i <= 2; i++) {
const hWave = Math.random() * 16 * dpr;
ctx.fillRect(i * 6 * dpr, -hWave, 3 * dpr, hWave);
}
ctx.restore();
}
// --- 6. 绘制按钮组 (SVG) ---
// 位于底部
const btnY = barY + 40 * dpr;
const btnGroupWidth = w * 0.9;
// 6个按钮5个间隙
const btnCount = 6;
const btnSpacing = btnGroupWidth / (btnCount - 1);
const startX = (w - btnGroupWidth) / 2;
ctx.save();
ctx.translate(0, btnY);
ctx.strokeStyle = '#fff';
ctx.fillStyle = '#fff';
ctx.lineWidth = 2 * dpr;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const drawIcon = (index, pathFunc) => {
ctx.save();
ctx.translate(startX + index * btnSpacing, 0);
ctx.scale(dpr, dpr); // Scale for high DPI
ctx.translate(-12, -12); // Center 24x24 icon
pathFunc(ctx);
ctx.restore();
};
// 1. Download
drawIcon(0, (c) => {
c.beginPath();
// M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4
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);
// polyline 7 10 12 15 17 10
c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10);
// line 12 15 12 3
c.moveTo(12, 15); c.lineTo(12, 3);
c.stroke();
});
// 2. Prev
drawIcon(1, (c) => {
c.beginPath();
// M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z
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();
});
// 3. Play/Pause
drawIcon(2, (c) => {
// Circle bg
c.translate(12, 12);
c.scale(2.5, 2.5); // Bigger button
c.translate(-12, -12);
c.beginPath();
c.arc(12, 12, 30, 0, Math.PI * 2);
// c.stroke(); // circle border
if (isPlaying) {
// Pause: M6 19h4V5H6v14zm8-14v14h4V5h-4z
c.beginPath();
c.rect(6, 5, 4, 14);
c.rect(14, 5, 4, 14);
c.fill();
} else {
// Play: M8 5v14l11-7z
c.beginPath();
c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath();
c.fill();
}
});
// 4. Next
drawIcon(3, (c) => {
c.beginPath();
// M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z
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();
});
// 5. List/Mode
drawIcon(4, (c) => {
c.beginPath();
// M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z
// Simplified rect drawing or Path2D
const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z");
c.fill(p);
});
// 6. Rec Toggle
drawIcon(5, (c) => {
c.beginPath();
c.arc(12, 12, 6, 0, Math.PI * 2);
// 如果正在录制,显示红色
if (recordEnabled) {
c.fillStyle = '#ff3b30';
c.fill();
// Reset fillStyle
c.fillStyle = '#fff';
} else {
c.fill(); // White dot
}
// Ring
// c.beginPath();
// c.arc(12, 12, 10, 0, Math.PI * 2);
// c.stroke();
});
ctx.restore();
}
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 drawCanvasFrame(containerRect, dpr) {
if (!mvCtx || !mvCanvas) return;
const ctx = mvCtx;
const w = mvCanvas.width;
const h = mvCanvas.height;
// 清空
ctx.clearRect(0, 0, w, h);
// 1. 背景 (.bg-image)
// 由于 filter: blur 在 Canvas 中性能尚可,我们尝试还原
// --- 1. 背景 (.bg-layer) ---
// CSS: filter: blur(60px) brightness(0.6); transform: scale(1.1);
ctx.save();
// 模拟 scale(1.1) 或呼吸效果
const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1;
const imgW = w * scale;
const imgH = h * scale;
const bgX = (w - imgW) / 2;
const bgY = (h - imgH) / 2;
// 绘制背景图
if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
// 性能优化Canvas filter 性能开销大,但在录制时为了画质可以接受
// 或者使用多步缩小+放大来模拟模糊,这里直接使用 filter
// 注意:部分浏览器可能不支持 context.filter
if (ctx.filter !== undefined) {
ctx.filter = 'blur(60px) brightness(0.6)';
ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
ctx.filter = 'none'; // 重置
} else {
// Fallback: 绘制暗色蒙层模拟 brightness模糊较难模拟
ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(0,0,w,h);
}
} else {
ctx.fillStyle = '#1a82ea';
ctx.fillRect(0, 0, w, h);
}
// 绘制蒙版 .bg-mask
// CSS: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.9));
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
ctx.restore();
// --- 1.1 绘制粒子 (Particles) ---
// 严格复刻 createParticles 和 CSS floatUp 动画逻辑
// CSS: bottom: -50px; left: random%; animation: floatUp 15s linear infinite;
// keyframes floatUp:
// 0%: translateY(0) scale(0.5), opacity: 0
// 20%: opacity: 0.4
// 80%: opacity: 0.2
// 100%: translateY(-100vh) scale(1.5), opacity: 0
ctx.save();
const now = Date.now();
// 使用固定的种子来模拟粒子,确保录制时粒子位置连贯
const particleCount = 20;
for (let i = 0; i < particleCount; i++) {
const seed = i * 1337;
// 模拟 CSS: animation-duration: 10s + random*10s -> 10000ms - 20000ms
const duration = 10000 + (seed % 10000);
// 模拟 CSS: animation-delay: random*5s -> 0 - 5000ms
const delay = (seed % 5000);
const time = (now + delay) % duration;
const p = time / duration; // 0 -> 1 进度
// CSS: left: random%
// 必须与 seed 绑定,保证每一帧位置不变
const leftPercent = ((seed * 7) % 100) / 100;
const x = leftPercent * w;
// CSS: bottom: -50px -> translateY(-100vh)
// startY = h + 50 (approx for bottom -50px)
// endY = startY - h - 100 (move up by 100vh + extra)
// Simplified: linear move from bottom to top
const startY = h + 50 * dpr;
const endY = -50 * dpr;
const y = startY - p * (startY - endY);
// CSS: size: random * 4 + 2 px
const baseSize = (2 + (seed % 4)) * dpr;
// Animation: Scale 0.5 -> 1.5
const scale = 0.5 + p * 1.0;
const currentSize = baseSize * scale;
// Animation: Opacity
let opacity = 0;
if (p < 0.2) {
// 0% -> 20%: 0 -> 0.4
opacity = (p / 0.2) * 0.4;
} else if (p < 0.8) {
// 20% -> 80%: 0.4 -> 0.2
opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2;
} else {
// 80% -> 100%: 0.2 -> 0
opacity = 0.2 * (1 - (p - 0.8) / 0.2);
}
ctx.beginPath();
ctx.arc(x, y, currentSize / 2, 0, Math.PI * 2); // size is width/diameter
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.fill();
}
ctx.restore();
// 3. 歌名 (.header-title)
drawDomElement(ctx, els.songTitle, dpr, containerRect, (c, cw, ch) => {
c.font = `bold ${22 * dpr}px sans-serif`;
c.fillStyle = '#ffffff';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.shadowColor = 'rgba(0,0,0,0.5)';
c.shadowBlur = 10 * dpr;
c.shadowOffsetY = 4 * dpr;
c.fillText(els.songTitle.innerText, cw / 2, ch / 2);
});
// Mini Lyrics
if (!isLyricView) {
const miniLines = document.querySelectorAll('.mini-line');
miniLines.forEach(line => {
drawDomElement(ctx, line, dpr, containerRect, (c, cw, ch) => {
const style = getComputedStyle(line);
c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
c.fillStyle = style.color; // Might handle rgba
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(line.innerText, cw / 2, ch / 2);
});
});
}
// 4. Disc Mode
if (!isLyricView) {
// Disc Container
const discContainer = document.querySelector('.disc-container');
drawDomElement(ctx, discContainer, dpr, containerRect, (c, cw, ch) => {
// Ripples
if (isPlaying) {
const cx = cw / 2;
const cy = ch / 2;
const discRadius = Math.min(cw, ch) / 2; // Approximate
for (let i = 0; i < 3; i++) {
const duration = 2500;
const delay = i * 800;
const time = (now - delay) % duration;
if (time >= 0) {
const p = time / duration;
const r = discRadius * (1 + p * 0.6);
const op = 0.4 * (1 - p);
c.beginPath();
c.arc(cx, cy, r, 0, Math.PI * 2);
c.strokeStyle = `rgba(255, 255, 255, ${op})`;
c.lineWidth = (4 * (1 - p)) * dpr;
c.stroke();
}
}
}
// Disc
c.save();
c.translate(cw/2, ch/2);
if (isPlaying) {
const angle = (Date.now() / 5000) * 360 % 360;
c.rotate(angle * Math.PI / 180);
}
const r = Math.min(cw, ch) / 2; // .disc size
// Black vinyl
c.beginPath();
c.arc(0, 0, r, 0, Math.PI * 2);
c.fillStyle = '#111';
c.fill();
c.lineWidth = 6 * dpr;
c.strokeStyle = '#080808';
c.stroke();
// Cover
const coverR = r * 0.65;
c.save();
c.beginPath();
c.arc(0, 0, coverR, 0, Math.PI * 2);
c.clip();
if (isPlaying) {
const beatScale = 1 + Math.sin(Date.now() / 200) * 0.01;
c.scale(beatScale, beatScale);
}
if (els.coverImg.complete) {
c.drawImage(els.coverImg, -coverR, -coverR, coverR * 2, coverR * 2);
}
c.restore();
c.restore();
});
// Needle
// Needle rotation logic is CSS transform based.
// We can use the CSS transform matrix or just replicate logic.
// Replicating logic is smoother.
drawDomElement(ctx, els.needle, dpr, containerRect, (c, cw, ch) => {
// The needle element in DOM is a wrapper.
// We need to draw the parts.
// CSS: transform-origin: 40px 20px;
// We are already translated to top-left of .needle
const pivotX = 40 * dpr; // 40px * dpr
const pivotY = 20 * dpr;
c.translate(pivotX, pivotY);
const angle = isPlaying ? -5 : -35;
c.rotate(angle * Math.PI / 180);
c.translate(-pivotX, -pivotY); // Rotate around pivot
// Draw Rod
c.fillStyle = '#ccc';
c.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
// Draw Head
c.fillStyle = '#1a1a1a';
c.fillRect(28 * dpr, 130 * dpr, 24 * dpr, 38 * dpr);
// Draw Pivot
c.beginPath();
c.arc(pivotX, pivotY, 20 * dpr, 0, Math.PI * 2);
c.fillStyle = '#e0e0e0';
c.fill();
});
}
// 5. Lyrics Mode
if (isLyricView) {
const lines = document.querySelectorAll('.lyric-line');
// Only draw lines that are inside the lyricsBox view
const box = document.getElementById('lyricsBox');
const boxRect = box.getBoundingClientRect();
// Draw mask for scroll
// Canvas doesn't have ease mask-image.
// We can just draw all texts and let them be clipped by viewport?
// No, we need to respect the container rect.
drawDomElement(ctx, box, dpr, containerRect, (c, cw, ch) => {
c.beginPath();
c.rect(0, 0, cw, ch);
c.clip(); // Clip to lyrics box
// Now draw lines relative to this box?
// No, drawDomElement translates to box top-left.
// But lines move relative to box.
// We should iterate lines and calculate their relative position to box.
lines.forEach(line => {
const lr = line.getBoundingClientRect();
// Check visibility
if (lr.bottom < boxRect.top || lr.top > boxRect.bottom) return;
const lx = (lr.left - boxRect.left) * dpr;
const ly = (lr.top - boxRect.top) * dpr;
c.save();
c.translate(lx, ly);
const style = getComputedStyle(line);
c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
c.fillStyle = style.color;
c.textAlign = 'center';
c.textBaseline = 'top'; // DOM text aligns top usually
// Text wrap is hard in Canvas. Assuming single line for now or simple wrap.
// For lyrics, usually centered single line.
c.fillText(line.innerText, (lr.width * dpr) / 2, 0);
c.restore();
});
});
}
// 6. Controls
// 绘制 controls-area 背景 (Glassmorphism)
// CSS: background: rgba(20, 20, 20, 0.6); backdrop-filter: blur(20px) saturate(180%);
// border-top: 1px solid rgba(255, 255, 255, 0.08);
// box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
const controlsEl = document.querySelector('.controls-area');
if (controlsEl) {
const cr = controlsEl.getBoundingClientRect();
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.5)';
ctx.shadowBlur = 40 * dpr;
ctx.shadowOffsetY = -10 * dpr;
// 背景
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
// 绘制路径 (左上右上圆角)
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)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1 * dpr;
ctx.stroke();
ctx.restore();
}
// Progress Bar
drawDomElement(ctx, els.progressBar, dpr, containerRect, (c, cw, ch) => {
c.fillStyle = 'rgba(255, 255, 255, 0.15)';
c.beginPath();
c.roundRect(0, 0, cw, ch, 2 * dpr);
c.fill();
});
drawDomElement(ctx, els.progressFill, dpr, containerRect, (c, cw, ch) => {
const grad = c.createLinearGradient(0, 0, cw, 0);
grad.addColorStop(0, '#fff');
grad.addColorStop(1, '#d4af37');
c.fillStyle = grad;
c.beginPath();
c.roundRect(0, 0, cw, ch, 2 * dpr);
c.fill();
// Dot
const dot = document.querySelector('.progress-dot');
if (dot) {
const dr = dot.getBoundingClientRect();
// Draw dot relative to fill? No, relative to fill right end.
// But simpler to just draw circle at cw.
c.beginPath();
c.arc(cw, ch/2, 6 * dpr, 0, Math.PI * 2);
c.fillStyle = '#fff';
c.fill();
}
});
// Times
drawDomElement(ctx, els.currTime, dpr, containerRect, (c, cw, ch) => {
c.font = `${11 * dpr}px monospace`;
c.fillStyle = 'rgba(255, 255, 255, 0.5)';
c.fillText(els.currTime.innerText, 0, ch);
});
drawDomElement(ctx, els.totalTime, dpr, containerRect, (c, cw, ch) => {
c.font = `${11 * dpr}px monospace`;
c.fillStyle = 'rgba(255, 255, 255, 0.5)';
c.textAlign = 'right';
c.fillText(els.totalTime.innerText, cw, ch);
});
// 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();
}
// Draw Icon (SVG)
// We can parse the SVG inside the button
const svg = btn.querySelector('svg');
if (svg && svg.style.display !== 'none') {
// Simplified: Draw fallback icons or parse SVG path
// To be 1:1, we should use the paths we defined before, but mapped to this position.
// Or better: Re-use the drawIcon logic from previous, but passing the context translated to this btn.
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';
// Identify button by index or class is hard.
// Let's use simple logic based on innerHTML or known order.
// Or just draw generic icons based on known button types.
// 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 === 'playBtn') {
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();
}
} 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 - Check SVG content hash or class?
// Simplified:
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);
}
}
}
});
});
}
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>