2448 lines
78 KiB
HTML
2448 lines
78 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>朴见潮音</title>
|
||
<meta property="og:url" content="" id="ogUrl" />
|
||
<meta property="og:title" content="" id="ogTitle" />
|
||
<meta property="og:description" content="" id="ogDesc" />
|
||
<meta property="og:image" content="" id="ogImage" />
|
||
<style>
|
||
/* --- 1. 核心变量与重置 --- */
|
||
:root {
|
||
--theme-color: #d4af37;
|
||
--text-main: #ffffff;
|
||
--text-sub: rgba(255, 255, 255, 0.65);
|
||
--glass-bg: rgba(20, 20, 20, 0.6);
|
||
--glass-border: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
-webkit-tap-highlight-color: transparent;
|
||
outline: none;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Roboto, sans-serif;
|
||
/* background: #1a82ea; */
|
||
color: var(--text-main);
|
||
height: 100dvh;
|
||
min-height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* --- 2. 氛围背景与粒子特效 --- */
|
||
.bg-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 1;
|
||
overflow: hidden;
|
||
background: #1a82ea;
|
||
|
||
}
|
||
|
||
.bg-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
background-size: cover;
|
||
background-position: center;
|
||
filter: blur(60px) brightness(0.6);
|
||
transform: scale(1.1);
|
||
transition: transform 10s ease;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
/* 播放时的背景呼吸效果 */
|
||
body.is-playing .bg-image {
|
||
animation: breathe 20s infinite alternate ease-in-out;
|
||
}
|
||
|
||
@keyframes breathe {
|
||
from {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
to {
|
||
transform: scale(1.3);
|
||
}
|
||
}
|
||
|
||
.bg-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.9));
|
||
}
|
||
|
||
.particles span {
|
||
position: absolute;
|
||
bottom: -50px;
|
||
background: radial-gradient(circle, rgba(255, 255, 255, 0.8), transparent);
|
||
box-shadow: 0 0 15px 2px rgba(255, 255, 255, 0.1);
|
||
border-radius: 50%;
|
||
animation: floatUp 15s linear infinite;
|
||
opacity: 0;
|
||
}
|
||
|
||
@keyframes floatUp {
|
||
0% {
|
||
transform: translateY(0) scale(0.5);
|
||
opacity: 0;
|
||
}
|
||
|
||
20% {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
80% {
|
||
opacity: 0.2;
|
||
}
|
||
|
||
100% {
|
||
transform: translateY(-100vh) scale(1.5);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
/* --- 3. 主布局 --- */
|
||
.main-container {
|
||
order: 1;
|
||
position: relative;
|
||
z-index: 10;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding-top: 10vh;
|
||
background: #1a82ea;
|
||
}
|
||
|
||
/* 顶部区域 (歌名) */
|
||
.header-info {
|
||
order: 2;
|
||
text-align: center;
|
||
z-index: 25;
|
||
opacity: 0.9;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
letter-spacing: 0.5px;
|
||
text-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.header-artist {
|
||
font-size: 13px;
|
||
color: var(--text-sub);
|
||
background: rgba(255, 255, 255, 0.1);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
display: inline-block;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
/* --- 4. 核心视图切换区 --- */
|
||
.view-wrapper {
|
||
order: 1;
|
||
position: relative;
|
||
width: 100%;
|
||
flex: 1;
|
||
display: flex;
|
||
justify-content: center;
|
||
transition: opacity 0.4s ease;
|
||
}
|
||
|
||
/* A. 唱片模式 */
|
||
.disc-mode {
|
||
position: absolute;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
transition: opacity 0.5s, transform 0.5s;
|
||
}
|
||
|
||
.disc-mode.hidden {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
/* 唱臂组件 */
|
||
.needle {
|
||
position: absolute;
|
||
top: -50px;
|
||
left: 50%;
|
||
width: 80px;
|
||
height: 140px;
|
||
z-index: 20;
|
||
transform-origin: 40px 20px;
|
||
/* 修正旋转中心 */
|
||
transform: translateX(-10px) rotate(-35deg);
|
||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||
pointer-events: none;
|
||
filter: drop-shadow(4px 8px 10px rgba(0, 0, 0, 0.5));
|
||
/* 增加立体投影 */
|
||
}
|
||
|
||
.needle.playing {
|
||
transform: translateX(-10px) rotate(-5deg);
|
||
/* 修正角度 */
|
||
}
|
||
|
||
.needle-pivot {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 20px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 30% 30%, #e0e0e0, #555);
|
||
border: 1px solid #444;
|
||
z-index: 3;
|
||
}
|
||
|
||
.needle-pivot::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #222;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.needle-rod {
|
||
position: absolute;
|
||
top: 30px;
|
||
left: 36px;
|
||
width: 8px;
|
||
height: 100px;
|
||
background: linear-gradient(90deg, #555, #ccc, #555);
|
||
z-index: 2;
|
||
}
|
||
|
||
.needle-head {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 28px;
|
||
width: 24px;
|
||
height: 38px;
|
||
background: #1a1a1a;
|
||
border-radius: 4px;
|
||
z-index: 2;
|
||
border-top: 2px solid #555;
|
||
}
|
||
|
||
/* 唱片本体容器 */
|
||
.disc-container {
|
||
width: 55vw;
|
||
height: 55vw;
|
||
max-width: 300px;
|
||
max-height: 300px;
|
||
margin-top: 35px;
|
||
position: relative;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
/* --- 增强特效:动态光波 (Ripple) --- */
|
||
.ripple {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
opacity: 0;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 播放时触发 ripple 动画 */
|
||
.disc-mode.playing .ripple:nth-child(1) {
|
||
animation: rippleAnim 2.5s linear infinite;
|
||
animation-delay: 0s;
|
||
}
|
||
|
||
.disc-mode.playing .ripple:nth-child(2) {
|
||
animation: rippleAnim 2.5s linear infinite;
|
||
animation-delay: 0.8s;
|
||
}
|
||
|
||
.disc-mode.playing .ripple:nth-child(3) {
|
||
animation: rippleAnim 2.5s linear infinite;
|
||
animation-delay: 1.6s;
|
||
}
|
||
|
||
@keyframes rippleAnim {
|
||
0% {
|
||
width: 100%;
|
||
height: 100%;
|
||
opacity: 0.4;
|
||
border-width: 4px;
|
||
}
|
||
|
||
100% {
|
||
width: 160%;
|
||
height: 160%;
|
||
opacity: 0;
|
||
border-width: 0px;
|
||
}
|
||
}
|
||
|
||
/* 唱片主体 */
|
||
.disc {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background:
|
||
radial-gradient(circle, #111 0%, #1a1a1a 100%);
|
||
/* 纹理 */
|
||
background-image: repeating-radial-gradient(#111 0, #111 2px, #222 3px, #252525 4px);
|
||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
animation: rotate 20s linear infinite;
|
||
animation-play-state: paused;
|
||
position: relative;
|
||
z-index: 2;
|
||
border: 6px solid #080808;
|
||
}
|
||
|
||
/* 唱片光泽反射层 (Gloss) */
|
||
.disc::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
border-radius: 50%;
|
||
background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.08) 50%, transparent 60%);
|
||
z-index: 4;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.disc.playing {
|
||
animation-play-state: running;
|
||
}
|
||
|
||
.album-cover {
|
||
width: 65%;
|
||
height: 65%;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
z-index: 3;
|
||
border: 4px solid #000;
|
||
position: relative;
|
||
}
|
||
|
||
.album-cover img {
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
/* 封面跳动效果 */
|
||
.disc.playing .album-cover img {
|
||
animation: beat 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite alternate;
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes beat {
|
||
from {
|
||
transform: scale(1);
|
||
filter: brightness(1);
|
||
}
|
||
|
||
to {
|
||
transform: scale(1.03);
|
||
filter: brightness(1.05);
|
||
}
|
||
}
|
||
|
||
.hint-text {
|
||
margin-top: 40px;
|
||
font-size: 12px;
|
||
color: var(--text-sub);
|
||
background: rgba(0, 0, 0, 0.3);
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* B. 歌词模式 */
|
||
.lyrics-mode {
|
||
position: absolute;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transform: translateY(30px);
|
||
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
z-index: 50;
|
||
}
|
||
|
||
.lyrics-mode.active {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.lyrics-header {
|
||
margin-top: 20px;
|
||
margin-bottom: 20px;
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.lyrics-scroll {
|
||
width: 85%;
|
||
height: 70%;
|
||
overflow-y: auto;
|
||
scroll-behavior: smooth;
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.lyrics-scroll::-webkit-scrollbar {
|
||
width: 4px;
|
||
background: transparent;
|
||
}
|
||
|
||
.lyrics-scroll::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.lyric-line {
|
||
padding: 12px 0;
|
||
font-size: 16px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
transition: all 0.4s ease;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.lyric-line.active {
|
||
color: var(--theme-color);
|
||
font-size: 22px;
|
||
font-weight: bold;
|
||
text-shadow: 0 0 15px rgba(212, 175, 55, 0.4);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.mini-lyrics {
|
||
overflow: hidden;
|
||
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, black 40%, black 60%, rgba(0, 0, 0, 0.2) 100%);
|
||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, black 40%, black 60%, rgba(0, 0, 0, 0.2) 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
display: none;
|
||
}
|
||
|
||
.mini-line {
|
||
height: 22px;
|
||
line-height: 22px;
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
text-align: center;
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.mini-line.active {
|
||
color: var(--theme-color);
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.mini-line.dim {
|
||
opacity: 0.5;
|
||
filter: blur(0.4px);
|
||
}
|
||
|
||
/* --- 5. 底部控制区 (Glassmorphism) --- */
|
||
.controls-area {
|
||
order: 3;
|
||
width: 100%;
|
||
padding: 20px 25px 40px;
|
||
padding-bottom: calc(40px + env(safe-area-inset-bottom));
|
||
padding-bottom: calc(40px + constant(safe-area-inset-bottom));
|
||
background: var(--glass-bg);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
border-top: 1px solid var(--glass-border);
|
||
border-top-left-radius: 24px;
|
||
border-top-right-radius: 24px;
|
||
z-index: 100;
|
||
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
/* 音乐频谱波形 (模拟) */
|
||
.music-waves {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
height: 16px;
|
||
gap: 3px;
|
||
margin-bottom: 15px;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.wave-bar {
|
||
width: 3px;
|
||
background: #fff;
|
||
border-radius: 2px;
|
||
height: 4px;
|
||
transition: height 0.1s;
|
||
}
|
||
|
||
/* 播放时的波形动画 */
|
||
body.is-playing .wave-bar {
|
||
animation: waveBounce 0.6s infinite ease-in-out alternate;
|
||
}
|
||
|
||
body.is-playing .wave-bar:nth-child(1) {
|
||
animation-delay: 0.1s;
|
||
animation-duration: 0.5s;
|
||
}
|
||
|
||
body.is-playing .wave-bar:nth-child(2) {
|
||
animation-delay: 0.3s;
|
||
animation-duration: 0.7s;
|
||
}
|
||
|
||
body.is-playing .wave-bar:nth-child(3) {
|
||
animation-delay: 0.0s;
|
||
animation-duration: 0.6s;
|
||
}
|
||
|
||
body.is-playing .wave-bar:nth-child(4) {
|
||
animation-delay: 0.4s;
|
||
animation-duration: 0.5s;
|
||
}
|
||
|
||
body.is-playing .wave-bar:nth-child(5) {
|
||
animation-delay: 0.2s;
|
||
animation-duration: 0.8s;
|
||
}
|
||
|
||
@keyframes waveBounce {
|
||
0% {
|
||
height: 4px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
100% {
|
||
height: 16px;
|
||
opacity: 1;
|
||
background: var(--theme-color);
|
||
}
|
||
}
|
||
|
||
/* 进度条 */
|
||
.progress-container {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 25px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.progress-bar {
|
||
flex: 1;
|
||
height: 4px;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-radius: 4px;
|
||
margin: 0 12px;
|
||
position: relative;
|
||
cursor: pointer;
|
||
overflow: visible;
|
||
/* 允许圆点超出 */
|
||
}
|
||
|
||
/* 增加触摸区域 */
|
||
.progress-bar::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -10px;
|
||
bottom: -10px;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.progress-fill {
|
||
width: 0%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #fff, var(--theme-color));
|
||
border-radius: 4px;
|
||
position: relative;
|
||
}
|
||
|
||
.progress-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
position: absolute;
|
||
right: -6px;
|
||
top: -4px;
|
||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
|
||
transform: scale(0);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.progress-bar:active .progress-dot,
|
||
.is-playing .progress-dot {
|
||
transform: scale(1);
|
||
}
|
||
|
||
/* 按钮组 */
|
||
.btn-group {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
opacity: 0.7;
|
||
transition: all 0.2s;
|
||
color: #fff;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.btn:active {
|
||
transform: scale(0.9);
|
||
opacity: 1;
|
||
}
|
||
|
||
.btn-play {
|
||
width: 65px;
|
||
height: 65px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||
opacity: 1;
|
||
}
|
||
|
||
.btn-play svg {
|
||
fill: #fff;
|
||
}
|
||
|
||
.btn-side {
|
||
width: 40px;
|
||
height: 40px;
|
||
}
|
||
|
||
/* Loading */
|
||
.loader {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #000;
|
||
z-index: 200;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
transition: opacity 0.6s;
|
||
}
|
||
|
||
.loader-text {
|
||
margin-top: 15px;
|
||
font-size: 12px;
|
||
letter-spacing: 3px;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
animation: blink 1.5s infinite;
|
||
}
|
||
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||
border-top-color: var(--theme-color);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes blink {
|
||
50% {
|
||
opacity: 0.3;
|
||
}
|
||
}
|
||
|
||
@keyframes recBlink {
|
||
0% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
</style>
|
||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||
<script src="/static/js/ffmpeg.min.js"></script>
|
||
<script src="/static/js/html2canvas.min.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>
|
||
// VConsole will be exported to `window.VConsole` by default.
|
||
var vConsole = new window.VConsole();
|
||
</script>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<div class="loader" id="loader">
|
||
<div class="spinner"></div>
|
||
<div class="loader-text">LOADING...</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="main-container">
|
||
<!-- 背景层 -->
|
||
<div class="bg-layer">
|
||
<div class="bg-image" id="bgImg"></div>
|
||
<div class="bg-mask"></div>
|
||
<div class="particles" id="particles"></div>
|
||
</div>
|
||
|
||
<!-- 歌名信息 -->
|
||
<div class="header-info">
|
||
<div class="header-title" id="songTitle">...</div>
|
||
<div class="mini-lyrics" id="miniLyrics">
|
||
<div class="mini-line dim"></div>
|
||
<div class="mini-line active"></div>
|
||
<div class="mini-line dim"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中间视图切换 -->
|
||
<div class="view-wrapper" onclick="toggleView()">
|
||
<!-- 唱片模式 -->
|
||
<div class="disc-mode" id="discMode">
|
||
<div class="needle" id="needle">
|
||
<div class="needle-pivot"></div>
|
||
<div class="needle-rod"></div>
|
||
<div class="needle-head"></div>
|
||
</div>
|
||
|
||
<div class="disc-container">
|
||
<!-- 动效:光波 -->
|
||
<div class="ripple"></div>
|
||
<div class="ripple"></div>
|
||
<div class="ripple"></div>
|
||
|
||
<div class="disc" id="disc">
|
||
<div class="album-cover">
|
||
<img src="" id="coverImg" alt="cover">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌词模式 -->
|
||
<div class="lyrics-mode" id="lyricsMode">
|
||
<div class="lyrics-header">歌词 LYRICS</div>
|
||
<div class="lyrics-scroll" id="lyricsBox">
|
||
<div class="lyric-line">暂无歌词</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部控制 -->
|
||
<div class="controls-area">
|
||
<!-- 模拟音频频谱波形 -->
|
||
<div class="music-waves">
|
||
<div class="wave-bar"></div>
|
||
<div class="wave-bar"></div>
|
||
<div class="wave-bar"></div>
|
||
<div class="wave-bar"></div>
|
||
<div class="wave-bar"></div>
|
||
<div class="wave-bar"></div>
|
||
</div>
|
||
|
||
<div class="progress-container">
|
||
<span id="currTime">00:00</span>
|
||
<div class="progress-bar" id="progressBar">
|
||
<div class="progress-fill" id="progressFill">
|
||
<div class="progress-dot"></div>
|
||
</div>
|
||
</div>
|
||
<span id="totalTime">00:00</span>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<!-- 下载 -->
|
||
<button class="btn btn-side" onclick="downloadMusic()" title="下载">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 上一首 -->
|
||
<button class="btn btn-side" onclick="msg('这是单曲展示')">
|
||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 播放/暂停 -->
|
||
<button class="btn btn-play" id="playBtn">
|
||
<svg id="iconPlay" width="30" height="30" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
<svg id="iconPause" width="30" height="30" viewBox="0 0 24 24" fill="currentColor" style="display:none">
|
||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 下一首 -->
|
||
<button class="btn btn-side" onclick="msg('这是单曲展示')">
|
||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 歌词切换按钮 -->
|
||
<button class="btn btn-side" onclick="toggleView(event)">
|
||
<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" />
|
||
</svg>
|
||
</button>
|
||
|
||
<button class="btn btn-side" id="recToggleBtn" onclick="toggleRecording()" title="录制开关">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="12" cy="12" r="6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</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)">
|
||
<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>
|
||
</div>
|
||
|
||
<audio id="audio" crossorigin="anonymous"></audio>
|
||
<video id="tabCaptureVideo" playsinline muted style="display:none"></video>
|
||
|
||
<script>
|
||
// 获取参数
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const musicId = urlParams.get('id');
|
||
const lrcParam = urlParams.get('lrc');
|
||
const API_URL = `https://pujianchaoyin.com/api/getMusicDetail?id=${musicId}`;
|
||
|
||
let musicData = {};
|
||
let isPlaying = false;
|
||
let isLyricView = false;
|
||
let lyricLines = [];
|
||
|
||
const els = {
|
||
body: document.body,
|
||
loader: document.getElementById('loader'),
|
||
bgImg: document.getElementById('bgImg'),
|
||
coverImg: document.getElementById('coverImg'),
|
||
songTitle: document.getElementById('songTitle'),
|
||
audio: document.getElementById('audio'),
|
||
playBtn: document.getElementById('playBtn'),
|
||
iconPlay: document.getElementById('iconPlay'),
|
||
iconPause: document.getElementById('iconPause'),
|
||
disc: document.getElementById('disc'),
|
||
discMode: document.getElementById('discMode'),
|
||
needle: document.getElementById('needle'),
|
||
progressBar: document.getElementById('progressBar'),
|
||
progressFill: document.getElementById('progressFill'),
|
||
currTime: document.getElementById('currTime'),
|
||
totalTime: document.getElementById('totalTime'),
|
||
lyricsMode: document.getElementById('lyricsMode'),
|
||
lyricsBox: document.getElementById('lyricsBox'),
|
||
miniLyrics: document.getElementById('miniLyrics'),
|
||
recIndicator: document.getElementById('recIndicator'),
|
||
recToggleBtn: document.getElementById('recToggleBtn'),
|
||
tabVideo: document.getElementById('tabCaptureVideo')
|
||
};
|
||
|
||
// 初始化
|
||
async function init() {
|
||
createParticles();
|
||
try {
|
||
const res = await fetch(API_URL);
|
||
const json = await res.json();
|
||
if (json.code === 200) {
|
||
musicData = json.data;
|
||
renderUI(musicData);
|
||
const lrcText = musicData.lrc || '';
|
||
parseLyrics(lrcText);
|
||
if (lrcParam && lrcText.trim()) {
|
||
isLyricView = true;
|
||
els.discMode.classList.add('hidden');
|
||
els.lyricsMode.classList.add('active');
|
||
// if (els.miniLyrics) els.miniLyrics.style.display = 'none';
|
||
syncLyrics(els.audio.currentTime || 0);
|
||
}
|
||
} else {
|
||
els.songTitle.innerText = "获取失败";
|
||
closeLoader();
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
els.songTitle.innerText = "网络错误";
|
||
closeLoader();
|
||
}
|
||
}
|
||
|
||
function closeLoader() {
|
||
els.loader.style.opacity = 0;
|
||
setTimeout(() => els.loader.remove(), 600);
|
||
}
|
||
|
||
async function renderUI(data) {
|
||
els.songTitle.innerText = data.title;
|
||
|
||
// 预加载图片并转换为 Blob URL (解决跨域和缓存问题)
|
||
try {
|
||
const imgRes = await fetch(data.img);
|
||
const imgBlob = await imgRes.blob();
|
||
const imgUrl = URL.createObjectURL(imgBlob);
|
||
els.coverImg.src = imgUrl;
|
||
els.bgImg.style.backgroundImage = `url('${imgUrl}')`;
|
||
} catch (e) {
|
||
console.warn('图片缓存失败,使用原始链接', e);
|
||
els.coverImg.src = data.img;
|
||
els.bgImg.style.backgroundImage = `url('${data.img}')`;
|
||
}
|
||
|
||
els.audio.src = data.playurl;
|
||
|
||
// 图片加载后显示
|
||
els.coverImg.onload = () => {
|
||
closeLoader();
|
||
// 尝试自动播放
|
||
els.audio.play().catch(err => {
|
||
console.warn('自动播放被浏览器阻止', err);
|
||
});
|
||
};
|
||
// 防止图片加载失败导致一直loading
|
||
setTimeout(closeLoader, 3000);
|
||
|
||
document.title = data.title;
|
||
|
||
document.getElementById('ogUrl').content = window.location.href;
|
||
document.getElementById('ogTitle').content = data.title;
|
||
document.getElementById('ogDesc').content = data.desc;
|
||
document.getElementById('ogImage').content = data.img;
|
||
|
||
// 分享到微信
|
||
initWeixinShare(data);
|
||
}
|
||
|
||
// 播放控制
|
||
els.playBtn.addEventListener('click', () => {
|
||
if (isPlaying) els.audio.pause();
|
||
else els.audio.play();
|
||
});
|
||
|
||
els.audio.addEventListener('play', () => {
|
||
isPlaying = true;
|
||
updatePlayState();
|
||
if (recordEnabled) startSilentMV();
|
||
});
|
||
|
||
els.audio.addEventListener('pause', () => {
|
||
isPlaying = false;
|
||
updatePlayState();
|
||
});
|
||
|
||
els.audio.addEventListener('ended', () => {
|
||
stopSilentMV();
|
||
});
|
||
|
||
function downloadFile(name, blob) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function transcodeToMp4(webmBlob, title) {
|
||
try {
|
||
if (!window.FFmpeg || !FFmpeg.createFFmpeg) {
|
||
downloadFile(`${title}.webm`, webmBlob);
|
||
msg('已保存 WebM');
|
||
return;
|
||
}
|
||
const { createFFmpeg, fetchFile } = FFmpeg;
|
||
const ffmpeg = createFFmpeg({ log: false, corePath: 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/ffmpeg-core.js' });
|
||
msg('正在转码为 MP4');
|
||
await ffmpeg.load();
|
||
ffmpeg.FS('writeFile', 'input.webm', await fetchFile(webmBlob));
|
||
await ffmpeg.run('-i', 'input.webm', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', 'output.mp4');
|
||
const data = ffmpeg.FS('readFile', 'output.mp4');
|
||
const mp4Blob = new Blob([data.buffer], { type: 'video/mp4' });
|
||
downloadFile(`${title}.mp4`, mp4Blob);
|
||
msg('MP4 已生成');
|
||
} catch (e) {
|
||
console.error(e);
|
||
downloadFile(`${title}.webm`, webmBlob);
|
||
msg('转码失败,已保存 WebM');
|
||
}
|
||
}
|
||
|
||
function updatePlayState() {
|
||
if (isPlaying) {
|
||
els.body.classList.add('is-playing');
|
||
els.needle.classList.add('playing');
|
||
els.disc.classList.add('playing');
|
||
els.discMode.classList.add('playing'); // 触发 Ripple
|
||
els.iconPlay.style.display = 'none';
|
||
els.iconPause.style.display = 'block';
|
||
} else {
|
||
els.body.classList.remove('is-playing');
|
||
els.needle.classList.remove('playing');
|
||
els.disc.classList.remove('playing');
|
||
els.discMode.classList.remove('playing');
|
||
els.iconPlay.style.display = 'block';
|
||
els.iconPause.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 进度与歌词
|
||
els.audio.addEventListener('timeupdate', () => {
|
||
const { currentTime, duration } = els.audio;
|
||
if (!duration) return;
|
||
|
||
const percent = (currentTime / duration) * 100;
|
||
els.progressFill.style.width = `${percent}%`;
|
||
els.currTime.innerText = formatTime(currentTime);
|
||
|
||
syncLyrics(currentTime);
|
||
});
|
||
|
||
els.audio.addEventListener('loadedmetadata', () => {
|
||
els.totalTime.innerText = formatTime(els.audio.duration);
|
||
});
|
||
|
||
els.progressBar.addEventListener('click', (e) => {
|
||
const rect = els.progressBar.getBoundingClientRect();
|
||
let p = (e.clientX - rect.left) / rect.width;
|
||
p = Math.max(0, Math.min(1, p));
|
||
const newTime = p * (els.audio.duration || 0);
|
||
els.audio.currentTime = newTime;
|
||
syncLyrics(newTime);
|
||
if (!isPlaying) els.audio.play();
|
||
});
|
||
|
||
let seeking = false;
|
||
function seekByClientX(x) {
|
||
const rect = els.progressBar.getBoundingClientRect();
|
||
let p = (x - rect.left) / rect.width;
|
||
p = Math.max(0, Math.min(1, p));
|
||
els.progressFill.style.width = `${p * 100}%`;
|
||
const dur = els.audio.duration || 0;
|
||
const newTime = p * dur;
|
||
if (dur) {
|
||
els.audio.currentTime = newTime;
|
||
}
|
||
syncLyrics(newTime);
|
||
}
|
||
|
||
els.progressBar.addEventListener('mousedown', (e) => {
|
||
seeking = true;
|
||
seekByClientX(e.clientX);
|
||
});
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!seeking) return;
|
||
seekByClientX(e.clientX);
|
||
});
|
||
document.addEventListener('mouseup', () => {
|
||
if (!seeking) return;
|
||
seeking = false;
|
||
if (!isPlaying) els.audio.play();
|
||
});
|
||
|
||
els.progressBar.addEventListener('touchstart', (e) => {
|
||
if (!e.touches || !e.touches.length) return;
|
||
seeking = true;
|
||
seekByClientX(e.touches[0].clientX);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
document.addEventListener('touchmove', (e) => {
|
||
if (!seeking || !e.touches || !e.touches.length) return;
|
||
seekByClientX(e.touches[0].clientX);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
document.addEventListener('touchend', () => {
|
||
if (!seeking) return;
|
||
seeking = false;
|
||
if (!isPlaying) els.audio.play();
|
||
});
|
||
|
||
// 歌词解析
|
||
function parseLyrics(lrc) {
|
||
lyricLines = [];
|
||
if (!lrc) {
|
||
if (els.miniLyrics) els.miniLyrics.style.display = 'none';
|
||
els.lyricsBox.innerHTML = '<div class="lyric-line">纯音乐 / 暂无歌词</div>';
|
||
return;
|
||
}
|
||
|
||
lrc = lrc.replace(/\r\n/g, '\n').replace(/\\n/g, '\n').replace(/<br\s*\/>?/gi, '\n');
|
||
const lines = lrc.split('\n');
|
||
const timeReg = /\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?\]/;
|
||
|
||
els.lyricsBox.innerHTML = '';
|
||
|
||
lines.forEach(line => {
|
||
const match = timeReg.exec(line);
|
||
if (match) {
|
||
const min = parseInt(match[1]);
|
||
const sec = parseInt(match[2]);
|
||
const ms = match[3] ? parseFloat("0." + String(match[3]).padEnd(3, '0')) : 0;
|
||
const time = min * 60 + sec + ms;
|
||
const text = line.replace(/\[.*?\]/g, '').trim();
|
||
|
||
if (text) {
|
||
const div = document.createElement('div');
|
||
div.className = 'lyric-line';
|
||
div.innerText = text;
|
||
div.dataset.time = time;
|
||
els.lyricsBox.appendChild(div);
|
||
lyricLines.push({ time, el: div });
|
||
}
|
||
}
|
||
});
|
||
|
||
lyricLines.sort((a, b) => a.time - b.time);
|
||
|
||
// 移除额外留白,保持精准滚动定位
|
||
if (isLyricView && lyricLines.length > 0) {
|
||
syncLyrics(els.audio.currentTime || 0);
|
||
}
|
||
}
|
||
|
||
// 歌词同步
|
||
function syncLyrics(time) {
|
||
if (lyricLines.length === 0) return;
|
||
|
||
let nextIdx = lyricLines.findIndex(l => time < l.time);
|
||
let activeIdx = nextIdx === -1 ? lyricLines.length - 1 : Math.max(0, nextIdx - 1);
|
||
|
||
document.querySelectorAll('.lyric-line.active').forEach(el => el.classList.remove('active'));
|
||
const activeEl = lyricLines[activeIdx].el;
|
||
activeEl.classList.add('active');
|
||
|
||
const box = els.lyricsBox;
|
||
const offset = activeEl.offsetTop - box.clientHeight / 2 + activeEl.clientHeight / 2;
|
||
box.scrollTo({ top: Math.max(0, offset), behavior: seeking ? 'auto' : 'smooth' });
|
||
|
||
updateMiniLyrics(activeIdx);
|
||
}
|
||
|
||
function updateMiniLyrics(idx) {
|
||
if (!els.miniLyrics) return;
|
||
|
||
|
||
if (els.miniLyrics) els.miniLyrics.style.display = isLyricView ? 'none' : 'block';
|
||
|
||
const lines = els.miniLyrics.querySelectorAll('.mini-line');
|
||
const getText = (i) => (lyricLines[i] ? lyricLines[i].el.innerText : '');
|
||
if (lines.length >= 3) {
|
||
lines[0].className = 'mini-line dim';
|
||
lines[1].className = 'mini-line active';
|
||
lines[2].className = 'mini-line dim';
|
||
lines[0].innerText = getText(Math.max(0, idx - 1));
|
||
lines[1].innerText = getText(idx);
|
||
lines[2].innerText = getText(Math.min(lyricLines.length - 1, idx + 1));
|
||
}
|
||
}
|
||
|
||
// 视图切换
|
||
window.toggleView = function (e) {
|
||
if (e) e.stopPropagation();
|
||
|
||
isLyricView = !isLyricView;
|
||
if (isLyricView) {
|
||
els.discMode.classList.add('hidden');
|
||
els.lyricsMode.classList.add('active');
|
||
if (els.miniLyrics) els.miniLyrics.style.display = 'none';
|
||
syncLyrics(els.audio.currentTime);
|
||
} else {
|
||
els.discMode.classList.remove('hidden');
|
||
els.lyricsMode.classList.remove('active');
|
||
if (els.miniLyrics) els.miniLyrics.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
window.downloadMusic = async function () {
|
||
if (!musicData.playurl) return;
|
||
msg('正在准备下载,请稍候...');
|
||
try {
|
||
const response = await fetch(musicData.playurl);
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${musicData.title || 'audio'}.mp3`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
msg('下载已开始');
|
||
} catch (error) {
|
||
console.error('下载失败:', error);
|
||
msg('下载失败,请检查网络或稍后重试');
|
||
}
|
||
}
|
||
|
||
// --- 微信分享配置 ---
|
||
async function initWeixinShare(shareData) {
|
||
// 判断是否在微信环境中
|
||
if (!/micromessenger/i.test(navigator.userAgent)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 1. 从您的后端获取签名
|
||
const signatureData = await getWeixinSignature();
|
||
|
||
// 2. 配置JS-SDK
|
||
wx.config({
|
||
debug: false, // 调试时可开启
|
||
appId: signatureData.appId,
|
||
timestamp: signatureData.timestamp,
|
||
nonceStr: signatureData.nonceStr,
|
||
signature: signatureData.signature,
|
||
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData']
|
||
});
|
||
|
||
// 3. 配置分享内容
|
||
wx.ready(() => {
|
||
const sharePayload = {
|
||
title: shareData.title,
|
||
desc: '立即收听:' + shareData.title,
|
||
link: window.location.href,
|
||
imgUrl: shareData.img,
|
||
success: () => msg('分享成功'),
|
||
cancel: () => msg('取消分享')
|
||
};
|
||
wx.updateAppMessageShareData(sharePayload); // 分享给朋友
|
||
wx.updateTimelineShareData(sharePayload); // 分享到朋友圈
|
||
});
|
||
|
||
wx.error(err => {
|
||
console.error('微信JS-SDK配置失败:', err);
|
||
// msg('微信分享配置失败');
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('初始化微信分享失败:', error);
|
||
// msg('初始化微信分享失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description 从后端获取微信JS-SDK签名
|
||
* @returns {Promise<object>} 包含 appId, timestamp, nonceStr, signature 的对象
|
||
*
|
||
* 后端实现参考 (Node.js):
|
||
* 1. 安装 `axios` 和 `sha1` 库: `npm install axios sha1`
|
||
* 2. 后端代码需要缓存 access_token 和 jsapi_ticket,避免频繁请求微信接口
|
||
* 3. 签名算法请严格参考微信官方文档
|
||
*/
|
||
async function getWeixinSignature() {
|
||
// 在这里,您需要向您的后端服务发起一个请求
|
||
// 后端需要根据当前的 URL 生成一个有效的签名
|
||
// 例如: const res = await fetch('https://your-backend.com/api/weixin-signature?url=' + encodeURIComponent(location.href.split('#')[0]));
|
||
// return await res.json();
|
||
|
||
// --- 以下为模拟数据,请务必替换为真实后端请求 ---
|
||
console.warn('正在使用模拟的微信签名数据,请替换为您的后端实现');
|
||
return new Promise(resolve => {
|
||
resolve({
|
||
appId: "YOUR_APP_ID", // 替换为您的公众号AppID
|
||
timestamp: "1678886400", // 替换为后端生成的时间戳
|
||
nonceStr: "YOUR_NONCE_STR", // 替换为后端生成的随机字符串
|
||
signature: "YOUR_SIGNATURE", // 替换为后端生成的签名
|
||
});
|
||
});
|
||
}
|
||
|
||
function createParticles() {
|
||
const container = document.getElementById('particles');
|
||
for (let i = 0; i < 20; i++) {
|
||
const span = document.createElement('span');
|
||
const size = Math.random() * 4 + 2;
|
||
span.style.width = `${size}px`;
|
||
span.style.height = `${size}px`;
|
||
span.style.left = `${Math.random() * 100}%`;
|
||
span.style.animationDelay = `${Math.random() * 5}s`;
|
||
span.style.animationDuration = `${Math.random() * 10 + 10}s`;
|
||
container.appendChild(span);
|
||
}
|
||
}
|
||
|
||
function formatTime(s) {
|
||
const m = Math.floor(s / 60);
|
||
const sec = Math.floor(s % 60);
|
||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function msg(txt) {
|
||
// 简单提示
|
||
const div = document.createElement('div');
|
||
div.innerText = txt;
|
||
div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.7);color:#fff;padding:10px 20px;border-radius:20px;z-index:999;font-size:14px;';
|
||
document.body.appendChild(div);
|
||
setTimeout(() => div.remove(), 2000);
|
||
}
|
||
|
||
let recordEnabled = false;
|
||
let mvCanvas = null;
|
||
let mvCtx = null;
|
||
let mvRecorder = null;
|
||
let mvChunks = [];
|
||
let mvRenderTimer = null;
|
||
let isMVRecording = false;
|
||
let mvStream = null;
|
||
let mvAudioTrack = null;
|
||
let mvRafId = 0;
|
||
|
||
els.viewWrapper = document.querySelector('.view-wrapper');
|
||
els.captureTarget = document.querySelector('.view-wrapper');
|
||
const isMobile = /Mobile|Android|iP(hone|od|ad)|IEMobile|BlackBerry|Opera Mini/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 1);
|
||
|
||
function buildSVGFromElement(el, w, h) {
|
||
const styles = Array.from(document.querySelectorAll('style')).map(s => s.textContent).join('\n');
|
||
const html = el.outerHTML;
|
||
const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${w}\" height=\"${h}\"><foreignObject width=\"100%\" height=\"100%\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><style>${styles}</style>${html}</div></foreignObject></svg>`;
|
||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||
}
|
||
|
||
// 手动绘制 Canvas 帧(高性能)
|
||
function drawCanvasFrame(containerRect, dpr) {
|
||
if (!mvCtx || !mvCanvas) return;
|
||
const w = mvCanvas.width;
|
||
const h = mvCanvas.height;
|
||
const ctx = mvCtx;
|
||
dpr = dpr || window.devicePixelRatio || 1;
|
||
|
||
// 确保 containerRect 存在
|
||
if (!containerRect) {
|
||
const main = document.querySelector('.main-container');
|
||
if (main) containerRect = main.getBoundingClientRect();
|
||
}
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
// --- 1. 绘制背景 ---
|
||
// A. Base Color (#1a82ea)
|
||
// 强制使用 #1a82ea 以确保背景色可见
|
||
ctx.fillStyle = '#1a82ea';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// B. Background Image (Opacity 0.3 + Breathe)
|
||
// 模拟 .bg-image 的呼吸效果
|
||
ctx.save();
|
||
const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1; // 呼吸动画
|
||
|
||
if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
|
||
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);
|
||
|
||
// --- 1.1 绘制粒子 (Particles) ---
|
||
// 模拟 20 个粒子,从下往上浮动
|
||
ctx.save();
|
||
const now = Date.now();
|
||
for (let i = 0; i < 20; i++) {
|
||
// 使用伪随机数生成固定的粒子属性
|
||
const seed = i * 1337;
|
||
const speed = 10000 + (seed % 5000); // 10-15s
|
||
const delay = (seed % 5000);
|
||
|
||
const time = (now + delay) % speed;
|
||
const p = time / speed; // 0 -> 1
|
||
|
||
// floatUp keyframes:
|
||
// 0%: y=0, op=0, s=0.5
|
||
// 20%: op=0.4
|
||
// 80%: op=0.2
|
||
// 100%: y=-h, op=0, s=1.5
|
||
|
||
const y = h - (p * (h + 100)); // 从底向上
|
||
const x = ((seed * 7) % 100) / 100 * w;
|
||
const size = (2 + (seed % 4)) * dpr * (0.5 + p); // 变大
|
||
|
||
let opacity = 0;
|
||
if (p < 0.2) opacity = (p / 0.2) * 0.4;
|
||
else if (p < 0.8) opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2; // 0.4 -> 0.2
|
||
else opacity = 0.2 * (1 - (p - 0.8) / 0.2); // 0.2 -> 0
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
ctx.restore();
|
||
|
||
// --- 2. 绘制标题 ---
|
||
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);
|
||
|
||
// 绘制 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);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
|
||
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
|
||
// 不再使用硬编码坐标,而是完全基于 DOM 元素的位置和尺寸
|
||
|
||
if (!isLyricView) {
|
||
// === 唱片模式 (.disc-mode) ===
|
||
// 核心思路:分别获取 .disc-container, .disc, .album-cover, .needle 等元素的 rect
|
||
|
||
// 1. 光波 (Ripple) - 已移除
|
||
|
||
// 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;
|
||
|
||
ctx.save();
|
||
ctx.translate(dx, dy);
|
||
|
||
// 旋转:读取 computed style 的 transform 可能会比较麻烦(matrix),
|
||
// 简单起见,如果正在播放,我们手动模拟旋转角度,或者尝试解析 matrix
|
||
// 为了平滑录制,手动模拟旋转是最佳实践,因为 captureStream 可能会丢帧导致 matrix 跳变
|
||
if (isPlaying) {
|
||
const angle = (Date.now() / 20000) * 360 % 360; // 20s per round
|
||
ctx.rotate(angle * Math.PI / 180);
|
||
}
|
||
|
||
// 唱片本体背景
|
||
// background: radial-gradient(circle, #111 0%, #1a1a1a 100%);
|
||
const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||
bgGrad.addColorStop(0, '#111');
|
||
bgGrad.addColorStop(1, '#1a1a1a');
|
||
ctx.fillStyle = bgGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 纹理 (repeating-radial-gradient) - Canvas 模拟比较耗时,画几个圈代替
|
||
ctx.strokeStyle = '#222';
|
||
ctx.lineWidth = 2 * dpr;
|
||
for(let r = radius * 0.68; r < radius * 0.95; r += 6 * dpr) {
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 边框 border: 6px solid #080808;
|
||
ctx.strokeStyle = '#080808';
|
||
ctx.lineWidth = 6 * dpr; // border is usually inside or outside? CSS border is outside if content-box, inside if border-box. usually inside visual boundary.
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// 封面 (.album-cover)
|
||
// width: 65%; height: 65%;
|
||
const coverRadius = radius * 0.65;
|
||
|
||
ctx.save();
|
||
// 封面跳动
|
||
if (isPlaying) {
|
||
const beatScale = 1 + Math.sin(Date.now() / 200) * 0.005; // 微弱跳动
|
||
ctx.scale(beatScale, beatScale);
|
||
}
|
||
|
||
// 封面边框 border: 4px solid #000;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fill(); // fill black behind image
|
||
|
||
// Image
|
||
if (els.coverImg.complete) {
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, coverRadius - 2 * dpr, 0, Math.PI * 2); // minus border
|
||
ctx.clip();
|
||
ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
|
||
}
|
||
ctx.restore(); // end cover
|
||
|
||
// 光泽 (.disc::before) - 简单画个半透明渐变
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||
const glossGrad = ctx.createLinearGradient(-radius, -radius, radius, radius);
|
||
glossGrad.addColorStop(0, 'transparent');
|
||
glossGrad.addColorStop(0.45, 'transparent');
|
||
glossGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)');
|
||
glossGrad.addColorStop(0.55, 'transparent');
|
||
glossGrad.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = glossGrad;
|
||
ctx.fill();
|
||
|
||
ctx.restore(); // end disc rotate
|
||
}
|
||
|
||
// 4. 唱臂 (.needle)
|
||
const needle = document.getElementById('needle');
|
||
if (needle) {
|
||
// CSS: top: -50px; left: 50%; transform-origin: 40px 20px;
|
||
// transform: translateX(-10px) rotate(...);
|
||
|
||
const parentRect = document.querySelector('.disc-mode').getBoundingClientRect();
|
||
// Calculate base position (before transform) relative to container
|
||
// parentRect.width/2 corresponds to left: 50%
|
||
// -50 corresponds to top: -50px
|
||
const needleBaseX = (parentRect.left - containerRect.left + parentRect.width / 2) * dpr;
|
||
const needleBaseY = (parentRect.top - containerRect.top - 50) * dpr;
|
||
|
||
ctx.save();
|
||
ctx.translate(needleBaseX, needleBaseY);
|
||
|
||
// Apply transforms: translateX(-10px) then rotate
|
||
// And respect transform-origin: 40px 20px
|
||
const originX = 40 * dpr;
|
||
const originY = 20 * dpr;
|
||
const angle = isPlaying ? -5 : -35;
|
||
|
||
// Matrix order for: transform-origin(ox, oy) + translateX(tx) + rotate(r)
|
||
// M = T(ox, oy) * T(tx, 0) * R(r) * T(-ox, -oy)
|
||
|
||
ctx.translate(originX, originY);
|
||
ctx.translate(-10 * dpr, 0);
|
||
ctx.rotate(angle * Math.PI / 180);
|
||
ctx.translate(-originX, -originY);
|
||
|
||
// Draw Parts
|
||
|
||
// 1. Pivot (top: 0, left: 20px, w: 40px, h: 40px)
|
||
ctx.save();
|
||
ctx.translate(20 * dpr, 0);
|
||
ctx.beginPath();
|
||
ctx.arc(20 * dpr, 20 * dpr, 20 * dpr, 0, Math.PI * 2);
|
||
const pivotGrad = ctx.createRadialGradient(12 * dpr, 12 * dpr, 0, 20 * dpr, 20 * dpr, 20 * dpr);
|
||
pivotGrad.addColorStop(0, '#e0e0e0');
|
||
pivotGrad.addColorStop(1, '#555');
|
||
ctx.fillStyle = pivotGrad;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#444';
|
||
ctx.lineWidth = 1 * dpr;
|
||
ctx.stroke();
|
||
|
||
// Center screw
|
||
ctx.beginPath();
|
||
ctx.arc(20 * dpr, 20 * dpr, 6 * dpr, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#222';
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 2. Rod (top: 30px, left: 36px, w: 8px, h: 100px)
|
||
// Gradient: linear-gradient(90deg, #555, #ccc, #555)
|
||
const rodGrad = ctx.createLinearGradient(36 * dpr, 0, 44 * dpr, 0);
|
||
rodGrad.addColorStop(0, '#555');
|
||
rodGrad.addColorStop(0.5, '#ccc');
|
||
rodGrad.addColorStop(1, '#555');
|
||
ctx.fillStyle = rodGrad;
|
||
ctx.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
|
||
|
||
// 3. Head (bottom: 0 -> top: 102px, left: 28px, w: 24px, h: 38px)
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 38 * dpr);
|
||
// Top border
|
||
ctx.fillStyle = '#555';
|
||
ctx.fillRect(28 * dpr, 102 * dpr, 24 * dpr, 2 * dpr);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
} else {
|
||
// === 歌词模式 ===
|
||
ctx.save();
|
||
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;
|
||
}
|
||
if (activeIndex === -1) activeIndex = 0;
|
||
|
||
const lineHeight = 40 * dpr;
|
||
const maxLines = 7; // 显示行数
|
||
|
||
for (let i = -3; i <= 3; i++) {
|
||
const idx = activeIndex + i;
|
||
if (idx >= 0 && idx < lyricLines.length) {
|
||
const line = lyricLines[idx];
|
||
const y = centerY + i * lineHeight;
|
||
|
||
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, centerX, y);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// --- 4. 绘制底部进度条 ---
|
||
const barY = h * 0.85;
|
||
const barWidth = w * 0.8;
|
||
const barX = (w - barWidth) / 2;
|
||
|
||
// 总进度条背景
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
||
ctx.beginPath();
|
||
ctx.roundRect(barX, barY, barWidth, 4 * dpr, 2 * dpr);
|
||
ctx.fill();
|
||
|
||
// 当前进度
|
||
const duration = els.audio.duration || 1;
|
||
const progress = els.audio.currentTime / duration;
|
||
const currentBarWidth = barWidth * progress;
|
||
|
||
// 进度填充
|
||
const gradBar = ctx.createLinearGradient(barX, 0, barX + currentBarWidth, 0);
|
||
gradBar.addColorStop(0, '#fff');
|
||
gradBar.addColorStop(1, '#d4af37');
|
||
ctx.fillStyle = gradBar;
|
||
ctx.beginPath();
|
||
ctx.roundRect(barX, barY, currentBarWidth, 4 * dpr, 2 * dpr);
|
||
ctx.fill();
|
||
|
||
// 进度点
|
||
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.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||
ctx.font = `${11 * dpr}px monospace`;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(els.currTime.innerText, barX, barY - 15 * dpr);
|
||
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);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// --- 6. 绘制按钮组 (SVG) ---
|
||
// 位于底部
|
||
const btnY = barY + 40 * dpr;
|
||
const btnGroupWidth = w * 0.9;
|
||
// 6个按钮,5个间隙
|
||
const btnCount = 6;
|
||
const btnSpacing = btnGroupWidth / (btnCount - 1);
|
||
const startX = (w - btnGroupWidth) / 2;
|
||
|
||
ctx.save();
|
||
ctx.translate(0, btnY);
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.fillStyle = '#fff';
|
||
ctx.lineWidth = 2 * dpr;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
|
||
const drawIcon = (index, pathFunc) => {
|
||
ctx.save();
|
||
ctx.translate(startX + index * btnSpacing, 0);
|
||
ctx.scale(dpr, dpr); // Scale for high DPI
|
||
ctx.translate(-12, -12); // Center 24x24 icon
|
||
pathFunc(ctx);
|
||
ctx.restore();
|
||
};
|
||
|
||
// 1. Download
|
||
drawIcon(0, (c) => {
|
||
c.beginPath();
|
||
// M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4
|
||
c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15);
|
||
// polyline 7 10 12 15 17 10
|
||
c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10);
|
||
// line 12 15 12 3
|
||
c.moveTo(12, 15); c.lineTo(12, 3);
|
||
c.stroke();
|
||
});
|
||
|
||
// 2. Prev
|
||
drawIcon(1, (c) => {
|
||
c.beginPath();
|
||
// M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z
|
||
c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath();
|
||
c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath();
|
||
c.fill();
|
||
});
|
||
|
||
// 3. Play/Pause
|
||
drawIcon(2, (c) => {
|
||
// Circle bg
|
||
c.translate(12, 12);
|
||
c.scale(2.5, 2.5); // Bigger button
|
||
c.translate(-12, -12);
|
||
|
||
c.beginPath();
|
||
c.arc(12, 12, 30, 0, Math.PI * 2);
|
||
// c.stroke(); // circle border
|
||
|
||
if (isPlaying) {
|
||
// Pause: M6 19h4V5H6v14zm8-14v14h4V5h-4z
|
||
c.beginPath();
|
||
c.rect(6, 5, 4, 14);
|
||
c.rect(14, 5, 4, 14);
|
||
c.fill();
|
||
} else {
|
||
// Play: M8 5v14l11-7z
|
||
c.beginPath();
|
||
c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath();
|
||
c.fill();
|
||
}
|
||
});
|
||
|
||
// 4. Next
|
||
drawIcon(3, (c) => {
|
||
c.beginPath();
|
||
// M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z
|
||
c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath();
|
||
c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath();
|
||
c.fill();
|
||
});
|
||
|
||
// 5. List/Mode
|
||
drawIcon(4, (c) => {
|
||
c.beginPath();
|
||
// M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z
|
||
// Simplified rect drawing or Path2D
|
||
const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z");
|
||
c.fill(p);
|
||
});
|
||
|
||
// 6. Rec Toggle
|
||
drawIcon(5, (c) => {
|
||
c.beginPath();
|
||
c.arc(12, 12, 6, 0, Math.PI * 2);
|
||
// 如果正在录制,显示红色
|
||
if (recordEnabled) {
|
||
c.fillStyle = '#ff3b30';
|
||
c.fill();
|
||
// Reset fillStyle
|
||
c.fillStyle = '#fff';
|
||
} else {
|
||
c.fill(); // White dot
|
||
}
|
||
// Ring
|
||
// c.beginPath();
|
||
// c.arc(12, 12, 10, 0, Math.PI * 2);
|
||
// c.stroke();
|
||
});
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
async function startSilentMV() {
|
||
if (!recordEnabled || isMVRecording) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const mainContainer = document.querySelector('.main-container');
|
||
const rect = mainContainer.getBoundingClientRect();
|
||
|
||
// 创建 Canvas
|
||
mvCanvas = document.createElement('canvas');
|
||
mvCtx = mvCanvas.getContext('2d');
|
||
mvCanvas.width = rect.width * dpr;
|
||
mvCanvas.height = rect.height * dpr;
|
||
|
||
// 120 FPS
|
||
const targetFPS = 120;
|
||
|
||
async function renderLoop() {
|
||
if (!isMVRecording) return;
|
||
|
||
try {
|
||
drawCanvasFrame(rect, dpr);
|
||
} catch (e) {
|
||
console.error('Render error:', e);
|
||
}
|
||
|
||
if (isMVRecording) {
|
||
mvRafId = requestAnimationFrame(renderLoop);
|
||
}
|
||
}
|
||
mvRafId = requestAnimationFrame(renderLoop);
|
||
|
||
mvStream = mvCanvas.captureStream(targetFPS);
|
||
|
||
// 添加音频
|
||
const audioEl = document.getElementById('audio');
|
||
if (audioEl) {
|
||
try {
|
||
let audioStream;
|
||
if (audioEl.captureStream) audioStream = audioEl.captureStream();
|
||
else if (audioEl.mozCaptureStream) audioStream = audioEl.mozCaptureStream();
|
||
if (audioStream) {
|
||
const audioTrack = audioStream.getAudioTracks()[0];
|
||
if (audioTrack) mvStream.addTrack(audioTrack);
|
||
}
|
||
} catch (e) { console.warn(e); }
|
||
}
|
||
|
||
mvChunks = [];
|
||
const optsList = [
|
||
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2' },
|
||
{ mimeType: 'video/mp4' },
|
||
{ mimeType: 'video/webm;codecs=vp9,opus' },
|
||
{ mimeType: 'video/webm' }
|
||
];
|
||
let opts = {};
|
||
for (const o of optsList) {
|
||
if (MediaRecorder.isTypeSupported(o.mimeType)) {
|
||
opts = { ...o, videoBitsPerSecond: 25000000 };
|
||
break;
|
||
}
|
||
}
|
||
|
||
mvRecorder = new MediaRecorder(mvStream, opts);
|
||
mvRecorder.ondataavailable = e => { if (e.data && e.data.size) mvChunks.push(e.data); };
|
||
mvRecorder.onstop = async () => {
|
||
const title = (musicData.title || 'mv').replace(/\s+/g, '_');
|
||
const blob = new Blob(mvChunks, { type: mvRecorder.mimeType || 'video/webm' });
|
||
const type = mvRecorder.mimeType || 'video/webm';
|
||
if (/mp4/i.test(type)) downloadFile(`${title}.mp4`, blob);
|
||
else await transcodeToMp4(blob, title);
|
||
|
||
// Stop tracks
|
||
if (mvStream) mvStream.getTracks().forEach(t => t.stop());
|
||
};
|
||
|
||
mvRecorder.start();
|
||
isMVRecording = true;
|
||
if (els.recIndicator) els.recIndicator.style.display = 'flex';
|
||
if (els.recToggleBtn) els.recToggleBtn.style.color = '#ff3b30';
|
||
}
|
||
|
||
// 辅助函数:绘制 DOM 元素到 Canvas
|
||
function drawDomElement(ctx, el, dpr, containerRect, drawFn) {
|
||
if (!el || el.style.display === 'none' || el.style.opacity === '0') return;
|
||
const r = el.getBoundingClientRect();
|
||
// 计算相对于 main-container 的坐标
|
||
const x = (r.left - containerRect.left) * dpr;
|
||
const y = (r.top - containerRect.top) * dpr;
|
||
const w = r.width * dpr;
|
||
const h = r.height * dpr;
|
||
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
drawFn(ctx, w, h, r); // Pass raw rect too if needed
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawCanvasFrame(containerRect, dpr) {
|
||
if (!mvCtx || !mvCanvas) return;
|
||
const ctx = mvCtx;
|
||
const w = mvCanvas.width;
|
||
const h = mvCanvas.height;
|
||
|
||
// 清空
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
// 1. 背景 (.bg-image)
|
||
// 由于 filter: blur 在 Canvas 中性能尚可,我们尝试还原
|
||
// --- 1. 背景 (.bg-layer) ---
|
||
// CSS: filter: blur(60px) brightness(0.6); transform: scale(1.1);
|
||
ctx.save();
|
||
|
||
// 模拟 scale(1.1) 或呼吸效果
|
||
const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1;
|
||
const imgW = w * scale;
|
||
const imgH = h * scale;
|
||
const bgX = (w - imgW) / 2;
|
||
const bgY = (h - imgH) / 2;
|
||
|
||
// 绘制背景图
|
||
if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
|
||
// 性能优化:Canvas filter 性能开销大,但在录制时为了画质可以接受
|
||
// 或者使用多步缩小+放大来模拟模糊,这里直接使用 filter
|
||
// 注意:部分浏览器可能不支持 context.filter
|
||
if (ctx.filter !== undefined) {
|
||
ctx.filter = 'blur(60px) brightness(0.6)';
|
||
ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
|
||
ctx.filter = 'none'; // 重置
|
||
} else {
|
||
// Fallback: 绘制暗色蒙层模拟 brightness,模糊较难模拟
|
||
ctx.drawImage(els.coverImg, bgX, bgY, imgW, imgH);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.4)';
|
||
ctx.fillRect(0,0,w,h);
|
||
}
|
||
} else {
|
||
ctx.fillStyle = '#1a82ea';
|
||
ctx.fillRect(0, 0, w, h);
|
||
}
|
||
|
||
// 绘制蒙版 .bg-mask
|
||
// CSS: linear-gradient(to bottom, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5) 50%, rgba(0, 0, 0, 0.9));
|
||
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
||
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
|
||
gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
|
||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.restore();
|
||
|
||
// --- 1.1 绘制粒子 (Particles) ---
|
||
// 严格复刻 createParticles 和 CSS floatUp 动画逻辑
|
||
// CSS: bottom: -50px; left: random%; animation: floatUp 15s linear infinite;
|
||
// keyframes floatUp:
|
||
// 0%: translateY(0) scale(0.5), opacity: 0
|
||
// 20%: opacity: 0.4
|
||
// 80%: opacity: 0.2
|
||
// 100%: translateY(-100vh) scale(1.5), opacity: 0
|
||
|
||
ctx.save();
|
||
const now = Date.now();
|
||
// 使用固定的种子来模拟粒子,确保录制时粒子位置连贯
|
||
const particleCount = 20;
|
||
|
||
for (let i = 0; i < particleCount; i++) {
|
||
const seed = i * 1337;
|
||
// 模拟 CSS: animation-duration: 10s + random*10s -> 10000ms - 20000ms
|
||
const duration = 10000 + (seed % 10000);
|
||
// 模拟 CSS: animation-delay: random*5s -> 0 - 5000ms
|
||
const delay = (seed % 5000);
|
||
|
||
const time = (now + delay) % duration;
|
||
const p = time / duration; // 0 -> 1 进度
|
||
|
||
// CSS: left: random%
|
||
// 必须与 seed 绑定,保证每一帧位置不变
|
||
const leftPercent = ((seed * 7) % 100) / 100;
|
||
const x = leftPercent * w;
|
||
|
||
// CSS: bottom: -50px -> translateY(-100vh)
|
||
// startY = h + 50 (approx for bottom -50px)
|
||
// endY = startY - h - 100 (move up by 100vh + extra)
|
||
// Simplified: linear move from bottom to top
|
||
const startY = h + 50 * dpr;
|
||
const endY = -50 * dpr;
|
||
const y = startY - p * (startY - endY);
|
||
|
||
// CSS: size: random * 4 + 2 px
|
||
const baseSize = (2 + (seed % 4)) * dpr;
|
||
|
||
// Animation: Scale 0.5 -> 1.5
|
||
const scale = 0.5 + p * 1.0;
|
||
const currentSize = baseSize * scale;
|
||
|
||
// Animation: Opacity
|
||
let opacity = 0;
|
||
if (p < 0.2) {
|
||
// 0% -> 20%: 0 -> 0.4
|
||
opacity = (p / 0.2) * 0.4;
|
||
} else if (p < 0.8) {
|
||
// 20% -> 80%: 0.4 -> 0.2
|
||
opacity = 0.4 - ((p - 0.2) / 0.6) * 0.2;
|
||
} else {
|
||
// 80% -> 100%: 0.2 -> 0
|
||
opacity = 0.2 * (1 - (p - 0.8) / 0.2);
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, currentSize / 2, 0, Math.PI * 2); // size is width/diameter
|
||
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
// 3. 歌名 (.header-title)
|
||
drawDomElement(ctx, els.songTitle, dpr, containerRect, (c, cw, ch) => {
|
||
c.font = `bold ${22 * dpr}px sans-serif`;
|
||
c.fillStyle = '#ffffff';
|
||
c.textAlign = 'center';
|
||
c.textBaseline = 'middle';
|
||
c.shadowColor = 'rgba(0,0,0,0.5)';
|
||
c.shadowBlur = 10 * dpr;
|
||
c.shadowOffsetY = 4 * dpr;
|
||
c.fillText(els.songTitle.innerText, cw / 2, ch / 2);
|
||
});
|
||
|
||
// Mini Lyrics
|
||
if (!isLyricView) {
|
||
const miniLines = document.querySelectorAll('.mini-line');
|
||
miniLines.forEach(line => {
|
||
drawDomElement(ctx, line, dpr, containerRect, (c, cw, ch) => {
|
||
const style = getComputedStyle(line);
|
||
c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
|
||
c.fillStyle = style.color; // Might handle rgba
|
||
c.textAlign = 'center';
|
||
c.textBaseline = 'middle';
|
||
c.fillText(line.innerText, cw / 2, ch / 2);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 4. Disc Mode
|
||
if (!isLyricView) {
|
||
// Disc Container
|
||
const discContainer = document.querySelector('.disc-container');
|
||
drawDomElement(ctx, discContainer, dpr, containerRect, (c, cw, ch) => {
|
||
// Ripples
|
||
if (isPlaying) {
|
||
const cx = cw / 2;
|
||
const cy = ch / 2;
|
||
const discRadius = Math.min(cw, ch) / 2; // Approximate
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const duration = 2500;
|
||
const delay = i * 800;
|
||
const time = (now - delay) % duration;
|
||
if (time >= 0) {
|
||
const p = time / duration;
|
||
const r = discRadius * (1 + p * 0.6);
|
||
const op = 0.4 * (1 - p);
|
||
c.beginPath();
|
||
c.arc(cx, cy, r, 0, Math.PI * 2);
|
||
c.strokeStyle = `rgba(255, 255, 255, ${op})`;
|
||
c.lineWidth = (4 * (1 - p)) * dpr;
|
||
c.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Disc
|
||
c.save();
|
||
c.translate(cw/2, ch/2);
|
||
if (isPlaying) {
|
||
const angle = (Date.now() / 5000) * 360 % 360;
|
||
c.rotate(angle * Math.PI / 180);
|
||
}
|
||
const r = Math.min(cw, ch) / 2; // .disc size
|
||
|
||
// Black vinyl
|
||
c.beginPath();
|
||
c.arc(0, 0, r, 0, Math.PI * 2);
|
||
c.fillStyle = '#111';
|
||
c.fill();
|
||
c.lineWidth = 6 * dpr;
|
||
c.strokeStyle = '#080808';
|
||
c.stroke();
|
||
|
||
// Cover
|
||
const coverR = r * 0.65;
|
||
c.save();
|
||
c.beginPath();
|
||
c.arc(0, 0, coverR, 0, Math.PI * 2);
|
||
c.clip();
|
||
if (isPlaying) {
|
||
const beatScale = 1 + Math.sin(Date.now() / 200) * 0.01;
|
||
c.scale(beatScale, beatScale);
|
||
}
|
||
if (els.coverImg.complete) {
|
||
c.drawImage(els.coverImg, -coverR, -coverR, coverR * 2, coverR * 2);
|
||
}
|
||
c.restore();
|
||
c.restore();
|
||
});
|
||
|
||
// Needle
|
||
// Needle rotation logic is CSS transform based.
|
||
// We can use the CSS transform matrix or just replicate logic.
|
||
// Replicating logic is smoother.
|
||
drawDomElement(ctx, els.needle, dpr, containerRect, (c, cw, ch) => {
|
||
// The needle element in DOM is a wrapper.
|
||
// We need to draw the parts.
|
||
// CSS: transform-origin: 40px 20px;
|
||
// We are already translated to top-left of .needle
|
||
const pivotX = 40 * dpr; // 40px * dpr
|
||
const pivotY = 20 * dpr;
|
||
|
||
c.translate(pivotX, pivotY);
|
||
const angle = isPlaying ? -5 : -35;
|
||
c.rotate(angle * Math.PI / 180);
|
||
c.translate(-pivotX, -pivotY); // Rotate around pivot
|
||
|
||
// Draw Rod
|
||
c.fillStyle = '#ccc';
|
||
c.fillRect(36 * dpr, 30 * dpr, 8 * dpr, 100 * dpr);
|
||
|
||
// Draw Head
|
||
c.fillStyle = '#1a1a1a';
|
||
c.fillRect(28 * dpr, 130 * dpr, 24 * dpr, 38 * dpr);
|
||
|
||
// Draw Pivot
|
||
c.beginPath();
|
||
c.arc(pivotX, pivotY, 20 * dpr, 0, Math.PI * 2);
|
||
c.fillStyle = '#e0e0e0';
|
||
c.fill();
|
||
});
|
||
}
|
||
|
||
// 5. Lyrics Mode
|
||
if (isLyricView) {
|
||
const lines = document.querySelectorAll('.lyric-line');
|
||
// Only draw lines that are inside the lyricsBox view
|
||
const box = document.getElementById('lyricsBox');
|
||
const boxRect = box.getBoundingClientRect();
|
||
|
||
// Draw mask for scroll
|
||
// Canvas doesn't have ease mask-image.
|
||
// We can just draw all texts and let them be clipped by viewport?
|
||
// No, we need to respect the container rect.
|
||
|
||
drawDomElement(ctx, box, dpr, containerRect, (c, cw, ch) => {
|
||
c.beginPath();
|
||
c.rect(0, 0, cw, ch);
|
||
c.clip(); // Clip to lyrics box
|
||
|
||
// Now draw lines relative to this box?
|
||
// No, drawDomElement translates to box top-left.
|
||
// But lines move relative to box.
|
||
// We should iterate lines and calculate their relative position to box.
|
||
|
||
lines.forEach(line => {
|
||
const lr = line.getBoundingClientRect();
|
||
// Check visibility
|
||
if (lr.bottom < boxRect.top || lr.top > boxRect.bottom) return;
|
||
|
||
const lx = (lr.left - boxRect.left) * dpr;
|
||
const ly = (lr.top - boxRect.top) * dpr;
|
||
|
||
c.save();
|
||
c.translate(lx, ly);
|
||
|
||
const style = getComputedStyle(line);
|
||
c.font = `${style.fontWeight} ${parseFloat(style.fontSize) * dpr}px ${style.fontFamily}`;
|
||
c.fillStyle = style.color;
|
||
c.textAlign = 'center';
|
||
c.textBaseline = 'top'; // DOM text aligns top usually
|
||
|
||
// Text wrap is hard in Canvas. Assuming single line for now or simple wrap.
|
||
// For lyrics, usually centered single line.
|
||
c.fillText(line.innerText, (lr.width * dpr) / 2, 0);
|
||
|
||
c.restore();
|
||
});
|
||
});
|
||
}
|
||
|
||
// 6. Controls
|
||
// 绘制 controls-area 背景 (Glassmorphism)
|
||
// CSS: background: rgba(20, 20, 20, 0.6); backdrop-filter: blur(20px) saturate(180%);
|
||
// border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||
// box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
|
||
const controlsEl = document.querySelector('.controls-area');
|
||
if (controlsEl) {
|
||
const cr = controlsEl.getBoundingClientRect();
|
||
const cx = (cr.left - containerRect.left) * dpr;
|
||
const cy = (cr.top - containerRect.top) * dpr;
|
||
const cw = cr.width * dpr;
|
||
const ch = cr.height * dpr;
|
||
|
||
ctx.save();
|
||
// 阴影
|
||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||
ctx.shadowBlur = 40 * dpr;
|
||
ctx.shadowOffsetY = -10 * dpr;
|
||
|
||
// 背景
|
||
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
|
||
|
||
// 绘制路径 (左上右上圆角)
|
||
const radius = 24 * dpr;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy + ch); // 左下
|
||
ctx.lineTo(cx, cy + radius); // 左上起始
|
||
ctx.arcTo(cx, cy, cx + radius, cy, radius); // 左上圆角
|
||
ctx.lineTo(cx + cw - radius, cy); // 顶边
|
||
ctx.arcTo(cx + cw, cy, cx + cw, cy + radius, radius); // 右上圆角
|
||
ctx.lineTo(cx + cw, cy + ch); // 右下
|
||
ctx.closePath();
|
||
|
||
ctx.fill();
|
||
|
||
// 边框 (Top border)
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||
ctx.lineWidth = 1 * dpr;
|
||
ctx.stroke();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// Progress Bar
|
||
drawDomElement(ctx, els.progressBar, dpr, containerRect, (c, cw, ch) => {
|
||
c.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
||
c.beginPath();
|
||
c.roundRect(0, 0, cw, ch, 2 * dpr);
|
||
c.fill();
|
||
});
|
||
|
||
drawDomElement(ctx, els.progressFill, dpr, containerRect, (c, cw, ch) => {
|
||
const grad = c.createLinearGradient(0, 0, cw, 0);
|
||
grad.addColorStop(0, '#fff');
|
||
grad.addColorStop(1, '#d4af37');
|
||
c.fillStyle = grad;
|
||
c.beginPath();
|
||
c.roundRect(0, 0, cw, ch, 2 * dpr);
|
||
c.fill();
|
||
|
||
// Dot
|
||
const dot = document.querySelector('.progress-dot');
|
||
if (dot) {
|
||
const dr = dot.getBoundingClientRect();
|
||
// Draw dot relative to fill? No, relative to fill right end.
|
||
// But simpler to just draw circle at cw.
|
||
c.beginPath();
|
||
c.arc(cw, ch/2, 6 * dpr, 0, Math.PI * 2);
|
||
c.fillStyle = '#fff';
|
||
c.fill();
|
||
}
|
||
});
|
||
|
||
// Times
|
||
drawDomElement(ctx, els.currTime, dpr, containerRect, (c, cw, ch) => {
|
||
c.font = `${11 * dpr}px monospace`;
|
||
c.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||
c.fillText(els.currTime.innerText, 0, ch);
|
||
});
|
||
drawDomElement(ctx, els.totalTime, dpr, containerRect, (c, cw, ch) => {
|
||
c.font = `${11 * dpr}px monospace`;
|
||
c.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||
c.textAlign = 'right';
|
||
c.fillText(els.totalTime.innerText, cw, ch);
|
||
});
|
||
|
||
// Buttons
|
||
const btns = document.querySelectorAll('.btn');
|
||
btns.forEach(btn => {
|
||
drawDomElement(ctx, btn, dpr, containerRect, (c, cw, ch) => {
|
||
// If it's play button, draw circle bg
|
||
if (btn.classList.contains('btn-play')) {
|
||
c.beginPath();
|
||
c.arc(cw/2, ch/2, cw/2, 0, Math.PI * 2);
|
||
c.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||
c.fill();
|
||
c.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
||
c.lineWidth = 1 * dpr;
|
||
c.stroke();
|
||
}
|
||
|
||
// Draw Icon (SVG)
|
||
// We can parse the SVG inside the button
|
||
const svg = btn.querySelector('svg');
|
||
if (svg && svg.style.display !== 'none') {
|
||
// Simplified: Draw fallback icons or parse SVG path
|
||
// To be 1:1, we should use the paths we defined before, but mapped to this position.
|
||
// Or better: Re-use the drawIcon logic from previous, but passing the context translated to this btn.
|
||
|
||
c.translate(cw/2, ch/2);
|
||
c.scale(dpr, dpr);
|
||
c.translate(-12, -12); // Assuming 24x24 base
|
||
c.fillStyle = '#fff';
|
||
c.strokeStyle = '#fff';
|
||
c.lineWidth = 2;
|
||
c.lineCap = 'round';
|
||
c.lineJoin = 'round';
|
||
|
||
// Identify button by index or class is hard.
|
||
// Let's use simple logic based on innerHTML or known order.
|
||
// Or just draw generic icons based on known button types.
|
||
|
||
// Fallback: Since we know the buttons:
|
||
if (btn.onclick && btn.onclick.toString().includes('downloadMusic')) {
|
||
// Download
|
||
c.beginPath(); c.moveTo(21, 15); c.lineTo(21, 19); c.arcTo(21, 21, 19, 21, 2); c.lineTo(5, 21); c.arcTo(3, 21, 3, 19, 2); c.lineTo(3, 15); c.moveTo(7, 10); c.lineTo(12, 15); c.lineTo(17, 10); c.moveTo(12, 15); c.lineTo(12, 3); c.stroke();
|
||
} else if (btn.id === 'playBtn') {
|
||
if (isPlaying) {
|
||
c.beginPath(); c.rect(6, 5, 4, 14); c.rect(14, 5, 4, 14); c.fill();
|
||
} else {
|
||
c.beginPath(); c.moveTo(8, 5); c.lineTo(8, 19); c.lineTo(19, 12); c.closePath(); c.fill();
|
||
}
|
||
} else if (btn.id === 'recToggleBtn') {
|
||
c.beginPath(); c.arc(12, 12, 6, 0, Math.PI * 2);
|
||
if (recordEnabled) { c.fillStyle = '#ff3b30'; c.fill(); } else { c.fillStyle = '#fff'; c.fill(); }
|
||
} else {
|
||
// Prev/Next/Mode - Check SVG content hash or class?
|
||
// Simplified:
|
||
const pathD = svg.querySelector('path').getAttribute('d');
|
||
if (pathD.startsWith('M11 18')) { // Prev
|
||
c.beginPath(); c.moveTo(11, 18); c.lineTo(11, 6); c.lineTo(2.5, 12); c.closePath(); c.moveTo(11.5, 12); c.lineTo(20, 18); c.lineTo(20, 6); c.closePath(); c.fill();
|
||
} else if (pathD.startsWith('M4 18')) { // Next
|
||
c.beginPath(); c.moveTo(4, 18); c.lineTo(12.5, 12); c.lineTo(4, 6); c.closePath(); c.moveTo(13, 6); c.lineTo(13, 18); c.lineTo(21.5, 12); c.closePath(); c.fill();
|
||
} else if (pathD.startsWith('M15 15')) { // Mode
|
||
const p = new Path2D("M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z"); c.fill(p);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function stopSilentMV() {
|
||
if (!isMVRecording) return;
|
||
try { mvRecorder && mvRecorder.stop(); } catch (_) { }
|
||
try { if (mvStream) mvStream.getTracks().forEach(t => t.stop()); } catch (_) { }
|
||
if (mvRafId) cancelAnimationFrame(mvRafId);
|
||
mvRafId = 0;
|
||
clearInterval(mvRenderTimer);
|
||
mvRenderTimer = null;
|
||
isMVRecording = false;
|
||
if (els.recIndicator) els.recIndicator.style.display = 'none';
|
||
if (els.recToggleBtn) els.recToggleBtn.style.color = '';
|
||
}
|
||
|
||
function toggleRecording() {
|
||
recordEnabled = !recordEnabled;
|
||
if (recordEnabled && isPlaying) startSilentMV();
|
||
if (!recordEnabled && isMVRecording) stopSilentMV();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |