diff --git a/player.html b/player.html
index c3b21a8..9c37ce3 100644
--- a/player.html
+++ b/player.html
@@ -126,6 +126,7 @@
flex-direction: column;
align-items: center;
padding-top: 10vh;
+ background: #1a82ea;
}
/* 顶部区域 (歌名) */
@@ -440,6 +441,7 @@
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 {
@@ -1379,7 +1381,6 @@
let mvStream = null;
let mvAudioTrack = null;
let mvRafId = 0;
- let displayStream = null;
els.viewWrapper = document.querySelector('.view-wrapper');
els.captureTarget = document.querySelector('.view-wrapper');
@@ -1393,43 +1394,90 @@
}
// 手动绘制 Canvas 帧(高性能)
- function drawCanvasFrame() {
+ function drawCanvasFrame(containerRect, dpr) {
if (!mvCtx || !mvCanvas) return;
const w = mvCanvas.width;
const h = mvCanvas.height;
const ctx = mvCtx;
- const dpr = window.devicePixelRatio || 1;
+ 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) {
- // 绘制模糊背景图
- // 为了性能,不使用 context.filter (部分浏览器支持不佳且慢),而是假设背景是暗色或绘制蒙版
- // 这里简单绘制放大的图片并覆盖半透明蒙层
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);
- } else {
- ctx.fillStyle = '#222';
- ctx.fillRect(0, 0, w, h);
+ ctx.restore();
}
// 绘制蒙版 .bg-mask
- const gradient = ctx.createLinearGradient(0, 0, 0, h);
- gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
- gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
- gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, w, h);
+ // const gradient = ctx.createLinearGradient(0, 0, 0, h);
+ // // gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
+ // // gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
+ // // gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
+ // // ctx.fillStyle = gradient;
+ // ctx.fillRect(0, 0, w, h);
+
+ // --- 1.1 绘制粒子 (Particles) ---
+ // 模拟 20 个粒子,从下往上浮动
+ ctx.save();
+ const now = Date.now();
+ for (let i = 0; i < 20; i++) {
+ // 使用伪随机数生成固定的粒子属性
+ const seed = i * 1337;
+ const speed = 10000 + (seed % 5000); // 10-15s
+ const delay = (seed % 5000);
+
+ const time = (now + delay) % speed;
+ const p = time / speed; // 0 -> 1
+
+ // floatUp keyframes:
+ // 0%: y=0, op=0, s=0.5
+ // 20%: op=0.4
+ // 80%: op=0.2
+ // 100%: y=-h, op=0, s=1.5
+
+ const y = h - (p * (h + 100)); // 从底向上
+ const x = ((seed * 7) % 100) / 100 * w;
+ const size = (2 + (seed % 4)) * dpr * (0.5 + p); // 变大
+
+ let opacity = 0;
+ if (p < 0.2) opacity = (p / 0.2) * 0.4;
+ else if (p < 0.8) opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2; // 0.4 -> 0.2
+ else opacity = 0.2 * (1 - (p - 0.8) / 0.2); // 0.2 -> 0
+
+ ctx.beginPath();
+ ctx.arc(x, y, size, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
+ ctx.fill();
+ }
+ ctx.restore();
+
ctx.restore();
// --- 2. 绘制标题 ---
@@ -1457,89 +1505,174 @@
ctx.restore();
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
- const centerX = w / 2;
- const centerY = h * 0.5; // 垂直居中
+ // 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
if (!isLyricView) {
- // === 唱片模式 ===
- const discRadius = Math.min(w, h) * 0.35;
+ // === 唱片模式 (.disc-mode) ===
+ // 核心思路:分别获取 .disc-container, .disc, .album-cover, .needle 等元素的 rect
+
+ // 1. 光波 (Ripple) - 已移除
- ctx.save();
- ctx.translate(centerX, centerY);
+ // 2. 唱臂 (.needle) - 必须放在唱片上面?不,CSS z-index: 20,在唱片(z-index: 2)之上
+ // 我们先画唱片,再画唱臂
- // 旋转动画
- if (isPlaying) {
- const angle = (Date.now() / 5000) * 360 % 360;
- ctx.rotate(angle * Math.PI / 180);
- } else {
- // 暂停时不旋转,保持当前角度 (简化处理,直接重置或保持0)
- // 为了平滑,最好记录上次角度,这里简化为0
+ // 3. 唱片 (.disc)
+ const disc = document.querySelector('.disc');
+ if (disc) {
+ const dr = disc.getBoundingClientRect();
+ const dx = (dr.left - containerRect.left + dr.width / 2) * dpr;
+ const dy = (dr.top - containerRect.top + dr.height / 2) * dpr;
+ const radius = (dr.width / 2) * dpr;
+
+ ctx.save();
+ ctx.translate(dx, dy);
+
+ // 旋转:读取 computed style 的 transform 可能会比较麻烦(matrix),
+ // 简单起见,如果正在播放,我们手动模拟旋转角度,或者尝试解析 matrix
+ // 为了平滑录制,手动模拟旋转是最佳实践,因为 captureStream 可能会丢帧导致 matrix 跳变
+ if (isPlaying) {
+ const angle = (Date.now() / 20000) * 360 % 360; // 20s per round
+ ctx.rotate(angle * Math.PI / 180);
+ }
+
+ // 唱片本体背景
+ // background: radial-gradient(circle, #111 0%, #1a1a1a 100%);
+ const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
+ bgGrad.addColorStop(0, '#111');
+ bgGrad.addColorStop(1, '#1a1a1a');
+ ctx.fillStyle = bgGrad;
+ ctx.beginPath();
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // 纹理 (repeating-radial-gradient) - Canvas 模拟比较耗时,画几个圈代替
+ ctx.strokeStyle = '#222';
+ ctx.lineWidth = 2 * dpr;
+ for(let r = radius * 0.68; r < radius * 0.95; r += 6 * dpr) {
+ ctx.beginPath();
+ ctx.arc(0, 0, r, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // 边框 border: 6px solid #080808;
+ ctx.strokeStyle = '#080808';
+ ctx.lineWidth = 6 * dpr; // border is usually inside or outside? CSS border is outside if content-box, inside if border-box. usually inside visual boundary.
+ ctx.beginPath();
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // 封面 (.album-cover)
+ // width: 65%; height: 65%;
+ const coverRadius = radius * 0.65;
+
+ ctx.save();
+ // 封面跳动
+ if (isPlaying) {
+ const beatScale = 1 + Math.sin(Date.now() / 200) * 0.005; // 微弱跳动
+ ctx.scale(beatScale, beatScale);
+ }
+
+ // 封面边框 border: 4px solid #000;
+ ctx.beginPath();
+ ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
+ ctx.fillStyle = '#000';
+ ctx.fill(); // fill black behind image
+
+ // Image
+ if (els.coverImg.complete) {
+ ctx.beginPath();
+ ctx.arc(0, 0, coverRadius - 2 * dpr, 0, Math.PI * 2); // minus border
+ ctx.clip();
+ ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
+ }
+ ctx.restore(); // end cover
+
+ // 光泽 (.disc::before) - 简单画个半透明渐变
+ ctx.beginPath();
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
+ const glossGrad = ctx.createLinearGradient(-radius, -radius, radius, radius);
+ glossGrad.addColorStop(0, 'transparent');
+ glossGrad.addColorStop(0.45, 'transparent');
+ glossGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)');
+ glossGrad.addColorStop(0.55, 'transparent');
+ glossGrad.addColorStop(1, 'transparent');
+ ctx.fillStyle = glossGrad;
+ ctx.fill();
+
+ ctx.restore(); // end disc rotate
}
- // 唱片外圈 (黑色纹理)
- 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();
+ // 4. 唱臂 (.needle)
+ const needle = document.getElementById('needle');
+ if (needle) {
+ // CSS: top: -50px; left: 50%; transform-origin: 40px 20px;
+ // transform: translateX(-10px) rotate(...);
+
+ const parentRect = document.querySelector('.disc-mode').getBoundingClientRect();
+ // Calculate base position (before transform) relative to container
+ // parentRect.width/2 corresponds to left: 50%
+ // -50 corresponds to top: -50px
+ const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr;
+ const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr;
- // 唱片纹理 (简单模拟)
- ctx.beginPath();
- ctx.arc(0, 0, discRadius * 0.9, 0, Math.PI * 2);
- ctx.strokeStyle = '#222';
- ctx.lineWidth = 2 * dpr;
- ctx.stroke();
+ ctx.save();
+ ctx.translate(needleBaseX, needleBaseY);
- // 封面图片 (圆形裁切)
- 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);
+ // Apply transforms: translateX(-10px) then rotate
+ // And respect transform-origin: 40px 20px
+ const originX = 40 * dpr;
+ const originY = 20 * dpr;
+ const angle = isPlaying ? -5 : -35;
+
+ // Matrix order for: transform-origin(ox, oy) + translateX(tx) + rotate(r)
+ // M = T(ox, oy) * T(tx, 0) * R(r) * T(-ox, -oy)
+
+ ctx.translate(originX, originY);
+ ctx.translate(-10 * dpr, 0);
+ ctx.rotate(angle * Math.PI / 180);
+ ctx.translate(-originX, -originY);
+
+ // Draw Parts
+
+ // 1. Pivot (top: 0, left: 20px, w: 40px, h: 40px)
+ ctx.save();
+ ctx.translate(20 * dpr, 0);
+ ctx.beginPath();
+ ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2);
+ const pivotGrad = ctx.createRadialGradient(12 * dpr, 12 * dpr, 0, 20 * dpr, 20 * dpr, 20 * dpr);
+ pivotGrad.addColorStop(0, '#e0e0e0');
+ pivotGrad.addColorStop(1, '#555');
+ ctx.fillStyle = pivotGrad;
+ ctx.fill();
+ ctx.strokeStyle = '#444';
+ ctx.lineWidth = 1 * dpr;
+ ctx.stroke();
+
+ // Center screw
+ ctx.beginPath();
+ ctx.arc(20 * dpr, 20 * dpr, 6 * dpr, 0, Math.PI * 2);
+ ctx.fillStyle = '#222';
+ ctx.fill();
+ ctx.restore();
+
+ // 2. Rod (top: 30px, left: 36px, w: 8px, h: 100px)
+ // Gradient: linear-gradient(90deg, #555, #ccc, #555)
+ const rodGrad = ctx.createLinearGradient(36 * dpr, 0, 44 * dpr, 0);
+ rodGrad.addColorStop(0, '#555');
+ rodGrad.addColorStop(0.5, '#ccc');
+ rodGrad.addColorStop(1, '#555');
+ ctx.fillStyle = rodGrad;
+ ctx.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
+
+ // 3. Head (bottom: 0 -> top: 102px, left: 28px, w: 24px, h: 38px)
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 38 * dpr);
+ // Top border
+ ctx.fillStyle = '#555';
+ ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 2 * dpr);
+
+ ctx.restore();
}
- 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 {
// === 歌词模式 ===
@@ -1640,50 +1773,144 @@
}
ctx.restore();
}
+
+ // --- 6. 绘制按钮组 (SVG) ---
+ // 位于底部
+ const btnY = barY + 40 * dpr;
+ const btnGroupWidth = w * 0.9;
+ // 6个按钮,5个间隙
+ const btnCount = 6;
+ const btnSpacing = btnGroupWidth / (btnCount - 1);
+ const startX = (w - btnGroupWidth) / 2;
+
+ ctx.save();
+ ctx.translate(0, btnY);
+ ctx.strokeStyle = '#fff';
+ ctx.fillStyle = '#fff';
+ ctx.lineWidth = 2 * dpr;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ const drawIcon = (index, pathFunc) => {
+ ctx.save();
+ ctx.translate(startX + index * btnSpacing, 0);
+ ctx.scale(dpr, dpr); // Scale for high DPI
+ ctx.translate(-12, -12); // Center 24x24 icon
+ pathFunc(ctx);
+ ctx.restore();
+ };
+
+ // 1. Download
+ drawIcon(0, (c) => {
+ c.beginPath();
+ // M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4
+ c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15);
+ // polyline 7 10 12 15 17 10
+ c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10);
+ // line 12 15 12 3
+ c.moveTo(12, 15); c.lineTo(12, 3);
+ c.stroke();
+ });
+
+ // 2. Prev
+ drawIcon(1, (c) => {
+ c.beginPath();
+ // M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z
+ c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath();
+ c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath();
+ c.fill();
+ });
+
+ // 3. Play/Pause
+ drawIcon(2, (c) => {
+ // Circle bg
+ c.translate(12, 12);
+ c.scale(2.5, 2.5); // Bigger button
+ c.translate(-12, -12);
+
+ c.beginPath();
+ c.arc(12, 12, 30, 0, Math.PI * 2);
+ // c.stroke(); // circle border
+
+ if (isPlaying) {
+ // Pause: M6 19h4V5H6v14zm8-14v14h4V5h-4z
+ c.beginPath();
+ c.rect(6, 5, 4, 14);
+ c.rect(14, 5, 4, 14);
+ c.fill();
+ } else {
+ // Play: M8 5v14l11-7z
+ c.beginPath();
+ c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath();
+ c.fill();
+ }
+ });
+
+ // 4. Next
+ drawIcon(3, (c) => {
+ c.beginPath();
+ // M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z
+ c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath();
+ c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath();
+ c.fill();
+ });
+
+ // 5. List/Mode
+ drawIcon(4, (c) => {
+ c.beginPath();
+ // M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z
+ // Simplified rect drawing or Path2D
+ const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z");
+ c.fill(p);
+ });
+
+ // 6. Rec Toggle
+ drawIcon(5, (c) => {
+ c.beginPath();
+ c.arc(12, 12, 6, 0, Math.PI * 2);
+ // 如果正在录制,显示红色
+ if (recordEnabled) {
+ c.fillStyle = '#ff3b30';
+ c.fill();
+ // Reset fillStyle
+ c.fillStyle = '#fff';
+ } else {
+ c.fill(); // White dot
+ }
+ // Ring
+ // c.beginPath();
+ // c.arc(12, 12, 10, 0, Math.PI * 2);
+ // c.stroke();
+ });
+
+ ctx.restore();
}
async function startSilentMV() {
if (!recordEnabled || isMVRecording) return;
- // if (isMobile) { msg('移动端暂不支持录制'); return; }
- // 初始化 Canvas (基于 .main-container 大小)
- // 注意:这里使用固定比例或窗口大小,为了清晰度,使用 devicePixelRatio
- const rect = els.mainContainer ? els.mainContainer.getBoundingClientRect() : document.body.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
+ const mainContainer = document.querySelector('.main-container');
+ const rect = mainContainer.getBoundingClientRect();
- // 创建新的 Canvas 实例用于录制
+ // 创建 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 限流,而是全力渲染
+ // 120 FPS
const targetFPS = 120;
async function renderLoop() {
if (!isMVRecording) return;
- console.log('renderLoop');
-
-
- // 标记开始处理
- const beginTime = Date.now();
-
+
try {
- const source = document.querySelector('.main-container');
- if (source) {
- // 使用高性能的手动绘制函数替代 html-to-image
- // 这能将帧率从 5 FPS 提升到 60+ FPS
- drawCanvasFrame();
- }
+ drawCanvasFrame(rect, dpr);
} catch (e) {
- console.error('Frame capture error:', e);
+ console.error('Render error:', e);
}
- // 不再使用 setTimeout 进行延时,避免 JS 定时器精度问题导致的抖动
- // 直接请求下一帧,让浏览器决定最佳时机(通常是 60Hz,如果设备支持高刷则更高)
- // 这样可以消除人为引入的卡顿
if (isMVRecording) {
mvRafId = requestAnimationFrame(renderLoop);
}
@@ -1692,69 +1919,513 @@
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 (audioEl.captureStream) audioStream = audioEl.captureStream();
+ else if (audioEl.mozCaptureStream) audioStream = audioEl.mozCaptureStream();
if (audioStream) {
const audioTrack = audioStream.getAudioTracks()[0];
- if (audioTrack) {
- mvStream.addTrack(audioTrack);
- }
+ if (audioTrack) mvStream.addTrack(audioTrack);
}
- } catch (e) {
- console.warn('Audio capture failed:', e);
- }
+ } catch (e) { console.warn(e); }
}
mvChunks = [];
const optsList = [
- { mimeType: 'video/mp4;codecs=h264,aac' },
+ { mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2' },
{ 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 高码率,确保视频高清
- };
+ 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);
- }
+ if (/mp4/i.test(type)) downloadFile(`${title}.mp4`, blob);
+ else await transcodeToMp4(blob, title);
+
+ // Stop tracks
+ if (mvStream) mvStream.getTracks().forEach(t => t.stop());
};
+
mvRecorder.start();
isMVRecording = true;
if (els.recIndicator) els.recIndicator.style.display = 'flex';
if (els.recToggleBtn) els.recToggleBtn.style.color = '#ff3b30';
}
+ // 辅助函数:绘制 DOM 元素到 Canvas
+ function drawDomElement(ctx, el, dpr, containerRect, drawFn) {
+ if (!el || el.style.display === 'none' || el.style.opacity === '0') return;
+ const r = el.getBoundingClientRect();
+ // 计算相对于 main-container 的坐标
+ const x = (r.left - containerRect.left) * dpr;
+ const y = (r.top - containerRect.top) * dpr;
+ const w = r.width * dpr;
+ const h = r.height * dpr;
+
+ ctx.save();
+ ctx.translate(x, y);
+ drawFn(ctx, w, h, r); // Pass raw rect too if needed
+ ctx.restore();
+ }
+
+ function drawCanvasFrame(containerRect, dpr) {
+ if (!mvCtx || !mvCanvas) return;
+ const ctx = mvCtx;
+ const w = mvCanvas.width;
+ const h = mvCanvas.height;
+
+ // 清空
+ ctx.clearRect(0, 0, w, h);
+
+ // 1. 背景 (.bg-image)
+ // 由于 filter: blur 在 Canvas 中性能尚可,我们尝试还原
+ // --- 1. 背景 (.bg-layer) ---
+ // CSS: filter: blur(60px) brightness(0.6); transform: scale(1.1);
+ ctx.save();
+
+ // 模拟 scale(1.1) 或呼吸效果
+ const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1;
+ const imgW = w * scale;
+ const imgH = h * scale;
+ const bgX = (w - imgW) / 2;
+ const bgY = (h - imgH) / 2;
+
+ // 绘制背景图
+ if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
+ // 性能优化:Canvas filter 性能开销大,但在录制时为了画质可以接受
+ // 或者使用多步缩小+放大来模拟模糊,这里直接使用 filter
+ // 注意:部分浏览器可能不支持 context.filter
+ if (ctx.filter !== undefined) {
+ ctx.filter = 'blur(60px) brightness(0.6)';
+ ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
+ ctx.filter = 'none'; // 重置
+ } else {
+ // Fallback: 绘制暗色蒙层模拟 brightness,模糊较难模拟
+ ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
+ ctx.fillStyle = 'rgba(0,0,0,0.4)';
+ ctx.fillRect(0,0,w,h);
+ }
+ } else {
+ ctx.fillStyle = '#1a82ea';
+ ctx.fillRect(0, 0, w, h);
+ }
+
+ // 绘制蒙版 .bg-mask
+ // CSS: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.9));
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
+ gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
+ gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, w, h);
+ ctx.restore();
+
+ // --- 1.1 绘制粒子 (Particles) ---
+ // 严格复刻 createParticles 和 CSS floatUp 动画逻辑
+ // CSS: bottom: -50px; left: random%; animation: floatUp 15s linear infinite;
+ // keyframes floatUp:
+ // 0%: translateY(0) scale(0.5), opacity: 0
+ // 20%: opacity: 0.4
+ // 80%: opacity: 0.2
+ // 100%: translateY(-100vh) scale(1.5), opacity: 0
+
+ ctx.save();
+ const now = Date.now();
+ // 使用固定的种子来模拟粒子,确保录制时粒子位置连贯
+ const particleCount = 20;
+
+ for (let i = 0; i < particleCount; i++) {
+ const seed = i * 1337;
+ // 模拟 CSS: animation-duration: 10s + random*10s -> 10000ms - 20000ms
+ const duration = 10000 + (seed % 10000);
+ // 模拟 CSS: animation-delay: random*5s -> 0 - 5000ms
+ const delay = (seed % 5000);
+
+ const time = (now + delay) % duration;
+ const p = time / duration; // 0 -> 1 进度
+
+ // CSS: left: random%
+ // 必须与 seed 绑定,保证每一帧位置不变
+ const leftPercent = ((seed * 7) % 100) / 100;
+ const x = leftPercent * w;
+
+ // CSS: bottom: -50px -> translateY(-100vh)
+ // startY = h + 50 (approx for bottom -50px)
+ // endY = startY - h - 100 (move up by 100vh + extra)
+ // Simplified: linear move from bottom to top
+ const startY = h + 50 * dpr;
+ const endY = -50 * dpr;
+ const y = startY - p * (startY - endY);
+
+ // CSS: size: random * 4 + 2 px
+ const baseSize = (2 + (seed % 4)) * dpr;
+
+ // Animation: Scale 0.5 -> 1.5
+ const scale = 0.5 + p * 1.0;
+ const currentSize = baseSize * scale;
+
+ // Animation: Opacity
+ let opacity = 0;
+ if (p < 0.2) {
+ // 0% -> 20%: 0 -> 0.4
+ opacity = (p / 0.2) * 0.4;
+ } else if (p < 0.8) {
+ // 20% -> 80%: 0.4 -> 0.2
+ opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2;
+ } else {
+ // 80% -> 100%: 0.2 -> 0
+ opacity = 0.2 * (1 - (p - 0.8) / 0.2);
+ }
+
+ ctx.beginPath();
+ ctx.arc(x, y, currentSize / 2, 0, Math.PI * 2); // size is width/diameter
+ ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
+ ctx.fill();
+ }
+ ctx.restore();
+
+ // 3. 歌名 (.header-title)
+ drawDomElement(ctx, els.songTitle, dpr, containerRect, (c, cw, ch) => {
+ c.font = `bold ${22 * dpr}px sans-serif`;
+ c.fillStyle = '#ffffff';
+ c.textAlign = 'center';
+ c.textBaseline = 'middle';
+ c.shadowColor = 'rgba(0,0,0,0.5)';
+ c.shadowBlur = 10 * dpr;
+ c.shadowOffsetY = 4 * dpr;
+ c.fillText(els.songTitle.innerText, cw / 2, ch / 2);
+ });
+
+ // Mini Lyrics
+ if (!isLyricView) {
+ const miniLines = document.querySelectorAll('.mini-line');
+ miniLines.forEach(line => {
+ drawDomElement(ctx, line, dpr, containerRect, (c, cw, ch) => {
+ const style = getComputedStyle(line);
+ c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
+ c.fillStyle = style.color; // Might handle rgba
+ c.textAlign = 'center';
+ c.textBaseline = 'middle';
+ c.fillText(line.innerText, cw / 2, ch / 2);
+ });
+ });
+ }
+
+ // 4. Disc Mode
+ if (!isLyricView) {
+ // Disc Container
+ const discContainer = document.querySelector('.disc-container');
+ drawDomElement(ctx, discContainer, dpr, containerRect, (c, cw, ch) => {
+ // Ripples
+ if (isPlaying) {
+ const cx = cw / 2;
+ const cy = ch / 2;
+ const discRadius = Math.min(cw, ch) / 2; // Approximate
+
+ for (let i = 0; i < 3; i++) {
+ const duration = 2500;
+ const delay = i * 800;
+ const time = (now - delay) % duration;
+ if (time >= 0) {
+ const p = time / duration;
+ const r = discRadius * (1 + p * 0.6);
+ const op = 0.4 * (1 - p);
+ c.beginPath();
+ c.arc(cx, cy, r, 0, Math.PI * 2);
+ c.strokeStyle = `rgba(255, 255, 255, ${op})`;
+ c.lineWidth = (4 * (1 - p)) * dpr;
+ c.stroke();
+ }
+ }
+ }
+
+ // Disc
+ c.save();
+ c.translate(cw/2, ch/2);
+ if (isPlaying) {
+ const angle = (Date.now() / 5000) * 360 % 360;
+ c.rotate(angle * Math.PI / 180);
+ }
+ const r = Math.min(cw, ch) / 2; // .disc size
+
+ // Black vinyl
+ c.beginPath();
+ c.arc(0, 0, r, 0, Math.PI * 2);
+ c.fillStyle = '#111';
+ c.fill();
+ c.lineWidth = 6 * dpr;
+ c.strokeStyle = '#080808';
+ c.stroke();
+
+ // Cover
+ const coverR = r * 0.65;
+ c.save();
+ c.beginPath();
+ c.arc(0, 0, coverR, 0, Math.PI * 2);
+ c.clip();
+ if (isPlaying) {
+ const beatScale = 1 + Math.sin(Date.now() / 200) * 0.01;
+ c.scale(beatScale, beatScale);
+ }
+ if (els.coverImg.complete) {
+ c.drawImage(els.coverImg, -coverR, -coverR, coverR * 2, coverR * 2);
+ }
+ c.restore();
+ c.restore();
+ });
+
+ // Needle
+ // Needle rotation logic is CSS transform based.
+ // We can use the CSS transform matrix or just replicate logic.
+ // Replicating logic is smoother.
+ drawDomElement(ctx, els.needle, dpr, containerRect, (c, cw, ch) => {
+ // The needle element in DOM is a wrapper.
+ // We need to draw the parts.
+ // CSS: transform-origin: 40px 20px;
+ // We are already translated to top-left of .needle
+ const pivotX = 40 * dpr; // 40px * dpr
+ const pivotY = 20 * dpr;
+
+ c.translate(pivotX, pivotY);
+ const angle = isPlaying ? -5 : -35;
+ c.rotate(angle * Math.PI / 180);
+ c.translate(-pivotX, -pivotY); // Rotate around pivot
+
+ // Draw Rod
+ c.fillStyle = '#ccc';
+ c.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
+
+ // Draw Head
+ c.fillStyle = '#1a1a1a';
+ c.fillRect(28 * dpr, 130 * dpr, 24 * dpr, 38 * dpr);
+
+ // Draw Pivot
+ c.beginPath();
+ c.arc(pivotX, pivotY, 20 * dpr, 0, Math.PI * 2);
+ c.fillStyle = '#e0e0e0';
+ c.fill();
+ });
+ }
+
+ // 5. Lyrics Mode
+ if (isLyricView) {
+ const lines = document.querySelectorAll('.lyric-line');
+ // Only draw lines that are inside the lyricsBox view
+ const box = document.getElementById('lyricsBox');
+ const boxRect = box.getBoundingClientRect();
+
+ // Draw mask for scroll
+ // Canvas doesn't have ease mask-image.
+ // We can just draw all texts and let them be clipped by viewport?
+ // No, we need to respect the container rect.
+
+ drawDomElement(ctx, box, dpr, containerRect, (c, cw, ch) => {
+ c.beginPath();
+ c.rect(0, 0, cw, ch);
+ c.clip(); // Clip to lyrics box
+
+ // Now draw lines relative to this box?
+ // No, drawDomElement translates to box top-left.
+ // But lines move relative to box.
+ // We should iterate lines and calculate their relative position to box.
+
+ lines.forEach(line => {
+ const lr = line.getBoundingClientRect();
+ // Check visibility
+ if (lr.bottom < boxRect.top || lr.top > boxRect.bottom) return;
+
+ const lx = (lr.left - boxRect.left) * dpr;
+ const ly = (lr.top - boxRect.top) * dpr;
+
+ c.save();
+ c.translate(lx, ly);
+
+ const style = getComputedStyle(line);
+ c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
+ c.fillStyle = style.color;
+ c.textAlign = 'center';
+ c.textBaseline = 'top'; // DOM text aligns top usually
+
+ // Text wrap is hard in Canvas. Assuming single line for now or simple wrap.
+ // For lyrics, usually centered single line.
+ c.fillText(line.innerText, (lr.width * dpr) / 2, 0);
+
+ c.restore();
+ });
+ });
+ }
+
+ // 6. Controls
+ // 绘制 controls-area 背景 (Glassmorphism)
+ // CSS: background: rgba(20, 20, 20, 0.6); backdrop-filter: blur(20px) saturate(180%);
+ // border-top: 1px solid rgba(255, 255, 255, 0.08);
+ // box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
+ const controlsEl = document.querySelector('.controls-area');
+ if (controlsEl) {
+ const cr = controlsEl.getBoundingClientRect();
+ const cx = (cr.left - containerRect.left) * dpr;
+ const cy = (cr.top - containerRect.top) * dpr;
+ const cw = cr.width * dpr;
+ const ch = cr.height * dpr;
+
+ ctx.save();
+ // 阴影
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
+ ctx.shadowBlur = 40 * dpr;
+ ctx.shadowOffsetY = -10 * dpr;
+
+ // 背景
+ ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
+
+ // 绘制路径 (左上右上圆角)
+ const radius = 24 * dpr;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy + ch); // 左下
+ ctx.lineTo(cx, cy + radius); // 左上起始
+ ctx.arcTo(cx, cy, cx + radius, cy, radius); // 左上圆角
+ ctx.lineTo(cx + cw - radius, cy); // 顶边
+ ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius); // 右上圆角
+ ctx.lineTo(cx + cw, cy + ch); // 右下
+ ctx.closePath();
+
+ ctx.fill();
+
+ // 边框 (Top border)
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
+ ctx.lineWidth = 1 * dpr;
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ // Progress Bar
+ drawDomElement(ctx, els.progressBar, dpr, containerRect, (c, cw, ch) => {
+ c.fillStyle = 'rgba(255, 255, 255, 0.15)';
+ c.beginPath();
+ c.roundRect(0, 0, cw, ch, 2 * dpr);
+ c.fill();
+ });
+
+ drawDomElement(ctx, els.progressFill, dpr, containerRect, (c, cw, ch) => {
+ const grad = c.createLinearGradient(0, 0, cw, 0);
+ grad.addColorStop(0, '#fff');
+ grad.addColorStop(1, '#d4af37');
+ c.fillStyle = grad;
+ c.beginPath();
+ c.roundRect(0, 0, cw, ch, 2 * dpr);
+ c.fill();
+
+ // Dot
+ const dot = document.querySelector('.progress-dot');
+ if (dot) {
+ const dr = dot.getBoundingClientRect();
+ // Draw dot relative to fill? No, relative to fill right end.
+ // But simpler to just draw circle at cw.
+ c.beginPath();
+ c.arc(cw, ch/2, 6 * dpr, 0, Math.PI * 2);
+ c.fillStyle = '#fff';
+ c.fill();
+ }
+ });
+
+ // Times
+ drawDomElement(ctx, els.currTime, dpr, containerRect, (c, cw, ch) => {
+ c.font = `${11 * dpr}px monospace`;
+ c.fillStyle = 'rgba(255, 255, 255, 0.5)';
+ c.fillText(els.currTime.innerText, 0, ch);
+ });
+ drawDomElement(ctx, els.totalTime, dpr, containerRect, (c, cw, ch) => {
+ c.font = `${11 * dpr}px monospace`;
+ c.fillStyle = 'rgba(255, 255, 255, 0.5)';
+ c.textAlign = 'right';
+ c.fillText(els.totalTime.innerText, cw, ch);
+ });
+
+ // Buttons
+ const btns = document.querySelectorAll('.btn');
+ btns.forEach(btn => {
+ drawDomElement(ctx, btn, dpr, containerRect, (c, cw, ch) => {
+ // If it's play button, draw circle bg
+ if (btn.classList.contains('btn-play')) {
+ c.beginPath();
+ c.arc(cw/2, ch/2, cw/2, 0, Math.PI * 2);
+ c.fillStyle = 'rgba(255, 255, 255, 0.1)';
+ c.fill();
+ c.strokeStyle = 'rgba(255, 255, 255, 0.2)';
+ c.lineWidth = 1 * dpr;
+ c.stroke();
+ }
+
+ // Draw Icon (SVG)
+ // We can parse the SVG inside the button
+ const svg = btn.querySelector('svg');
+ if (svg && svg.style.display !== 'none') {
+ // Simplified: Draw fallback icons or parse SVG path
+ // To be 1:1, we should use the paths we defined before, but mapped to this position.
+ // Or better: Re-use the drawIcon logic from previous, but passing the context translated to this btn.
+
+ c.translate(cw/2, ch/2);
+ c.scale(dpr, dpr);
+ c.translate(-12, -12); // Assuming 24x24 base
+ c.fillStyle = '#fff';
+ c.strokeStyle = '#fff';
+ c.lineWidth = 2;
+ c.lineCap = 'round';
+ c.lineJoin = 'round';
+
+ // Identify button by index or class is hard.
+ // Let's use simple logic based on innerHTML or known order.
+ // Or just draw generic icons based on known button types.
+
+ // Fallback: Since we know the buttons:
+ if (btn.onclick && btn.onclick.toString().includes('downloadMusic')) {
+ // Download
+ c.beginPath(); c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15); c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10); c.moveTo(12, 15); c.lineTo(12, 3); c.stroke();
+ } else if (btn.id === 'playBtn') {
+ if (isPlaying) {
+ c.beginPath(); c.rect(6, 5, 4, 14); c.rect(14, 5, 4, 14); c.fill();
+ } else {
+ c.beginPath(); c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath(); c.fill();
+ }
+ } else if (btn.id === 'recToggleBtn') {
+ c.beginPath(); c.arc(12, 12, 6, 0, Math.PI * 2);
+ if (recordEnabled) { c.fillStyle = '#ff3b30'; c.fill(); } else { c.fillStyle = '#fff'; c.fill(); }
+ } else {
+ // Prev/Next/Mode - Check SVG content hash or class?
+ // Simplified:
+ const pathD = svg.querySelector('path').getAttribute('d');
+ if (pathD.startsWith('M11 18')) { // Prev
+ c.beginPath(); c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath(); c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath(); c.fill();
+ } else if (pathD.startsWith('M4 18')) { // Next
+ c.beginPath(); c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath(); c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath(); c.fill();
+ } else if (pathD.startsWith('M15 15')) { // Mode
+ const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z"); c.fill(p);
+ }
+ }
+ }
+ });
+ });
+ }
+
function stopSilentMV() {
if (!isMVRecording) return;
try { mvRecorder && mvRecorder.stop(); } catch (_) { }
try { if (mvStream) mvStream.getTracks().forEach(t => t.stop()); } catch (_) { }
- try { if (displayStream) displayStream.getTracks().forEach(t => t.stop()); } catch (_) { }
if (mvRafId) cancelAnimationFrame(mvRafId);
mvRafId = 0;
clearInterval(mvRenderTimer);