feat: 优化音乐播放器录制功能并添加唱片和唱针图片
- 添加静态图片资源 disc.png 和 needle.png - 优化录制功能,修复暂停时音频延迟问题 - 使用图片替代手动绘制的唱片和唱针 - 改进歌词和标题的绘制逻辑 - 移除调试用的 vconsole
This commit is contained in:
340
player.html
340
player.html
@@ -254,10 +254,10 @@
|
|||||||
|
|
||||||
/* 唱片本体容器 */
|
/* 唱片本体容器 */
|
||||||
.disc-container {
|
.disc-container {
|
||||||
width: 55vw;
|
width: 300px;
|
||||||
height: 55vw;
|
height: 300px;
|
||||||
max-width: 300px;
|
/* max-width: 300px;
|
||||||
max-height: 300px;
|
max-height: 300px; */
|
||||||
margin-top: 35px;
|
margin-top: 35px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -748,10 +748,10 @@
|
|||||||
<script src="/static/js/ffmpeg.min.js"></script>
|
<script src="/static/js/ffmpeg.min.js"></script>
|
||||||
<script src="/static/js/html2canvas.min.js"></script>
|
<script src="/static/js/html2canvas.min.js"></script>
|
||||||
<script src="/static/js/html-to-image.js"></script>
|
<script src="/static/js/html-to-image.js"></script>
|
||||||
<script src="https://unpkg.com/vconsole@3.15.1/dist/vconsole.min.js"></script>
|
<!-- <script src="https://unpkg.com/vconsole@3.15.1/dist/vconsole.min.js"></script> -->
|
||||||
<script>
|
<script>
|
||||||
// VConsole will be exported to `window.VConsole` by default.
|
// VConsole will be exported to `window.VConsole` by default.
|
||||||
var vConsole = new window.VConsole();
|
// var vConsole = new window.VConsole();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -874,7 +874,8 @@
|
|||||||
<!-- 歌词切换按钮 -->
|
<!-- 歌词切换按钮 -->
|
||||||
<button class="btn btn-side" onclick="toggleView(event)">
|
<button class="btn btn-side" onclick="toggleView(event)">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z" />
|
<path
|
||||||
|
d="M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -888,8 +889,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="recIndicator" style="position:fixed;top:12px;right:12px;z-index:999;display:none;align-items:center;gap:6px;background:rgba(0,0,0,0.4);padding:6px 10px;border-radius:16px;color:#fff;backdrop-filter:blur(6px)">
|
<div id="recIndicator"
|
||||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ff3b30;box-shadow:0 0 10px #ff3b30;animation:recBlink 1s infinite"></span>
|
style="position:fixed;top:12px;right:12px;z-index:999;display:none;align-items:center;gap:6px;background:rgba(0,0,0,0.4);padding:6px 10px;border-radius:16px;color:#fff;backdrop-filter:blur(6px)">
|
||||||
|
<span
|
||||||
|
style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ff3b30;box-shadow:0 0 10px #ff3b30;animation:recBlink 1s infinite"></span>
|
||||||
<span style="font-size:12px;letter-spacing:1px">REC</span>
|
<span style="font-size:12px;letter-spacing:1px">REC</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -930,8 +933,16 @@
|
|||||||
miniLyrics: document.getElementById('miniLyrics'),
|
miniLyrics: document.getElementById('miniLyrics'),
|
||||||
recIndicator: document.getElementById('recIndicator'),
|
recIndicator: document.getElementById('recIndicator'),
|
||||||
recToggleBtn: document.getElementById('recToggleBtn'),
|
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() {
|
async function init() {
|
||||||
@@ -1016,16 +1027,49 @@
|
|||||||
els.audio.addEventListener('play', () => {
|
els.audio.addEventListener('play', () => {
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
updatePlayState();
|
updatePlayState();
|
||||||
if (recordEnabled) startSilentMV();
|
// 如果录制开启,但没有正在录制,则开始
|
||||||
|
if (recordEnabled && !isMVRecording) {
|
||||||
|
startSilentMV();
|
||||||
|
}
|
||||||
|
// 恢复音频轨道
|
||||||
|
if (isMVRecording && mvStream) {
|
||||||
|
// 直接恢复,不延迟
|
||||||
|
mvStream.getAudioTracks().forEach(t => t.enabled = true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
els.audio.addEventListener('pause', () => {
|
els.audio.addEventListener('pause', () => {
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
updatePlayState();
|
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', () => {
|
els.audio.addEventListener('ended', () => {
|
||||||
stopSilentMV();
|
// 播放结束,停止录制
|
||||||
|
if (isMVRecording) stopSilentMV();
|
||||||
});
|
});
|
||||||
|
|
||||||
function downloadFile(name, blob) {
|
function downloadFile(name, blob) {
|
||||||
@@ -1396,6 +1440,9 @@
|
|||||||
// 手动绘制 Canvas 帧(高性能)
|
// 手动绘制 Canvas 帧(高性能)
|
||||||
function drawCanvasFrame(containerRect, dpr) {
|
function drawCanvasFrame(containerRect, dpr) {
|
||||||
if (!mvCtx || !mvCanvas) return;
|
if (!mvCtx || !mvCanvas) return;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
const w = mvCanvas.width;
|
const w = mvCanvas.width;
|
||||||
const h = mvCanvas.height;
|
const h = mvCanvas.height;
|
||||||
const ctx = mvCtx;
|
const ctx = mvCtx;
|
||||||
@@ -1419,7 +1466,7 @@
|
|||||||
// B. Background Image (Opacity 0.3 + Breathe)
|
// B. Background Image (Opacity 0.3 + Breathe)
|
||||||
// 模拟 .bg-image 的呼吸效果
|
// 模拟 .bg-image 的呼吸效果
|
||||||
ctx.save();
|
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; // 呼吸动画
|
||||||
|
|
||||||
// 绘制背景图
|
// 绘制背景图
|
||||||
// 使用渐变替代背景图片绘制
|
// 使用渐变替代背景图片绘制
|
||||||
@@ -1484,7 +1531,24 @@
|
|||||||
|
|
||||||
// ctx.restore(); // Removed redundant restore (moved up)
|
// ctx.restore(); // Removed redundant restore (moved up)
|
||||||
|
|
||||||
// --- 2. 绘制标题 ---
|
// --- 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.save();
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -1492,21 +1556,57 @@
|
|||||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||||
ctx.shadowBlur = 10 * dpr;
|
ctx.shadowBlur = 10 * dpr;
|
||||||
ctx.shadowOffsetY = 4 * dpr;
|
ctx.shadowOffsetY = 4 * dpr;
|
||||||
ctx.font = `bold ${22 * dpr}px sans-serif`;
|
ctx.font = `bold ${22 * dpr}px sans-serif`; // Match CSS .header-title
|
||||||
// 顶部留出空间,大约 15% 高度处
|
ctx.fillText(titleEl.innerText, w / 2, y);
|
||||||
ctx.fillText(els.songTitle.innerText, w / 2, h * 0.15);
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 绘制 Mini Lyrics (.mini-lyrics)
|
||||||
|
// 仅当不在歌词模式下显示 (isLyricView=false),或者如果页面上在歌词模式下也显示(通常不会)
|
||||||
|
// 根据 updateMiniLyrics 逻辑: if (els.miniLyrics) els.miniLyrics.style.display = isLyricView ? 'none' : 'block';
|
||||||
|
// 所以 miniLyrics 只在非歌词模式下显示。
|
||||||
|
|
||||||
// 绘制 Mini Lyrics (如果不在歌词模式)
|
|
||||||
if (!isLyricView) {
|
if (!isLyricView) {
|
||||||
// 简单绘制一行当前歌词
|
const miniEl = document.querySelector('.mini-lyrics');
|
||||||
const activeLine = document.querySelector('.mini-line.active');
|
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) {
|
if (activeLine) {
|
||||||
ctx.font = `bold ${16 * dpr}px sans-serif`;
|
ctx.font = `bold ${18 * dpr}px sans-serif`;
|
||||||
ctx.fillStyle = '#d4af37'; // var(--theme-color)
|
ctx.fillStyle = '#d4af37';
|
||||||
ctx.fillText(activeLine.innerText, w / 2, h * 0.22);
|
ctx.fillText(activeLine.innerText, w / 2, centerY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
|
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
|
||||||
// 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
|
// 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
|
||||||
@@ -1549,33 +1649,11 @@
|
|||||||
ctx.rotate(angle * Math.PI / 180);
|
ctx.rotate(angle * Math.PI / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 唱片本体背景
|
// 唱片本体 - 使用图片 /img/disc.png
|
||||||
// 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)
|
// const imgSize = 420 * dpr; // 最大值300
|
||||||
// 纯黑背景下的深色纹路
|
const imgSize = 420 * dpr; // 最大值300
|
||||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
|
ctx.drawImage(els.discImg, -imgSize / 2, -imgSize / 2, imgSize, imgSize);
|
||||||
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();
|
|
||||||
|
|
||||||
// 封面 (.album-cover)
|
// 封面 (.album-cover)
|
||||||
const coverRadius = radius * 0.65;
|
const coverRadius = radius * 0.65;
|
||||||
@@ -1609,10 +1687,23 @@
|
|||||||
// CSS: top: -50px; left: 50%; transform-origin: 40px 20px;
|
// CSS: top: -50px; left: 50%; transform-origin: 40px 20px;
|
||||||
// transform: translateX(-10px) rotate(...);
|
// transform: translateX(-10px) rotate(...);
|
||||||
|
|
||||||
const parentRect = document.querySelector('.disc-mode').getBoundingClientRect();
|
// 定位调整:10vh - 50px
|
||||||
// Calculate base position (before transform) relative to container
|
// containerRect 是 main-container 的 rect
|
||||||
const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr;
|
// .disc-mode 位于 .view-wrapper 内, .view-wrapper 是 flex: 1,位于 header-info 之下
|
||||||
const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr;
|
// 页面结构: 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.save();
|
||||||
ctx.translate(needleBaseX, needleBaseY);
|
ctx.translate(needleBaseX, needleBaseY);
|
||||||
@@ -1620,47 +1711,40 @@
|
|||||||
// Apply transforms
|
// Apply transforms
|
||||||
const originX = 40 * dpr;
|
const originX = 40 * dpr;
|
||||||
const originY = 20 * 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(originX, originY);
|
||||||
ctx.translate(-10 * dpr, 0);
|
ctx.translate(-10 * dpr, 0);
|
||||||
ctx.rotate(angle * Math.PI / 180);
|
ctx.rotate(needleAngle * Math.PI / 180);
|
||||||
ctx.translate(-originX, -originY);
|
ctx.translate(-originX, -originY);
|
||||||
|
|
||||||
// Draw Parts (Flat Matte Style - No Reflections)
|
const nW = 80 * dpr;
|
||||||
|
const nH = 140 * dpr;
|
||||||
|
// 绘制图片,使其 (40*dpr, 20*dpr) 对应原点
|
||||||
|
// console.log(-14 * dpr, -20 * dpr, nW, nH);
|
||||||
|
|
||||||
// 1. Pivot
|
ctx.drawImage(els.needleImg, 0, 0, nW, nH);
|
||||||
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();
|
|
||||||
|
|
||||||
// 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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
@@ -1671,42 +1755,68 @@
|
|||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
const boxH = h * 0.6;
|
// 绘制标题 "歌词 LYRICS"
|
||||||
const startY = (h - boxH) / 2;
|
const headerEl = document.querySelector('.lyrics-header');
|
||||||
|
if (headerEl) {
|
||||||
// 简单的歌词绘制逻辑:绘制当前句及前后几句
|
const r = headerEl.getBoundingClientRect();
|
||||||
// 找到 active 的歌词
|
const hy = (r.top - containerRect.top + r.height/2) * dpr;
|
||||||
let activeIndex = lyricLines.findIndex(l => Math.abs(l.time - els.audio.currentTime) < 0.5);
|
ctx.font = `bold ${18 * dpr}px sans-serif`;
|
||||||
// 如果没找到精确匹配,找最近的一个过去的时间
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||||
if (activeIndex === -1) {
|
// 标题需要左对齐一点,或者根据 .lyrics-header 的实际位置
|
||||||
activeIndex = lyricLines.filter(l => l.time <= els.audio.currentTime).length - 1;
|
// 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;
|
||||||
|
|
||||||
for (let i = -3; i <= 3; i++) {
|
ctx.beginPath();
|
||||||
const idx = activeIndex + i;
|
ctx.rect(sx, sy, sw, sh);
|
||||||
if (idx >= 0 && idx < lyricLines.length) {
|
ctx.clip();
|
||||||
const line = lyricLines[idx];
|
|
||||||
const y = (h - boxH) / 2 + boxH / 2 + i * lineHeight; // Center vertically
|
// 绘制歌词
|
||||||
|
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!
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
// 当前句
|
|
||||||
ctx.font = `bold ${22 * dpr}px sans-serif`;
|
ctx.font = `bold ${22 * dpr}px sans-serif`;
|
||||||
ctx.fillStyle = '#d4af37';
|
ctx.fillStyle = '#d4af37';
|
||||||
ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
|
ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
|
||||||
ctx.shadowBlur = 10;
|
ctx.shadowBlur = 15 * dpr; // Increased to match CSS 15px
|
||||||
} else {
|
} else {
|
||||||
// 其他句
|
// .lyric-line
|
||||||
|
// font-size: 16px
|
||||||
|
// color: rgba(255, 255, 255, 0.4)
|
||||||
|
|
||||||
ctx.font = `${16 * dpr}px sans-serif`;
|
ctx.font = `${16 * dpr}px sans-serif`;
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillText(line.el.innerText, w / 2, y);
|
ctx.fillText(line.innerText, w / 2, ly);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
@@ -1784,12 +1894,14 @@
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// 进度点
|
// 进度点
|
||||||
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(barX + currentBarWidth, barY + 2 * dpr, 6 * dpr, 0, Math.PI * 2);
|
ctx.arc(barX + currentBarWidth, barY + 2 * dpr, 6 * dpr, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
|
||||||
ctx.shadowBlur = 10 * dpr;
|
ctx.shadowBlur = 10 * dpr;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
// 时间文字
|
// 时间文字
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||||
@@ -1862,7 +1974,7 @@
|
|||||||
// CSS var(--theme-color) is #d4af37
|
// CSS var(--theme-color) is #d4af37
|
||||||
const r = 255 + (212 - 255) * p;
|
const r = 255 + (212 - 255) * p;
|
||||||
const g = 255 + (175 - 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})`;
|
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${opVal})`;
|
||||||
|
|
||||||
@@ -1888,7 +2000,7 @@
|
|||||||
// If it's play button, draw circle bg
|
// If it's play button, draw circle bg
|
||||||
if (btn.classList.contains('btn-play')) {
|
if (btn.classList.contains('btn-play')) {
|
||||||
c.beginPath();
|
c.beginPath();
|
||||||
c.arc(cw/2, ch/2, cw/2, 0, Math.PI * 2);
|
c.arc(cw / 2, ch / 2, cw / 2, 0, Math.PI * 2);
|
||||||
c.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
c.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
c.fill();
|
c.fill();
|
||||||
c.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
c.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
||||||
@@ -1899,7 +2011,7 @@
|
|||||||
// Handle Play Button Explicitly
|
// Handle Play Button Explicitly
|
||||||
if (btn.id === 'playBtn') {
|
if (btn.id === 'playBtn') {
|
||||||
c.save();
|
c.save();
|
||||||
c.translate(cw/2, ch/2);
|
c.translate(cw / 2, ch / 2);
|
||||||
c.scale(dpr, dpr);
|
c.scale(dpr, dpr);
|
||||||
c.translate(-12, -12);
|
c.translate(-12, -12);
|
||||||
c.fillStyle = '#fff';
|
c.fillStyle = '#fff';
|
||||||
@@ -1916,7 +2028,7 @@
|
|||||||
// Draw Icon (SVG)
|
// Draw Icon (SVG)
|
||||||
const svg = btn.querySelector('svg');
|
const svg = btn.querySelector('svg');
|
||||||
if (svg && svg.style.display !== 'none') {
|
if (svg && svg.style.display !== 'none') {
|
||||||
c.translate(cw/2, ch/2);
|
c.translate(cw / 2, ch / 2);
|
||||||
c.scale(dpr, dpr);
|
c.scale(dpr, dpr);
|
||||||
c.translate(-12, -12); // Assuming 24x24 base
|
c.translate(-12, -12); // Assuming 24x24 base
|
||||||
c.fillStyle = '#fff';
|
c.fillStyle = '#fff';
|
||||||
|
|||||||
BIN
static/img/disc.png
Normal file
BIN
static/img/disc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
static/img/needle.png
Normal file
BIN
static/img/needle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Reference in New Issue
Block a user