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);