@@ -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 @@