From 489e141f47d43a7e651fc4a3b0663416e21e9851 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RQ919RC\\Pc" <1300399510@qq.com>
Date: Thu, 11 Dec 2025 10:30:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=9F=B3=E4=B9=90?=
=?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?=
=?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=94=B1=E7=89=87=E5=92=8C=E5=94=B1?=
=?UTF-8?q?=E9=92=88=E5=9B=BE=E7=89=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加静态图片资源 disc.png 和 needle.png
- 优化录制功能,修复暂停时音频延迟问题
- 使用图片替代手动绘制的唱片和唱针
- 改进歌词和标题的绘制逻辑
- 移除调试用的 vconsole
---
player.html | 590 +++++++++++++++++++++++++-----------------
static/img/disc.png | Bin 0 -> 109998 bytes
static/img/needle.png | Bin 0 -> 3941 bytes
3 files changed, 351 insertions(+), 239 deletions(-)
create mode 100644 static/img/disc.png
create mode 100644 static/img/needle.png
diff --git a/player.html b/player.html
index 86f6725..7784b40 100644
--- a/player.html
+++ b/player.html
@@ -254,10 +254,10 @@
/* 唱片本体容器 */
.disc-container {
- width: 55vw;
- height: 55vw;
- max-width: 300px;
- max-height: 300px;
+ width: 300px;
+ height: 300px;
+ /* max-width: 300px;
+ max-height: 300px; */
margin-top: 35px;
position: relative;
display: flex;
@@ -748,10 +748,10 @@
-
+
@@ -874,7 +874,8 @@
@@ -888,8 +889,10 @@
-
-
+
+
REC
@@ -930,9 +933,17 @@
miniLyrics: document.getElementById('miniLyrics'),
recIndicator: document.getElementById('recIndicator'),
recToggleBtn: document.getElementById('recToggleBtn'),
- tabVideo: document.getElementById('tabCaptureVideo')
+ tabVideo: document.getElementById('tabCaptureVideo'),
+ discImg: new Image(),
+ needleImg: new Image()
};
+ els.discImg.src = '/static/img/disc.png';
+ els.needleImg.src = '/static/img/needle.png';
+ // Animation state for needle
+ let needleAngle = -35; // Initial angle
+ let lastTime = 0;
+
// 初始化
async function init() {
createParticles();
@@ -1016,16 +1027,49 @@
els.audio.addEventListener('play', () => {
isPlaying = true;
updatePlayState();
- if (recordEnabled) startSilentMV();
+ // 如果录制开启,但没有正在录制,则开始
+ if (recordEnabled && !isMVRecording) {
+ startSilentMV();
+ }
+ // 恢复音频轨道
+ if (isMVRecording && mvStream) {
+ // 直接恢复,不延迟
+ mvStream.getAudioTracks().forEach(t => t.enabled = true);
+ }
});
els.audio.addEventListener('pause', () => {
isPlaying = false;
updatePlayState();
+
+ // 解决:录制时停止播放了但是导出的视频里暂停那段还有音频,且有延迟
+ // 方案:
+ // 1. 立即禁用轨道 (enabled = false)
+ // 2. 如果 MediaRecorder 有缓冲,可能需要 requestData 强制刷新,但这可能会切断文件块
+ // 3. 延迟通常是因为 audioEl.captureStream 的缓冲区
+ // 尝试在 pause 时,直接将 MediaStreamTrack 替换为静音轨道?不,太复杂。
+ // 最直接的方式是立即执行 mute 操作,并尽可能减少缓冲。
+ // 另外,MediaRecorder 的 buffer 也可能导致延迟写入,但这通常不影响录制内容的时间戳。
+ // 关键在于 audio 元素实际停止输出声音的时间点和 captureStream 捕获到的时间点。
+
+ if (isMVRecording && mvStream) {
+ const audioTracks = mvStream.getAudioTracks();
+ audioTracks.forEach(track => {
+ // 立即静音
+ track.enabled = false;
+ // 尝试停止轨道以清除缓冲?不,停止了就无法恢复了。
+ });
+
+ // 强制 MediaRecorder 刷新一下数据,可能有助于同步?
+ if (mvRecorder && mvRecorder.state === 'recording') {
+ mvRecorder.requestData();
+ }
+ }
});
els.audio.addEventListener('ended', () => {
- stopSilentMV();
+ // 播放结束,停止录制
+ if (isMVRecording) stopSilentMV();
});
function downloadFile(name, blob) {
@@ -1396,6 +1440,9 @@
// 手动绘制 Canvas 帧(高性能)
function drawCanvasFrame(containerRect, dpr) {
if (!mvCtx || !mvCanvas) return;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
const w = mvCanvas.width;
const h = mvCanvas.height;
const ctx = mvCtx;
@@ -1419,7 +1466,7 @@
// 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; // 呼吸动画
+ // const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1; // 呼吸动画
// 绘制背景图
// 使用渐变替代背景图片绘制
@@ -1443,10 +1490,10 @@
bgGradient.addColorStop(1, '#000000');
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, w, h);
-
+
// 必须在这里调用一次 restore 以结束 scale(1.1) 的上下文
- ctx.restore();
-
+ ctx.restore();
+
// --- 1.1 绘制粒子 (Particles) ---
// 模拟 20 个粒子,从下往上浮动
// ctx.save(); // Removed redundant save
@@ -1481,32 +1528,85 @@
ctx.fill();
}
// ctx.restore(); // Removed redundant restore (moved up)
-
+
// ctx.restore(); // Removed redundant restore (moved up)
- // --- 2. 绘制标题 ---
- ctx.save();
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillStyle = '#ffffff';
- ctx.shadowColor = 'rgba(0,0,0,0.5)';
- ctx.shadowBlur = 10 * dpr;
- ctx.shadowOffsetY = 4 * dpr;
- ctx.font = `bold ${22 * dpr}px sans-serif`;
- // 顶部留出空间,大约 15% 高度处
- ctx.fillText(els.songTitle.innerText, w / 2, h * 0.15);
+ // --- 2. 绘制标题 (.header-info) ---
+ // 包含: .header-title (songTitle) 和 .mini-lyrics (miniLyrics)
+
+ const headerInfoEl = document.querySelector('.header-info');
+ // 无论是否是歌词模式,只要 headerInfoEl 存在且可见,就应该绘制
+ // 但在歌词模式下,通常 header-info 会隐藏或者有不同布局?
+ // 查看 CSS: .header-info { order: 2; ... }
+ // toggleView 只是切换 .disc-mode 和 .lyrics-mode 的显隐,并没有隐藏 header-info
+ // 但是在 parseLyrics 中,如果 isLyricView 为 true,可能会隐藏 miniLyrics
+ // 逻辑修正:只要 header-info 可见,就绘制它
+
+ if (headerInfoEl && headerInfoEl.style.display !== 'none') {
+ // 1. 绘制歌名 (.header-title)
+ const titleEl = document.getElementById('songTitle');
+ if (titleEl && titleEl.style.display !== 'none') {
+ const rect = titleEl.getBoundingClientRect();
+ const y = (rect.top - containerRect.top + rect.height / 2) * dpr;
+
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#ffffff';
+ ctx.shadowColor = 'rgba(0,0,0,0.5)';
+ ctx.shadowBlur = 10 * dpr;
+ ctx.shadowOffsetY = 4 * dpr;
+ ctx.font = `bold ${22 * dpr}px sans-serif`; // Match CSS .header-title
+ ctx.fillText(titleEl.innerText, w / 2, y);
+ ctx.restore();
+ }
- // 绘制 Mini Lyrics (如果不在歌词模式)
- if (!isLyricView) {
- // 简单绘制一行当前歌词
- const activeLine = document.querySelector('.mini-line.active');
- if (activeLine) {
- ctx.font = `bold ${16 * dpr}px sans-serif`;
- ctx.fillStyle = '#d4af37'; // var(--theme-color)
- ctx.fillText(activeLine.innerText, w / 2, h * 0.22);
+ // 2. 绘制 Mini Lyrics (.mini-lyrics)
+ // 仅当不在歌词模式下显示 (isLyricView=false),或者如果页面上在歌词模式下也显示(通常不会)
+ // 根据 updateMiniLyrics 逻辑: if (els.miniLyrics) els.miniLyrics.style.display = isLyricView ? 'none' : 'block';
+ // 所以 miniLyrics 只在非歌词模式下显示。
+
+ if (!isLyricView) {
+ const miniEl = document.querySelector('.mini-lyrics');
+ if (miniEl && miniEl.style.display !== 'none') {
+ const rect = miniEl.getBoundingClientRect();
+ const centerY = (rect.top - containerRect.top + rect.height / 2) * dpr;
+ const lineHeight = 22 * dpr; // 22px line height
+
+ const lines = miniEl.querySelectorAll('.mini-line');
+
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ if (lines.length >= 3) {
+ // Prev
+ ctx.font = `${14 * dpr}px sans-serif`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+ ctx.fillText(lines[0].innerText, w / 2, centerY - lineHeight);
+
+ // Active
+ ctx.font = `bold ${18 * dpr}px sans-serif`;
+ ctx.fillStyle = '#d4af37';
+ ctx.fillText(lines[1].innerText, w / 2, centerY);
+
+ // Next
+ ctx.font = `${14 * dpr}px sans-serif`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+ ctx.fillText(lines[2].innerText, w / 2, centerY + lineHeight);
+ } else {
+ // Fallback if not 3 lines structure
+ const activeLine = miniEl.querySelector('.mini-line.active');
+ if (activeLine) {
+ ctx.font = `bold ${18 * dpr}px sans-serif`;
+ ctx.fillStyle = '#d4af37';
+ ctx.fillText(activeLine.innerText, w / 2, centerY);
+ }
+ }
+ ctx.restore();
+ }
}
}
- ctx.restore();
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
// 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
@@ -1518,7 +1618,7 @@
// 1. 光波 (Ripple) - 位于 .disc-container 中心
const discContainer = document.querySelector('.disc-container');
if (discContainer) {
- // Ripples (Disabled)
+ // Ripples (Disabled)
}
// 2. 唱臂 (.needle) - 必须放在唱片上面?不,CSS z-index: 20,在唱片(z-index: 2)之上
@@ -1528,16 +1628,16 @@
const disc = document.querySelector('.disc');
if (disc && discContainer) {
// Fix: Use discContainer for geometry to avoid wobbling caused by CSS rotation of .disc
- const dr = discContainer.getBoundingClientRect();
+ 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;
-
+ 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);
@@ -1549,38 +1649,16 @@
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();
+ // 唱片本体 - 使用图片 /img/disc.png
- // 纹理 (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, r, 0, Math.PI * 2);
- ctx.stroke();
- }
-
- // 边框 border: 6px solid #080808;
- ctx.strokeStyle = '#080808';
- ctx.lineWidth = 6 * dpr;
- ctx.beginPath();
- ctx.arc(0, 0, radius, 0, Math.PI * 2);
- ctx.stroke();
+ // const imgSize = 420 * dpr; // 最大值300
+ const imgSize = 420 * dpr; // 最大值300
+ ctx.drawImage(els.discImg, -imgSize / 2, -imgSize / 2, imgSize, imgSize);
// 封面 (.album-cover)
const coverRadius = radius * 0.65;
ctx.save();
-
+
// 封面边框
ctx.beginPath();
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
@@ -1609,10 +1687,23 @@
// 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
- const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr;
- const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr;
+ // 定位调整:10vh - 50px
+ // containerRect 是 main-container 的 rect
+ // .disc-mode 位于 .view-wrapper 内, .view-wrapper 是 flex: 1,位于 header-info 之下
+ // 页面结构: header-info (padding-top: 10vh) -> view-wrapper -> disc-mode
+ // 因此 disc-mode 的 top 实际上就是 view-wrapper 的 top
+ // 这里的 needle 是 absolute, top: -50px
+ // 我们需要计算相对于 canvas (main-container) 的绝对位置
+
+ // 使用视口高度计算 10vh
+
+ // main-container 的高度应该接近视口高度
+ // top = 10vh - 50px
+ const topPos = (vh * 0.1 - 50) * dpr;
+ const centerX = w / 2; // canvas 宽度的一半,即居中
+
+ const needleBaseX = centerX;
+ const needleBaseY = topPos;
ctx.save();
ctx.translate(needleBaseX, needleBaseY);
@@ -1620,47 +1711,40 @@
// Apply transforms
const originX = 40 * dpr;
const originY = 20 * dpr;
- const angle = isPlaying ? -5 : -35;
+
+ // CSS transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+ // Target angles
+ const targetAngle = isPlaying ? -5 : -35;
+
+ // Smooth animation logic
+ // Determine speed based on frametime or fixed step
+ // Since this is 120fps recording, we can use a small step
+ // But to match CSS transition time (0.6s), we need to interpolate.
+
+ const diff = targetAngle - needleAngle;
+ if (Math.abs(diff) > 0.1) {
+ // Ease-out-ish interpolation
+ // Move 10% of the difference per frame (simple easing)
+ // Or use linear speed
+ // CSS is 0.6s. At 120fps that's 72 frames.
+ // diff / 72 would be linear.
+ // Let's use simple lerp for smoothness
+ needleAngle += diff * 0.08;
+ } else {
+ needleAngle = targetAngle;
+ }
ctx.translate(originX, originY);
ctx.translate(-10 * dpr, 0);
- ctx.rotate(angle * Math.PI / 180);
+ ctx.rotate(needleAngle * Math.PI / 180);
ctx.translate(-originX, -originY);
- // Draw Parts (Flat Matte Style - No Reflections)
-
- // 1. Pivot
- ctx.save();
- ctx.translate(20 * dpr, 0);
- ctx.beginPath();
- ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2);
+ const nW = 80 * dpr;
+ const nH = 140 * dpr;
+ // 绘制图片,使其 (40*dpr, 20*dpr) 对应原点
+ // console.log(-14 * dpr, -20 * dpr, nW, nH);
- // 纯色哑光深灰,无渐变
- ctx.fillStyle = '#333';
- ctx.fill();
-
- ctx.strokeStyle = '#222';
- ctx.lineWidth = 1 * dpr;
- ctx.stroke();
-
- // 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.drawImage(els.needleImg, 0, 0, nW, nH);
ctx.restore();
}
@@ -1671,42 +1755,68 @@
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- const boxH = h * 0.6;
- const startY = (h - boxH) / 2;
-
- // 简单的歌词绘制逻辑:绘制当前句及前后几句
- // 找到 active 的歌词
- let activeIndex = lyricLines.findIndex(l => Math.abs(l.time - els.audio.currentTime) < 0.5);
- // 如果没找到精确匹配,找最近的一个过去的时间
- if (activeIndex === -1) {
- activeIndex = lyricLines.filter(l => l.time <= els.audio.currentTime).length - 1;
+ // 绘制标题 "歌词 LYRICS"
+ const headerEl = document.querySelector('.lyrics-header');
+ if (headerEl) {
+ const r = headerEl.getBoundingClientRect();
+ const hy = (r.top - containerRect.top + r.height/2) * dpr;
+ ctx.font = `bold ${18 * dpr}px sans-serif`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+ // 标题需要左对齐一点,或者根据 .lyrics-header 的实际位置
+ // CSS: .lyrics-header { text-align: center; }
+ // So center alignment is correct relative to width.
+ ctx.fillText('歌词 LYRICS', w / 2, hy);
}
- if (activeIndex === -1) activeIndex = 0;
- const lineHeight = 40 * dpr;
- const maxLines = 7; // 显示行数
+ // 歌词滚动区域
+ const scrollEl = document.getElementById('lyricsBox');
+ if (scrollEl) {
+ const r = scrollEl.getBoundingClientRect();
+ // Clip to scroll area
+ const sx = (r.left - containerRect.left) * dpr;
+ const sy = (r.top - containerRect.top) * dpr;
+ const sw = r.width * dpr;
+ const sh = r.height * dpr;
+
+ ctx.beginPath();
+ ctx.rect(sx, sy, sw, sh);
+ ctx.clip();
- for (let i = -3; i <= 3; i++) {
- const idx = activeIndex + i;
- if (idx >= 0 && idx < lyricLines.length) {
- const line = lyricLines[idx];
- const y = (h - boxH) / 2 + boxH / 2 + i * lineHeight; // Center vertically
-
- if (i === 0) {
- // 当前句
- ctx.font = `bold ${22 * dpr}px sans-serif`;
- ctx.fillStyle = '#d4af37';
- ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
- ctx.shadowBlur = 10;
- } else {
- // 其他句
- ctx.font = `${16 * dpr}px sans-serif`;
- ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
- ctx.shadowBlur = 0;
- }
-
- ctx.fillText(line.el.innerText, w / 2, y);
- }
+ // 绘制歌词
+ const lines = scrollEl.querySelectorAll('.lyric-line');
+ lines.forEach(line => {
+ const lr = line.getBoundingClientRect();
+ // Check visibility in scroll box
+ if (lr.bottom > r.top && lr.top < r.bottom) {
+ const ly = (lr.top - containerRect.top + lr.height/2) * dpr;
+
+ // Styles match CSS .lyric-line and .lyric-line.active
+ if (line.classList.contains('active')) {
+ // .lyric-line.active
+ // color: var(--theme-color) -> #d4af37
+ // font-size: 22px
+ // font-weight: bold
+ // text-shadow: 0 0 15px rgba(212, 175, 55, 0.4)
+ // transform: scale(1.05) - boundingClientRect already includes transform scaling!
+
+ ctx.font = `bold ${22 * dpr}px sans-serif`;
+ ctx.fillStyle = '#d4af37';
+ ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
+ ctx.shadowBlur = 15 * dpr; // Increased to match CSS 15px
+ } else {
+ // .lyric-line
+ // font-size: 16px
+ // color: rgba(255, 255, 255, 0.4)
+
+ ctx.font = `${16 * dpr}px sans-serif`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ }
+
+ ctx.fillText(line.innerText, w / 2, ly);
+ }
+ });
}
ctx.restore();
}
@@ -1728,7 +1838,7 @@
ctx.shadowOffsetY = -15 * dpr;
// 背景 (加深不透明度,提升对比度)
- ctx.fillStyle = 'rgba(20, 20, 20, 0.8)';
+ ctx.fillStyle = 'rgba(20, 20, 20, 0.8)';
// 绘制路径 (左上右上圆角)
const radius = 24 * dpr;
@@ -1743,11 +1853,11 @@
ctx.fill();
// 边框 (Top border enhancement - 更亮更粗)
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
- ctx.lineWidth = 1.5 * dpr;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
+ ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
// 只描绘顶部和圆角,延伸一点下来
- ctx.moveTo(cx, cy + radius + 20 * dpr);
+ 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);
@@ -1784,12 +1894,14 @@
ctx.fill();
// 进度点
+ ctx.save();
ctx.beginPath();
ctx.arc(barX + currentBarWidth, barY + 2 * dpr, 6 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
ctx.shadowBlur = 10 * dpr;
ctx.fill();
+ ctx.restore();
// 时间文字
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
@@ -1828,44 +1940,44 @@
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);
+ 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;
+ const b = 255 + (55 - 255) * p;
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${opVal})`;
-
+
// Draw
ctx.beginPath();
// align bottom
@@ -1880,71 +1992,71 @@
// --- 6. 绘制按钮组 (SVG) ---
// 位于底部
// 已移除手动绘制,统一使用 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();
+ 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);
}
-
- // 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);
- }
- }
- }
- });
+ }
+ }
+ });
});
}
@@ -1966,9 +2078,9 @@
async function renderLoop() {
if (!isMVRecording) return;
-
+
try {
- drawCanvasFrame(rect, dpr);
+ drawCanvasFrame(rect, dpr);
} catch (e) {
console.error('Render error:', e);
}
@@ -2018,7 +2130,7 @@
const type = mvRecorder.mimeType || 'video/webm';
if (/mp4/i.test(type)) downloadFile(`${title}.mp4`, blob);
else await transcodeToMp4(blob, title);
-
+
// Stop tracks
if (mvStream) mvStream.getTracks().forEach(t => t.stop());
};
@@ -2031,18 +2143,18 @@
// 辅助函数:绘制 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();
+ 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 stopSilentMV() {
@@ -2068,4 +2180,4 @@