feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#060612"/>
<circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25" fill="none"/>
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/>
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/>
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/>
<ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/>
<rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/>
<ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/>
<ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/>
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="50" cy="7" r="1.5" fill="#e8b86d"/>
</svg>
`;
// 使用 Node.js 自带的方式生成图标
// 这里我们先创建临时 SVG 文件,然后可以通过浏览器或其他工具转换
const svgPath = path.join(__dirname, 'icon.svg');
fs.writeFileSync(svgPath, svgIcon.trim(), 'utf8');
console.log('SVG icon generated at:', svgPath);
console.log('To convert to PNG, you can use online tools or ImageMagick:');
console.log(' convert icon.svg -resize 192x192 icon-192.png');
console.log(' convert icon.svg -resize 512x512 icon-512.png');
console.log(' convert icon.svg -resize 180x180 apple-touch-icon.png');
// 我们也可以用 base64 方式提供SVG作为临时方案
const svgBase64 = Buffer.from(svgIcon.trim()).toString('base64');
console.log('SVG base64 ready');
+311
View File
@@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="八音盒">
<meta name="theme-color" content="#060612">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="apple-touch-icon.svg">
<title>八音盒</title>
<link rel="stylesheet" href="zen_box.css">
</head>
<body>
<!-- Starfield Background -->
<div class="starfield">
<div class="stars-sm"></div>
<div class="stars-md"></div>
<div class="stars-lg"></div>
</div>
<div class="app">
<!-- Header -->
<div class="header">
<div class="header-title">
<svg class="header-icon" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25"/>
<ellipse cx="50" cy="84" rx="22" ry="4" fill="rgba(232,184,109,0.12)"/>
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/>
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/>
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/>
<ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/>
<rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/>
<ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/>
<ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/>
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="50" cy="7" r="1.5" fill="#e8b86d" opacity="0.8"/>
</svg>
八<span>音</span>盒
</div>
<div class="header-count" id="trackCount">0 首</div>
</div>
<!-- Zen Center -->
<div class="zen-center">
<div class="orbit-wrap">
<div class="orbit-ring"></div>
<div class="orbit-ring-2"></div>
<div class="orbit-dot-1"></div>
<div class="orbit-dot-2"></div>
<div class="zen-glow" id="zenGlow"></div>
<div class="zen-waves">
<div class="zen-wave" id="zenWave0"></div>
<div class="zen-wave" id="zenWave1"></div>
<div class="zen-wave" id="zenWave2"></div>
</div>
<div class="zen-figure" id="zenFigure">
<!-- Zen meditation figure — natural lotus posture, warm gold silhouette -->
<svg viewBox="0 0 200 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Ground shadow -->
<ellipse cx="100" cy="178" rx="44" ry="5.5" fill="rgba(232,184,109,0.08)"/>
<!-- === LOWER BODY: Full lotus base === -->
<!-- Lap platform — wide, grounded oval -->
<ellipse cx="100" cy="158" rx="46" ry="14" fill="#c8973e" opacity="0.70"/>
<!-- Left knee extending out and down -->
<path d="M 54 150 C 44 152, 36 158, 36 165 C 36 171, 42 175, 52 174 C 60 173, 68 167, 72 160 C 74 155, 70 149, 64 148 Z" fill="#d4a045" opacity="0.78"/>
<!-- Right knee extending out and down -->
<path d="M 146 150 C 156 152, 164 158, 164 165 C 164 171, 158 175, 148 174 C 140 173, 132 167, 128 160 C 126 155, 130 149, 136 148 Z" fill="#d4a045" opacity="0.78"/>
<!-- Left foot resting on right thigh — visible instep -->
<path d="M 124 148 C 132 144, 142 145, 146 150 C 148 153, 145 157, 138 157 C 131 157, 124 153, 122 150 Z" fill="#c8973e" opacity="0.72"/>
<!-- Right foot resting on left thigh — visible instep -->
<path d="M 76 148 C 68 144, 58 145, 54 150 C 52 153, 55 157, 62 157 C 69 157, 76 153, 78 150 Z" fill="#c8973e" opacity="0.72"/>
<!-- === TORSO: Upright, slightly tapered === -->
<path d="
M 72 152
C 70 140, 69 126, 70 114
C 71 102, 75 92, 82 85
C 88 79, 94 77, 100 76
C 106 77, 112 79, 118 85
C 125 92, 129 102, 130 114
C 131 126, 130 140, 128 152
C 120 157, 110 160, 100 160
C 90 160, 80 157, 72 152
Z
" fill="#e0a84e" opacity="0.88"/>
<!-- Chest line — subtle center crease -->
<path d="M 100 88 C 99 108, 99 130, 100 152" stroke="#c8882a" stroke-width="0.8" opacity="0.18" stroke-linecap="round"/>
<!-- === ARMS: Draped down naturally to lap === -->
<!-- Left upper arm — hangs from shoulder, angles inward -->
<path d="M 70 114 C 64 116, 58 122, 56 130 C 54 136, 56 142, 62 146 C 68 148, 75 146, 78 140 C 74 136, 70 128, 70 118 Z" fill="#d4a045" opacity="0.82"/>
<!-- Right upper arm -->
<path d="M 130 114 C 136 116, 142 122, 144 130 C 146 136, 144 142, 138 146 C 132 148, 125 146, 122 140 C 126 136, 130 128, 130 118 Z" fill="#d4a045" opacity="0.82"/>
<!-- === HANDS in dhyana mudra: left palm up, right resting on top === -->
<!-- Left hand — palm up, fingers slightly curved -->
<path d="M 84 154 C 80 152, 76 152, 75 155 C 74 158, 76 162, 82 163 C 88 164, 96 162, 98 158 C 98 155, 92 154, 84 154 Z" fill="#d4a045" opacity="0.80"/>
<!-- Right hand resting on left, fingers forward -->
<path d="M 102 152 C 98 150, 94 150, 92 153 C 90 156, 91 160, 96 162 C 102 163, 110 161, 114 157 C 116 154, 112 151, 106 151 Z" fill="#c8973e" opacity="0.78"/>
<!-- Thumb tips touching — small oval -->
<ellipse cx="99" cy="153" rx="4" ry="2.5" fill="#e8b86d" opacity="0.55"/>
<!-- === NECK: slim, natural === -->
<path d="M 93 78 C 93 74, 96 70, 100 69 C 104 70, 107 74, 107 78 L 107 86 L 93 86 Z" fill="#e0a84e" opacity="0.88"/>
<!-- === HEAD: slightly bowed forward, serene === -->
<!-- Head shape — slightly elongated, chin tucked -->
<path d="
M 82 54
C 82 36, 90 26, 100 25
C 110 26, 118 36, 118 54
C 118 66, 112 74, 106 77
L 100 79
L 94 77
C 88 74, 82 66, 82 54
Z
" fill="#e0a84e" opacity="0.88"/>
<!-- === HAIR: simple smooth cap === -->
<path d="M 82 50 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 50 C 112 43, 106 39, 100 38 C 94 39, 88 43, 82 50 Z" fill="#b8812a" opacity="0.80"/>
<!-- === FACE: eyes closed, barely-there features === -->
<!-- Brow ridge — very subtle -->
<path d="M 89 53 Q 93 51, 97 53" stroke="#8a6020" stroke-width="0.8" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M 103 53 Q 107 51, 111 53" stroke="#8a6020" stroke-width="0.8" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Eyes closed — downward arc, peaceful -->
<path d="M 89 57 Q 93 61, 97 57" stroke="#7a5010" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.65"/>
<path d="M 103 57 Q 107 61, 111 57" stroke="#7a5010" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.65"/>
<!-- Nose — single gentle hint -->
<path d="M 100 62 C 99 65, 97 67, 97 68" stroke="#8a6020" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.25"/>
<!-- Lips — serene half-smile -->
<path d="M 95 72 Q 100 75, 105 72" stroke="#8a6020" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.30"/>
<!-- === CROWN CHAKRA: sahasrara point === -->
<circle cx="100" cy="24" r="3.5" fill="rgba(232,184,109,0.20)"/>
<circle cx="100" cy="24" r="1.8" fill="#e8b86d" opacity="0.75"/>
<circle cx="100" cy="24" r="0.8" fill="#fff" opacity="0.60"/>
<!-- === SUBTLE INNER LIGHT: warm glow at heart center === -->
<circle cx="100" cy="110" r="12" fill="rgba(232,184,109,0.04)"/>
</svg>
</div>
</div>
</div>
<!-- Track Info -->
<div class="track-info">
<div class="track-title marquee-host" id="trackTitleHost">
<div class="marquee-track" id="trackTitleTrack">
<span class="marquee-copy marquee-copy-primary" id="trackTitle">尚未选择曲目</span>
<span class="marquee-gap" aria-hidden="true"></span>
<span class="marquee-copy marquee-copy-duplicate" id="trackTitleDuplicate" aria-hidden="true">尚未选择曲目</span>
</div>
</div>
<div class="track-artist" id="trackArtist">—</div>
</div>
<!-- Bottom Control Bar -->
<div class="bottom-bar">
<div class="glass-bar">
<!-- Progress -->
<div class="progress-wrap">
<input type="range" class="progress-bar" id="progressBar" min="0" max="100" value="0"
oninput="onProgressInput(this.value)" onchange="onProgressChange(this.value)">
<div class="time-row">
<span id="timeCurrent">0:00</span>
<span id="timeTotal">0:00</span>
</div>
</div>
<!-- Main Controls -->
<div class="controls">
<button class="ctrl-btn" id="btnShuffle" onclick="toggleShuffle()" title="随机">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 1 21 3 21 8"></polyline>
<path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20"></path>
<polyline points="16 23 21 23 21 18"></polyline>
<path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4"></path>
</svg>
</button>
<button class="ctrl-btn" onclick="prevTrack()" title="上一首">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6V6zm3.5 6 8.5 6V6l-8.5 6z"></path>
</svg>
</button>
<button class="play-btn" id="playBtn" onclick="togglePlay()" title="播放/暂停">
<svg id="iconPlay" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"></path>
</svg>
<svg id="iconPause" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="display:none">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
</svg>
</button>
<button class="ctrl-btn" onclick="nextTrack()" title="下一首">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 6h2v12h-2V6zm-10 0v12l8.5-6L6 6z"></path>
</svg>
</button>
<button class="ctrl-btn" id="btnRepeat" onclick="cycleRepeat()" title="循环">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
</button>
</div>
<!-- Secondary row: playlist toggle + volume -->
<div class="secondary-controls">
<button class="sec-btn" onclick="openSheet()" title="播放列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
<button class="sec-btn" id="btnVolToggle" onclick="toggleVolume()" title="音量">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" id="volIcon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"></path>
</svg>
</button>
<button class="sec-btn" onclick="openSettings()" title="设置">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 1-2 0 1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 1 0-2 1.65 1.65 0 0 0 .6-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 1 2 0 1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c0 .38.14.74.4 1a1.65 1.65 0 0 1 0 2c-.26.26-.4.62-.4 1z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="settings-overlay" id="settingsOverlay" onclick="closeSettings()"></div>
<div class="settings-panel" id="settingsPanel">
<div class="settings-header">
<div class="settings-title">设置</div>
<button class="sheet-close" onclick="closeSettings()" title="关闭设置">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="settings-body">
<div class="setting-group">
<div class="setting-label">当前歌单</div>
<select id="playlistSelect" class="setting-select"></select>
<div class="setting-note">自动读取当前目录下所有以 playlist.json 结尾的歌单文件,并记住你上次选择。</div>
</div>
<div class="setting-group">
<div class="setting-label">缓存对照</div>
<div class="setting-summary" id="cacheSummary">正在检查缓存...</div>
<div class="setting-note">按歌曲地址对照当前歌单与浏览器缓存,标记哪些已缓存、哪些未缓存。</div>
</div>
<div class="setting-group">
<div class="setting-label">播放记忆</div>
<label class="setting-switch">
<input type="checkbox" id="resumePlaybackToggle">
<span>记住上次位置继续听</span>
</label>
<div class="setting-note">默认开启。下次打开时,会从上次暂停的位置继续播放当前音频。</div>
</div>
</div>
</div>
<!-- Playlist Sheet Overlay -->
<div class="sheet-overlay" id="sheetOverlay" onclick="closeSheet()"></div>
<div class="sheet" id="sheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<div class="sheet-title">播放列表</div>
<button class="sheet-close" onclick="closeSheet()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="sheet-list" id="sheetList">
<div class="empty-state" id="emptyState">
暂无曲目<br>
<span style="font-size:12px;opacity:0.6">请将音乐文件添加至播放列表</span>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<!-- Hidden Audio -->
<audio id="audio" preload="metadata"></audio>
<script src="zen_box.js"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
const CACHE = 'music-box-v3';
self.addEventListener('install', e => {
self.skipWaiting();
e.waitUntil(
caches.open(CACHE).then(c => c.addAll([
'./',
'./index.html',
'./zen_box.js',
'./zen_box.css',
'./playlist.json'
]))
);
});
self.addEventListener('activate', e => {
e.waitUntil(clients.claim());
});
self.addEventListener('fetch', e => {
// 音频文件直接使用 Network Only
if (e.request.url.includes('.mp3') || e.request.url.includes('oss')) {
e.respondWith(fetch(e.request));
return;
}
// playlist.json 使用 Network First
if (e.request.url.includes('playlist.json')) {
e.respondWith(
fetch(e.request)
.then(networkResponse => {
return caches.open(CACHE).then(cache => {
cache.put(e.request, networkResponse.clone());
return networkResponse;
});
})
.catch(() => {
return caches.match(e.request);
})
);
return;
}
// 其他文件(App Shell)使用 Cache First
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
+808
View File
@@ -0,0 +1,808 @@
:root {
--bg-deep: #060612;
--bg-mid: #0a0a1e;
--surface: rgba(255,255,255,0.06);
--surface2: rgba(255,255,255,0.10);
--border: rgba(255,255,255,0.08);
--gold: #e8b86d;
--gold-dim: #a07840;
--gold-glow: rgba(232,184,109,0.15);
--gold-soft: rgba(232,184,109,0.08);
--text: #e8e6f0;
--text-dim: #7a7890;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg-deep);
color: var(--text);
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
overflow: hidden;
user-select: none;
}
.starfield {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
background: radial-gradient(ellipse at 50% 40%, #0d0d2b 0%, #080818 40%, #050510 70%, #030308 100%);
}
.starfield::before {
content: '';
position: absolute;
width: 300px; height: 300px;
top: 28%; left: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(232,184,109,0.04) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.stars-sm, .stars-md, .stars-lg {
position: absolute;
inset: 0;
border-radius: 50%;
}
.stars-sm {
width: 1px; height: 1px;
background: transparent;
box-shadow:
12px 45px 0 0.5px rgba(255,255,255,0.22),
78px 120px 0 0.5px rgba(255,255,255,0.18),
145px 30px 0 0.5px rgba(255,255,255,0.26),
200px 88px 0 0.5px rgba(255,255,255,0.16),
260px 155px 0 0.5px rgba(255,255,255,0.20),
310px 22px 0 0.5px rgba(255,255,255,0.24),
55px 210px 0 0.5px rgba(255,255,255,0.14),
180px 260px 0 0.5px rgba(255,255,255,0.18),
340px 95px 0 0.5px rgba(255,255,255,0.22),
30px 310px 0 0.5px rgba(255,255,255,0.16),
120px 340px 0 0.5px rgba(255,255,255,0.20),
225px 180px 0 0.5px rgba(255,255,255,0.14),
290px 280px 0 0.5px rgba(255,255,255,0.18),
370px 40px 0 0.5px rgba(255,255,255,0.22),
95px 140px 0 0.5px rgba(255,255,255,0.17),
350px 200px 0 0.5px rgba(255,255,255,0.15),
160px 70px 0 0.5px rgba(255,255,255,0.24),
240px 320px 0 0.5px rgba(255,255,255,0.19),
40px 380px 0 0.5px rgba(255,255,255,0.14),
300px 360px 0 0.5px rgba(255,255,255,0.21),
190px 400px 0 0.5px rgba(255,255,255,0.16),
70px 450px 0 0.5px rgba(255,255,255,0.18),
330px 420px 0 0.5px rgba(255,255,255,0.14),
250px 470px 0 0.5px rgba(255,255,255,0.20),
140px 500px 0 0.5px rgba(255,255,255,0.17),
20px 540px 0 0.5px rgba(255,255,255,0.22),
200px 560px 0 0.5px rgba(255,255,255,0.15),
310px 550px 0 0.5px rgba(255,255,255,0.19),
100px 580px 0 0.5px rgba(255,255,255,0.14),
360px 580px 0 0.5px rgba(255,255,255,0.18),
410px 55px 0 0.5px rgba(255,255,255,0.16),
440px 140px 0 0.5px rgba(255,255,255,0.20),
390px 250px 0 0.5px rgba(255,255,255,0.14),
420px 330px 0 0.5px rgba(255,255,255,0.18),
460px 75px 0 0.5px rgba(255,255,255,0.22),
470px 200px 0 0.5px rgba(255,255,255,0.15),
450px 390px 0 0.5px rgba(255,255,255,0.16),
430px 470px 0 0.5px rgba(255,255,255,0.20),
400px 600px 0 0.5px rgba(255,255,255,0.14),
8px 8px 0 0.5px rgba(255,255,255,0.12),
110px 12px 0 0.5px rgba(255,255,255,0.14),
230px 6px 0 0.5px rgba(255,255,255,0.10),
355px 14px 0 0.5px rgba(255,255,255,0.13),
460px 10px 0 0.5px rgba(255,255,255,0.11),
50px 680px 0 0.5px rgba(255,255,255,0.12),
180px 720px 0 0.5px rgba(255,255,255,0.14),
290px 700px 0 0.5px rgba(255,255,255,0.10),
380px 740px 0 0.5px rgba(255,255,255,0.13),
440px 680px 0 0.5px rgba(255,255,255,0.11),
150px 800px 0 0.5px rgba(255,255,255,0.10),
330px 820px 0 0.5px rgba(255,255,255,0.12);
animation: twinkle-sm 6s ease-in-out infinite alternate;
}
.stars-md {
width: 1.5px; height: 1.5px;
background: transparent;
box-shadow:
50px 80px 0 0.5px rgba(255,255,255,0.32),
170px 150px 0 0.5px rgba(255,255,255,0.28),
280px 60px 0 0.5px rgba(255,255,255,0.35),
350px 130px 0 0.5px rgba(255,255,255,0.26),
90px 250px 0 0.5px rgba(255,255,255,0.30),
220px 300px 0 0.5px rgba(255,255,255,0.24),
320px 230px 0 0.5px rgba(255,255,255,0.32),
40px 350px 0 0.5px rgba(255,255,255,0.28),
150px 420px 0 0.5px rgba(255,255,255,0.26),
270px 380px 0 0.5px rgba(255,255,255,0.34),
110px 490px 0 0.5px rgba(255,255,255,0.30),
340px 460px 0 0.5px rgba(255,255,255,0.28),
200px 520px 0 0.5px rgba(255,255,255,0.24),
60px 600px 0 0.5px rgba(255,255,255,0.32),
300px 620px 0 0.5px rgba(255,255,255,0.26),
405px 90px 0 0.5px rgba(255,255,255,0.28),
445px 210px 0 0.5px rgba(255,255,255,0.24),
415px 360px 0 0.5px rgba(255,255,255,0.30),
460px 480px 0 0.5px rgba(255,255,255,0.22),
435px 580px 0 0.5px rgba(255,255,255,0.26),
80px 660px 0 0.5px rgba(255,255,255,0.24),
240px 690px 0 0.5px rgba(255,255,255,0.28),
370px 670px 0 0.5px rgba(255,255,255,0.22),
130px 760px 0 0.5px rgba(255,255,255,0.20),
310px 780px 0 0.5px rgba(255,255,255,0.24);
animation: twinkle-md 8s ease-in-out infinite alternate;
}
.stars-lg {
width: 2px; height: 2px;
background: transparent;
box-shadow:
130px 100px 0 0.5px rgba(232,184,109,0.32),
300px 170px 0 0.5px rgba(255,255,255,0.42),
70px 300px 0 0.5px rgba(232,184,109,0.22),
250px 400px 0 0.5px rgba(255,255,255,0.38),
360px 350px 0 0.5px rgba(232,184,109,0.28),
180px 550px 0 0.5px rgba(255,255,255,0.36),
330px 530px 0 0.5px rgba(232,184,109,0.20),
420px 160px 0 0.5px rgba(255,255,255,0.38),
455px 320px 0 0.5px rgba(232,184,109,0.24),
410px 500px 0 0.5px rgba(255,255,255,0.34),
100px 650px 0 0.5px rgba(232,184,109,0.18),
280px 700px 0 0.5px rgba(255,255,255,0.32),
400px 660px 0 0.5px rgba(232,184,109,0.20);
animation: twinkle-lg 10s ease-in-out infinite alternate;
}
@keyframes twinkle-sm { 0% { opacity: 0.6; } 100% { opacity: 1; } }
@keyframes twinkle-md { 0% { opacity: 0.7; } 100% { opacity: 1; } }
@keyframes twinkle-lg { 0% { opacity: 0.5; } 100% { opacity: 1; } }
.app {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100dvh;
max-width: 480px;
margin: 0 auto;
}
.header {
padding: 16px 20px 8px;
padding-top: calc(12px + var(--safe-top));
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.header-title {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.header-title span { color: var(--gold); }
.header-icon {
width: 24px; height: 24px;
flex-shrink: 0;
}
.header-count {
font-size: 13px;
color: var(--text-dim);
}
.zen-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0 20px;
min-height: 0;
}
.orbit-wrap {
position: relative;
width: 220px;
height: 220px;
display: flex;
align-items: center;
justify-content: center;
}
.orbit-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(232,184,109,0.12);
animation: orbit-spin 30s linear infinite;
animation-play-state: running;
}
.orbit-ring-2 {
position: absolute;
inset: -18px;
border-radius: 50%;
border: 1px solid rgba(232,184,109,0.06);
animation: orbit-spin 45s linear infinite reverse;
}
.orbit-dot-1 {
position: absolute;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--gold);
opacity: 0.55;
box-shadow: 0 0 8px rgba(232,184,109,0.5);
top: calc(50% - 110px - 3px);
left: calc(50% - 3px);
transform-origin: 3px calc(110px + 3px);
animation: orbit-spin 30s linear infinite;
}
.orbit-dot-2 {
position: absolute;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--gold);
opacity: 0.30;
box-shadow: 0 0 6px rgba(232,184,109,0.3);
top: calc(50% + 128px - 2px);
left: calc(50% - 2px);
transform-origin: 2px calc(-128px + 2px);
animation: orbit-spin 45s linear infinite reverse;
}
@keyframes orbit-spin { to { transform: rotate(360deg); } }
.zen-glow {
position: absolute;
width: 160px; height: 160px;
border-radius: 50%;
background: radial-gradient(circle, var(--gold-soft) 0%, transparent 70%);
opacity: 0;
transition: opacity 1.2s ease;
pointer-events: none;
}
.zen-glow.active {
opacity: 1;
animation: glow-pulse 4s ease-in-out infinite;
}
@keyframes glow-pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 1; }
}
.zen-figure {
position: relative;
z-index: 2;
width: 140px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.zen-figure svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 20px rgba(232,184,109,0.15));
}
.zen-waves {
position: absolute;
inset: -10px;
pointer-events: none;
z-index: 1;
}
.zen-wave {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(232,184,109,0.08);
opacity: 0;
pointer-events: none;
}
.zen-wave.active {
animation: wave-expand 3s ease-out infinite;
}
.zen-wave:nth-child(1) { width: 160px; height: 160px; animation-delay: 0s; }
.zen-wave:nth-child(2) { width: 160px; height: 160px; animation-delay: 1s; }
.zen-wave:nth-child(3) { width: 160px; height: 160px; animation-delay: 2s; }
@keyframes wave-expand {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.3; }
100% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
}
.track-info {
text-align: center;
padding: 0 20px;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.track-title {
font-size: 17px;
font-weight: 600;
color: var(--text);
margin-bottom: 3px;
}
.track-artist {
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bottom-bar {
flex-shrink: 0;
padding: 12px 20px;
padding-bottom: calc(12px + var(--safe-bottom));
position: relative;
z-index: 20;
}
.glass-bar {
background: rgba(10,10,30,0.65);
backdrop-filter: blur(24px) saturate(1.2);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 14px 18px 16px;
}
.progress-wrap {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
}
.progress-bar {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 3px;
background: rgba(255,255,255,0.10);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.progress-bar::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--gold);
cursor: pointer;
box-shadow: 0 0 8px rgba(232,184,109,0.4);
}
.progress-bar::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
}
.time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.ctrl-btn {
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
padding: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
}
.ctrl-btn:active { transform: scale(0.9); }
.ctrl-btn.active-mode { color: var(--gold); }
.ctrl-btn svg { display: block; }
.play-btn {
width: 52px;
height: 52px;
min-width: 52px;
min-height: 52px;
flex-shrink: 0;
border-radius: 50%;
background: var(--gold);
color: var(--bg-deep);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, box-shadow 0.3s;
box-shadow: 0 0 20px rgba(232,184,109,0.25);
-webkit-tap-highlight-color: transparent;
position: relative;
overflow: hidden;
}
.play-btn:active { transform: scale(0.94); }
.play-btn::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 1px solid rgba(232,184,109,0.2);
pointer-events: none;
}
.secondary-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 10px;
}
.sec-btn {
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.sec-btn:active { opacity: 0.6; }
.sec-btn.active-mode { color: var(--gold); }
.sheet-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0,0,0,0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.sheet-overlay.open {
opacity: 1;
visibility: visible;
}
.sheet {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(100%);
width: 100%;
max-width: 480px;
max-height: 70dvh;
z-index: 51;
background: rgba(12,12,30,0.85);
backdrop-filter: blur(30px) saturate(1.3);
-webkit-backdrop-filter: blur(30px) saturate(1.3);
border-top: 1px solid var(--border);
border-radius: 20px 20px 0 0;
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
display: flex;
flex-direction: column;
}
.sheet.open {
transform: translateX(-50%) translateY(0);
}
.sheet-handle {
width: 36px;
height: 4px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
margin: 10px auto 6px;
flex-shrink: 0;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 20px 12px;
flex-shrink: 0;
}
.sheet-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.sheet-close {
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.sheet-close:active { opacity: 0.6; }
.sheet-list {
flex: 1;
overflow-y: auto;
padding: 0 20px;
padding-bottom: calc(16px + var(--safe-bottom));
-webkit-overflow-scrolling: touch;
}
.sheet-list::-webkit-scrollbar { display: none; }
.playlist-item {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s;
}
.playlist-item:last-child { border-bottom: none; }
.playlist-item:active { opacity: 0.6; }
.playlist-item.active .item-title { color: var(--gold); }
.item-num {
font-size: 13px;
color: var(--text-dim);
width: 24px;
text-align: center;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.item-num.playing-icon { color: var(--gold); }
.item-info { flex: 1; min-width: 0; }
.item-title {
font-size: 15px;
font-weight: 500;
color: var(--text);
margin-bottom: 2px;
}
.item-sub {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-cache {
flex-shrink: 0;
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
color: var(--text-dim);
}
.item-cache.cached {
color: var(--gold);
border-color: rgba(232,184,109,0.32);
background: rgba(232,184,109,0.08);
}
.item-cache.pending {
background: rgba(255,255,255,0.04);
}
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
z-index: 24;
}
.settings-overlay.open {
opacity: 1;
pointer-events: auto;
}
.settings-panel {
position: fixed;
left: 50%;
top: calc(18px + var(--safe-top));
width: min(420px, calc(100vw - 24px));
transform: translateX(-50%) translateY(-12px);
opacity: 0;
pointer-events: none;
background: rgba(17,17,30,0.94);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: 0 18px 48px rgba(0,0,0,0.38);
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 25;
}
.settings-panel.open {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 10px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.settings-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.settings-body {
padding: 14px 18px 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-label {
font-size: 13px;
color: var(--text);
font-weight: 500;
}
.setting-select {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.06);
color: var(--text);
outline: none;
}
.setting-select:focus {
border-color: rgba(232,184,109,0.38);
}
.setting-summary {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
color: var(--gold);
font-size: 13px;
}
.setting-note {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.marquee-host {
width: 100%;
overflow: hidden;
min-width: 0;
}
.marquee-track {
display: inline-flex;
align-items: center;
min-width: 100%;
transform: translateX(0);
}
.marquee-copy {
white-space: nowrap;
flex-shrink: 0;
}
.marquee-gap {
width: 42px;
flex-shrink: 0;
display: none;
}
.marquee-copy-duplicate {
display: none;
}
.marquee-host:not(.marquee-active) .marquee-track {
display: block;
}
.marquee-host:not(.marquee-active) .marquee-copy-primary {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.marquee-host.marquee-active .marquee-gap,
.marquee-host.marquee-active .marquee-copy-duplicate {
display: inline-block;
}
.marquee-host.marquee-active .marquee-track {
width: max-content;
animation: marquee-scroll var(--marquee-duration, 14s) linear infinite;
animation-delay: 1.2s;
}
@keyframes marquee-scroll {
0%, 8% {
transform: translateX(0);
}
92%, 100% {
transform: translateX(calc(-1 * var(--marquee-distance, 0px)));
}
}
.setting-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.setting-switch input {
width: 16px;
height: 16px;
accent-color: var(--gold);
}
.toast {
position: fixed;
top: calc(60px + var(--safe-top));
left: 50%;
transform: translateX(-50%) translateY(-10px);
background: rgba(35,34,53,0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text);
font-size: 13px;
padding: 8px 18px;
border-radius: 20px;
border: 1px solid var(--border);
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
white-space: nowrap;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--text-dim);
font-size: 14px;
line-height: 1.7;
}
+717
View File
@@ -0,0 +1,717 @@
const PLAYLIST_LIST_API = '/api/zen_box/playlists';
const PLAYLIST_STORAGE_KEY = 'zen_box_selected_playlist';
const RESUME_PLAYBACK_STORAGE_KEY = 'zen_box_resume_playback_enabled';
const PLAYBACK_POSITION_KEY_PREFIX = 'zen_box_playback_position';
let PLAYLIST = [];
let playlistFiles = [];
let currentPlaylistFile = 'playlist.json';
let cachedTrackUrls = new Set();
let currentIdx = 0;
let isPlaying = false;
let isSeeking = false;
let repeatMode = 0;
let shuffleOn = false;
let shuffleOrder = [];
let sheetOpen = false;
let settingsOpen = false;
let volumeVisible = false;
let resumePlaybackEnabled = true;
let lastSavedPositionSeconds = 0;
const audio = document.getElementById('audio');
async function init() {
resumePlaybackEnabled = getResumePlaybackEnabled();
bindSettingsEvents();
registerSW();
await loadPlaylistFiles();
await restorePlaylistSelection();
setupMediaSession();
window.addEventListener('resize', refreshMarqueeState);
}
async function loadPlaylistFiles() {
try {
const response = await fetch(PLAYLIST_LIST_API, { cache: 'no-store' });
const data = await response.json();
if (data && data.ok && Array.isArray(data.playlists) && data.playlists.length) {
playlistFiles = data.playlists.map(item => String(item || '')).filter(Boolean);
} else {
playlistFiles = ['playlist.json'];
}
} catch (error) {
console.warn('Failed to load playlist files:', error);
playlistFiles = ['playlist.json'];
}
if (!playlistFiles.includes('playlist.json')) playlistFiles.unshift('playlist.json');
}
async function restorePlaylistSelection() {
const saved = getSavedPlaylistFile();
const target = playlistFiles.includes(saved) ? saved : 'playlist.json';
await switchPlaylist(target, { silent: true });
}
function getSavedPlaylistFile() {
try {
return String(localStorage.getItem(PLAYLIST_STORAGE_KEY) || 'playlist.json') || 'playlist.json';
} catch {
return 'playlist.json';
}
}
function savePlaylistFile(fileName) {
try {
localStorage.setItem(PLAYLIST_STORAGE_KEY, fileName);
} catch {}
}
function getResumePlaybackEnabled() {
try {
const value = localStorage.getItem(RESUME_PLAYBACK_STORAGE_KEY);
if (value === null) return true;
return value === '1';
} catch {
return true;
}
}
function saveResumePlaybackEnabled(enabled) {
resumePlaybackEnabled = !!enabled;
try {
localStorage.setItem(RESUME_PLAYBACK_STORAGE_KEY, enabled ? '1' : '0');
} catch {}
}
function getPlaybackPositionKey(trackSrc) {
return `${PLAYBACK_POSITION_KEY_PREFIX}:${currentPlaylistFile}::${normalizeTrackSrc(trackSrc)}`;
}
function getSavedPlaybackPosition(trackSrc) {
try {
const value = localStorage.getItem(getPlaybackPositionKey(trackSrc));
const seconds = Number(value || 0);
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
} catch {
return 0;
}
}
function clearSavedPlaybackPosition(trackSrc) {
try {
localStorage.removeItem(getPlaybackPositionKey(trackSrc));
} catch {}
}
function saveCurrentPlaybackPosition({ force = false, explicitPosition = null, explicitDuration = null } = {}) {
if (!resumePlaybackEnabled) return;
const track = PLAYLIST[currentIdx];
if (!track || !track.src) return;
const currentTime = explicitPosition === null ? audio.currentTime : explicitPosition;
const duration = explicitDuration === null ? audio.duration : explicitDuration;
const safeSeconds = Math.max(0, Math.floor(Number(currentTime) || 0));
const safeDuration = Math.max(0, Math.floor(Number(duration) || 0));
const nearEnd = safeDuration > 0 && safeDuration - safeSeconds <= 30;
if (safeSeconds < 5 || nearEnd) {
clearSavedPlaybackPosition(track.src);
lastSavedPositionSeconds = 0;
return;
}
if (!force && Math.abs(safeSeconds - lastSavedPositionSeconds) < 15) {
return;
}
try {
localStorage.setItem(getPlaybackPositionKey(track.src), String(safeSeconds));
lastSavedPositionSeconds = safeSeconds;
} catch {}
}
function getPlaylistRequestPath(fileName) {
return encodeURIComponent(String(fileName || 'playlist.json'));
}
async function fetchPlaylistData(fileName) {
const response = await fetch(getPlaylistRequestPath(fileName), { cache: 'no-store' });
if (!response.ok) throw new Error(`playlist_http_${response.status}`);
const data = await response.json();
return Array.isArray(data) ? data : [];
}
async function switchPlaylist(fileName, { silent = false } = {}) {
const target = String(fileName || 'playlist.json');
const fallback = playlistFiles.includes(target) ? target : 'playlist.json';
const previousFile = currentPlaylistFile;
const previousSrc = PLAYLIST[currentIdx] ? normalizeTrackSrc(PLAYLIST[currentIdx].src) : '';
const wasPlaying = isPlaying;
const playlistSelect = document.getElementById('playlistSelect');
if (playlistSelect) playlistSelect.disabled = true;
saveCurrentPlaybackPosition({ force: true });
pauseAudio();
try {
const nextPlaylist = await fetchPlaylistData(fallback);
currentPlaylistFile = fallback;
savePlaylistFile(fallback);
PLAYLIST = nextPlaylist.map(track => ({ ...track, isCached: false }));
await refreshCacheState();
const matchedIdx = previousSrc ? PLAYLIST.findIndex(track => normalizeTrackSrc(track.src) === previousSrc) : -1;
currentIdx = matchedIdx >= 0 ? matchedIdx : 0;
updateTrackCount();
buildShuffleOrder();
renderSettings();
renderPlaylist();
if (PLAYLIST.length > 0) {
loadTrack(currentIdx, false);
if (wasPlaying && matchedIdx >= 0) playAudio();
} else {
resetTrackInfo();
}
closeSheet();
if (!silent) showToast(`已切换歌单:${fallback}`);
} catch (error) {
currentPlaylistFile = previousFile || 'playlist.json';
renderSettings();
console.warn('Failed to switch playlist:', error);
if (!silent) showToast('歌单切换失败');
} finally {
if (playlistSelect) playlistSelect.disabled = false;
}
}
async function refreshCacheState() {
cachedTrackUrls = await collectCachedTrackUrls();
PLAYLIST = PLAYLIST.map(track => ({
...track,
isCached: cachedTrackUrls.has(normalizeTrackSrc(track.src))
}));
updateCacheSummary();
}
async function collectCachedTrackUrls() {
const result = new Set();
if (!('caches' in window)) return result;
try {
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
requests.forEach(request => {
const url = normalizeTrackSrc(request && request.url ? request.url : '');
if (isAudioCacheUrl(url)) result.add(url);
});
}
} catch (error) {
console.warn('Failed to inspect cache storage:', error);
}
return result;
}
function isAudioCacheUrl(url) {
return !!url && (url.includes('.mp3') || url.includes('oss'));
}
function normalizeTrackSrc(src) {
try {
const url = new URL(String(src || ''), window.location.href);
url.hash = '';
return url.href;
} catch {
return String(src || '').trim();
}
}
function updateTrackCount() {
const countText = `${PLAYLIST.length} 首`;
document.getElementById('trackCount').textContent = countText;
}
function setMarqueeText(host, text) {
if (!host) return;
const content = String(text || '');
const primary = host.querySelector('.marquee-copy-primary');
const duplicate = host.querySelector('.marquee-copy-duplicate');
if (primary) primary.textContent = content;
if (duplicate) duplicate.textContent = content;
}
function refreshMarqueeHost(host, enabled) {
if (!host) return;
const primary = host.querySelector('.marquee-copy-primary');
const duplicate = host.querySelector('.marquee-copy-duplicate');
if (!primary || !duplicate) return;
const overflow = primary.scrollWidth > host.clientWidth + 6;
if (!enabled || !overflow) {
host.classList.remove('marquee-active');
host.style.removeProperty('--marquee-distance');
host.style.removeProperty('--marquee-duration');
return;
}
const distance = Math.ceil(primary.scrollWidth + 42);
const duration = Math.max(10, Math.round(distance / 24));
host.style.setProperty('--marquee-distance', `${distance}px`);
host.style.setProperty('--marquee-duration', `${duration}s`);
host.classList.add('marquee-active');
}
function refreshCurrentTrackMarquee() {
const host = document.getElementById('trackTitleHost');
refreshMarqueeHost(host, !!PLAYLIST[currentIdx]);
}
function refreshPlaylistMarquee() {
document.querySelectorAll('.playlist-item .marquee-host').forEach(host => {
const item = host.closest('.playlist-item');
refreshMarqueeHost(host, !!(item && item.classList.contains('active')));
});
}
function refreshMarqueeState() {
refreshCurrentTrackMarquee();
refreshPlaylistMarquee();
}
function resetTrackInfo() {
audio.removeAttribute('src');
audio.load();
setMarqueeText(document.getElementById('trackTitleHost'), '尚未选择曲目');
document.getElementById('trackArtist').textContent = currentPlaylistFile || '—';
document.getElementById('timeCurrent').textContent = '0:00';
document.getElementById('timeTotal').textContent = '0:00';
document.getElementById('progressBar').value = 0;
updateSliderFill(document.getElementById('progressBar'), 0);
updatePlaylistHighlight();
refreshCurrentTrackMarquee();
}
function renderSettings() {
const select = document.getElementById('playlistSelect');
if (select) {
select.innerHTML = '';
playlistFiles.forEach(file => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
if (file === currentPlaylistFile) option.selected = true;
select.appendChild(option);
});
select.value = currentPlaylistFile;
}
const resumeToggle = document.getElementById('resumePlaybackToggle');
if (resumeToggle) {
resumeToggle.checked = !!resumePlaybackEnabled;
}
updateCacheSummary();
}
function updateCacheSummary() {
const summary = document.getElementById('cacheSummary');
if (!summary) return;
const cachedCount = PLAYLIST.filter(track => !!track.isCached).length;
summary.textContent = PLAYLIST.length
? `已缓存 ${cachedCount} / ${PLAYLIST.length} 首`
: '当前歌单暂无曲目';
}
function bindSettingsEvents() {
const panel = document.getElementById('settingsPanel');
if (panel) {
panel.addEventListener('click', event => event.stopPropagation());
}
const select = document.getElementById('playlistSelect');
if (select) {
select.addEventListener('change', async event => {
const nextFile = String(event.target && event.target.value || 'playlist.json');
await switchPlaylist(nextFile);
});
}
const resumeToggle = document.getElementById('resumePlaybackToggle');
if (resumeToggle) {
resumeToggle.addEventListener('change', event => {
const enabled = !!(event.target && event.target.checked);
saveResumePlaybackEnabled(enabled);
renderSettings();
showToast(enabled ? '已开启断点续播' : '已关闭断点续播');
});
}
}
function openSettings() {
settingsOpen = true;
renderSettings();
document.getElementById('settingsOverlay').classList.add('open');
document.getElementById('settingsPanel').classList.add('open');
}
function closeSettings() {
settingsOpen = false;
document.getElementById('settingsOverlay').classList.remove('open');
document.getElementById('settingsPanel').classList.remove('open');
}
function loadTrack(idx, autoplay) {
if (PLAYLIST[currentIdx] && audio.src) {
saveCurrentPlaybackPosition({ force: true });
}
currentIdx = idx;
const track = PLAYLIST[idx];
if (!track) return;
const savedPosition = resumePlaybackEnabled ? getSavedPlaybackPosition(track.src) : 0;
audio.src = track.src;
audio.dataset.resumePosition = savedPosition > 5 ? String(savedPosition) : '0';
audio.load();
setMarqueeText(document.getElementById('trackTitleHost'), track.title || '未知曲目');
document.getElementById('trackArtist').textContent = track.artist || '未知歌手';
document.getElementById('timeCurrent').textContent = '0:00';
document.getElementById('timeTotal').textContent = '0:00';
document.getElementById('progressBar').value = 0;
updateSliderFill(document.getElementById('progressBar'), 0);
updatePlaylistHighlight();
updateMediaSessionMetadata();
refreshCurrentTrackMarquee();
lastSavedPositionSeconds = savedPosition > 5 ? Math.floor(savedPosition) : 0;
if (autoplay) playAudio();
}
function togglePlay() {
if (!PLAYLIST.length) {
showToast('播放列表为空');
return;
}
if (!audio.src || audio.src === location.href) {
loadTrack(0, true);
return;
}
isPlaying ? pauseAudio() : playAudio();
}
function playAudio() {
audio.play().then(() => {
isPlaying = true;
updatePlayUI(true);
}).catch(error => {
console.warn('Play failed', error);
showToast('播放失败,请检查音频地址');
});
}
function pauseAudio() {
audio.pause();
isPlaying = false;
updatePlayUI(false);
}
function updatePlayUI(playing) {
document.getElementById('iconPlay').style.display = playing ? 'none' : 'block';
document.getElementById('iconPause').style.display = playing ? 'block' : 'none';
const glow = document.getElementById('zenGlow');
glow.classList.toggle('active', playing);
for (let i = 0; i < 3; i += 1) {
document.getElementById(`zenWave${i}`).classList.toggle('active', playing);
}
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
}
updatePlaylistHighlight();
}
function nextTrack() {
if (!PLAYLIST.length) return;
if (repeatMode === 1) {
audio.currentTime = 0;
if (isPlaying) playAudio();
return;
}
let next;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
next = shuffleOrder[(pos + 1) % shuffleOrder.length];
} else {
next = (currentIdx + 1) % PLAYLIST.length;
}
loadTrack(next, isPlaying);
}
function prevTrack() {
if (!PLAYLIST.length) return;
if (audio.currentTime > 3) {
audio.currentTime = 0;
return;
}
let prev;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
prev = shuffleOrder[(pos - 1 + shuffleOrder.length) % shuffleOrder.length];
} else {
prev = (currentIdx - 1 + PLAYLIST.length) % PLAYLIST.length;
}
loadTrack(prev, isPlaying);
}
function buildShuffleOrder() {
shuffleOrder = [...Array(PLAYLIST.length).keys()];
for (let i = shuffleOrder.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]];
}
}
function toggleShuffle() {
shuffleOn = !shuffleOn;
if (shuffleOn) buildShuffleOrder();
document.getElementById('btnShuffle').classList.toggle('active-mode', shuffleOn);
showToast(shuffleOn ? '随机播放 开' : '随机播放 关');
}
const repeatLabels = ['顺序播放', '单曲循环', '列表循环'];
function cycleRepeat() {
repeatMode = (repeatMode + 1) % 3;
const btn = document.getElementById('btnRepeat');
btn.classList.toggle('active-mode', repeatMode > 0);
btn.innerHTML = repeatMode === 1
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="11" y="14" font-size="6" fill="currentColor" stroke="none" text-anchor="middle">1</text></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';
showToast(repeatLabels[repeatMode]);
}
function toggleVolume() {
volumeVisible = !volumeVisible;
showToast(volumeVisible ? '请用 iPhone 侧面音量键调节' : '');
}
function onProgressInput(val) {
isSeeking = true;
updateSliderFill(document.getElementById('progressBar'), val);
}
function onProgressChange(val) {
if (audio.duration) audio.currentTime = (val / 100) * audio.duration;
isSeeking = false;
saveCurrentPlaybackPosition({ force: true });
}
function updateSliderFill(el, pct) {
el.style.background = `linear-gradient(to right, #e8b86d 0%, #e8b86d ${pct}%, rgba(255,255,255,0.10) ${pct}%, rgba(255,255,255,0.10) 100%)`;
}
function fmtTime(seconds) {
if (isNaN(seconds) || seconds < 0) return '0:00';
const minutes = Math.floor(seconds / 60);
const remain = Math.floor(seconds % 60);
return `${minutes}:${String(remain).padStart(2, '0')}`;
}
audio.addEventListener('timeupdate', () => {
if (isSeeking) return;
const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
document.getElementById('progressBar').value = pct;
updateSliderFill(document.getElementById('progressBar'), pct);
document.getElementById('timeCurrent').textContent = fmtTime(audio.currentTime);
if ('mediaSession' in navigator && audio.duration) {
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
playbackRate: audio.playbackRate,
position: audio.currentTime
});
} catch {}
}
if (isPlaying) {
saveCurrentPlaybackPosition();
}
});
audio.addEventListener('loadedmetadata', () => {
const resumeSeconds = Number(audio.dataset.resumePosition || '0');
if (resumePlaybackEnabled && resumeSeconds > 5 && audio.duration && resumeSeconds < Math.max(audio.duration - 30, 5)) {
audio.currentTime = resumeSeconds;
lastSavedPositionSeconds = Math.floor(resumeSeconds);
} else {
lastSavedPositionSeconds = 0;
}
audio.dataset.resumePosition = '0';
document.getElementById('timeTotal').textContent = fmtTime(audio.duration);
});
audio.addEventListener('ended', () => {
const track = PLAYLIST[currentIdx];
if (track && track.src) {
clearSavedPlaybackPosition(track.src);
}
lastSavedPositionSeconds = 0;
isPlaying = false;
updatePlayUI(false);
if (repeatMode === 1) {
audio.currentTime = 0;
playAudio();
} else {
const isLast = !shuffleOn && currentIdx === PLAYLIST.length - 1;
if (isLast && repeatMode === 0) return;
nextTrack();
playAudio();
}
});
audio.addEventListener('play', () => {
isPlaying = true;
updatePlayUI(true);
});
audio.addEventListener('pause', () => {
isPlaying = false;
updatePlayUI(false);
saveCurrentPlaybackPosition({ force: true });
});
audio.addEventListener('error', () => {
isPlaying = false;
updatePlayUI(false);
const err = audio.error;
let msg = '音频加载失败';
if (err) {
switch (err.code) {
case MediaError.MEDIA_ERR_ABORTED: msg = '播放被中止'; break;
case MediaError.MEDIA_ERR_NETWORK: msg = '网络错误,无法加载音频'; break;
case MediaError.MEDIA_ERR_DECODE: msg = '音频解码失败'; break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: msg = '音频地址无效或格式不支持'; break;
default: break;
}
}
console.warn('Audio error:', err, audio.src);
showToast(msg);
});
function renderPlaylist() {
const wrap = document.getElementById('sheetList');
const empty = document.getElementById('emptyState');
if (!PLAYLIST.length) {
empty.style.display = 'block';
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
return;
}
empty.style.display = 'none';
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
PLAYLIST.forEach((track, index) => {
const div = document.createElement('div');
div.className = `playlist-item${index === currentIdx ? ' active' : ''}`;
div.dataset.idx = index;
div.innerHTML = `
<div class="item-num" id="itemNum${index}">${index + 1}</div>
<div class="item-info">
<div class="item-title marquee-host">
<div class="marquee-track">
<span class="marquee-copy marquee-copy-primary">${escHtml(track.title || '未知曲目')}</span>
<span class="marquee-gap" aria-hidden="true"></span>
<span class="marquee-copy marquee-copy-duplicate" aria-hidden="true">${escHtml(track.title || '未知曲目')}</span>
</div>
</div>
<div class="item-sub">${escHtml(track.artist || '未知歌手')}</div>
</div>
<div class="item-cache ${track.isCached ? 'cached' : 'pending'}">${track.isCached ? '已缓存' : '未缓存'}</div>
`;
div.addEventListener('click', () => {
if (index === currentIdx) {
togglePlay();
} else {
loadTrack(index, true);
closeSheet();
}
});
wrap.appendChild(div);
});
refreshPlaylistMarquee();
}
function updatePlaylistHighlight() {
document.querySelectorAll('.playlist-item').forEach((el, index) => {
el.classList.toggle('active', index === currentIdx);
const num = document.getElementById(`itemNum${index}`);
if (num) {
num.textContent = (index === currentIdx && isPlaying) ? '▶' : (index + 1);
num.className = `item-num${index === currentIdx ? ' playing-icon' : ''}`;
}
});
refreshPlaylistMarquee();
}
function openSheet() {
sheetOpen = true;
document.getElementById('sheetOverlay').classList.add('open');
const sheet = document.getElementById('sheet');
sheet.classList.add('open');
sheet.onclick = event => event.stopPropagation();
updatePlaylistHighlight();
}
function closeSheet() {
sheetOpen = false;
document.getElementById('sheetOverlay').classList.remove('open');
document.getElementById('sheet').classList.remove('open');
}
function setupMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => playAudio());
navigator.mediaSession.setActionHandler('pause', () => pauseAudio());
navigator.mediaSession.setActionHandler('nexttrack', () => nextTrack());
navigator.mediaSession.setActionHandler('previoustrack', () => prevTrack());
navigator.mediaSession.setActionHandler('seekto', event => {
if (event.seekTime !== undefined && audio.duration) {
audio.currentTime = event.seekTime;
}
});
}
function updateMediaSessionMetadata() {
if (!('mediaSession' in navigator)) return;
const track = PLAYLIST[currentIdx];
if (!track) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || '未知曲目',
artist: track.artist || '未知歌手',
album: '八音盒',
artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : []
});
}
function registerSW() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveCurrentPlaybackPosition({ force: true });
}
});
window.addEventListener('beforeunload', () => {
saveCurrentPlaybackPosition({ force: true });
});
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
let toastTimer;
function showToast(msg) {
const el = document.getElementById('toast');
if (!msg) {
el.classList.remove('show');
el.textContent = '';
return;
}
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
}
init();
setTimeout(() => {
updateSliderFill(document.getElementById('progressBar'), 0);
}, 50);