refactor: 重构禅音工具路径与配置,新增私人剪贴板技能

这是一次大型重构和功能新增:
1. 将原 zen_box 工具重命名为 web_zen_box,调整所有相关路径与配置
2. 新增 web_order_box 原生Web应用的完整资源与部署脚本
3. 新增私人剪贴板后端技能与配套测试脚本
4. 修复订单盒子APP的API地址配置,避免路径重复拼接
5. 新增本地开发跨域白名单支持localhost
6. 清理旧版zen_box的冗余文件
This commit is contained in:
yangxiangyuan
2026-07-26 21:03:28 +08:00
parent a062a7e2c0
commit 212dbc4e1d
47 changed files with 6446 additions and 755 deletions
@@ -0,0 +1,7 @@
{
"_comment": "框子工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权(需要从导航登录);false:完全放开访问",
"enable_auth": false,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365)",
"max_age_days": 30
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,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="theme-color" content="#080b12">
<title>框子 - 运动提醒宝</title>
<link rel="icon" href="/tools/web_order_box/favicon.ico">
<link rel="stylesheet" href="/tools/web_order_box/order_box.css">
</head>
<body>
<!-- ===== Hub 首页 ===== -->
<div id="viewHub" class="view">
<div class="hub-header">
<h1 class="hub-title">框子</h1>
<p class="hub-subtitle">kit 聚合壳。当前阶段先落地第一个工具:运动提醒宝。</p>
</div>
<div class="hub-kit-list">
<button class="kit-card" id="btnEnterMotion" disabled>
<div class="kit-icon-wrap">
<svg class="kit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5h11M6.5 17.5h11M3 10v4M21 10v4M6.5 6.5v11M17.5 6.5v11"/></svg>
</div>
<div class="kit-info">
<span class="kit-name">运动提醒宝</span>
<span class="kit-desc">流程卡片、自动切步、语音播报、大字倒计时、下一个动作常驻显示。</span>
</div>
<svg class="kit-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<!-- ===== 运动提醒宝 主容器 ===== -->
<div id="viewMotion" class="view" style="display:none">
<!-- 顶部栏 -->
<div class="motion-header">
<button class="btn-back" id="btnBackHub">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span>返回框子</span>
</button>
</div>
<!-- 页面内容区 -->
<div class="motion-body">
<!-- 卡片页 -->
<div id="pageCards" class="page">
<!-- 残留 session 警告 -->
<div id="staleWarning" class="stale-warning" style="display:none">
<div class="stale-title">检测到服务端仍保留一个运行中的 session</div>
<div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div>
<button class="btn-stale-clean" id="btnCleanStale">清理占用</button>
</div>
<!-- 新建按钮 -->
<div class="cards-toolbar">
<button class="btn-primary" id="btnNewCard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span>新建卡片</span>
</button>
</div>
<!-- 卡片列表 -->
<div id="cardsList" class="cards-list"></div>
<!-- 空状态 -->
<div id="cardsEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有流程卡片</div>
<div class="empty-desc">先建一个训练流程,再从卡片直接启动执行界面。</div>
</div>
<!-- 加载 -->
<div id="cardsLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
<!-- 历史页 -->
<div id="pageHistory" class="page" style="display:none">
<div id="historyList" class="history-list"></div>
<div id="historyEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有执行记录</div>
<div class="empty-desc">从卡片页启动一次流程后,这里会出现历史记录。</div>
</div>
<div id="historyLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
</div>
<!-- 底部 Tab -->
<div class="tab-bar">
<button class="tab-item active" data-tab="cards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>卡片</span>
</button>
<button class="tab-item" data-tab="history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>
<span>历史</span>
</button>
</div>
</div>
<!-- ===== 卡片编辑器 Modal ===== -->
<div id="editorModal" class="modal-overlay" style="display:none">
<div class="modal-sheet">
<div class="sheet-header">
<span class="sheet-title" id="editorTitle">新建流程卡片</span>
<button class="btn-icon" id="btnEditorClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="sheet-body" id="editorBody">
<!-- 动态渲染 -->
</div>
</div>
</div>
<!-- ===== 执行器 Modal ===== -->
<div id="runnerModal" class="modal-overlay modal-fullscreen" style="display:none">
<div class="runner-container" id="runnerContainer">
<!-- 动态渲染 -->
</div>
</div>
<!-- ===== 通用弹窗 ===== -->
<div id="dialogOverlay" class="dialog-overlay" style="display:none">
<div class="dialog-box" id="dialogBox">
<div class="dialog-title" id="dialogTitle"></div>
<div class="dialog-body" id="dialogBody"></div>
<div class="dialog-actions" id="dialogActions"></div>
</div>
</div>
<!-- ===== 交互详情弹窗 ===== -->
<div id="interactionOverlay" class="dialog-overlay" style="display:none">
<div class="dialog-box dialog-interaction" id="interactionBox">
<div class="dialog-header-row">
<span class="dialog-title" id="interactionTitle">交互详情</span>
<button class="btn-icon btn-copy" id="btnInteractionCopy" title="复制">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<pre class="interaction-content" id="interactionContent"></pre>
<div class="dialog-actions" id="interactionActions"></div>
</div>
</div>
<!-- ===== Toast ===== -->
<div id="toast" class="toast"></div>
<script src="/tools/web_order_box/order_box.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"version":1,"type":"native_web","description":"框子 - 运动提醒宝 Web 版(原生 HTML/CSS/JS)"}
+439
View File
@@ -0,0 +1,439 @@
/* ============================================================
框子 · 运动提醒宝 — order_box.css
深色主题 / 移动端优先 / 所有色值统一走 CSS 变量
============================================================ */
/* ---------- 主题变量 ---------- */
:root {
--bg: #080b12;
--surface: #121826;
--surface-soft: #1a2234;
--surface-strong: #202b42;
--primary: #f0b35f;
--primary-soft: rgba(240, 179, 95, 0.16);
--text: #f6f7fb;
--text-secondary: #9ba7bc;
--text-disabled: #5a6578;
--border: #2a3449;
--border-hover: #3b4866;
--success: #4ade80;
--warning: #fbbf24;
--error: #ef4444;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 24px;
}
/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
background-color: var(--bg);
background-image:
radial-gradient(1100px 560px at 88% -12%, rgba(240,179,95,0.07), transparent 60%),
radial-gradient(900px 520px at -12% 32%, rgba(72,104,180,0.10), transparent 55%);
background-attachment: fixed;
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
-webkit-user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent;
overflow-x: hidden;
}
input, textarea { -webkit-user-select: text; user-select: text; font-family: inherit; }
button { border: none; background: none; cursor: pointer; font: inherit; color: inherit; -webkit-tap-highlight-color: transparent; }
button:disabled { cursor: not-allowed; }
svg { display: block; flex-shrink: 0; }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* ---------- 滚动条 ---------- */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- 动画 ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pausedBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
/* ============================================================
视图骨架
============================================================ */
.view { min-height: 100dvh; display: flex; flex-direction: column; }
.page { animation: fadeUp 0.25s ease both; }
/* ============================================================
Hub 首页
============================================================ */
#viewHub { justify-content: center; padding: 24px; }
.hub-header { margin-bottom: 28px; animation: fadeUp 0.35s ease both; }
.hub-title { font-size: 28px; font-weight: 700; letter-spacing: 1px; line-height: 1.2; }
.hub-subtitle { font-size: 16px; color: var(--text-secondary); line-height: 1.5; margin-top: 8px; }
.hub-kit-list { display: flex; flex-direction: column; gap: 12px; }
.kit-card {
display: flex; flex-direction: row; align-items: center; gap: 16px; width: 100%;
padding: 16px; background: var(--surface); border-radius: var(--radius-md);
border: 1px solid var(--border); text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
animation: fadeUp 0.4s 0.08s ease both;
}
.kit-card:not(:disabled):hover { border-color: var(--border-hover); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
.kit-card:not(:disabled):active { transform: translateY(0) scale(0.98); }
.kit-card:disabled { opacity: 0.5; }
.kit-icon-wrap { width: 48px; height: 48px; border-radius: 50%; background: var(--primary-soft); display: flex; align-items: center; justify-content: center; }
.kit-icon { width: 28px; height: 28px; color: var(--primary); }
.kit-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.kit-name { font-size: 20px; font-weight: 600; }
.kit-desc { font-size: 14px; color: var(--text-secondary); line-height: 1.45; }
.kit-arrow { width: 24px; height: 24px; color: var(--text-secondary); transition: transform 0.2s; }
.kit-card:not(:disabled):hover .kit-arrow { transform: translateX(3px); color: var(--primary); }
/* ============================================================
运动提醒宝 · 顶部栏 / 主体 / Tab
============================================================ */
.motion-header { padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top)); }
.btn-back { display: flex; flex-direction: row; align-items: center; gap: 4px; color: var(--text-secondary); font-size: 14px; transition: color 0.2s, transform 0.15s; }
.btn-back svg { width: 18px; height: 18px; }
.btn-back:hover { color: var(--text); }
.btn-back:active { transform: scale(0.96); }
.motion-body { flex: 1; overflow-y: auto; padding: 0 16px 80px; }
.tab-bar { position: fixed; bottom: 0; left: 0; right: 0; background: var(--surface); border-top: 1px solid var(--border); display: flex; flex-direction: row; padding-bottom: env(safe-area-inset-bottom); z-index: 50; }
.tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; color: var(--text-secondary); font-size: 12px; transition: color 0.2s; }
.tab-item svg { width: 22px; height: 22px; transition: transform 0.2s; }
.tab-item:hover { color: var(--text); }
.tab-item.active { color: var(--primary); }
.tab-item.active svg { transform: translateY(-1px); }
/* ============================================================
残留 session 警告
============================================================ */
.stale-warning { padding: 16px; border: 1px solid var(--warning); border-radius: var(--radius-md); margin-bottom: 16px; background: rgba(251,191,36,0.07); animation: fadeUp 0.3s ease both; }
.stale-title { font-size: 14px; font-weight: 600; color: var(--warning); }
.stale-desc { font-size: 13px; color: var(--text-secondary); margin-top: 4px; line-height: 1.5; }
.btn-stale-clean { margin-top: 12px; padding: 8px 16px; background: var(--warning); color: #000; border-radius: var(--radius-sm); font-weight: 600; font-size: 14px; transition: transform 0.15s, filter 0.2s; }
.btn-stale-clean:hover { filter: brightness(1.08); }
.btn-stale-clean:active { transform: scale(0.96); }
/* ============================================================
卡片页
============================================================ */
.cards-toolbar { padding: 8px 0; display: flex; justify-content: flex-end; }
.btn-primary {
display: flex; flex-direction: row; align-items: center; gap: 6px;
padding: 10px 20px; background: var(--primary); color: #081018;
border-radius: var(--radius-md); font-weight: 700; font-size: 14px;
box-shadow: 0 4px 16px rgba(240,179,95,0.25);
transition: transform 0.15s, filter 0.2s, box-shadow 0.2s;
}
.btn-primary svg { width: 18px; height: 18px; }
.btn-primary:hover:not(:disabled) { filter: brightness(1.06); box-shadow: 0 6px 20px rgba(240,179,95,0.35); }
.btn-primary:active:not(:disabled) { transform: scale(0.96); }
.btn-primary:disabled { opacity: 0.5; box-shadow: none; }
.cards-list { display: flex; flex-direction: column; }
.card-item {
background: var(--surface); border-radius: var(--radius-md); padding: 16px;
margin-bottom: 12px; border: 1px solid var(--border);
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
animation: fadeUp 0.3s ease both;
}
.card-item:hover { border-color: var(--border-hover); transform: translateY(-1px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
.card-item-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 8px; }
.card-title { font-size: 20px; font-weight: 600; }
.card-meta { font-size: 14px; color: var(--text-secondary); margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-disabled); margin-top: 2px; }
/* 步骤预览 — JS 用 .step-preview */
.step-preview { margin-top: 8px; }
.step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
.card-actions { display: flex; flex-direction: row; gap: 8px; margin-top: 12px; }
/* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */
.btn-card-primary {
display: flex; flex-direction: row; align-items: center; gap: 4px;
padding: 8px 16px; background: var(--primary); color: #081018;
border-radius: var(--radius-sm); font-weight: 700; font-size: 13px;
transition: transform 0.15s, filter 0.2s;
}
.btn-card-primary svg { width: 16px; height: 16px; }
.btn-card-primary:hover { filter: brightness(1.08); }
.btn-card-primary:active { transform: scale(0.95); }
.btn-card-ghost {
padding: 8px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text-secondary); font-size: 13px;
transition: border-color 0.2s, color 0.2s, transform 0.15s;
}
.btn-card-ghost:hover { border-color: var(--border-hover); color: var(--text); }
.btn-card-ghost:active { transform: scale(0.95); }
.btn-text-danger { color: var(--error); }
/* 通用 btn-text span */
.btn-text { display: inline; }
/* 对话框里用的实心危险按钮 */
.btn-danger { padding: 8px 16px; background: var(--error); color: #fff; border-radius: var(--radius-sm); font-size: 14px; font-weight: 600; transition: transform 0.15s, filter 0.2s; }
.btn-danger:hover { filter: brightness(1.1); }
.btn-danger:active { transform: scale(0.95); }
/* ============================================================
空状态 / 加载
============================================================ */
.empty-state { text-align: center; padding: 48px 24px; animation: fadeIn 0.3s ease both; }
.empty-title { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.empty-desc { font-size: 14px; color: var(--text-disabled); margin-top: 8px; line-height: 1.5; }
.loading-wrap { display: flex; align-items: center; justify-content: center; padding: 48px; }
.spinner { width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
/* ============================================================
历史页 — JS 用 .history-line / .history-badge
============================================================ */
.history-list { display: flex; flex-direction: column; padding-top: 8px; }
.history-item {
background: var(--surface); border-radius: var(--radius-md); padding: 16px;
margin-bottom: 12px; border: 1px solid var(--border);
transition: border-color 0.2s; animation: fadeUp 0.3s ease both;
}
.history-item:hover { border-color: var(--border-hover); }
.history-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.history-line { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.history-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.history-badge.badge-warning { background: rgba(251,191,36,0.15); color: var(--warning); }
.history-badge.badge-success { background: rgba(74,222,128,0.15); color: var(--success); }
.history-badge.badge-muted { background: var(--surface-strong); color: var(--text-disabled); }
/* ============================================================
Modal(底部抽屉 / 全屏)
============================================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.58); z-index: 100; display: flex; flex-direction: column; justify-content: flex-end; animation: fadeIn 0.2s ease both; }
.modal-fullscreen { justify-content: stretch; align-items: stretch; }
.modal-sheet { width: 100%; background: var(--bg); border-radius: var(--radius-xl) var(--radius-xl) 0 0; max-height: 92dvh; display: flex; flex-direction: column; animation: slideUp 0.3s ease-out both; border-top: 1px solid var(--border); }
.sheet-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.sheet-title { font-size: 18px; font-weight: 600; }
.sheet-body { flex: 1; overflow-y: auto; padding: 20px; }
.btn-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; color: var(--text); border-radius: var(--radius-sm); transition: color 0.2s, background 0.2s, transform 0.15s; }
.btn-icon svg { width: 24px; height: 24px; }
.btn-icon:hover { background: var(--surface-soft); }
.btn-icon:active { transform: scale(0.9); }
/* ============================================================
通用弹窗 / 交互详情弹窗
============================================================ */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.15s ease both; }
.dialog-box { background: var(--surface); border-radius: var(--radius-lg); padding: 24px; max-width: 340px; width: calc(100% - 48px); animation: scaleIn 0.2s ease both; border: 1px solid var(--border); box-shadow: 0 16px 48px rgba(0,0,0,0.5); }
.dialog-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.dialog-body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 20px; white-space: pre-wrap; word-break: break-word; }
.dialog-actions { display: flex; flex-direction: row; gap: 8px; justify-content: flex-end; }
.dialog-header-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.dialog-header-row .dialog-title { margin-bottom: 0; }
.dialog-interaction { max-width: 420px; max-height: 80dvh; display: flex; flex-direction: column; }
.interaction-content { flex: 1; overflow-y: auto; background: var(--bg); border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 12px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; margin-bottom: 16px; font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace; }
.btn-copy { color: var(--text-secondary); }
.btn-copy:hover { color: var(--primary); }
/* ============================================================
Toast
============================================================ */
.toast { position: fixed; top: 60px; left: 50%; transform: translateX(-50%) translateY(-8px); padding: 10px 24px; border-radius: 999px; font-size: 14px; color: #fff; z-index: 300; opacity: 0; transition: opacity 0.3s, transform 0.3s; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.4); max-width: calc(100vw - 48px); text-align: center; }
.toast-show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-info { background: #3b82f6; }
.toast-success { background: #16a34a; }
.toast-error { background: #ef4444; }
/* ============================================================
编辑器 — 匹配 JS 实际 class 名
============================================================ */
.editor-field { margin-bottom: 16px; }
.editor-label { display: block; font-size: 14px; color: var(--text-secondary); margin-bottom: 6px; }
.editor-input {
width: 100%; padding: 12px 16px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text); font-size: 16px; outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.editor-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(240,179,95,0.12); }
.editor-input::placeholder { color: var(--text-disabled); }
/* 循环方式分段按钮 — JS 用 .segment-row / .segment-btn */
.segment-row { display: flex; flex-direction: row; gap: 8px; }
.segment-btn {
flex: 1; padding: 10px; text-align: center; background: var(--surface-strong);
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 14px;
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.segment-btn:hover { color: var(--text); }
.segment-btn:active { transform: scale(0.96); }
.segment-btn.active { background: var(--primary); color: #081018; font-weight: 600; }
/* 编辑器步骤区头部 — JS 用 .editor-steps-header */
.editor-steps-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin: 20px 0 12px; }
.editor-section-title { font-size: 20px; font-weight: 600; margin: 0; }
.editor-steps-buttons { display: flex; flex-direction: row; gap: 8px; }
.btn-small { padding: 8px 14px; background: var(--surface-strong); border-radius: var(--radius-sm); color: var(--text); font-size: 13px; transition: filter 0.2s, transform 0.15s; }
.btn-small:hover { filter: brightness(1.2); }
.btn-small:active { transform: scale(0.95); }
/* 总时长预览 — JS 用 .editor-total-duration */
.editor-total-duration {
display: flex; flex-direction: row; align-items: center; gap: 8px;
padding: 12px 16px; background: var(--surface-soft); border-radius: var(--radius-sm);
margin-bottom: 16px; font-size: 14px; color: var(--text-secondary);
}
.editor-total-duration svg { width: 18px; height: 18px; color: var(--primary); }
/* 步骤列表容器 — JS 用 .editor-steps-list */
.editor-steps-list { display: flex; flex-direction: column; }
/* --- 步骤卡片 — JS 用 .step-editor --- */
.step-editor {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 14px; margin-bottom: 10px;
position: relative;
transition: border-color 0.2s, opacity 0.2s, transform 0.2s;
animation: fadeUp 0.25s ease both;
}
.step-editor.step-action { border-color: var(--primary-soft); border-left: 3px solid var(--primary); }
.step-editor.step-rest { border-color: rgba(74,222,128,0.16); border-left: 3px solid var(--success); }
/* 步骤头部 — JS 用 .step-editor-header */
.step-editor-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.step-title-row { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.step-badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
.step-editor.step-action .step-badge { background: var(--primary); color: #081018; }
.step-editor.step-rest .step-badge { background: var(--success); color: #081018; }
.step-index { font-size: 14px; color: var(--text-secondary); }
/* 步骤操作按钮 — JS 用 .step-actions / .btn-step-icon */
.step-actions { display: flex; flex-direction: row; align-items: center; gap: 4px; }
.btn-step-icon {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
color: var(--text-secondary); border-radius: var(--radius-sm);
transition: color 0.2s, background 0.2s, transform 0.15s;
}
.btn-step-icon svg { width: 18px; height: 18px; }
.btn-step-icon:hover:not(:disabled) { background: var(--surface-soft); }
.btn-step-icon:active:not(:disabled) { transform: scale(0.9); }
.btn-step-icon:disabled, .btn-step-icon.disabled { color: var(--text-disabled); pointer-events: none; }
.btn-step-remove:not(:disabled):not(.disabled) { color: var(--error); }
/* 拖拽手柄 — JS 用 .step-drag-handle */
.step-drag-handle {
display: flex; align-items: center; justify-content: center;
color: var(--text-disabled); cursor: grab; padding: 4px;
-webkit-user-select: none; user-select: none; transition: color 0.2s;
}
.step-drag-handle svg { width: 18px; height: 18px; }
.step-drag-handle:hover { color: var(--text-secondary); }
.step-drag-handle:active { cursor: grabbing; }
.step-editor.dragging { opacity: 0.4; }
.step-editor.drag-over { border-top: 2px solid var(--primary); }
/* 步骤内输入框 — JS 用 .editor-input.step-name-input / .step-duration-input */
.step-name-input { margin-bottom: 8px; font-size: 14px; padding: 10px 12px; background: var(--bg); }
.step-duration-input { width: 100px; font-size: 14px; padding: 10px 12px; background: var(--bg); margin-bottom: 4px; }
.step-duration-hint { font-size: 13px; color: var(--text-secondary); white-space: nowrap; margin-top: 4px; }
/* 编辑器底部统计 — JS 用 .editor-duration-info / .duration-info-row */
.editor-duration-info { margin-top: 16px; padding: 14px 16px; background: var(--surface-soft); border-radius: var(--radius-sm); }
.duration-info-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; font-size: 14px; color: var(--text-secondary); line-height: 2; }
.duration-info-label { color: var(--text-secondary); }
.duration-info-value { color: var(--text); font-weight: 600; }
.duration-info-highlight { color: var(--primary); }
/* 编辑器保存栏 — JS 用 .editor-footer */
.editor-footer { padding: 16px 0; padding-bottom: calc(16px + env(safe-area-inset-bottom)); }
.editor-footer .btn-primary { width: 100%; justify-content: center; padding: 14px; font-size: 16px; }
/* ============================================================
执行器(全屏)— 匹配 JS 实际 class 名
============================================================ */
.runner-container {
width: 100%; height: 100dvh; background: var(--bg);
display: flex; flex-direction: column; padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
overflow-y: auto; animation: fadeIn 0.25s ease both;
}
/* 执行器头部 — JS 用 .runner-header */
.runner-header { text-align: center; margin-bottom: 16px; }
.runner-badge {
display: inline-block; padding: 4px 16px; border-radius: 999px;
font-size: 13px; font-weight: 600; margin-bottom: 8px;
background: transparent; transition: color 0.3s, border-color 0.3s;
}
.runner-badge.badge-action { border: 1px solid var(--primary); color: var(--primary); background: var(--primary-soft); }
.runner-badge.badge-rest { border: 1px solid var(--success); color: var(--success); background: rgba(74,222,128,0.12); }
/* 卡片标题 — JS 用 .runner-title */
.runner-title { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
/* 轮次信息 — JS 用 .runner-loop-text */
.runner-loop-text { font-size: 14px; color: var(--text-secondary); margin-bottom: 16px; }
/* 步骤进度条 — JS 用 .runner-progress / .runner-progress-bar */
.runner-progress { height: 10px; background: var(--surface); border-radius: 5px; overflow: hidden; margin-bottom: 20px; }
.runner-progress-bar { height: 100%; background: var(--primary); border-radius: 5px; transition: width 0.3s; box-shadow: 0 0 12px rgba(240,179,95,0.4); }
/* 主体区 — JS 用 .runner-main */
.runner-main { flex: 1; display: flex; flex-direction: column; }
.runner-label { font-size: 16px; color: var(--text-secondary); margin-bottom: 4px; }
/* 当前动作名 — JS 用 .runner-current */
.runner-current { font-size: 34px; font-weight: 700; margin-bottom: 8px; line-height: 1.2; word-break: break-word; }
.runner-current.current-action { color: var(--primary); }
.runner-current.current-rest { color: var(--success); }
/* 倒计时 — JS 用 .runner-timer */
.runner-timer {
font-size: 90px; font-weight: 700; color: var(--primary);
text-align: center; margin: 16px 0;
font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: 2px;
text-shadow: 0 0 36px rgba(240,179,95,0.35);
}
.runner-container.is-paused .runner-timer { animation: pausedBlink 1.2s ease-in-out infinite; }
/* 下一个动作卡片 */
.runner-next-card { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); margin-bottom: 16px; transition: border-color 0.3s; }
/* 下一个动作名 — JS 用 .runner-next */
.runner-next { font-size: 26px; font-weight: 700; word-break: break-word; }
/* 底部区 — JS 用 .runner-footer */
.runner-footer { margin-top: auto; }
/* 执行信息卡片 */
.runner-info-card { padding: 14px 16px; background: var(--surface-soft); border-radius: var(--radius-sm); margin-bottom: 16px; }
.runner-info-row { display: flex; flex-direction: row; justify-content: space-between; font-size: 14px; color: var(--text-secondary); line-height: 2; font-variant-numeric: tabular-nums; }
.runner-info-label { color: var(--text-secondary); }
.runner-info-value { color: var(--text); }
.runner-info-highlight { color: var(--primary); font-weight: 600; }
/* 控制按钮 — JS 用 .runner-buttons / .runner-btn */
.runner-buttons { display: flex; flex-direction: row; gap: 10px; margin-bottom: 16px; }
.runner-btn {
flex: 1; padding: 14px; background: var(--surface-strong);
border-radius: var(--radius-md); color: var(--text);
font-size: 15px; font-weight: 600;
transition: filter 0.2s, transform 0.15s;
}
.runner-btn:hover { filter: brightness(1.25); }
.runner-btn:active { transform: scale(0.96); }
.runner-btn.runner-btn-danger { background: var(--error); color: #fff; }
.runner-btn.runner-btn-danger:hover { filter: brightness(1.1); }
/* 全局进度 — JS 用 .runner-global */
.runner-global { margin-top: 16px; }
.runner-global-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 18px; }
.runner-global-label { color: var(--text); }
.runner-global-percent { font-size: 22px; font-weight: 700; color: var(--primary); font-variant-numeric: tabular-nums; }
.runner-global-bar-wrap { height: 24px; background: var(--surface-soft); border-radius: 12px; overflow: hidden; margin-bottom: 8px; }
.runner-global-bar { height: 100%; background: var(--primary); border-radius: 12px; transition: width 0.3s; box-shadow: 0 0 14px rgba(240,179,95,0.35); }
.runner-global-stats { font-size: 13px; color: var(--text-secondary); text-align: center; font-variant-numeric: tabular-nums; display: flex; flex-direction: row; justify-content: center; gap: 4px; flex-wrap: wrap; }
.runner-global-divider { color: var(--text-disabled); }
.runner-global-remaining { color: var(--text-secondary); }
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -5,15 +5,36 @@
<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="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>
<title>观空禅音</title>
<link rel="stylesheet" href="zen_box.css">
</head>
<body>
<!-- Splash Screen -->
<div class="splash" id="splash">
<svg class="splash-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>
<div class="splash-title">观空禅音</div>
<div class="splash-sub">正在加载歌单与缓存...</div>
<div class="splash-spinner"></div>
</div>
<!-- Starfield Background -->
<div class="starfield">
<div class="stars-sm"></div>
@@ -40,7 +61,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>盒
观<span>空</span>禅音
</div>
<div class="header-count" id="trackCount">0 首</div>
</div>
@@ -161,9 +182,9 @@
<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-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>
<span class="marquee-copy marquee-copy-duplicate" id="trackTitleDuplicate" aria-hidden="true">未选择歌曲</span>
</div>
</div>
<div class="track-artist" id="trackArtist">—</div>
@@ -218,7 +239,7 @@
</button>
</div>
<!-- Secondary row: playlist toggle + volume -->
<!-- Secondary row: playlist + settings -->
<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">
@@ -230,11 +251,6 @@
<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>
@@ -259,23 +275,52 @@
</button>
</div>
<div class="settings-body">
<div class="setting-group">
<div class="setting-label">账号</div>
<div id="accountSection"></div>
</div>
<div class="setting-group">
<div class="setting-label">当前歌单</div>
<select id="playlistSelect" class="setting-select"></select>
<div class="setting-note">自动读取当前目录下所有以 playlist.json 结尾的歌单文件,并记住你上次选择。</div>
<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="cacheOnlyToggle">
<span>只用缓存听歌曲</span>
</label>
<div class="setting-note">开启后,切歌时只播放已下载的歌曲,未缓存的歌曲需先在播放列表下载。</div>
</div>
<div class="setting-group">
<div class="setting-label">缓存管理</div>
<div class="cache-size-row">
<span>已缓存大小</span>
<span class="cache-size-value" id="cacheSizeText">正在统计...</span>
</div>
<button class="clear-cache-btn" id="btnClearCache" onclick="clearAllCache()">清除所有缓存</button>
</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 class="setting-note">默认开启,下次打开时从上次暂停的位置继续播放</div>
</div>
<div class="about-card">
<div class="about-head">
<div class="about-name">观空禅音</div>
<div class="about-sub">ZenVoid</div>
</div>
<div class="about-desc">这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。</div>
<div class="about-desc">"ZenVoid"和"观空"都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。</div>
<div class="about-version">v1.0.0</div>
</div>
</div>
</div>
@@ -285,7 +330,10 @@
<div class="sheet" id="sheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<div class="sheet-title">播放列表</div>
<div class="sheet-title">
播放列表
<span class="offline-badge" id="offlineBadge" style="display:none">离线模式</span>
</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>
@@ -298,6 +346,48 @@
<span style="font-size:12px;opacity:0.6">请将音乐文件添加至播放列表</span>
</div>
</div>
<div class="sheet-footer">
<div class="sort-row">
<button class="sort-btn" id="sortOriginal" onclick="applySort('original')">原始顺序</button>
<button class="sort-btn" id="sortAsc" onclick="applySort('asc')">名称正序</button>
<button class="sort-btn" id="sortDesc" onclick="applySort('desc')">名称倒序</button>
</div>
<button class="download-all-btn" id="btnDownloadAll" onclick="downloadAllTracks()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
<span id="btnDownloadAllText">一键下载所有</span>
</button>
<div class="batch-progress" id="batchProgress" style="display:none">
<div class="batch-progress-title" id="batchProgressTitle">—</div>
<div class="batch-progress-bar">
<div class="batch-progress-fill" id="batchProgressFill"></div>
</div>
<div class="batch-progress-meta">
<span id="batchProgressPct">0%</span>
<span id="batchProgressCount">0/0</span>
</div>
</div>
</div>
</div>
<!-- Login Overlay -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<button class="sheet-close login-close" onclick="closeLoginOverlay()" 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 class="login-title">登录</div>
<div class="login-sub">登录后可跨设备同步你的配置</div>
<input class="login-input" id="loginUsername" type="text" placeholder="账号" autocomplete="username">
<input class="login-input" id="loginPassword" type="password" placeholder="密码" autocomplete="current-password">
<div class="login-error" id="loginError"></div>
<button class="login-btn" id="btnLogin" onclick="doLogin()">登 录</button>
</div>
</div>
<!-- Toast -->
@@ -1,6 +1,6 @@
{
"name": "八音盒",
"short_name": "八音盒",
"name": "观空禅音",
"short_name": "观空禅音",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#060612",
@@ -0,0 +1,366 @@
[
{
"rank": 1,
"title": "Flowers",
"artist": "Miley Cyrus",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/01.%20Miley%20Cyrus%20-%20Flowers%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 2,
"title": "Anti-Hero",
"artist": "Taylor Swift",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/02.%20Taylor%20Swift%20-%20Anti-Hero%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 3,
"title": "As It Was",
"artist": "Harry Styles",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/03.%20Harry%20Styles%20-%20As%20It%20Was%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 4,
"title": "Unholy",
"artist": "Sam Smith & Kim Petras",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/unholy.mp3",
"cover": ""
},
{
"rank": 5,
"title": "Kill Bill",
"artist": "SZA",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/05.%20SZA%20-%20Kill%20Bill%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 6,
"title": "Cruel Summer",
"artist": "Taylor Swift",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/06.%20Taylor%20Swift%20-%20Cruel%20Summer%20(Official%20Audio).mp3",
"cover": ""
},
{
"rank": 7,
"title": "vampire",
"artist": "Olivia Rodrigo",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/07.%20Olivia%20Rodrigo%20-%20vampire%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 8,
"title": "Paint The Town Red",
"artist": "Doja Cat",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/08.%20Doja%20Cat%20-%20Paint%20The%20Town%20Red%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 9,
"title": "greedy",
"artist": "Tate McRae",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/09.%20Tate%20McRae%20-%20greedy%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 10,
"title": "Snooze",
"artist": "SZA",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/10.%20SZA%20-%20Snooze%20(Audio).mp3",
"cover": ""
},
{
"rank": 11,
"title": "Water",
"artist": "Tyla",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/11.%20Tyla%20-%20Water%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 12,
"title": "Lovin On Me",
"artist": "Jack Harlow",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/12.%20Jack%20Harlow%20-%20Lovin%20On%20Me%20%5BOfficial%20Music%20Video%5D.mp3",
"cover": ""
},
{
"rank": 13,
"title": "yes, and?",
"artist": "Ariana Grande",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/yes_and.mp3",
"cover": ""
},
{
"rank": 14,
"title": "Houdini",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/14.%20Dua%20Lipa%20-%20Houdini%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 15,
"title": "Espresso",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/15.%20Sabrina%20Carpenter%20-%20Espresso.mp3",
"cover": ""
},
{
"rank": 16,
"title": "Please Please Please",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/16.%20Sabrina%20Carpenter%20-%20Please%20Please%20Please.mp3",
"cover": ""
},
{
"rank": 17,
"title": "BIRDS OF A FEATHER",
"artist": "Billie Eilish",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/17.%20Billie%20Eilish%20-%20BIRDS%20OF%20A%20FEATHER%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 18,
"title": "Die With A Smile",
"artist": "Lady Gaga & Bruno Mars",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/die_with_smile.mp3",
"cover": ""
},
{
"rank": 19,
"title": "APT.",
"artist": "ROSÉ & Bruno Mars",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/19.%20ROSE%CC%81%20%26%20Bruno%20Mars%20-%20APT.%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 20,
"title": "Good Luck, Babe!",
"artist": "Chappell Roan",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/good_luck_babe.mp3",
"cover": ""
},
{
"rank": 21,
"title": "Fortnight",
"artist": "Taylor Swift ft. Post Malone",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/21.%20Taylor%20Swift%20-%20Fortnight%20(feat.%20Post%20Malone)%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 22,
"title": "I Had Some Help",
"artist": "Post Malone ft. Morgan Wallen",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/22.%20Post%20Malone%20-%20I%20Had%20Some%20Help%20(feat.%20Morgan%20Wallen)%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 23,
"title": "Lose Control",
"artist": "Teddy Swims",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/23.%20Teddy%20Swims%20-%20Lose%20Control%20(The%20Village%20Sessions).mp3",
"cover": ""
},
{
"rank": 24,
"title": "Beautiful Things",
"artist": "Benson Boone",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/24.%20Benson%20Boone%20-%20Beautiful%20Things%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 25,
"title": "Stick Season",
"artist": "Noah Kahan",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/25.%20Noah%20Kahan%20-%20Stick%20Season.mp3",
"cover": ""
},
{
"rank": 26,
"title": "Too Sweet",
"artist": "Hozier",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/26.%20Hozier%20-%20Too%20Sweet%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 27,
"title": "A Bar Song (Tipsy)",
"artist": "Shaboozey",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/27.%20Shaboozey%20-%20A%20Bar%20Song%20(Tipsy)%20%5BOfficial%20Visualizer%5D.mp3",
"cover": ""
},
{
"rank": 28,
"title": "Not Like Us",
"artist": "Kendrick Lamar",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/28.%20Kendrick%20Lamar%20-%20Not%20Like%20Us.mp3",
"cover": ""
},
{
"rank": 29,
"title": "TEXAS HOLD 'EM",
"artist": "Beyoncé",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/29.%20Beyonc%C3%A9%20-%20TEXAS%20HOLD%20'EM%20(Official%20Visualizer).mp3",
"cover": ""
},
{
"rank": 30,
"title": "we can't be friends",
"artist": "Ariana Grande",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/30.%20Ariana%20Grande%20-%20we%20can't%20be%20friends%20(wait%20for%20your%20love)%20(official%20music%20video).mp3",
"cover": ""
},
{
"rank": 31,
"title": "Illusion",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/31.%20Dua%20Lipa%20-%20Illusion%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 32,
"title": "Training Season",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/32.%20Dua%20Lipa%20-%20Training%20Season%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 33,
"title": "Gata Only",
"artist": "FloyyMenor & Cris MJ",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/33.%20The%20Tale%20Of%20The%20Princess%20Kaguya-The%20Procession%20of%20Celestial%20Beings.mp3",
"cover": ""
},
{
"rank": 34,
"title": "Taste",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/36.%20Sabrina%20Carpenter%20-%20Taste%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 35,
"title": "Bed Chem",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/35.%20Rauw%20Alejandro%20x%20Chris%20Brown%20x%20Rvssian%20-%20Nost%C3%A1lgico%20(Letra%E2%A7%B8Lyrics).mp3",
"cover": ""
},
{
"rank": 36,
"title": "Juno",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/36.%20Sabrina%20Carpenter%20-%20Taste%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 37,
"title": "Sailor Song",
"artist": "Gigi Perez",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/37.%20Sabrina%20Carpenter%20-%20Bed%20Chem%20(Official%20Lyric%20Video).mp3",
"cover": ""
},
{
"rank": 38,
"title": "Timeless",
"artist": "The Weeknd & Playboi Carti",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/38.%20Sabrina%20Carpenter%20-%20Juno%20(Official%20Lyric%20Video).mp3",
"cover": ""
},
{
"rank": 39,
"title": "Starboy",
"artist": "The Weeknd ft. Daft Punk",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/39.%20Gigi%20Perez%20-%20Sailor%20Song%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 40,
"title": "Blinding Lights",
"artist": "The Weeknd",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/42.%20The%20Weeknd%20-%20Blinding%20Lights%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 41,
"title": "Save Your Tears",
"artist": "The Weeknd",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/41.%20The%20Weeknd%20-%20Starboy%20ft.%20Daft%20Punk%20(Official%20Video)%20ft.%20Daft%20Punk.mp3",
"cover": ""
},
{
"rank": 42,
"title": "Levitating",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/42.%20The%20Weeknd%20-%20Blinding%20Lights%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 43,
"title": "Don't Start Now",
"artist": "Dua Lipa",
"src": "",
"cover": ""
},
{
"rank": 44,
"title": "Physical",
"artist": "Dua Lipa",
"src": "",
"cover": ""
},
{
"rank": 45,
"title": "Used To Be Young",
"artist": "Miley Cyrus",
"src": "",
"cover": ""
},
{
"rank": 46,
"title": "Karma",
"artist": "Taylor Swift ft. Ice Spice",
"src": "",
"cover": ""
},
{
"rank": 47,
"title": "Calm Down",
"artist": "Rema & Selena Gomez",
"src": "",
"cover": ""
},
{
"rank": 48,
"title": "Vampire",
"artist": "Olivia Rodrigo",
"src": "",
"cover": ""
},
{
"rank": 49,
"title": "Seven",
"artist": "Jung Kook ft. Latto",
"src": "",
"cover": ""
},
{
"rank": 50,
"title": "What Was I Made For?",
"artist": "Billie Eilish",
"src": "",
"cover": ""
},
{
"rank": 33,
"title": "The Tale Of The Princess Kaguya-The Procession of Celestial Beings",
"artist": "",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/33.%20The%20Tale%20Of%20The%20Princess%20Kaguya-The%20Procession%20of%20Celestial%20Beings.mp3",
"cover": ""
},
{
"rank": 35,
"title": "Rauw Alejandro x Chris Brown x Rvssian - Nostálgico (Letra⧸Lyrics)",
"artist": "",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/35.%20Rauw%20Alejandro%20x%20Chris%20Brown%20x%20Rvssian%20-%20Nost%C3%A1lgico%20(Letra%E2%A7%B8Lyrics).mp3",
"cover": ""
}
]
@@ -1,4 +1,5 @@
const CACHE = 'music-box-v3';
const CACHE = 'music-box-v4';
const AUDIO_CACHE = 'zen-audio-cache';
self.addEventListener('install', e => {
self.skipWaiting();
@@ -7,20 +8,32 @@ self.addEventListener('install', e => {
'./',
'./index.html',
'./zen_box.js',
'./zen_box.css',
'./playlist.json'
'./zen_box.css'
]))
);
});
self.addEventListener('activate', e => {
e.waitUntil(clients.claim());
e.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE && k !== AUDIO_CACHE).map(k => caches.delete(k))
)
).then(() => clients.claim())
);
});
self.addEventListener('fetch', e => {
// 音频文件直接使用 Network Only
// 音频文件:优先命中 zen-audio-cache(离线播放必需),否则走网络
if (e.request.url.includes('.mp3') || e.request.url.includes('oss')) {
e.respondWith(fetch(e.request));
e.respondWith(
caches.open(AUDIO_CACHE).then(audioCache =>
audioCache.match(e.request).then(cached => {
if (cached) return cached;
return fetch(e.request);
})
).catch(() => fetch(e.request))
);
return;
}
@@ -519,6 +519,9 @@ html, body {
font-size: 16px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.sheet-close {
border: none;
@@ -582,24 +585,47 @@ html, body {
text-overflow: ellipsis;
}
.item-cache {
.dl-btn {
flex-shrink: 0;
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
}
.item-cache.cached {
.dl-btn:not(:disabled):active {
transform: scale(0.88);
color: var(--gold);
}
.dl-btn.cached {
color: var(--gold);
border-color: rgba(232,184,109,0.32);
background: rgba(232,184,109,0.08);
cursor: default;
}
.dl-btn.downloading {
color: var(--gold);
border-color: rgba(232,184,109,0.32);
cursor: default;
}
.dl-btn svg { display: block; }
.item-cache.pending {
background: rgba(255,255,255,0.04);
.dl-spinner {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid rgba(232,184,109,0.25);
border-top-color: var(--gold);
animation: zen-spin 0.8s linear infinite;
}
@keyframes zen-spin { to { transform: rotate(360deg); } }
.settings-overlay {
position: fixed;
@@ -621,6 +647,8 @@ html, body {
left: 50%;
top: calc(18px + var(--safe-top));
width: min(420px, calc(100vw - 24px));
max-height: calc(100dvh - 36px - var(--safe-top) - var(--safe-bottom));
overflow-y: auto;
transform: translateX(-50%) translateY(-12px);
opacity: 0;
pointer-events: none;
@@ -633,6 +661,7 @@ html, body {
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 25;
}
.settings-panel::-webkit-scrollbar { display: none; }
.settings-panel.open {
opacity: 1;
@@ -806,3 +835,431 @@ html, body {
font-size: 14px;
line-height: 1.7;
}
/* ========== Sheet Footer: Sort + Batch Download ========== */
.sheet-footer {
flex-shrink: 0;
padding: 10px 20px;
padding-bottom: calc(12px + var(--safe-bottom));
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.sort-row {
display: flex;
gap: 8px;
}
.sort-btn {
flex: 1;
padding: 7px 0;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.sort-btn.active {
color: var(--gold);
border-color: rgba(232,184,109,0.38);
background: rgba(232,184,109,0.10);
}
.sort-btn:not(.active):active {
background: rgba(255,255,255,0.08);
}
.download-all-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
width: 100%;
padding: 10px 0;
border-radius: 12px;
border: 1px solid rgba(232,184,109,0.28);
background: rgba(232,184,109,0.08);
color: var(--gold);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.download-all-btn:not(:disabled):active {
background: rgba(232,184,109,0.16);
}
.download-all-btn:disabled {
opacity: 0.6;
cursor: default;
}
.batch-progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.batch-progress-title {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-progress-bar {
width: 100%;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.batch-progress-fill {
height: 100%;
width: 0;
border-radius: 2px;
background: var(--gold);
transition: width 0.3s ease;
}
.batch-progress-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* ========== Offline Badge ========== */
.offline-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
background: rgba(232,184,109,0.12);
border: 1px solid rgba(232,184,109,0.30);
color: var(--gold);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* ========== Splash Screen ========== */
.splash {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-deep);
transition: opacity 0.7s ease, visibility 0.7s;
}
.splash.hide {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.splash-icon {
width: 80px;
height: 80px;
margin-bottom: 16px;
}
.splash-title {
font-size: 24px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
letter-spacing: 0.08em;
}
.splash-sub {
font-size: 14px;
color: var(--text-dim);
margin-bottom: 24px;
}
.splash-spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid rgba(232,184,109,0.25);
border-top-color: var(--gold);
animation: zen-spin 1s linear infinite;
}
/* ========== Login Overlay ========== */
.login-overlay {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-overlay.open {
display: flex;
}
.login-card {
width: 100%;
max-width: 320px;
background: rgba(17,17,30,0.94);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 24px;
position: relative;
}
.login-close {
position: absolute;
top: 14px;
right: 14px;
}
.login-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.login-sub {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 20px;
}
.login-input {
width: 100%;
padding: 12px 14px;
margin-bottom: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.15s;
}
.login-input:focus {
border-color: rgba(232,184,109,0.38);
}
.login-input::placeholder {
color: var(--text-dim);
}
.login-error {
min-height: 20px;
font-size: 13px;
color: #ff4d4f;
margin-bottom: 12px;
}
.login-btn {
width: 100%;
padding: 12px;
border-radius: 12px;
border: none;
background: var(--gold);
color: var(--bg-deep);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.login-btn:not(:disabled):active {
opacity: 0.85;
}
.login-btn:disabled {
opacity: 0.7;
cursor: default;
}
/* ========== Account Section ========== */
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-name {
font-size: 14px;
color: var(--gold);
font-weight: 500;
}
.logout-btn {
padding: 6px 14px;
border-radius: 10px;
border: 1px solid rgba(255,77,79,0.35);
background: rgba(255,77,79,0.08);
color: #ff4d4f;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.logout-btn:active {
background: rgba(255,77,79,0.16);
}
.login-entry-btn {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(232,184,109,0.28);
background: rgba(232,184,109,0.08);
color: var(--gold);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.login-entry-btn:active {
background: rgba(232,184,109,0.16);
}
/* ========== Cache Management ========== */
.cache-size-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
font-size: 13px;
color: var(--text);
}
.cache-size-value {
color: var(--gold);
font-variant-numeric: tabular-nums;
}
.clear-cache-btn {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255,77,79,0.30);
background: rgba(255,77,79,0.06);
color: #ff4d4f;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.clear-cache-btn:not(:disabled):active {
background: rgba(255,77,79,0.14);
}
.clear-cache-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* ========== About Card ========== */
.about-card {
padding: 16px;
border-radius: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 8px;
}
.about-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.about-name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.about-sub {
font-size: 12px;
color: var(--gold-dim);
letter-spacing: 0.06em;
}
.about-desc {
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
}
.about-version {
font-size: 11px;
color: var(--text-dim);
opacity: 0.6;
margin-top: 2px;
}
/* ========== Playing Indicator: Three-Dot Animation ========== */
.eq-dots {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.eq-dots i {
display: block;
width: 3px;
border-radius: 1.5px;
background: var(--gold);
animation: eq-bounce 1s ease-in-out infinite;
}
.eq-dots i:nth-child(1) { height: 6px; animation-delay: 0s; }
.eq-dots i:nth-child(2) { height: 10px; animation-delay: 0.2s; }
.eq-dots i:nth-child(3) { height: 7px; animation-delay: 0.4s; }
@keyframes eq-bounce {
0%, 100% { transform: scaleY(0.5); opacity: 0.6; }
50% { transform: scaleY(1); opacity: 1; }
}
File diff suppressed because it is too large Load Diff
-717
View File
@@ -1,717 +0,0 @@
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);