Files
PC-official/player.html
DESKTOP-RQ919RC\Pc 489e141f47 feat: 优化音乐播放器录制功能并添加唱片和唱针图片
- 添加静态图片资源 disc.png 和 needle.png
- 优化录制功能,修复暂停时音频延迟问题
- 使用图片替代手动绘制的唱片和唱针
- 改进歌词和标题的绘制逻辑
- 移除调试用的 vconsole
2025-12-11 10:30:12 +08:00

2183 lines
69 KiB
HTML
Raw Permalink 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: 300px;
height: 300px;
/* 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'),
discImg: new Image(),
needleImg: new Image()
};
els.discImg.src = '/static/img/disc.png';
els.needleImg.src = '/static/img/needle.png';
// Animation state for needle
let needleAngle = -35; // Initial angle
let lastTime = 0;
// 初始化
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 && !isMVRecording) {
startSilentMV();
}
// 恢复音频轨道
if (isMVRecording && mvStream) {
// 直接恢复,不延迟
mvStream.getAudioTracks().forEach(t => t.enabled = true);
}
});
els.audio.addEventListener('pause', () => {
isPlaying = false;
updatePlayState();
// 解决:录制时停止播放了但是导出的视频里暂停那段还有音频,且有延迟
// 方案:
// 1. 立即禁用轨道 (enabled = false)
// 2. 如果 MediaRecorder 有缓冲,可能需要 requestData 强制刷新,但这可能会切断文件块
// 3. 延迟通常是因为 audioEl.captureStream 的缓冲区
// 尝试在 pause 时,直接将 MediaStreamTrack 替换为静音轨道?不,太复杂。
// 最直接的方式是立即执行 mute 操作,并尽可能减少缓冲。
// 另外MediaRecorder 的 buffer 也可能导致延迟写入,但这通常不影响录制内容的时间戳。
// 关键在于 audio 元素实际停止输出声音的时间点和 captureStream 捕获到的时间点。
if (isMVRecording && mvStream) {
const audioTracks = mvStream.getAudioTracks();
audioTracks.forEach(track => {
// 立即静音
track.enabled = false;
// 尝试停止轨道以清除缓冲?不,停止了就无法恢复了。
});
// 强制 MediaRecorder 刷新一下数据,可能有助于同步?
if (mvRecorder && mvRecorder.state === 'recording') {
mvRecorder.requestData();
}
}
});
els.audio.addEventListener('ended', () => {
// 播放结束,停止录制
if (isMVRecording) 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 vw = window.innerWidth;
const vh = window.innerHeight;
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. 绘制标题 (.header-info) ---
// 包含: .header-title (songTitle) 和 .mini-lyrics (miniLyrics)
const headerInfoEl = document.querySelector('.header-info');
// 无论是否是歌词模式,只要 headerInfoEl 存在且可见,就应该绘制
// 但在歌词模式下,通常 header-info 会隐藏或者有不同布局?
// 查看 CSS: .header-info { order: 2; ... }
// toggleView 只是切换 .disc-mode 和 .lyrics-mode 的显隐,并没有隐藏 header-info
// 但是在 parseLyrics 中,如果 isLyricView 为 true可能会隐藏 miniLyrics
// 逻辑修正:只要 header-info 可见,就绘制它
if (headerInfoEl && headerInfoEl.style.display !== 'none') {
// 1. 绘制歌名 (.header-title)
const titleEl = document.getElementById('songTitle');
if (titleEl && titleEl.style.display !== 'none') {
const rect = titleEl.getBoundingClientRect();
const y = (rect.top - containerRect.top + rect.height / 2) * dpr;
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`; // Match CSS .header-title
ctx.fillText(titleEl.innerText, w / 2, y);
ctx.restore();
}
// 2. 绘制 Mini Lyrics (.mini-lyrics)
// 仅当不在歌词模式下显示 (isLyricView=false),或者如果页面上在歌词模式下也显示(通常不会)
// 根据 updateMiniLyrics 逻辑: if (els.miniLyrics) els.miniLyrics.style.display = isLyricView ? 'none' : 'block';
// 所以 miniLyrics 只在非歌词模式下显示。
if (!isLyricView) {
const miniEl = document.querySelector('.mini-lyrics');
if (miniEl && miniEl.style.display !== 'none') {
const rect = miniEl.getBoundingClientRect();
const centerY = (rect.top - containerRect.top + rect.height / 2) * dpr;
const lineHeight = 22 * dpr; // 22px line height
const lines = miniEl.querySelectorAll('.mini-line');
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (lines.length >= 3) {
// Prev
ctx.font = `${14 * dpr}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.fillText(lines[0].innerText, w / 2, centerY - lineHeight);
// Active
ctx.font = `bold ${18 * dpr}px sans-serif`;
ctx.fillStyle = '#d4af37';
ctx.fillText(lines[1].innerText, w / 2, centerY);
// Next
ctx.font = `${14 * dpr}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.fillText(lines[2].innerText, w / 2, centerY + lineHeight);
} else {
// Fallback if not 3 lines structure
const activeLine = miniEl.querySelector('.mini-line.active');
if (activeLine) {
ctx.font = `bold ${18 * dpr}px sans-serif`;
ctx.fillStyle = '#d4af37';
ctx.fillText(activeLine.innerText, w / 2, centerY);
}
}
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);
}
// 唱片本体 - 使用图片 /img/disc.png
// const imgSize = 420 * dpr; // 最大值300
const imgSize = 420 * dpr; // 最大值300
ctx.drawImage(els.discImg, -imgSize / 2, -imgSize / 2, imgSize, imgSize);
// 封面 (.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(...);
// 定位调整10vh - 50px
// containerRect 是 main-container 的 rect
// .disc-mode 位于 .view-wrapper 内, .view-wrapper 是 flex: 1位于 header-info 之下
// 页面结构: header-info (padding-top: 10vh) -> view-wrapper -> disc-mode
// 因此 disc-mode 的 top 实际上就是 view-wrapper 的 top
// 这里的 needle 是 absolute, top: -50px
// 我们需要计算相对于 canvas (main-container) 的绝对位置
// 使用视口高度计算 10vh
// main-container 的高度应该接近视口高度
// top = 10vh - 50px
const topPos = (vh * 0.1 - 50) * dpr;
const centerX = w / 2; // canvas 宽度的一半,即居中
const needleBaseX = centerX;
const needleBaseY = topPos;
ctx.save();
ctx.translate(needleBaseX, needleBaseY);
// Apply transforms
const originX = 40 * dpr;
const originY = 20 * dpr;
// CSS transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
// Target angles
const targetAngle = isPlaying ? -5 : -35;
// Smooth animation logic
// Determine speed based on frametime or fixed step
// Since this is 120fps recording, we can use a small step
// But to match CSS transition time (0.6s), we need to interpolate.
const diff = targetAngle - needleAngle;
if (Math.abs(diff) > 0.1) {
// Ease-out-ish interpolation
// Move 10% of the difference per frame (simple easing)
// Or use linear speed
// CSS is 0.6s. At 120fps that's 72 frames.
// diff / 72 would be linear.
// Let's use simple lerp for smoothness
needleAngle += diff * 0.08;
} else {
needleAngle = targetAngle;
}
ctx.translate(originX, originY);
ctx.translate(-10 * dpr, 0);
ctx.rotate(needleAngle * Math.PI / 180);
ctx.translate(-originX, -originY);
const nW = 80 * dpr;
const nH = 140 * dpr;
// 绘制图片,使其 (40*dpr, 20*dpr) 对应原点
// console.log(-14 * dpr, -20 * dpr, nW, nH);
ctx.drawImage(els.needleImg, 0, 0, nW, nH);
ctx.restore();
}
} else {
// === 歌词模式 ===
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 绘制标题 "歌词 LYRICS"
const headerEl = document.querySelector('.lyrics-header');
if (headerEl) {
const r = headerEl.getBoundingClientRect();
const hy = (r.top - containerRect.top + r.height/2) * dpr;
ctx.font = `bold ${18 * dpr}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
// 标题需要左对齐一点,或者根据 .lyrics-header 的实际位置
// CSS: .lyrics-header { text-align: center; }
// So center alignment is correct relative to width.
ctx.fillText('歌词 LYRICS', w / 2, hy);
}
// 歌词滚动区域
const scrollEl = document.getElementById('lyricsBox');
if (scrollEl) {
const r = scrollEl.getBoundingClientRect();
// Clip to scroll area
const sx = (r.left - containerRect.left) * dpr;
const sy = (r.top - containerRect.top) * dpr;
const sw = r.width * dpr;
const sh = r.height * dpr;
ctx.beginPath();
ctx.rect(sx, sy, sw, sh);
ctx.clip();
// 绘制歌词
const lines = scrollEl.querySelectorAll('.lyric-line');
lines.forEach(line => {
const lr = line.getBoundingClientRect();
// Check visibility in scroll box
if (lr.bottom > r.top && lr.top < r.bottom) {
const ly = (lr.top - containerRect.top + lr.height/2) * dpr;
// Styles match CSS .lyric-line and .lyric-line.active
if (line.classList.contains('active')) {
// .lyric-line.active
// color: var(--theme-color) -> #d4af37
// font-size: 22px
// font-weight: bold
// text-shadow: 0 0 15px rgba(212, 175, 55, 0.4)
// transform: scale(1.05) - boundingClientRect already includes transform scaling!
ctx.font = `bold ${22 * dpr}px sans-serif`;
ctx.fillStyle = '#d4af37';
ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
ctx.shadowBlur = 15 * dpr; // Increased to match CSS 15px
} else {
// .lyric-line
// font-size: 16px
// color: rgba(255, 255, 255, 0.4)
ctx.font = `${16 * dpr}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
ctx.fillText(line.innerText, w / 2, ly);
}
});
}
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.save();
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.restore();
// 时间文字
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>