Files
PC-official/player.html
DESKTOP-RQ919RC\Pc 3118a84750 no message
2025-12-10 16:42:40 +08:00

1799 lines
53 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;
}
/* 顶部区域 (歌名) */
.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;
}
.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;
let displayStream = null;
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() {
if (!mvCtx || !mvCanvas) return;
const w = mvCanvas.width;
const h = mvCanvas.height;
const ctx = mvCtx;
const dpr = window.devicePixelRatio || 1;
// 清空画布
ctx.clearRect(0, 0, w, h);
// --- 1. 绘制背景 ---
// 模拟 .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) {
// 绘制模糊背景图
// 为了性能,不使用 context.filter (部分浏览器支持不佳且慢),而是假设背景是暗色或绘制蒙版
// 这里简单绘制放大的图片并覆盖半透明蒙层
const imgW = w * scale;
const imgH = h * scale;
const x = (w - imgW) / 2;
const y = (h - imgH) / 2;
ctx.drawImage(els.coverImg, x, y, imgW, imgH);
} else {
ctx.fillStyle = '#222';
ctx.fillRect(0, 0, w, h);
}
// 绘制蒙版 .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);
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. 绘制中间内容 (唱片 或 歌词) ---
const centerX = w / 2;
const centerY = h * 0.5; // 垂直居中
if (!isLyricView) {
// === 唱片模式 ===
const discRadius = Math.min(w, h) * 0.35;
ctx.save();
ctx.translate(centerX, centerY);
// 旋转动画
if (isPlaying) {
const angle = (Date.now() / 5000) * 360 % 360;
ctx.rotate(angle * Math.PI / 180);
} else {
// 暂停时不旋转,保持当前角度 (简化处理直接重置或保持0)
// 为了平滑最好记录上次角度这里简化为0
}
// 唱片外圈 (黑色纹理)
ctx.beginPath();
ctx.arc(0, 0, discRadius, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.lineWidth = 6 * dpr;
ctx.strokeStyle = '#080808';
ctx.stroke();
// 唱片纹理 (简单模拟)
ctx.beginPath();
ctx.arc(0, 0, discRadius * 0.9, 0, Math.PI * 2);
ctx.strokeStyle = '#222';
ctx.lineWidth = 2 * dpr;
ctx.stroke();
// 封面图片 (圆形裁切)
const coverRadius = discRadius * 0.65;
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
ctx.clip();
// 封面跳动效果
const beatScale = isPlaying ? (1 + Math.sin(Date.now() / 200) * 0.01) : 1;
ctx.scale(beatScale, beatScale);
if (els.coverImg.complete) {
ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
}
ctx.restore();
ctx.restore(); // 结束唱片旋转
// 绘制唱臂 (Needle)
// 唱臂是固定在顶部的,不随唱片旋转,但随播放状态旋转
ctx.save();
// 唱臂支点位置:唱片上方
const pivotX = centerX;
const pivotY = centerY - discRadius - (40 * dpr);
ctx.translate(pivotX, pivotY); // 移动到支点附近
// 唱臂旋转角度:播放时 -5度暂停时 -35度
// 这里坐标系不同,需要调整
// 假设 pivot 在上方,针头向下
const needleAngle = isPlaying ? -5 : -35;
// 调整绘制原点偏移
ctx.translate(0, -50 * dpr); // 模拟 CSS 的 transform-origin
ctx.rotate(needleAngle * Math.PI / 180);
// 绘制唱臂杆
ctx.fillStyle = '#ccc';
ctx.fillRect(-4 * dpr, 0, 8 * dpr, 140 * dpr);
// 绘制唱头
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(-12 * dpr, 140 * dpr, 24 * dpr, 38 * dpr);
// 绘制支点
ctx.beginPath();
ctx.arc(0, 0, 20 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#e0e0e0';
ctx.fill();
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();
}
}
async function startSilentMV() {
if (!recordEnabled || isMVRecording) return;
// if (isMobile) { msg('移动端暂不支持录制'); return; }
// 初始化 Canvas (基于 .main-container 大小)
// 注意:这里使用固定比例或窗口大小,为了清晰度,使用 devicePixelRatio
const rect = els.mainContainer ? els.mainContainer.getBoundingClientRect() : document.body.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// 创建新的 Canvas 实例用于录制
mvCanvas = document.createElement('canvas');
mvCtx = mvCanvas.getContext('2d');
// 设置 Canvas 尺寸
mvCanvas.width = rect.width * dpr;
mvCanvas.height = rect.height * dpr;
// 启动渲染循环html-to-image 截图
// 目标帧率 120 FPS不再使用 setTimeout 限流,而是全力渲染
const targetFPS = 120;
async function renderLoop() {
if (!isMVRecording) return;
// 标记开始处理
const beginTime = Date.now();
try {
const source = document.querySelector('.main-container');
if (source) {
// 恢复高清录制,限制最大 dpr 为 2 以平衡性能
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = await htmlToImage.toCanvas(source, {
backgroundColor: null,
pixelRatio: dpr,
skipAutoScale: true,
cacheBust: false,
fontEmbedCSS: '', // 禁用字体嵌入,防止崩溃
filter: (node) => {
// 过滤无效图片防止崩溃
if (node.tagName === 'IMG' && (!node.src || node.src === window.location.href)) return false;
return true;
},
style: {
transform: 'translateZ(0)'
}
});
console.log('canvas', canvas);
// 将捕捉到的画面绘制到录制画布上
mvCtx.clearRect(0, 0, mvCanvas.width, mvCanvas.height);
mvCtx.drawImage(canvas, 0, 0, mvCanvas.width, mvCanvas.height);
}
} catch (e) {
console.error('Frame capture error:', e);
}
// 不再使用 setTimeout 进行延时,避免 JS 定时器精度问题导致的抖动
// 直接请求下一帧,让浏览器决定最佳时机(通常是 60Hz如果设备支持高刷则更高
// 这样可以消除人为引入的卡顿
console.log('isMVRecording', isMVRecording);
if (isMVRecording) {
mvRafId = requestAnimationFrame(renderLoop);
console.log('mvRafId', mvRafId);
}
}
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('Audio capture failed:', e);
}
}
mvChunks = [];
const optsList = [
{ mimeType: 'video/mp4;codecs=h264,aac' },
{ mimeType: 'video/mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus' },
{ mimeType: 'video/webm;codecs=vp8,opus' },
{ mimeType: 'video/webm' }
];
let opts = {};
for (const o of optsList) {
if (MediaRecorder.isTypeSupported(o.mimeType)) {
opts = {
...o,
videoBitsPerSecond: 25000000 // 25 Mbps 高码率,确保视频高清
};
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);
}
};
mvRecorder.start();
isMVRecording = true;
if (els.recIndicator) els.recIndicator.style.display = 'flex';
if (els.recToggleBtn) els.recToggleBtn.style.color = '#ff3b30';
}
function stopSilentMV() {
if (!isMVRecording) return;
try { mvRecorder && mvRecorder.stop(); } catch (_) { }
try { if (mvStream) mvStream.getTracks().forEach(t => t.stop()); } catch (_) { }
try { if (displayStream) displayStream.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>