From 0a90e17d590f21990b442d03fd3cbe841b9cb232 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RQ919RC\\Pc" <1300399510@qq.com> Date: Wed, 10 Dec 2025 19:29:23 +0800 Subject: [PATCH] no message --- player.html | 1069 +++++++++++++++++---------------------------------- 1 file changed, 346 insertions(+), 723 deletions(-) diff --git a/player.html b/player.html index 9c37ce3..86f6725 100644 --- a/player.html +++ b/player.html @@ -794,9 +794,9 @@
+
@@ -1403,8 +1403,8 @@ // 确保 containerRect 存在 if (!containerRect) { - const main = document.querySelector('.main-container'); - if (main) containerRect = main.getBoundingClientRect(); + const main = document.querySelector('.main-container'); + if (main) containerRect = main.getBoundingClientRect(); } // 清空画布 @@ -1421,31 +1421,35 @@ ctx.save(); const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1; // 呼吸动画 - if (els.coverImg.complete && els.coverImg.naturalWidth > 0) { - const imgW = w * scale; - const imgH = h * scale; - const x = (w - imgW) / 2; - const y = (h - imgH) / 2; + // 绘制背景图 + // 使用渐变替代背景图片绘制 + // if (els.coverImg.complete && els.coverImg.naturalWidth > 0) { + // const imgW = w * scale; + // const imgH = h * scale; + // const x = (w - imgW) / 2; + // const y = (h - imgH) / 2; - ctx.save(); - // 增加 brightness(0.6) 滤镜以匹配 CSS,防止图片过亮遮盖背景色 - ctx.filter = 'brightness(0.6)'; - ctx.globalAlpha = 0.3; // Match CSS opacity - ctx.drawImage(els.coverImg, x, y, imgW, imgH); - ctx.restore(); - } - - // 绘制蒙版 .bg-mask - // const gradient = ctx.createLinearGradient(0, 0, 0, h); - // // gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)'); - // // gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)'); - // // gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)'); - // // ctx.fillStyle = gradient; - // ctx.fillRect(0, 0, w, h); + // ctx.save(); + // // 增加 brightness(0.6) 滤镜以匹配 CSS,防止图片过亮遮盖背景色 + // ctx.filter = 'brightness(0.6)'; + // ctx.globalAlpha = 0.3; // Match CSS opacity + // ctx.drawImage(els.coverImg, x, y, imgW, imgH); + // ctx.restore(); + // } + // 绘制渐变背景 #1a82ea -> #000 + const bgGradient = ctx.createLinearGradient(0, 0, 0, h); + bgGradient.addColorStop(0, '#1a82ea'); + bgGradient.addColorStop(1, '#000000'); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, w, h); + + // 必须在这里调用一次 restore 以结束 scale(1.1) 的上下文 + ctx.restore(); + // --- 1.1 绘制粒子 (Particles) --- // 模拟 20 个粒子,从下往上浮动 - ctx.save(); + // ctx.save(); // Removed redundant save const now = Date.now(); for (let i = 0; i < 20; i++) { // 使用伪随机数生成固定的粒子属性 @@ -1476,9 +1480,9 @@ ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`; ctx.fill(); } - ctx.restore(); - - ctx.restore(); + // ctx.restore(); // Removed redundant restore (moved up) + + // ctx.restore(); // Removed redundant restore (moved up) // --- 2. 绘制标题 --- ctx.save(); @@ -1510,168 +1514,155 @@ if (!isLyricView) { // === 唱片模式 (.disc-mode) === // 核心思路:分别获取 .disc-container, .disc, .album-cover, .needle 等元素的 rect - - // 1. 光波 (Ripple) - 已移除 + + // 1. 光波 (Ripple) - 位于 .disc-container 中心 + const discContainer = document.querySelector('.disc-container'); + if (discContainer) { + // Ripples (Disabled) + } // 2. 唱臂 (.needle) - 必须放在唱片上面?不,CSS z-index: 20,在唱片(z-index: 2)之上 // 我们先画唱片,再画唱臂 // 3. 唱片 (.disc) const disc = document.querySelector('.disc'); - if (disc) { - 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; + if (disc && discContainer) { + // Fix: Use discContainer for geometry to avoid wobbling caused by CSS rotation of .disc + const dr = discContainer.getBoundingClientRect(); + const dx = (dr.left - containerRect.left + dr.width / 2) * dpr; + const dy = (dr.top - containerRect.top + dr.height / 2) * dpr; + // Use the smaller dimension to ensure it fits, similar to CSS containment + const radius = (Math.min(dr.width, dr.height) / 2) * dpr; + + // Adjust radius slightly if needed to match .disc size (it might have margins? no, it's 100%) + // CSS: .disc { width: 100%; height: 100%; ... border: 6px solid ... } + // The border is usually part of the width/height in border-box. + + ctx.save(); + ctx.translate(dx, dy); - 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); - } + // 旋转:读取 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; + // 唱片本体背景 + // 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(); + + // 纹理 (Fine Vinyl Grooves - Dark Style) + // 纯黑背景下的深色纹路 + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 0.5 * dpr; + // 增加纹理密度 + for (let r = radius * 0.66; r < radius * 0.96; r += 1.5 * dpr) { 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.arc(0, 0, r, 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; + // 边框 border: 6px solid #080808; + ctx.strokeStyle = '#080808'; + ctx.lineWidth = 6 * dpr; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.stroke(); + + // 封面 (.album-cover) + const coverRadius = radius * 0.65; + ctx.save(); + + // 封面边框 + ctx.beginPath(); + ctx.arc(0, 0, coverRadius, 0, Math.PI * 2); + ctx.fillStyle = '#000'; + ctx.fill(); + + // Image + if (els.coverImg.complete) { ctx.beginPath(); - ctx.arc(0, 0, coverRadius, 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 + ctx.arc(0, 0, coverRadius - 2 * dpr, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2); + } + ctx.restore(); // end cover - // 光泽 (.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.save(); ... ctx.restore(); - ctx.restore(); // end disc rotate + ctx.restore(); // end disc rotate } // 4. 唱臂 (.needle) const needle = document.getElementById('needle'); if (needle) { - // CSS: top: -50px; left: 50%; transform-origin: 40px 20px; - // transform: translateX(-10px) rotate(...); - - const parentRect = document.querySelector('.disc-mode').getBoundingClientRect(); - // Calculate base position (before transform) relative to container - // parentRect.width/2 corresponds to left: 50% - // -50 corresponds to top: -50px - const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr; - const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr; + // CSS: top: -50px; left: 50%; transform-origin: 40px 20px; + // transform: translateX(-10px) rotate(...); - ctx.save(); - ctx.translate(needleBaseX, needleBaseY); + const parentRect = document.querySelector('.disc-mode').getBoundingClientRect(); + // Calculate base position (before transform) relative to container + const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr; + const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr; - // 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; + ctx.save(); + ctx.translate(needleBaseX, needleBaseY); - // 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); + // Apply transforms + const originX = 40 * dpr; + const originY = 20 * dpr; + const angle = isPlaying ? -5 : -35; - // 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(); + ctx.translate(originX, originY); + ctx.translate(-10 * dpr, 0); + ctx.rotate(angle * Math.PI / 180); + ctx.translate(-originX, -originY); - // 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); + // Draw Parts (Flat Matte Style - No Reflections) - // 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); + // 1. Pivot + ctx.save(); + ctx.translate(20 * dpr, 0); + ctx.beginPath(); + ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2); + + // 纯色哑光深灰,无渐变 + ctx.fillStyle = '#333'; + ctx.fill(); + + ctx.strokeStyle = '#222'; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); - ctx.restore(); + // Center screw + ctx.beginPath(); + ctx.arc(20 * dpr, 20 * dpr, 6 * dpr, 0, Math.PI * 2); + ctx.fillStyle = '#111'; + ctx.fill(); + ctx.restore(); + + // 2. Rod + // 纯色哑光中灰,无高光 + ctx.fillStyle = '#444'; + ctx.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr); + + // 3. Head + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 38 * dpr); + // Top border + ctx.fillStyle = '#333'; + ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 2 * dpr); + + ctx.restore(); } } else { @@ -1699,7 +1690,7 @@ const idx = activeIndex + i; if (idx >= 0 && idx < lyricLines.length) { const line = lyricLines[idx]; - const y = centerY + i * lineHeight; + const y = (h - boxH) / 2 + boxH / 2 + i * lineHeight; // Center vertically if (i === 0) { // 当前句 @@ -1714,12 +1705,59 @@ ctx.shadowBlur = 0; } - ctx.fillText(line.el.innerText, centerX, y); + ctx.fillText(line.el.innerText, w / 2, y); } } ctx.restore(); } + // --- 3.5 绘制 Controls Area 背景 (优化细节) --- + const controlsEl = document.querySelector('.controls-area'); + if (controlsEl) { + const cr = controlsEl.getBoundingClientRect(); + // 相对于 main-container + const cx = (cr.left - containerRect.left) * dpr; + const cy = (cr.top - containerRect.top) * dpr; + const cw = cr.width * dpr; + const ch = cr.height * dpr; + + ctx.save(); + // 阴影 + ctx.shadowColor = 'rgba(0, 0, 0, 0.6)'; + ctx.shadowBlur = 50 * dpr; + ctx.shadowOffsetY = -15 * dpr; + + // 背景 (加深不透明度,提升对比度) + ctx.fillStyle = 'rgba(20, 20, 20, 0.8)'; + + // 绘制路径 (左上右上圆角) + const radius = 24 * dpr; + ctx.beginPath(); + ctx.moveTo(cx, cy + ch); // 左下 + ctx.lineTo(cx, cy + radius); // 左上起始 + ctx.arcTo(cx, cy, cx + radius, cy, radius); // 左上圆角 + ctx.lineTo(cx + cw - radius, cy); // 顶边 + ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius); // 右上圆角 + ctx.lineTo(cx + cw, cy + ch); // 右下 + ctx.closePath(); + ctx.fill(); + + // 边框 (Top border enhancement - 更亮更粗) + ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.lineWidth = 1.5 * dpr; + ctx.beginPath(); + // 只描绘顶部和圆角,延伸一点下来 + ctx.moveTo(cx, cy + radius + 20 * dpr); + ctx.lineTo(cx, cy + radius); + ctx.arcTo(cx, cy, cx + radius, cy, radius); + ctx.lineTo(cx + cw - radius, cy); + ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius); + ctx.lineTo(cx + cw, cy + radius + 20 * dpr); + ctx.stroke(); + + ctx.restore(); + } + // --- 4. 绘制底部进度条 --- const barY = h * 0.85; const barWidth = w * 0.8; @@ -1761,129 +1799,153 @@ 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); + // --- 5. 绘制音频波形 (精准还原 CSS 动画) --- + const wavesEl = document.querySelector('.music-waves'); + if (wavesEl) { + const rect = wavesEl.getBoundingClientRect(); + // 如果元素不可见(例如被隐藏),则不绘制 + if (rect.width > 0 && rect.height > 0) { + const wx = (rect.left - containerRect.left) * dpr; + const wy = (rect.top - containerRect.top) * dpr; + const ww = rect.width * dpr; + const wh = rect.height * dpr; + const bottomY = wy + wh; + + // 动画参数配置 (对应 CSS) + // 0: dur 0.5s, del 0.1s + // 1: dur 0.7s, del 0.3s + // 2: dur 0.6s, del 0.0s + // 3: dur 0.5s, del 0.4s + // 4: dur 0.8s, del 0.2s + const bars = [ + { dur: 0.5, delay: 0.1 }, + { dur: 0.7, delay: 0.3 }, + { dur: 0.6, delay: 0.0 }, + { dur: 0.5, delay: 0.4 }, + { dur: 0.8, delay: 0.2 } + ]; + + const barW = 3 * dpr; + const gap = 3 * dpr; + const totalContentW = 5 * barW + 4 * gap; + + // 居中计算 + let currentX = wx + (ww - totalContentW) / 2; + + const now = Date.now() / 1000; + + for (let i = 0; i < 5; i++) { + let p = 0; // 0.0 ~ 1.0 + + if (isPlaying) { + const { dur, delay } = bars[i]; + const cycle = dur * 2; // 往返周期 + const t = (now + delay) % cycle; + const rawP = t / dur; + + // Alternate direction + const linearP = rawP <= 1 ? rawP : (2 - rawP); + + // Ease-in-out approximation (Cosine) + p = 0.5 - 0.5 * Math.cos(linearP * Math.PI); + } + + // Interpolate Properties + // Height: 4px -> 16px + const hVal = 4 + (16 - 4) * p; + const hBar = hVal * dpr; + + // Opacity: 0.3 -> 1 + const opVal = 0.3 + (1 - 0.3) * p; + + // Color: #ffffff -> #d4af37 (212, 175, 55) + // CSS var(--theme-color) is #d4af37 + const r = 255 + (212 - 255) * p; + const g = 255 + (175 - 255) * p; + const b = 255 + ( 55 - 255) * p; + + ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${opVal})`; + + // Draw + ctx.beginPath(); + // align bottom + ctx.roundRect(currentX, bottomY - hBar, barW, hBar, 2 * dpr); + ctx.fill(); + + currentX += barW + gap; + } } - 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(); + // 已移除手动绘制,统一使用 DOM 绘制逻辑 + + // Buttons + const btns = document.querySelectorAll('.btn'); + btns.forEach(btn => { + drawDomElement(ctx, btn, dpr, containerRect, (c, cw, ch) => { + // If it's play button, draw circle bg + if (btn.classList.contains('btn-play')) { + c.beginPath(); + c.arc(cw/2, ch/2, cw/2, 0, Math.PI * 2); + c.fillStyle = 'rgba(255, 255, 255, 0.1)'; + c.fill(); + c.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + c.lineWidth = 1 * dpr; + c.stroke(); + } + + // Handle Play Button Explicitly + if (btn.id === 'playBtn') { + c.save(); + c.translate(cw/2, ch/2); + c.scale(dpr, dpr); + c.translate(-12, -12); + c.fillStyle = '#fff'; + + if (isPlaying) { + c.beginPath(); c.rect(6, 5, 4, 14); c.rect(14, 5, 4, 14); c.fill(); + } else { + c.beginPath(); c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath(); c.fill(); + } + c.restore(); + return; + } + + // Draw Icon (SVG) + const svg = btn.querySelector('svg'); + if (svg && svg.style.display !== 'none') { + c.translate(cw/2, ch/2); + c.scale(dpr, dpr); + c.translate(-12, -12); // Assuming 24x24 base + c.fillStyle = '#fff'; + c.strokeStyle = '#fff'; + c.lineWidth = 2; + c.lineCap = 'round'; + c.lineJoin = 'round'; + + // Fallback: Since we know the buttons: + if (btn.onclick && btn.onclick.toString().includes('downloadMusic')) { + // Download + c.beginPath(); c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15); c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10); c.moveTo(12, 15); c.lineTo(12, 3); c.stroke(); + } else if (btn.id === 'recToggleBtn') { + c.beginPath(); c.arc(12, 12, 6, 0, Math.PI * 2); + if (recordEnabled) { c.fillStyle = '#ff3b30'; c.fill(); } else { c.fillStyle = '#fff'; c.fill(); } + } else { + // Prev/Next/Mode + const pathD = svg.querySelector('path').getAttribute('d'); + if (pathD.startsWith('M11 18')) { // Prev + c.beginPath(); c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath(); c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath(); c.fill(); + } else if (pathD.startsWith('M4 18')) { // Next + c.beginPath(); c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath(); c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath(); c.fill(); + } else if (pathD.startsWith('M15 15')) { // Mode + const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z"); c.fill(p); + } + } + } + }); }); - - // 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() { @@ -1983,445 +2045,6 @@ 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 (_) { } @@ -2445,4 +2068,4 @@ - \ No newline at end of file +