Archived
为什么:页面经 v4-v10 十层打磨后整体已精致,但截图复看仍有四处寡淡—— 纯文字下划线 Tab 缺乏点击感、背景氛围是中性灰与品牌无关、卡片顶边 平、账号区与音质区无分隔。 改动(全部为 CSS 叠加覆盖,不动类名/结构/JS 契约): - Tab 药丸化:hover 软底 + active 主色片 + 按压回弹,::after 下划线隐藏; 用 .tabs .tab 权重压过基础层与 v10 超小屏规则 - 背景氛围替换为品牌红 + 主题蓝双色辉光(明暗两套参数) - 卡片顶部内高光,浅色主题更挺括 - 账号栏补下分隔线,与音质栏边框上下呼应 - 主按钮补顶部高光渐变(对齐 success/danger 既有处理) - 页脚字距微调;空状态浮动动画 v4 已有,不重复 golden:index 快照按 docstring 约定同步刷新(sha256/len), 仅 2 行值变更。门禁:ruff/pyright 0-0,pytest 294 全绿; 浏览器实测明暗两主题 + Tab 切换正常。
7705 lines
456 KiB
HTML
7705 lines
456 KiB
HTML
<!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" />
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23ec4141'/%3E%3Ctext x='16' y='23' font-size='20' text-anchor='middle' fill='white' font-family='Arial'%3E%E2%99%AA%3C/text%3E%3C/svg%3E" />
|
||
<title>云村小助手</title>
|
||
<style>
|
||
:root {
|
||
--color-primary: #1677ff;
|
||
--color-primary-hover: #4096ff;
|
||
--color-primary-active: #0958d9;
|
||
--color-primary-bg: rgba(22, 119, 255, 0.1);
|
||
--color-success: #52c41a;
|
||
--color-warning: #faad14;
|
||
--color-error: #ff4d4f;
|
||
--color-text: rgba(0, 0, 0, 0.88);
|
||
--color-text-secondary: rgba(0, 0, 0, 0.45);
|
||
--color-text-tertiary: rgba(0, 0, 0, 0.25);
|
||
--color-border: #d9d9d9;
|
||
--color-border-split: rgba(5, 5, 5, 0.06);
|
||
--color-bg-layout: #f0f2f5;
|
||
--color-bg-container: #ffffff;
|
||
--color-bg-elevated: #ffffff;
|
||
--color-bg-subtle: #f5f6f8;
|
||
--color-bg-hover: rgba(0, 0, 0, 0.03);
|
||
--color-fill: rgba(0, 0, 0, 0.04);
|
||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||
--color-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 2px 8px rgba(15, 23, 42, 0.04);
|
||
--color-shadow-heavy: 0 2px 4px rgba(15, 23, 42, 0.03), 0 12px 32px -8px rgba(15, 23, 42, 0.12), 0 32px 64px -24px rgba(15, 23, 42, 0.10);
|
||
--player-bg: linear-gradient(135deg, #2a2a35, #1c1c26);
|
||
--player-text: #ffffff;
|
||
--vip-green: #52c41a; /* --vip-gold/--vip-red 死变量已删 */
|
||
--color-bg-header: rgba(255, 255, 255, 0.82);
|
||
}
|
||
html.dark {
|
||
--color-primary: #1668dc;
|
||
--color-primary-hover: #3c89e8;
|
||
--color-primary-active: #1554ad;
|
||
--color-primary-bg: rgba(22, 104, 220, 0.2);
|
||
--color-text: rgba(255, 255, 255, 0.85);
|
||
--color-text-secondary: rgba(255, 255, 255, 0.45);
|
||
--color-text-tertiary: rgba(255, 255, 255, 0.25);
|
||
--color-border: #424242;
|
||
--color-border-split: rgba(253, 253, 253, 0.12);
|
||
--color-bg-layout: #000000;
|
||
--color-bg-container: #141414;
|
||
--color-bg-elevated: #1f1f1f;
|
||
--color-bg-subtle: #1c1c20;
|
||
--color-bg-hover: rgba(255, 255, 255, 0.08);
|
||
--color-fill: rgba(255, 255, 255, 0.12);
|
||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||
--color-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||
--color-shadow-heavy: 0 2px 4px rgba(0, 0, 0, 0.4), 0 12px 32px -8px rgba(0, 0, 0, 0.55), 0 32px 64px -24px rgba(0, 0, 0, 0.45);
|
||
--color-bg-header: rgba(20, 20, 20, 0.82);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
background-color: var(--color-bg-layout);
|
||
color: var(--color-text);
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||
'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5715;
|
||
-webkit-font-smoothing: antialiased;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
a { color: var(--color-primary); text-decoration: none; }
|
||
a:hover { color: var(--color-primary-hover); }
|
||
|
||
/* ============ 顶部导航 ============ */
|
||
.app-header {
|
||
background: var(--color-bg-container);
|
||
background: var(--color-bg-header);
|
||
backdrop-filter: blur(14px) saturate(1.6);
|
||
-webkit-backdrop-filter: blur(14px) saturate(1.6);
|
||
box-shadow: var(--color-shadow);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
.app-header-inner {
|
||
max-width: 1250px;
|
||
margin: 0 auto;
|
||
padding: 0 24px;
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 12px; cursor: pointer; user-select: none; }
|
||
.brand-logo {
|
||
width: 36px; height: 36px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #f5576c, #ec4141);
|
||
color: #fff; font-size: 20px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex: none; box-shadow: 0 2px 6px rgba(236, 65, 65, 0.35);
|
||
}
|
||
.brand-title { font-size: 17px; font-weight: 600; color: var(--color-text); }
|
||
.brand-sub { font-size: 12px; color: var(--color-text-secondary); }
|
||
|
||
.header-actions { display: flex; align-items: center; gap: 10px; }
|
||
.icon-btn {
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-bg-container);
|
||
color: var(--color-text);
|
||
border-radius: 8px;
|
||
height: 34px; padding: 0 12px;
|
||
font-size: 14px; cursor: pointer;
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
transition: all 0.2s;
|
||
}
|
||
.icon-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
|
||
/* ============ 主容器 ============ */
|
||
.main-container {
|
||
width: 100%;
|
||
max-width: 1250px;
|
||
margin: 0 auto;
|
||
padding: 24px;
|
||
flex: 1;
|
||
}
|
||
|
||
/* ============ 卡片 ============ */
|
||
.card {
|
||
background: var(--color-bg-container);
|
||
border-radius: 12px;
|
||
box-shadow: var(--color-shadow);
|
||
padding: 24px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.card-title {
|
||
font-size: 16px; font-weight: 600; margin-bottom: 16px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.card-title .count { font-size: 13px; font-weight: 400; color: var(--color-text-secondary); }
|
||
|
||
/* ============ Tab 栏 ============ */
|
||
.tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--color-border-split);
|
||
margin-bottom: 20px;
|
||
overflow-x: auto;
|
||
}
|
||
.tab {
|
||
padding: 12px 0;
|
||
margin-right: 32px;
|
||
font-size: 15px;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
position: relative;
|
||
transition: color 0.2s;
|
||
}
|
||
.tab:hover { color: var(--color-primary); }
|
||
.tab.active { color: var(--color-primary); font-weight: 500; }
|
||
.tab::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0; right: 0; bottom: -1px;
|
||
height: 2px;
|
||
background: var(--color-primary);
|
||
border-radius: 2px;
|
||
transform: scaleX(0);
|
||
opacity: 0;
|
||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s;
|
||
}
|
||
.tab.active::after {
|
||
transform: scaleX(1);
|
||
opacity: 1;
|
||
}
|
||
|
||
.panel { display: none; }
|
||
.panel.active { display: block; }
|
||
|
||
/* ============ 全局音质选择栏 ============ */
|
||
.global-quality-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--color-border-split);
|
||
}
|
||
.gq-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--color-text);
|
||
}
|
||
.gq-hint { font-size: 12px; color: var(--color-text-tertiary); }
|
||
|
||
/* ============ 表单控件(AntD 风格) ============ */
|
||
.input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.input-row > .input { flex: 1; min-width: 220px; }
|
||
.input, .select {
|
||
height: 36px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
background: var(--color-bg-container);
|
||
color: var(--color-text);
|
||
padding: 4px 11px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color 0.2s, box-shadow 0.2s;
|
||
}
|
||
.input:focus, .select:focus {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 2px var(--color-primary-bg);
|
||
}
|
||
.input::placeholder { color: var(--color-text-tertiary); }
|
||
.select { cursor: pointer; }
|
||
|
||
.btn {
|
||
height: 36px;
|
||
padding: 4px 16px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
background: var(--color-bg-container);
|
||
color: var(--color-text);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
white-space: nowrap;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
}
|
||
.btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
.btn-primary {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
.btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; }
|
||
.btn-primary:active { background: var(--color-primary-active); }
|
||
.btn-success { background: var(--color-success); border-color: var(--color-success); color: #fff; }
|
||
.btn-success:hover { background: #73d13d; border-color: #73d13d; color: #fff; }
|
||
.btn-danger { background: var(--color-error); border-color: var(--color-error); color: #fff; }
|
||
.btn-danger:hover { background: #ff7875; border-color: #ff7875; color: #fff; }
|
||
.btn-sm { height: 30px; padding: 0 12px; font-size: 13px; border-radius: 6px; }
|
||
.btn-link { border: none; background: transparent; color: var(--color-primary); height: auto; padding: 2px 6px; }
|
||
.btn-link:hover { color: var(--color-primary-hover); }
|
||
|
||
.btn .spinner {
|
||
width: 14px; height: 14px;
|
||
border: 2px solid rgba(255,255,255,0.4);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
/* `.btn-default-loading .spinner` 死规则已删(setBtnLoading
|
||
走 .btn .spinner 且全部用于彩色按钮,本选择器从未被命中) */
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.hint { font-size: 13px; color: var(--color-text-secondary); margin-top: 10px; }
|
||
|
||
/* ============ 历史标签 ============ */
|
||
.history-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
||
.history-label { font-size: 13px; color: var(--color-text-secondary); }
|
||
.tag {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
background: var(--color-fill);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
padding: 1px 8px;
|
||
font-size: 12px;
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
max-width: 260px;
|
||
}
|
||
.tag:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
.tag .tag-close { color: var(--color-text-tertiary); font-size: 11px; }
|
||
.tag .tag-close:hover { color: var(--color-error); }
|
||
.tag-empty { font-size: 12px; color: var(--color-text-tertiary); }
|
||
|
||
/* ============ 歌曲列表 ============ */
|
||
.song-list { display: flex; flex-direction: column; }
|
||
.song-row {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
transition: background 0.2s;
|
||
}
|
||
.song-row:hover { background: var(--color-bg-hover); }
|
||
.song-cover {
|
||
width: 48px; height: 48px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
background: var(--color-fill);
|
||
flex: none;
|
||
}
|
||
.song-info { flex: 1; min-width: 0; }
|
||
.song-name {
|
||
font-weight: 500;
|
||
display: flex; align-items: center; gap: 6px;
|
||
min-width: 0; flex-wrap: wrap;
|
||
}
|
||
.song-name .n {
|
||
flex: 0 1 auto; min-width: 0;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.song-name .tag,
|
||
.song-name .badge {
|
||
flex: 0 1 auto; min-width: 0; max-width: 100%;
|
||
white-space: normal;
|
||
}
|
||
.song-meta {
|
||
font-size: 13px; color: var(--color-text-secondary);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.song-actions { display: flex; gap: 8px; flex: none; }
|
||
.divider { height: 1px; background: var(--color-border-split); margin: 8px 0; }
|
||
|
||
/* ============ 单曲解析结果 ============ */
|
||
.song-detail { display: flex; gap: 24px; flex-wrap: wrap; }
|
||
.song-detail-cover {
|
||
width: 200px; height: 200px;
|
||
border-radius: 16px;
|
||
object-fit: cover;
|
||
box-shadow: var(--color-shadow-heavy);
|
||
background: var(--color-fill);
|
||
flex: none;
|
||
}
|
||
.song-detail-info { flex: 1; min-width: 260px; }
|
||
.detail-name { font-size: 22px; font-weight: 600; margin: 4px 0 8px; word-break: break-all; }
|
||
.detail-meta { margin: 4px 0; color: var(--color-text-secondary); font-size: 14px; }
|
||
.detail-meta b { color: var(--color-text); font-weight: 500; margin-right: 8px; }
|
||
.detail-actions { margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||
.badge {
|
||
display: inline-flex; align-items: center;
|
||
font-size: 12px; padding: 2px 10px; border-radius: 999px;
|
||
border: 1px solid var(--color-primary);
|
||
color: var(--color-primary);
|
||
background: var(--color-primary-bg);
|
||
}
|
||
.badge-green { border-color: var(--color-success); color: var(--color-success); background: rgba(82, 196, 26, 0.1); }
|
||
|
||
/* ============ 播放器(自定义,深色) ============ */
|
||
.player {
|
||
margin-top: 24px;
|
||
border-radius: 12px;
|
||
background: var(--player-bg);
|
||
color: var(--player-text);
|
||
padding: 16px 20px;
|
||
box-shadow: var(--color-shadow-heavy);
|
||
}
|
||
.player-top { display: flex; align-items: center; gap: 14px; }
|
||
.player-cover {
|
||
width: 56px; height: 56px; border-radius: 10px;
|
||
object-fit: cover; background: rgba(255,255,255,0.15); flex: none;
|
||
}
|
||
.player-track { flex: 1; min-width: 0; }
|
||
.player-name {
|
||
font-size: 15px; font-weight: 500;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.player-artist { font-size: 13px; color: rgba(255,255,255,0.55); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.player-controls { display: flex; align-items: center; gap: 10px; flex: none; }
|
||
.player-ctrl {
|
||
width: 42px; height: 42px; border-radius: 50%;
|
||
background: rgba(255,255,255,0.15);
|
||
border: none; color: #fff; font-size: 16px;
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
transition: background 0.2s, transform 0.1s; flex: none;
|
||
}
|
||
.player-ctrl:hover { background: rgba(255,255,255,0.28); }
|
||
.player-ctrl:active { transform: scale(0.95); }
|
||
.player-ctrl-sm { width: 30px; height: 30px; border-radius: 8px; font-size: 13px; background: transparent; }
|
||
.player-ctrl-sm:hover { background: rgba(255,255,255,0.2); }
|
||
.player-bottom { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||
.player-time { font-size: 12px; color: rgba(255,255,255,0.6); font-variant-numeric: tabular-nums; flex: none; }
|
||
.progress {
|
||
flex: 1; height: 4px; border-radius: 2px;
|
||
background: rgba(255,255,255,0.2);
|
||
cursor: pointer; position: relative;
|
||
}
|
||
.progress-fill {
|
||
height: 100%; border-radius: 2px;
|
||
background: var(--color-primary);
|
||
width: 0;
|
||
position: relative;
|
||
}
|
||
.progress-fill::after {
|
||
content: ''; position: absolute; right: -5px; top: 50%;
|
||
width: 10px; height: 10px; border-radius: 50%;
|
||
background: #fff; transform: translateY(-50%);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||
opacity: 0; transition: opacity 0.2s;
|
||
}
|
||
.progress:hover .progress-fill::after { opacity: 1; }
|
||
.volume-wrap { display: flex; align-items: center; gap: 8px; flex: 0 0 140px; }
|
||
.volume-btn { background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; font-size: 15px; padding: 2px; }
|
||
.volume-slider { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.2); cursor: pointer; position: relative; }
|
||
.volume-fill { height: 100%; border-radius: 2px; background: rgba(255,255,255,0.7); width: 100%; }
|
||
|
||
/* ============ 歌词 ============ */
|
||
.lyric-panel {
|
||
margin-top: 20px;
|
||
background: var(--color-bg-container);
|
||
border-radius: 12px;
|
||
box-shadow: var(--color-shadow);
|
||
padding: 16px 20px;
|
||
}
|
||
.lyric-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
||
.lyric-calib { display: inline-flex; align-items: center; gap: 6px; position: relative; } /* relative:字号弹层以整组右缘定位,任何视口都不越出面板 */
|
||
.btn-mini { height: 24px; padding: 0 8px; font-size: 12px; border-radius: 6px; }
|
||
.lyric-offset-label { font-size: 12px; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; min-width: 70px; }
|
||
/* 歌词字号弹层(#lyric-fontsize 展开):变量 --lyric-fs 挂在 #lyric-box 上,重绘歌词不丢 */
|
||
.lyric-fs-pop {
|
||
position: absolute; top: calc(100% + 8px); right: 0; z-index: 50;
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: var(--color-bg-container);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 10px;
|
||
box-shadow: var(--color-shadow-heavy);
|
||
padding: 8px 10px;
|
||
animation: rowIn 0.18s var(--ease-out);
|
||
}
|
||
.lyric-fs-val { font-size: 12px; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; min-width: 38px; text-align: center; }
|
||
.lyric-fs-btn { height: 30px; min-width: 34px; padding: 0 10px; font-size: 13px; border-radius: 8px; }
|
||
.lyric-fs-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||
/* 弹层展开中:触发按钮主色高亮,开合状态一眼可见 */
|
||
#lyric-fontsize[aria-expanded="true"] { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-primary-bg); }
|
||
.lyric-box {
|
||
/* 高度随字号适度放大(上限 420px):大字号不再一屏只有两三对;老内核不支持 min() 回落 280px */
|
||
max-height: 280px;
|
||
max-height: min(calc(280px * var(--lyric-fs, 1)), 420px);
|
||
overflow-y: auto;
|
||
scrollbar-width: thin;
|
||
/* 右侧 16px:激活行 scale(1.02) 只向右伸长(origin left),宽屏下 ~14px 的伸长量落在这里,末字不再被裁 */
|
||
padding: 4px 16px 4px 0;
|
||
position: relative; /* 作为歌词行的定位祖先,保证 offsetTop 相对本容器 */
|
||
}
|
||
.lyric-box::-webkit-scrollbar { width: 6px; }
|
||
.lyric-box::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
||
.lyric-line {
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
color: var(--color-text-secondary); /* 未播放的歌词为灰色 */
|
||
font-size: calc(14px * var(--lyric-fs, 1)); /* 用户可调字号(#lyric-fontsize),默认 100% */
|
||
line-height: 1.6;
|
||
cursor: pointer;
|
||
transform-origin: left center; /* 高亮放大从左缘锚定:左对齐不变,最左字不再被 overflow-x:hidden 裁掉 */
|
||
transition: color 0.25s, background 0.2s, transform 0.25s;
|
||
}
|
||
.lyric-line:hover { background: var(--color-bg-hover); }
|
||
.lyric-line + .lyric-line { margin-top: 3px; } /* 行对之间稍松、行对内部紧凑(原文+译文),段落节奏更清楚 */
|
||
.lyric-line.active { /* 当前播放的句子居中并标蓝 */
|
||
color: var(--color-primary);
|
||
font-weight: 600;
|
||
transform: scale(1.02);
|
||
}
|
||
/* KTV 逐字高亮:有逐字时间轴(YRC)的行按字渲染(.kw)逐字推进填充;
|
||
无 YRC 的行不做仿逐字整行渐变扫色(owner 2026-09-22 拍板撤销原"回退整行渐变"),仅普通高亮(.active 整行点亮) */
|
||
.lyric-line.ktv.active.kw-line .kw { color: var(--color-text-secondary); }
|
||
.lyric-line.ktv.active.kw-line .kw.on { color: var(--color-primary); }
|
||
.lyric-line.ktv.active.kw-line .kw.cur {
|
||
background-image: linear-gradient(to right,
|
||
var(--color-primary) 0%, var(--color-primary) var(--pw, 0%),
|
||
var(--color-text-secondary) var(--pw, 0%), var(--color-text-secondary) 100%);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
color: transparent;
|
||
}
|
||
/* 译文弱化:所有模式统一(原先仅 KTV 生效,非 KTV 译文与原文同权);
|
||
激活行切换时译文随 0.25s 柔和淡入主色(与原文行的变色节奏一致;reduced-motion 全局规则自动关闭) */
|
||
.lyric-line .ktr { color: var(--color-text-tertiary); font-weight: 400; font-size: 0.9em;
|
||
transition: color 0.25s var(--ease-out), -webkit-text-fill-color 0.25s var(--ease-out); }
|
||
/* 激活行译文跟主色但不加粗(层级靠字号与字重,对齐官方播放器观感);自身声明优先级高于行继承 */
|
||
.lyric-line.active .ktr { color: var(--color-primary); }
|
||
.lyric-empty { color: var(--color-text-tertiary); font-size: 13px; padding: 10px; }
|
||
|
||
/* ============ 评论区 ============ */
|
||
.comment-panel {
|
||
margin-top: 20px;
|
||
background: var(--color-bg-container);
|
||
border-radius: 12px;
|
||
box-shadow: var(--color-shadow);
|
||
padding: 16px 20px;
|
||
}
|
||
.comment-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
||
.comment-list { display: flex; flex-direction: column; }
|
||
.comment-item {
|
||
display: flex; gap: 12px;
|
||
padding: 12px 8px;
|
||
border-bottom: 1px solid var(--color-border-split);
|
||
}
|
||
.comment-item:last-child { border-bottom: none; }
|
||
.comment-avatar {
|
||
width: 34px; height: 34px; border-radius: 50%;
|
||
object-fit: cover; background: var(--color-fill); flex: none;
|
||
}
|
||
.comment-body { flex: 1; min-width: 0; }
|
||
.comment-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.comment-nickname { font-weight: 500; font-size: 13px; }
|
||
.vip-badge {
|
||
font-size: 11px; padding: 0 6px; border-radius: 4px;
|
||
font-weight: 500; line-height: 18px; display: inline-flex;
|
||
}
|
||
.vip-vip { background: linear-gradient(90deg, #f7ba2a, #ea5455); color: #fff; }
|
||
.vip-svip { background: linear-gradient(90deg, #c8161d, #8f0f14); color: #fff; }
|
||
/* .vip-musician 死规则已删(前端不产生该类名) */
|
||
.vip-pkg { background: rgba(82, 196, 26, 0.15); color: var(--vip-green); border: 1px solid rgba(82,196,26,0.4); }
|
||
.comment-meta { font-size: 12px; color: var(--color-text-tertiary); }
|
||
.comment-content { font-size: 14px; margin-top: 4px; word-break: break-word; }
|
||
.comment-reply {
|
||
margin-top: 6px; font-size: 13px;
|
||
background: var(--color-fill); border-radius: 6px;
|
||
padding: 6px 10px; color: var(--color-text-secondary);
|
||
}
|
||
.comment-actions { margin-top: 6px; display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--color-text-tertiary); }
|
||
.comment-like { display: inline-flex; align-items: center; gap: 4px; }
|
||
.comment-more { text-align: center; margin-top: 12px; }
|
||
.comment-empty { color: var(--color-text-tertiary); font-size: 13px; padding: 16px 0; text-align: center; }
|
||
|
||
/* ============ 合集头部(歌单/专辑/歌手) ============ */
|
||
.collection-header { display: flex; gap: 20px; flex-wrap: wrap; }
|
||
.collection-cover {
|
||
width: 160px; height: 160px; border-radius: 16px;
|
||
object-fit: cover; box-shadow: var(--color-shadow-heavy);
|
||
background: var(--color-fill); flex: none;
|
||
}
|
||
.collection-cover-round { border-radius: 50%; }
|
||
.collection-info { flex: 1; min-width: 240px; }
|
||
.collection-name { font-size: 20px; font-weight: 600; margin: 4px 0 8px; }
|
||
.collection-meta { color: var(--color-text-secondary); font-size: 13px; margin: 3px 0; }
|
||
.collection-desc {
|
||
margin-top: 8px; font-size: 13px; color: var(--color-text-secondary);
|
||
max-height: 60px; overflow-y: auto;
|
||
background: var(--color-fill); border-radius: 8px; padding: 8px 12px;
|
||
}
|
||
.collection-actions { margin-top: 12px; }
|
||
|
||
/* ============ 榜单卡片墙 ============ */
|
||
.toplist-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.toplist-card {
|
||
cursor: pointer;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
transition: all 0.2s;
|
||
border: 1px solid transparent;
|
||
}
|
||
.toplist-card:hover { background: var(--color-bg-hover); transform: translateY(-2px); }
|
||
.toplist-card .tl-cover {
|
||
width: 100%; aspect-ratio: 1;
|
||
border-radius: 12px; object-fit: cover;
|
||
background: var(--color-fill); box-shadow: var(--color-shadow);
|
||
}
|
||
.toplist-card .tl-name {
|
||
font-size: 14px; font-weight: 500; margin-top: 8px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.toplist-card .tl-meta { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; }
|
||
|
||
/* ============ 播客 ============ */
|
||
.podcast-cate-bar {
|
||
display: flex; gap: 8px; overflow-x: auto; flex: 1;
|
||
padding: 4px 2px; scrollbar-width: thin;
|
||
}
|
||
.podcast-cate-item {
|
||
flex: none; font-size: 12px; padding: 4px 12px;
|
||
border-radius: 999px; border: 1px solid var(--color-border);
|
||
color: var(--color-text-secondary); cursor: pointer;
|
||
transition: all 0.2s; user-select: none; white-space: nowrap;
|
||
}
|
||
.podcast-cate-item:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
.podcast-cate-item.active {
|
||
background: var(--color-primary); border-color: var(--color-primary); color: #fff;
|
||
}
|
||
.podcast-desc {
|
||
display: -webkit-box; -webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical; overflow: hidden;
|
||
}
|
||
.podcast-ep-index {
|
||
width: 30px; text-align: center; color: var(--color-text-tertiary);
|
||
font-size: 13px; flex: none;
|
||
}
|
||
|
||
/* ============ 关于网站 ============ */
|
||
.about-hero { display: flex; align-items: center; gap: 16px; padding: 8px 0 4px; flex-wrap: wrap; }
|
||
.about-logo {
|
||
width: 64px; height: 64px; border-radius: 18px;
|
||
background: linear-gradient(135deg, var(--color-primary), #722ed1);
|
||
color: #fff; font-size: 30px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
box-shadow: var(--color-shadow-heavy); flex: none;
|
||
}
|
||
.about-title { font-size: 22px; font-weight: 600; }
|
||
.about-sub { color: var(--color-text-secondary); font-size: 13px; margin-top: 4px; }
|
||
.about-text { color: var(--color-text-secondary); font-size: 14px; line-height: 1.9; margin: 6px 0 0; }
|
||
.about-text b { color: var(--color-text); }
|
||
.about-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
gap: 12px; margin-top: 10px;
|
||
}
|
||
.about-card {
|
||
border: 1px solid var(--color-border-split);
|
||
background: var(--color-bg-layout);
|
||
border-radius: 12px; padding: 14px 16px;
|
||
transition: all 0.2s;
|
||
}
|
||
.about-card:hover { transform: translateY(-2px); box-shadow: var(--color-shadow); }
|
||
.about-card-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||
.about-card-desc { font-size: 12.5px; color: var(--color-text-secondary); line-height: 1.7; }
|
||
/* .about-author/.about-qq 等 7 条死规则已删(作者卡片在
|
||
改名时整体移除,样式残留且曾误导 v7 美化层给死类追加规则) */
|
||
|
||
/* ============ 设置抽屉 ============ */
|
||
.drawer-mask {
|
||
position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: rgba(0, 0, 0, 0.45);
|
||
z-index: 200; opacity: 0; transition: opacity 0.25s; pointer-events: none;
|
||
}
|
||
.drawer-mask.show { opacity: 1; pointer-events: auto; }
|
||
.drawer {
|
||
position: fixed; top: 0; right: 0; bottom: 0;
|
||
width: 340px; max-width: 90vw;
|
||
background: var(--color-bg-container);
|
||
box-shadow: -6px 0 16px -8px rgba(0,0,0,0.2);
|
||
z-index: 201;
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.drawer.show { transform: translateX(0); }
|
||
.drawer-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--color-border-split);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.drawer-title { font-size: 16px; font-weight: 600; }
|
||
.drawer-close {
|
||
border: none; background: none; font-size: 18px;
|
||
color: var(--color-text-secondary); cursor: pointer; line-height: 1;
|
||
}
|
||
.drawer-body { flex: 1; overflow-y: auto; padding: 8px 20px 20px; }
|
||
.settings-section { margin-top: 20px; }
|
||
.settings-section-title {
|
||
font-size: 13px; color: var(--color-text-secondary);
|
||
margin-bottom: 12px; font-weight: 500;
|
||
}
|
||
.settings-section-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.settings-section-head .settings-section-title { margin-bottom: 0; }
|
||
.check-grid { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||
.check-item {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 13px; cursor: pointer; color: var(--color-text);
|
||
}
|
||
.check-item input { accent-color: var(--color-primary); cursor: pointer; }
|
||
.stat-grid { display: flex; gap: 12px; flex-wrap: wrap; }
|
||
.stat-card {
|
||
flex: 1; min-width: 90px;
|
||
background: var(--color-fill); border-radius: 10px;
|
||
padding: 12px; text-align: center;
|
||
}
|
||
.stat-card .stat-num { font-size: 20px; font-weight: 600; color: var(--color-primary); }
|
||
.stat-card .stat-label { font-size: 12px; color: var(--color-text-secondary); margin-top: 2px; }
|
||
.stat-list-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 8px 6px; border-bottom: 1px solid var(--color-border-split);
|
||
font-size: 13px;
|
||
}
|
||
.stat-list-item:last-child { border-bottom: none; }
|
||
.stat-rank { width: 26px; text-align: center; color: var(--color-text-tertiary); font-weight: 600; flex: none; }
|
||
.stat-endpoint { flex: 1; min-width: 0; }
|
||
.stat-endpoint .ep-name { font-weight: 500; }
|
||
.stat-endpoint .ep-path { font-size: 12px; color: var(--color-text-tertiary); font-family: monospace; }
|
||
.stat-count { flex: none; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||
.stat-refresh-info { font-size: 12px; color: var(--color-text-tertiary); text-align: center; margin-top: 10px; }
|
||
.setting-item {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 0; gap: 12px;
|
||
}
|
||
.setting-label { font-size: 14px; }
|
||
.setting-desc { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; }
|
||
.switch {
|
||
width: 40px; height: 22px; border-radius: 999px;
|
||
background: var(--color-border); position: relative;
|
||
cursor: pointer; transition: background 0.2s; flex: none;
|
||
}
|
||
.switch::after {
|
||
content: ''; position: absolute; top: 2px; left: 2px;
|
||
width: 18px; height: 18px; border-radius: 50%;
|
||
background: #fff; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||
}
|
||
.switch.on { background: var(--color-primary); }
|
||
.switch.on::after { left: 20px; }
|
||
.color-palette { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.color-dot {
|
||
width: 28px; height: 28px; border-radius: 50%;
|
||
cursor: pointer; border: 2px solid transparent;
|
||
transition: all 0.15s; position: relative;
|
||
}
|
||
.color-dot:hover { transform: scale(1.1); }
|
||
.color-dot.active { border-color: var(--color-text); box-shadow: 0 0 0 2px var(--color-bg-container); }
|
||
.color-dot.active::after {
|
||
content: '✓'; position: absolute; top: 0; right: 0; bottom: 0; left: 0; inset: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #fff; font-size: 14px; font-weight: 700;
|
||
}
|
||
.mode-seg { display: flex; background: var(--color-fill); border-radius: 8px; padding: 3px; gap: 3px; }
|
||
.mode-seg .seg-item {
|
||
flex: 1; text-align: center; font-size: 13px;
|
||
padding: 5px 0; border-radius: 6px; cursor: pointer;
|
||
color: var(--color-text-secondary); transition: all 0.15s;
|
||
}
|
||
.mode-seg .seg-item.active { background: var(--color-bg-container); color: var(--color-primary); font-weight: 500; box-shadow: var(--color-shadow); }
|
||
.drawer-footer {
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--color-border-split);
|
||
display: flex; gap: 10px;
|
||
}
|
||
.drawer-footer .btn { flex: 1; }
|
||
|
||
/* ============ 弹窗(更新日志) ============ */
|
||
.modal-mask {
|
||
position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: rgba(0, 0, 0, 0.45);
|
||
z-index: 300; display: none; align-items: center; justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
.modal-mask.show { display: flex; }
|
||
.modal {
|
||
background: var(--color-bg-container);
|
||
border-radius: 12px;
|
||
box-shadow: var(--color-shadow-heavy);
|
||
width: 640px; max-width: 100%;
|
||
max-height: 80vh;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.modal-header {
|
||
padding: 16px 20px; border-bottom: 1px solid var(--color-border-split);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.modal-title { font-size: 16px; font-weight: 600; }
|
||
.modal-body { flex: 1; overflow-y: auto; padding: 12px 20px 20px; }
|
||
.version-item { padding: 12px 0; border-bottom: 1px solid var(--color-border-split); }
|
||
.version-item:last-child { border-bottom: none; }
|
||
.version-head { display: flex; align-items: center; gap: 10px; }
|
||
.version-num { font-weight: 600; font-size: 14px; color: var(--color-primary); }
|
||
.version-date { font-size: 12px; color: var(--color-text-tertiary); }
|
||
.version-items { margin: 8px 0 0; padding-left: 20px; font-size: 13px; color: var(--color-text-secondary); }
|
||
.version-items li { margin: 3px 0; }
|
||
|
||
/* ============ 认证(登录/注册/头像) ============ */
|
||
.auth-area { display: flex; align-items: center; gap: 8px; }
|
||
.user-avatar {
|
||
width: 34px; height: 34px; border-radius: 50%;
|
||
background: var(--color-primary); color: #fff;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 15px; font-weight: 600; cursor: pointer;
|
||
user-select: none; border: 2px solid var(--color-primary-bg);
|
||
transition: transform 0.15s;
|
||
}
|
||
.user-avatar:hover { transform: scale(1.08); }
|
||
.user-avatar.admin { background: var(--color-warning); border-color: rgba(250, 173, 20, 0.2); }
|
||
.auth-form { display: flex; flex-direction: column; gap: 12px; padding: 6px 2px; }
|
||
.auth-form .input { width: 100%; }
|
||
.auth-form-row { display: flex; gap: 10px; }
|
||
.auth-form-row .input { flex: 1; }
|
||
.auth-switch { text-align: center; font-size: 13px; color: var(--color-text-secondary); }
|
||
.auth-switch a { color: var(--color-primary); cursor: pointer; text-decoration: none; }
|
||
.auth-trial-box {
|
||
border: 1px dashed var(--color-border); border-radius: 10px;
|
||
padding: 12px; text-align: center; font-size: 13px; color: var(--color-text-secondary);
|
||
}
|
||
/* .auth-tabs 两条死规则已删(登录/注册弹窗早已改为四视图切换) */
|
||
|
||
/* ============ 美化层(视觉与交互打磨,全部基于现有变量体系) ============ */
|
||
html { scroll-behavior: smooth; }
|
||
::selection { background: var(--color-primary-bg); }
|
||
img { -webkit-user-drag: none; user-select: none; }
|
||
|
||
/* 全局细滚动条 */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--color-text-tertiary); }
|
||
html { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; }
|
||
|
||
/* 键盘操作焦点环(鼠标点击不显示) */
|
||
:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: 4px; }
|
||
.input:focus-visible, .select:focus-visible { outline: none; }
|
||
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--color-primary-bg); }
|
||
.tab:focus-visible { outline: none; }
|
||
|
||
/* 按钮微交互:悬浮轻抬、按下回落 */
|
||
.btn:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); }
|
||
.btn-primary:not(:disabled):hover { box-shadow: 0 4px 14px var(--color-primary-bg); }
|
||
.btn:not(:disabled):active { transform: translateY(0) scale(0.97); box-shadow: none; }
|
||
.icon-btn:hover { background: var(--color-bg-hover); }
|
||
|
||
/* Tab 切换:面板淡入上浮 */
|
||
.panel.active { animation: panelIn 0.22s ease; }
|
||
@keyframes panelIn {
|
||
from { opacity: 0; transform: translateY(5px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
|
||
/* 歌曲行:封面悬浮微缩放 */
|
||
.song-cover { transition: transform 0.2s; }
|
||
.song-row:hover .song-cover { transform: scale(1.04); }
|
||
|
||
/* 播放器进度条:悬浮加粗 */
|
||
.progress { transition: height 0.15s; }
|
||
.progress:hover { height: 6px; }
|
||
|
||
/* 弹窗:磨砂遮罩 + 弹入动画 */
|
||
.modal-mask, .drawer-mask { backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }
|
||
.modal-mask.show .modal { animation: modalIn 0.22s cubic-bezier(0.4, 0, 0.2, 1); }
|
||
@keyframes modalIn {
|
||
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
|
||
/* 表单细节:输入框悬浮着色 + 文本域舒展 */
|
||
.input:hover, .select:hover { border-color: var(--color-primary-hover); }
|
||
textarea.input { height: auto; min-height: 52px; padding: 8px 11px; resize: vertical; line-height: 1.6; }
|
||
|
||
/* 评论行悬浮着色 */
|
||
.comment-item { border-radius: 8px; transition: background 0.15s; }
|
||
.comment-item:hover { background: var(--color-bg-hover); }
|
||
|
||
/* 详情/合集封面悬浮微放大 */
|
||
.song-detail-cover, .collection-cover { transition: transform 0.3s, box-shadow 0.3s; }
|
||
.song-detail-cover:hover, .collection-cover:hover { transform: scale(1.02); }
|
||
|
||
/* 主题切换平滑过渡(applyTheme 挂 theme-fading 类) */
|
||
html.theme-fading, html.theme-fading body, html.theme-fading * {
|
||
transition: background-color 0.3s ease, color 0.3s ease,
|
||
border-color 0.3s ease, box-shadow 0.3s ease !important;
|
||
}
|
||
|
||
/* 尊重系统"减弱动态效果"偏好 */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
html { scroll-behavior: auto; }
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
|
||
/* 表单控件字体继承(按钮/输入框默认不继承字体栈,导致全站按钮字体不统一) */
|
||
button, input, select, textarea { font-family: inherit; }
|
||
|
||
/* 管理后台:表格行过渡已有着色,补充侧栏与卡片细节 */
|
||
.site-table td { transition: background 0.15s; }
|
||
.uc-card { transition: transform 0.2s, box-shadow 0.2s; }
|
||
.uc-card:hover { transform: translateY(-2px); box-shadow: var(--color-shadow); }
|
||
.stat-card { transition: transform 0.2s; }
|
||
.stat-card:hover { transform: translateY(-2px); }
|
||
|
||
/* 弹窗/抽屉关闭键交互 */
|
||
.drawer-close { transition: color 0.15s, transform 0.15s; }
|
||
.drawer-close:hover { color: var(--color-text); transform: scale(1.12); }
|
||
|
||
/* 链接式按钮下划线 */
|
||
.btn-link:hover { text-decoration: underline; text-underline-offset: 2px; }
|
||
|
||
/* KTV 已唱文字微光晕(呼应主题色) */
|
||
.lyric-line.ktv.active.kw-line .kw.on { text-shadow: 0 0 14px var(--color-primary-bg); }
|
||
|
||
/* ============ 个人中心 / 管理后台 ============ */
|
||
.uc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
||
.uc-card {
|
||
border: 1px solid var(--color-border-split); border-radius: 12px;
|
||
padding: 16px; background: var(--color-bg-subtle);
|
||
}
|
||
.uc-card-title { font-size: 12px; color: var(--color-text-tertiary); margin-bottom: 6px; }
|
||
.uc-card-value { font-size: 17px; font-weight: 600; word-break: break-all; }
|
||
/* .uc-card-value.danger 死规则已删 */
|
||
.uc-section { margin-top: 16px; }
|
||
.admin-layout { display: flex; gap: 16px; align-items: flex-start; }
|
||
.admin-side {
|
||
flex: none; width: 150px; display: flex; flex-direction: column; gap: 4px;
|
||
border: 1px solid var(--color-border-split); border-radius: 12px; padding: 8px;
|
||
background: var(--color-bg-subtle); position: sticky; top: 80px;
|
||
}
|
||
.admin-side-item {
|
||
padding: 9px 12px; border-radius: 8px; font-size: 13px; cursor: pointer;
|
||
color: var(--color-text-secondary); transition: all 0.15s; white-space: nowrap;
|
||
}
|
||
.admin-side-item:hover { background: var(--color-fill); color: var(--color-text); }
|
||
.admin-side-item.active { background: var(--color-primary-bg); color: var(--color-primary); font-weight: 600; }
|
||
.admin-side-sep { height: 1px; background: var(--color-border-split); margin: 6px 4px; }
|
||
.admin-main { flex: 1; min-width: 0; }
|
||
.admin-view { display: none; }
|
||
.admin-view.active { display: block; }
|
||
.admin-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||
.admin-toolbar .input, .admin-toolbar .select { width: auto; }
|
||
.site-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.site-table th, .site-table td {
|
||
text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--color-border-split);
|
||
word-break: break-all;
|
||
}
|
||
.site-table th { color: var(--color-text-tertiary); font-weight: 500; font-size: 12px; white-space: nowrap; }
|
||
.site-table tr:hover td { background: var(--color-fill); }
|
||
.tag-pill {
|
||
display: inline-block; padding: 2px 8px; border-radius: 20px;
|
||
font-size: 12px; border: 1px solid var(--color-border); color: var(--color-text-secondary);
|
||
}
|
||
.tag-pill.ok { border-color: var(--color-success); color: var(--color-success); }
|
||
.log-list { max-height: 420px; overflow-y: auto; } /* ua-log-list(管理后台日志页)在用,复核保留 */
|
||
/* .tag-pill.used/.log-item/.log-item .log-time 三条死规则已删
|
||
(管理后台日志走 site-table 渲染,log-item 类从未产生) */
|
||
.code-box {
|
||
background: var(--color-fill); border-radius: 8px; padding: 10px 12px;
|
||
font-family: monospace; font-size: 13px; word-break: break-all;
|
||
max-height: 180px; overflow-y: auto; line-height: 1.6;
|
||
}
|
||
.announce-body { white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: var(--color-text); }
|
||
@media (max-width: 720px) {
|
||
.admin-layout { flex-direction: column; }
|
||
.admin-side { width: 100%; flex-direction: row; flex-wrap: wrap; position: static; }
|
||
.admin-side-sep { display: none; }
|
||
.app-header-inner .brand-sub { display: none; }
|
||
.icon-btn span { display: none; }
|
||
/* 窄屏顶栏防挤压:标题不折行,次数 pill 收进个人中心(避免竖排破版) */
|
||
.brand-title { white-space: nowrap; font-size: 16px; }
|
||
#auth-duration-pill { display: none; }
|
||
.icon-btn { padding: 0 10px; }
|
||
.app-header-inner { padding: 0 16px; }
|
||
}
|
||
|
||
/* ============ Toast ============ */
|
||
.toast-wrap {
|
||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||
z-index: 1000; display: flex; flex-direction: column; gap: 10px; align-items: center;
|
||
pointer-events: none;
|
||
}
|
||
.toast {
|
||
padding: 9px 16px;
|
||
border-radius: 8px;
|
||
background: var(--color-bg-elevated);
|
||
color: var(--color-text);
|
||
box-shadow: var(--color-shadow-heavy);
|
||
font-size: 14px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
transition: all 0.25s;
|
||
pointer-events: auto;
|
||
max-width: 90vw;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateY(0); }
|
||
.toast-success { border-left: 3px solid var(--color-success); }
|
||
.toast-error { border-left: 3px solid var(--color-error); }
|
||
.toast-warning { border-left: 3px solid var(--color-warning); }
|
||
.toast-info { border-left: 3px solid var(--color-primary); }
|
||
|
||
/* ============ 空状态 / 加载 ============ */
|
||
.empty-state {
|
||
text-align: center; color: var(--color-text-tertiary);
|
||
padding: 48px 0; font-size: 14px;
|
||
}
|
||
.empty-state .empty-icon { font-size: 42px; margin-bottom: 8px; opacity: 0.4; }
|
||
|
||
.page-loading {
|
||
position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: var(--color-bg-layout);
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
gap: 16px; z-index: 999;
|
||
}
|
||
.page-loading .ring {
|
||
width: 40px; height: 40px;
|
||
border: 3px solid var(--color-border);
|
||
border-top-color: var(--color-primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
.page-loading span { color: var(--color-text-secondary); font-size: 14px; }
|
||
|
||
/* ============ 页脚 ============ */
|
||
.footer {
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
[hidden] { display: none !important; }
|
||
|
||
/* 登录弹窗找回密码视图模式——隐藏登录字段只留找回表单 */
|
||
.auth-form.forgot-mode > *:not(#forgot-view) { display: none; }
|
||
|
||
/* 登录弹窗管理员两步验证视图模式——隐藏登录字段只留 2FA 表单 */
|
||
.auth-form.admin2fa-mode > *:not(#admin2fa-view) { display: none; }
|
||
|
||
@media (max-width: 768px) {
|
||
.main-container { padding: 16px; }
|
||
.card { padding: 16px; }
|
||
.song-detail-cover { width: 100%; height: auto; aspect-ratio: 1; }
|
||
.volume-wrap { display: none; }
|
||
.header-actions .brand-sub { display: none; }
|
||
.toplist-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||
}
|
||
/* 实时排队 / 预计等待 悬浮框 */
|
||
.queue-float {
|
||
position: fixed; right: 18px; bottom: 18px; z-index: 9999;
|
||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||
min-width: 98px; padding: 10px 14px; border-radius: 14px;
|
||
background: var(--color-bg-elevated); color: var(--color-text);
|
||
box-shadow: var(--color-shadow-heavy); border: 1px solid var(--color-border-split);
|
||
font-size: 12px; cursor: default; user-select: none; opacity: .92; transition: opacity .3s;
|
||
}
|
||
.queue-float:hover { opacity: 1; }
|
||
.queue-float-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); }
|
||
.queue-float-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); }
|
||
.queue-float.busy .queue-float-dot { background: var(--color-error); animation: queue-pulse 1.2s infinite; }
|
||
@keyframes queue-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.5); }
|
||
50% { box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); }
|
||
}
|
||
.queue-float-num { font-size: 24px; font-weight: 700; line-height: 1.15; }
|
||
.queue-float-sub { font-size: 11px; color: var(--color-text-tertiary); white-space: nowrap; }
|
||
/* 下载任务悬浮窗(批量打包 + 单曲下载,可取消) */
|
||
.task-panel {
|
||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||
z-index: 9999; width: 340px; max-width: calc(100vw - 32px);
|
||
max-height: 300px; display: none; flex-direction: column;
|
||
background: var(--color-bg-elevated); color: var(--color-text);
|
||
box-shadow: var(--color-shadow-heavy); border: 1px solid var(--color-border-split);
|
||
border-radius: 14px; overflow: hidden;
|
||
}
|
||
.task-panel.show { display: flex; }
|
||
.task-panel-head {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 10px 14px; font-size: 13px; font-weight: 600;
|
||
border-bottom: 1px solid var(--color-border-split); flex-shrink: 0;
|
||
}
|
||
.tp-count { color: var(--color-primary); font-weight: 700; }
|
||
.tp-close { border: none; background: none; cursor: pointer; color: var(--color-text-secondary); font-size: 14px; padding: 2px 6px; }
|
||
.tp-close:hover { color: var(--color-text); }
|
||
.task-panel-list { overflow-y: auto; padding: 8px 12px; }
|
||
.task-item { padding: 8px 0; border-bottom: 1px solid var(--color-border-split); }
|
||
.task-item:last-child { border-bottom: none; }
|
||
.task-item-top { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||
.task-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.task-item-status { color: var(--color-text-secondary); flex-shrink: 0; }
|
||
.task-item-cancel {
|
||
flex-shrink: 0; border: 1px solid var(--color-border); background: none; cursor: pointer;
|
||
color: var(--color-error); font-size: 11px; padding: 2px 8px; border-radius: 6px;
|
||
}
|
||
.task-item-cancel:hover { background: var(--color-error); color: #fff; }
|
||
/* 已完成任务的重领/移除按钮(服务端文件保留 35 分钟内可重复领取) */
|
||
.task-item-claim {
|
||
flex-shrink: 0; border: 1px solid var(--color-primary); background: none; cursor: pointer;
|
||
color: var(--color-primary); font-size: 11px; padding: 2px 8px; border-radius: 6px;
|
||
}
|
||
.task-item-claim:hover { background: var(--color-primary); color: #fff; }
|
||
.task-item-dismiss {
|
||
flex-shrink: 0; border: none; background: none; cursor: pointer;
|
||
color: var(--color-text-tertiary); font-size: 13px; padding: 2px 4px; border-radius: 6px;
|
||
line-height: 1;
|
||
}
|
||
.task-item-dismiss:hover { color: var(--color-text-secondary); background: var(--color-fill); }
|
||
.task-item-bar { height: 6px; border-radius: 3px; background: var(--color-fill); overflow: hidden; margin-top: 6px; }
|
||
.task-item-bar > div { height: 100%; width: 0; border-radius: 3px; background: var(--color-primary); transition: width .3s; }
|
||
.task-item.ended .task-item-bar { display: none; }
|
||
|
||
/* ============================================================
|
||
美化层 v4(2026-09-17 全面高级感打磨)
|
||
原则:全部为叠加式覆盖,不修改上方任何既有规则;只动观感与过渡,
|
||
不改变任何布局结构 / 功能行为。后续视觉微调优先改本段。
|
||
============================================================ */
|
||
|
||
/* ---------- 全局基调:柔和背景氛围 + 字体细节 ---------- */
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||
'MiSans', 'HarmonyOS Sans SC', 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
background-color: var(--color-bg-layout);
|
||
background-image:
|
||
radial-gradient(1100px 480px at 12% -6%, rgba(100, 116, 139, 0.055), transparent 62%),
|
||
radial-gradient(900px 420px at 105% -2%, rgba(100, 116, 139, 0.035), transparent 58%);
|
||
background-repeat: no-repeat;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
html.dark body {
|
||
background-image:
|
||
radial-gradient(1100px 480px at 12% -6%, rgba(148, 163, 184, 0.06), transparent 62%),
|
||
radial-gradient(900px 420px at 105% -2%, rgba(148, 163, 184, 0.04), transparent 58%);
|
||
}
|
||
a { transition: color 0.15s; }
|
||
.detail-name, .collection-name, .about-title, .drawer-title, .modal-title { letter-spacing: 0.3px; }
|
||
[id$='-page-info'], .tp-count, .queue-float-num, .player-time { font-variant-numeric: tabular-nums; }
|
||
code {
|
||
background: var(--color-fill); border-radius: 5px; padding: 1px 6px;
|
||
font-size: 0.92em; font-family: ui-monospace, SFMono-Regular, 'Cascadia Mono', Consolas, monospace;
|
||
}
|
||
img[src=''] { opacity: 0; } /* 封面未加载时空 src 不显示破图 */
|
||
|
||
/* ---------- 顶栏:滚动感知阴影 + 品牌微动效 ---------- */
|
||
.app-header {
|
||
border-bottom: 1px solid transparent;
|
||
transition: box-shadow 0.3s, background 0.3s, border-color 0.3s;
|
||
}
|
||
.app-header.scrolled {
|
||
border-bottom-color: var(--color-border-split);
|
||
box-shadow: 0 8px 24px -12px rgba(15, 23, 42, 0.14);
|
||
}
|
||
html.dark .app-header.scrolled { box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.6); }
|
||
.brand-logo { transition: transform 0.3s var(--ease-out); }
|
||
.brand:hover .brand-logo { transform: rotate(-10deg) scale(1.08); }
|
||
.brand-title { letter-spacing: 0.5px; }
|
||
.icon-btn { border-radius: 999px; }
|
||
.icon-btn:not(:disabled):active { transform: scale(0.96); }
|
||
|
||
/* ---------- 卡片与区块标题 ---------- */
|
||
.card {
|
||
border: 1px solid var(--color-border-split);
|
||
border-radius: 14px;
|
||
animation: cardIn 0.3s var(--ease-out);
|
||
}
|
||
@keyframes cardIn {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.card-title { position: relative; padding-left: 13px; }
|
||
.card-title::before {
|
||
content: ''; position: absolute; left: 0; top: 2px; bottom: 2px;
|
||
width: 4px; border-radius: 2px;
|
||
background: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary));
|
||
}
|
||
.main-container { padding-bottom: 40px; }
|
||
|
||
/* ---------- Tab 字感 ---------- */
|
||
.tab { letter-spacing: 0.3px; }
|
||
.tab.active { font-weight: 600; }
|
||
|
||
/* ---------- 按钮:高光内阴影 / 渐变色钮 / 列表内稳定化 ---------- */
|
||
.btn { border-radius: 9px; }
|
||
.btn-sm { border-radius: 8px; }
|
||
.btn-primary { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 1px 2px rgba(15, 23, 42, 0.10); }
|
||
.btn-primary:not(:disabled):hover {
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 4px 14px var(--color-primary-bg);
|
||
}
|
||
.btn-success { background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); }
|
||
.btn-success:not(:disabled):hover {
|
||
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%);
|
||
box-shadow: 0 4px 14px rgba(82, 196, 26, 0.25);
|
||
}
|
||
.btn-danger { background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); }
|
||
.btn-danger:not(:disabled):hover {
|
||
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%);
|
||
box-shadow: 0 4px 14px rgba(255, 77, 79, 0.25);
|
||
}
|
||
/* 表格 / 歌曲行内的动作小按钮:不做抬升,避免列表观感跳动 */
|
||
.site-table .btn:not(:disabled):hover,
|
||
.song-actions .btn:not(:disabled):hover,
|
||
.task-item .btn:not(:disabled):hover { transform: none; box-shadow: none; }
|
||
|
||
/* ---------- 表单 ---------- */
|
||
.input, .select { border-radius: 9px; }
|
||
.input { caret-color: var(--color-primary); }
|
||
.auth-form .input { height: 38px; border-radius: 10px; }
|
||
.auth-trial-box { background: var(--color-primary-bg); border-color: transparent; border-radius: 10px; }
|
||
|
||
/* ---------- 歌曲行:级联入场 + 细节 ---------- */
|
||
.song-row { animation: rowIn 0.3s var(--ease-out) both; }
|
||
.song-row:nth-child(2) { animation-delay: 0.02s; }
|
||
.song-row:nth-child(3) { animation-delay: 0.04s; }
|
||
.song-row:nth-child(4) { animation-delay: 0.06s; }
|
||
.song-row:nth-child(5) { animation-delay: 0.08s; }
|
||
.song-row:nth-child(6) { animation-delay: 0.10s; }
|
||
.song-row:nth-child(7) { animation-delay: 0.12s; }
|
||
.song-row:nth-child(8) { animation-delay: 0.14s; }
|
||
.song-row:nth-child(9) { animation-delay: 0.16s; }
|
||
.song-row:nth-child(10) { animation-delay: 0.18s; }
|
||
@keyframes rowIn {
|
||
from { opacity: 0; transform: translateY(5px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.song-row { border-radius: 12px; }
|
||
.song-row:hover { background: linear-gradient(0deg, var(--color-bg-hover), var(--color-bg-hover)); }
|
||
.song-row > .song-meta:first-child {
|
||
color: var(--color-text-tertiary); font-size: 12px;
|
||
font-variant-numeric: tabular-nums; padding-top: 2px;
|
||
}
|
||
.song-cover { border-radius: 10px; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12); }
|
||
html.dark .song-cover { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); }
|
||
.song-name .tag {
|
||
border-color: transparent; background: var(--color-fill);
|
||
color: var(--color-text-secondary); font-weight: 400;
|
||
}
|
||
.song-row:hover .song-name .tag { background: var(--color-bg-container); }
|
||
|
||
/* ---------- 播放器:播放态光晕 / 渐变播放键 ---------- */
|
||
.player, .lyric-panel, .comment-panel { animation: rowIn 0.3s var(--ease-out); }
|
||
.player {
|
||
border-radius: 16px;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--color-shadow-heavy);
|
||
}
|
||
.player-cover { border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); }
|
||
#player-toggle {
|
||
background: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary));
|
||
box-shadow: 0 2px 10px var(--color-primary-bg);
|
||
transition: background 0.2s, transform 0.1s, box-shadow 0.3s;
|
||
}
|
||
#player-toggle:hover { background: linear-gradient(180deg, var(--color-primary), var(--color-primary-active)); }
|
||
.player.playing #player-toggle {
|
||
box-shadow: 0 0 0 5px var(--color-primary-bg), 0 2px 10px var(--color-primary-bg);
|
||
}
|
||
.progress-fill { background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover)); }
|
||
|
||
/* ---------- 歌词:边缘渐隐 + 滚动不穿透 ---------- */
|
||
.lyric-box {
|
||
overscroll-behavior: contain;
|
||
overflow-x: hidden; /* 激活行 scale(1.02) 的横向溢出不再拉出横向滚动条 */
|
||
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 20px), transparent 100%);
|
||
mask-image: linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 20px), transparent 100%);
|
||
}
|
||
|
||
/* ---------- Tab 栏:隐藏横向滚动条(保留滚动能力) ---------- */
|
||
.tabs { scrollbar-width: none; }
|
||
.tabs::-webkit-scrollbar { display: none; }
|
||
|
||
/* ---------- 评论 ---------- */
|
||
.comment-item { animation: rowIn 0.28s var(--ease-out) both; }
|
||
.comment-avatar { box-shadow: 0 0 0 1px var(--color-border-split); }
|
||
|
||
/* ---------- 榜单/播客卡片墙:悬浮阴影 + 封面缩放 + 级联入场 ---------- */
|
||
.toplist-card { border-radius: 14px; animation: rowIn 0.35s var(--ease-out) both; }
|
||
.toplist-card:nth-child(2) { animation-delay: 0.03s; }
|
||
.toplist-card:nth-child(3) { animation-delay: 0.06s; }
|
||
.toplist-card:nth-child(4) { animation-delay: 0.09s; }
|
||
.toplist-card:nth-child(5) { animation-delay: 0.12s; }
|
||
.toplist-card:nth-child(6) { animation-delay: 0.15s; }
|
||
.toplist-card:nth-child(7) { animation-delay: 0.18s; }
|
||
.toplist-card:nth-child(8) { animation-delay: 0.21s; }
|
||
.toplist-card:hover { box-shadow: 0 10px 24px -10px rgba(15, 23, 42, 0.18); }
|
||
html.dark .toplist-card:hover { box-shadow: 0 10px 24px -10px rgba(0, 0, 0, 0.6); }
|
||
.toplist-card .tl-cover { transition: transform 0.35s var(--ease-out); }
|
||
.toplist-card:hover .tl-cover { transform: scale(1.04); }
|
||
.toplist-card .tl-name { letter-spacing: 0.2px; }
|
||
|
||
/* ---------- 弹窗 / 抽屉 ---------- */
|
||
.modal { border-radius: 16px; border: 1px solid var(--color-border-split); }
|
||
.modal-header { padding: 18px 22px; }
|
||
.modal-body { padding: 14px 22px 22px; }
|
||
.modal-mask.show { animation: maskIn 0.2s ease; }
|
||
@keyframes maskIn { from { opacity: 0; } to { opacity: 1; } }
|
||
.modal-body, .drawer-body, .task-panel-list, .log-list { overscroll-behavior: contain; }
|
||
.drawer { border-left: 1px solid var(--color-border-split); border-radius: 16px 0 0 16px; }
|
||
|
||
/* ---------- Toast:类型着色 + 玻璃质感 ---------- */
|
||
.toast { border-radius: 10px; }
|
||
.toast { background: rgba(255, 255, 255, 0.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||
html.dark .toast { background: rgba(31, 31, 31, 0.88); }
|
||
.toast-success { background-image: linear-gradient(0deg, rgba(82, 196, 26, 0.08), rgba(82, 196, 26, 0.08)); }
|
||
.toast-error { background-image: linear-gradient(0deg, rgba(255, 77, 79, 0.08), rgba(255, 77, 79, 0.08)); }
|
||
.toast-warning { background-image: linear-gradient(0deg, rgba(250, 173, 20, 0.10), rgba(250, 173, 20, 0.10)); }
|
||
.toast-info { background-image: linear-gradient(0deg, rgba(22, 119, 255, 0.07), rgba(22, 119, 255, 0.07)); }
|
||
|
||
/* ---------- 空状态:图标轻浮动 ---------- */
|
||
.empty-state .empty-icon { display: inline-block; animation: floatY 3s ease-in-out infinite alternate; }
|
||
@keyframes floatY { from { transform: translateY(0); } to { transform: translateY(-6px); } }
|
||
|
||
/* ---------- 悬浮窗(排队 / 下载任务) ---------- */
|
||
.task-panel.show { animation: taskIn 0.28s var(--ease-out); }
|
||
@keyframes taskIn {
|
||
from { opacity: 0; transform: translate(-50%, 14px); }
|
||
to { opacity: 1; transform: translate(-50%, 0); }
|
||
}
|
||
.task-item-bar > div { background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover)); }
|
||
.queue-float {
|
||
background: rgba(255, 255, 255, 0.85);
|
||
backdrop-filter: blur(10px) saturate(1.5);
|
||
-webkit-backdrop-filter: blur(10px) saturate(1.5);
|
||
}
|
||
html.dark .queue-float { background: rgba(31, 31, 31, 0.85); }
|
||
|
||
/* ---------- 管理后台表格 / 侧栏 / 统计卡 ---------- */
|
||
.site-table th { border-bottom: 1px solid var(--color-border); padding-bottom: 10px; letter-spacing: 0.3px; }
|
||
.site-table tbody tr:nth-child(even) td { background: rgba(15, 23, 42, 0.014); }
|
||
html.dark .site-table tbody tr:nth-child(even) td { background: rgba(255, 255, 255, 0.025); }
|
||
.site-table tbody tr:hover td { background: var(--color-fill); }
|
||
.admin-side-item { border-radius: 9px; }
|
||
.admin-side-item.active { box-shadow: inset 0 0 0 1px var(--color-primary-bg); }
|
||
.stat-card { border-radius: 12px; background: var(--color-bg-subtle); }
|
||
.stat-card .stat-num { font-variant-numeric: tabular-nums; }
|
||
.code-box { border: 1px solid var(--color-border-split); font-size: 12.5px; }
|
||
|
||
/* ---------- 杂项细节 ---------- */
|
||
audio { border-radius: 10px; }
|
||
.version-item { border-radius: 8px; padding: 12px 10px; margin: 0 -10px; transition: background 0.15s; }
|
||
.version-item:hover { background: var(--color-bg-hover); }
|
||
.about-card:hover { border-color: var(--color-primary-bg); }
|
||
.user-avatar { box-shadow: 0 2px 8px rgba(15, 23, 42, 0.15); }
|
||
.user-avatar:not(.admin) { background: linear-gradient(135deg, var(--color-primary), var(--color-primary-active)); }
|
||
.podcast-cate-item.active {
|
||
background-image: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary));
|
||
box-shadow: 0 2px 8px var(--color-primary-bg);
|
||
}
|
||
.switch:active::after { transform: scaleX(1.18); }
|
||
.history-row .tag { border-radius: 999px; }
|
||
|
||
/* ============================================================
|
||
美化层 v5(2026-09-17 多端响应式适配 + 触屏体验打磨)
|
||
覆盖:小屏手机(≤400) / 常规手机(≤480) / 平板竖屏(481-768) /
|
||
平板横屏(769-1180) / 大屏桌面(≥1680) / 手机横屏矮视口 / 刘海屏安全区。
|
||
同样为叠加式覆盖,不修改上方任何既有规则。
|
||
============================================================ */
|
||
|
||
/* ---------- 触屏与移动基础 ---------- */
|
||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||
.btn, .icon-btn, .tab, .player-ctrl, .admin-side-item, .podcast-cate-item, .color-dot, .mode-seg .seg-item, .switch {
|
||
touch-action: manipulation; /* 消除老设备双击缩放延迟 */
|
||
}
|
||
|
||
/* 触屏设备(无真实悬浮):关闭悬浮抬升,避免"粘滞悬浮" */
|
||
@media (hover: none) {
|
||
.btn:not(:disabled):hover { transform: none; box-shadow: none; }
|
||
.btn-primary:not(:disabled):hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 1px 2px rgba(15, 23, 42, 0.10); }
|
||
.song-row:hover .song-cover { transform: none; }
|
||
.toplist-card:hover { transform: none; box-shadow: none; }
|
||
.toplist-card:hover .tl-cover { transform: none; }
|
||
.song-detail-cover:hover, .collection-cover:hover { transform: none; }
|
||
.uc-card:hover, .stat-card:hover, .about-card:hover { transform: none; }
|
||
.user-avatar:hover { transform: none; }
|
||
.color-dot:hover { transform: none; }
|
||
.brand:hover .brand-logo { transform: none; }
|
||
}
|
||
|
||
/* 粗指针设备:加大触控目标 */
|
||
@media (pointer: coarse) {
|
||
.btn-sm { min-height: 34px; }
|
||
.icon-btn { min-height: 38px; min-width: 38px; }
|
||
.btn-mini { height: 34px; min-height: 34px; padding: 0 10px; } /* 触屏统一 34px 触控标准 */
|
||
.player-ctrl-sm { width: 36px; height: 36px; }
|
||
.lyric-line { padding: 8px 10px; }
|
||
.history-row .tag { padding: 4px 10px; }
|
||
.tp-close { padding: 6px 10px; }
|
||
.task-item-cancel { padding: 4px 10px; }
|
||
}
|
||
|
||
/* ---------- 小屏手机(≤400px,iPhone SE / 小屏安卓) ---------- */
|
||
@media (max-width: 400px) {
|
||
/* 开放邮箱注册后游客顶栏多出「注册」按钮,小屏压缩防横向溢出:
|
||
注册/登录按钮缩 padding,图标按钮缩 padding 并收紧间距/标题字号 */
|
||
#btn-open-register { padding: 0 8px; font-size: 12px; }
|
||
#btn-open-login { padding: 0 10px; }
|
||
.icon-btn { padding: 0 6px; }
|
||
.header-actions { gap: 4px; }
|
||
.brand { gap: 6px; }
|
||
.brand-title { font-size: 14px; }
|
||
.app-header-inner { padding: 0 12px; }
|
||
|
||
.main-container { padding: 12px; }
|
||
.card { padding: 14px; border-radius: 12px; }
|
||
.tabs { margin-bottom: 14px; }
|
||
.tabs .tab { margin-right: 18px; font-size: 14px; }
|
||
.toplist-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.about-grid { grid-template-columns: 1fr; }
|
||
.song-row { gap: 10px; padding: 10px 6px; }
|
||
.song-actions { gap: 6px; }
|
||
.detail-name { font-size: 19px; }
|
||
.collection-name { font-size: 18px; }
|
||
.hint { font-size: 12.5px; }
|
||
}
|
||
|
||
/* ---------- 常规手机(≤480px) ---------- */
|
||
@media (max-width: 480px) {
|
||
/* iOS 聚焦防自动缩放:输入类控件字号 ≥16px */
|
||
.input, .select { font-size: 16px; }
|
||
.collection-cover { width: 120px; height: 120px; }
|
||
.song-detail { gap: 16px; }
|
||
.collection-header { gap: 14px; }
|
||
.player { padding: 12px 14px; }
|
||
.player-cover { width: 48px; height: 48px; }
|
||
#player-toggle { width: 40px; height: 40px; font-size: 15px; }
|
||
.lyric-box { max-height: 230px; max-height: min(calc(230px * var(--lyric-fs, 1)), 345px); }
|
||
.comment-avatar { width: 30px; height: 30px; }
|
||
.comment-item { gap: 10px; }
|
||
.empty-state { padding: 36px 0; }
|
||
.stat-card .stat-num { font-size: 18px; }
|
||
/* 弹窗全宽化 + 内边距收紧(width:100% 相对遮罩内容盒,避免 100vw 把滚动条宽度算进去) */
|
||
.modal-mask { padding: 12px; }
|
||
.modal { width: 100% !important; max-width: 100% !important; max-height: 86vh; }
|
||
.modal-header { padding: 14px 16px; }
|
||
.modal-body { padding: 12px 16px 16px; }
|
||
.drawer { width: 100%; max-width: 100vw; }
|
||
/* 管理后台表格在窄屏更紧凑 */
|
||
.site-table { font-size: 12px; }
|
||
.site-table th, .site-table td { padding: 8px 8px; }
|
||
/* 固定元素避让刘海屏安全区 */
|
||
.toast-wrap { top: calc(10px + env(safe-area-inset-top, 0px)); width: calc(100vw - 24px); }
|
||
.toast { font-size: 13px; }
|
||
.queue-float {
|
||
right: calc(10px + env(safe-area-inset-right, 0px));
|
||
bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||
min-width: 84px; padding: 8px 12px;
|
||
}
|
||
.queue-float-num { font-size: 20px; }
|
||
.task-panel { bottom: calc(12px + env(safe-area-inset-bottom, 0px)); width: calc(100vw - 20px); }
|
||
.drawer-footer { padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); }
|
||
}
|
||
|
||
/* ---------- 大屏手机 / 平板竖屏(481–768px) ---------- */
|
||
@media (min-width: 481px) and (max-width: 768px) {
|
||
.collection-cover { width: 140px; height: 140px; }
|
||
.toplist-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||
}
|
||
|
||
/* ---------- 平板横屏 / 小桌面(769–1180px) ---------- */
|
||
@media (min-width: 769px) and (max-width: 1180px) {
|
||
.main-container { padding: 20px; }
|
||
.admin-side { width: 136px; }
|
||
}
|
||
|
||
/* ---------- 大屏桌面(≥1680px):内容区适度加宽 ---------- */
|
||
@media (min-width: 1680px) {
|
||
.app-header-inner, .main-container { max-width: 1360px; }
|
||
}
|
||
|
||
/* ---------- 手机横屏(矮视口):压缩纵向占用 ---------- */
|
||
@media (max-height: 520px) and (orientation: landscape) {
|
||
.app-header-inner { height: 52px; }
|
||
.brand-logo { width: 30px; height: 30px; font-size: 16px; }
|
||
.brand-sub { display: none; }
|
||
.main-container { padding: 12px 16px; }
|
||
.lyric-box { max-height: 180px; } /* 横屏矮视口:垂直空间稀缺,不随字号放大 */
|
||
/* 弹窗横屏 92dvh 已移至 v6 视口块之后(修复同特异性级联覆盖) */
|
||
}
|
||
|
||
/* ============================================================
|
||
美化层 v6(2026-09-17 细节质感 + 移动端体验修复,第三轮)
|
||
============================================================ */
|
||
|
||
/* ---------- iOS 动态视口(地址栏收缩时 100vh 偏大) ----------
|
||
此块必须位于 v5 横屏 92dvh 规则之前——同特异性按源序
|
||
后者胜,v6 初版写在横屏块之后曾把"横屏 92dvh"静默覆盖成 80dvh */
|
||
body { min-height: 100vh; min-height: 100dvh; }
|
||
.modal { max-height: 80vh; max-height: 80dvh; }
|
||
@media (max-width: 480px) { .modal { max-height: 86vh; max-height: 86dvh; } } /* 恢复 v5 手机端高度 */
|
||
@media (max-height: 520px) and (orientation: landscape) { .modal { max-height: 92vh; max-height: 92dvh; } } /* 横屏最高(移到 80dvh 之后修复级联覆盖) */
|
||
|
||
/* ---------- 播放中:歌名旁均衡器律动(纯伪元素,零 DOM) ---------- */
|
||
.player.playing .player-name::before,
|
||
.player.playing .player-name::after {
|
||
content: ''; display: inline-block;
|
||
width: 3px; height: 4px; border-radius: 1px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
margin: 0 0 0 4px; vertical-align: middle;
|
||
animation: eqBar 0.9s ease-in-out infinite;
|
||
}
|
||
.player.playing .player-name::after {
|
||
animation-delay: 0.3s;
|
||
box-shadow: 5px 0 0 -0.5px rgba(255, 255, 255, 0.5); /* 第三根柱随动 */
|
||
margin-right: 8px;
|
||
}
|
||
.player.playing .player-name::before { animation-delay: 0.15s; }
|
||
@keyframes eqBar { 0%, 100% { height: 4px; } 50% { height: 13px; } }
|
||
|
||
/* ---------- 下拉框:统一自制箭头(替换各平台原生样式) ---------- */
|
||
.select {
|
||
appearance: none; -webkit-appearance: none;
|
||
padding-right: 30px;
|
||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23999999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 10px center;
|
||
}
|
||
html.dark .select {
|
||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23aaaaaa' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||
}
|
||
|
||
/* ---------- 区块计数:浅底胶囊(空计数自动隐藏) ---------- */
|
||
.card-title .count {
|
||
background: var(--color-fill); border-radius: 999px;
|
||
padding: 1px 10px; font-variant-numeric: tabular-nums;
|
||
}
|
||
.card-title .count:empty { display: none; }
|
||
|
||
/* ---------- 音量条悬浮加粗(与进度条一致) ---------- */
|
||
.volume-slider { transition: height 0.15s; }
|
||
.volume-slider:hover { height: 6px; }
|
||
|
||
/* ---------- 扫码二维码:呼吸光晕引导注意 ---------- */
|
||
#admin-qr-img, #personal-qr-img, #ua-qr-box img {
|
||
border-radius: 14px;
|
||
animation: qrBreath 2.6s ease-in-out infinite;
|
||
}
|
||
@keyframes qrBreath {
|
||
0%, 100% { box-shadow: 0 0 0 0 var(--color-primary-bg), var(--color-shadow-heavy); }
|
||
50% { box-shadow: 0 0 0 12px rgba(0, 0, 0, 0), var(--color-shadow-heavy); }
|
||
}
|
||
|
||
/* ---------- 空状态图标光晕 / 统计数字渐变 ---------- */
|
||
.empty-state .empty-icon { filter: drop-shadow(0 6px 14px var(--color-primary-bg)); }
|
||
.stat-card .stat-num {
|
||
background: linear-gradient(135deg, var(--color-primary-hover), var(--color-primary));
|
||
-webkit-background-clip: text; background-clip: text;
|
||
-webkit-text-fill-color: transparent; color: transparent;
|
||
}
|
||
|
||
/* ---------- 交互控件统一缓动曲线 ---------- */
|
||
.btn, .icon-btn, .tab, .tag, .podcast-cate-item, .admin-side-item { transition-timing-function: var(--ease-out); }
|
||
|
||
/* ---------- 页脚避让全面屏底部安全区 ---------- */
|
||
body > footer { padding-bottom: calc(26px + env(safe-area-inset-bottom, 0px)) !important; }
|
||
|
||
/* ---------- 超宽桌面(≥2200px):内容区再放宽 ---------- */
|
||
@media (min-width: 2200px) {
|
||
.app-header-inner, .main-container { max-width: 1480px; }
|
||
}
|
||
|
||
/* ---------- 小屏手机:排队悬浮窗移到右上,避让底部下载任务面板 ---------- */
|
||
@media (max-width: 480px) {
|
||
.queue-float {
|
||
top: calc(74px + env(safe-area-inset-top, 0px));
|
||
bottom: auto;
|
||
min-width: 78px;
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
美化层 v7(2026-09-17 部署前最终打磨)
|
||
============================================================ */
|
||
|
||
/* ---------- 登录/注册弹窗:品牌头部 ---------- */
|
||
.auth-brand { text-align: center; padding: 2px 0 14px; }
|
||
.auth-brand-logo {
|
||
width: 52px; height: 52px; border-radius: 16px; margin: 0 auto 10px;
|
||
background: linear-gradient(135deg, #f5576c, #ec4141); color: #fff;
|
||
font-size: 26px; display: flex; align-items: center; justify-content: center;
|
||
box-shadow: 0 6px 16px -6px rgba(236, 65, 65, 0.5);
|
||
}
|
||
.auth-brand-name { font-size: 17px; font-weight: 600; letter-spacing: 0.5px; }
|
||
.auth-brand-sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; }
|
||
|
||
/* ---------- 页面加载动画:品牌双色旋转环 ---------- */
|
||
.page-loading .ring {
|
||
border: none;
|
||
background: conic-gradient(from 0deg, var(--color-primary) 0 25%, var(--color-border) 25% 100%);
|
||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
|
||
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
|
||
}
|
||
|
||
/* ---------- 更新日志:时间线样式(圆点 + 连线) ---------- */
|
||
.version-item { position: relative; padding-left: 18px; border-bottom: none !important; }
|
||
.version-item::before {
|
||
content: ''; position: absolute; left: 0; top: 16px;
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-bg);
|
||
}
|
||
.version-item::after {
|
||
content: ''; position: absolute; left: 3.5px; top: 30px; bottom: -12px;
|
||
width: 1px; background: var(--color-border-split);
|
||
}
|
||
.version-item:last-child::after { display: none; }
|
||
|
||
/* ---------- 管理后台视图切换淡入 / 列表行 hover ---------- */
|
||
.admin-view.active { animation: rowIn 0.22s var(--ease-out); }
|
||
.stat-list-item { border-radius: 8px; transition: background 0.15s; }
|
||
.stat-list-item:hover { background: var(--color-bg-hover); }
|
||
/* .log-item 两条死规则(含本层追加的 hover)已随基座一并删除 */
|
||
|
||
/* ---------- 防移动端误触下拉刷新 ---------- */
|
||
html { overscroll-behavior-y: none; }
|
||
|
||
/* ---------- 隐私协议:details 展开指示箭头 ---------- */
|
||
#cookie-modal summary { list-style: none; position: relative; padding-right: 24px; user-select: none; }
|
||
#cookie-modal summary::-webkit-details-marker { display: none; }
|
||
#cookie-modal summary::after {
|
||
content: ''; position: absolute; right: 4px; top: 50%;
|
||
width: 7px; height: 7px;
|
||
border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
|
||
transform: translateY(-70%) rotate(45deg);
|
||
transition: transform 0.2s var(--ease-out);
|
||
}
|
||
#cookie-modal details[open] summary::after { transform: translateY(-30%) rotate(225deg); }
|
||
|
||
/* ---------- 自动填充修正(Chrome 自动填充的土黄底在深色模式下很突兀) ---------- */
|
||
input.input:-webkit-autofill {
|
||
-webkit-box-shadow: 0 0 0 1000px var(--color-bg-container) inset;
|
||
-webkit-text-fill-color: var(--color-text);
|
||
transition: background-color 99999s ease-out;
|
||
}
|
||
|
||
/* ============================================================
|
||
美化层 v8(2026-09-17 部署前最后补充)
|
||
============================================================ */
|
||
|
||
/* ---------- 播放器进度条:缓冲预览层(JS 注入 --buffered) ---------- */
|
||
.progress::before {
|
||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||
width: var(--buffered, 0%);
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border-radius: 2px;
|
||
transition: width 0.4s ease;
|
||
pointer-events: none;
|
||
}
|
||
.progress:hover::before { border-radius: 3px; }
|
||
|
||
/* ============================================================
|
||
美化层 v9(2026-09-17 国内浏览器兼容加固)
|
||
目标:微信 XWeb/X5、QQ 内置、360/QQ/UC 桌面(Blink 老内核)、
|
||
iOS WKWebView 老版本。全部为降级兜底,不影响现代浏览器效果。
|
||
============================================================ */
|
||
|
||
/* ---------- inset 展开:Chromium <87 的老 X5 不识别 inset 简写 ---------- */
|
||
.drawer-mask, .modal-mask, .page-loading { top: 0; right: 0; bottom: 0; left: 0; }
|
||
.color-dot.active::after { top: 0; right: 0; bottom: 0; left: 0; }
|
||
|
||
/* ---------- aspect-ratio 兜底:Chromium <88 / iOS <15 封面给固定高 ---------- */
|
||
@supports not (aspect-ratio: 1) {
|
||
.toplist-card .tl-cover { height: 170px; }
|
||
@media (max-width: 768px) {
|
||
.song-detail-cover { height: auto; min-height: 200px; }
|
||
}
|
||
}
|
||
|
||
/* ---------- conic-gradient 兜底:页面加载环在 Chromium <69 恢复转圈边框方案 ---------- */
|
||
@supports not (background: conic-gradient(from 0deg, #fff 0%, #000 100%)) {
|
||
.page-loading .ring { border: 3px solid var(--color-border); border-top-color: var(--color-primary); background: none; }
|
||
}
|
||
|
||
/* ---------- backdrop-filter 兜底:完全不支持毛玻璃时改用不透明背景(避免透视叠影) ---------- */
|
||
@supports not ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
|
||
.app-header { background: var(--color-bg-container); }
|
||
.toast { background: var(--color-bg-elevated); }
|
||
.queue-float { background: var(--color-bg-elevated); }
|
||
}
|
||
|
||
/* ---------- 微信/QQ 内置浏览器引导条 ---------- */
|
||
/* 弹窗/抽屉打开时隐藏实时排队与任务面板浮窗(状态类挂在 <html> 上,避免观察器自反馈) */
|
||
html.overlay-open .queue-float,
|
||
html.overlay-open .task-panel { opacity: 0 !important; pointer-events: none !important; }
|
||
/* 字号弹层打开期间同样隐藏两浮窗(弹层贴顶时不再被排队悬浮窗压住)——
|
||
由 setLyricFsPop 直接挂/摘 html.fs-pop-open 类,不经 MutationObserver(活锁教训) */
|
||
html.fs-pop-open .queue-float,
|
||
html.fs-pop-open .task-panel { opacity: 0 !important; pointer-events: none !important; }
|
||
.webview-guide {
|
||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||
padding: 10px 16px; margin-bottom: 16px;
|
||
background: rgba(250, 173, 20, 0.10);
|
||
border: 1px solid rgba(250, 173, 20, 0.35);
|
||
border-radius: 12px;
|
||
font-size: 13px; color: var(--color-text-secondary);
|
||
animation: rowIn 0.3s var(--ease-out);
|
||
}
|
||
|
||
/* ---------- 兼容层 v9+(2026-09-19 补充):极老内核阻断提示 + 键盘焦点环 ---------- */
|
||
/* IE / 360 兼容模式等 Trident 内核不支持 CSS 变量,页面必然错乱 —— 用纯字面色值的阻断页告知 */
|
||
#legacy-browser-blocker {
|
||
display: none; position: fixed; top: 0; right: 0; bottom: 0; left: 0;
|
||
z-index: 99999; background: #1f2329; color: #e8eaed;
|
||
padding: 40px 28px; text-align: center;
|
||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||
}
|
||
#legacy-browser-blocker.show { display: block; }
|
||
#legacy-browser-blocker .lb-icon { font-size: 44px; margin-bottom: 16px; }
|
||
#legacy-browser-blocker h2 { font-size: 20px; margin: 0 0 14px; color: #fff; }
|
||
#legacy-browser-blocker p { font-size: 14px; line-height: 1.9; margin: 0 0 8px; opacity: .85; }
|
||
#legacy-browser-blocker .lb-list {
|
||
margin-top: 18px; font-size: 13px; line-height: 2; opacity: .7; text-align: left;
|
||
display: inline-block;
|
||
}
|
||
/* 键盘焦点环::focus-visible 仅 Chromium 86+;老内核由 JS 在 Tab 导航时给 html 挂
|
||
kbd-nav 类激活以下 :focus 规则(鼠标点击不显示,色值带字面量兜底) */
|
||
html.kbd-nav :focus { outline: 2px solid var(--color-primary); outline: 2px solid #4a7dff; outline-offset: 2px; border-radius: 4px; }
|
||
html.kbd-nav .btn:focus, html.kbd-nav .tab:focus { outline: none; box-shadow: 0 0 0 3px var(--color-primary-bg); outline: 2px solid #4a7dff; }
|
||
html.kbd-nav .input:focus, html.kbd-nav .select:focus { outline: 2px solid var(--color-primary); outline: 2px solid #4a7dff; outline-offset: 0; }
|
||
.webview-guide .wg-icon { font-size: 16px; flex: none; }
|
||
.webview-guide .wg-text b { color: var(--color-text); }
|
||
.webview-guide .wg-close {
|
||
margin-left: auto; flex: none; height: 26px; padding: 0 12px;
|
||
border: 1px solid rgba(250, 173, 20, 0.5); border-radius: 999px;
|
||
background: transparent; color: var(--color-text-secondary);
|
||
font-size: 12px; cursor: pointer; transition: all 0.2s;
|
||
}
|
||
.webview-guide .wg-close:hover { background: rgba(250, 173, 20, 0.15); color: var(--color-text); }
|
||
|
||
/* ============================================================
|
||
美化层 v10(2026-09-17 系统与极端环境覆盖)
|
||
============================================================ */
|
||
|
||
/* ---------- 超小屏(≤359px:手表浏览器 / 折叠屏外屏) ----------
|
||
v10 两批次叠加曾让 gap:3px/font-size:14px 被本块后写
|
||
(gap:6px/display:none)静默作废——已合并为最终生效值,消除改前不生效陷阱 */
|
||
@media (max-width: 359px) {
|
||
#btn-open-register { padding: 0 6px; }
|
||
#btn-open-login { padding: 0 8px; }
|
||
.icon-btn { padding: 0 4px; }
|
||
.header-actions { gap: 6px; }
|
||
|
||
.main-container { padding: 8px; }
|
||
.card { padding: 12px; border-radius: 10px; }
|
||
.tabs .tab { margin-right: 14px; font-size: 13px; }
|
||
.toplist-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.song-actions .btn-sm { padding: 0 8px; }
|
||
.detail-name { font-size: 17px; }
|
||
.collection-cover { width: 100px; height: 100px; }
|
||
.player { padding: 10px 12px; }
|
||
/* 空间寸土寸金:排队悬浮窗让位,核心下载任务面板保留 */
|
||
.queue-float { display: none; }
|
||
/* 顶栏必须塞进视口:其内容最小宽度(约368px)超出 320px 视口时,移动端内核会把整个
|
||
布局视口撑宽(实测 innerWidth 320→352),遮罩/弹窗/全部 width:100% 元素跟着越界。
|
||
处置:隐藏站名文字只留 logo、收紧内边距与按钮间距(v10 超小屏层既有取舍的延伸) */
|
||
.app-header-inner { padding: 0 10px; }
|
||
.brand-title { display: none; }
|
||
}
|
||
|
||
/* ---------- Windows 高对比度(forced-colors):保住进度指示 ---------- */
|
||
@media (forced-colors: active) {
|
||
.progress-fill, .task-item-bar > div, .progress::before { forced-color-adjust: none; }
|
||
.card, .uc-card, .admin-side, .modal, .drawer { forced-color-adjust: auto; }
|
||
}
|
||
|
||
/* ============================================================
|
||
美化层 v11(2026-09-27 品牌氛围 + Tab 药丸化)
|
||
延续 v4-v10 原则:叠加式覆盖,不动布局结构 / 类名 / JS 契约。
|
||
============================================================ */
|
||
|
||
/* ---------- 背景氛围:品牌红 + 主题蓝 双色辉光(替换 v4 灰调氛围) ---------- */
|
||
body {
|
||
background-image:
|
||
radial-gradient(1100px 480px at 10% -8%, rgba(236, 65, 65, 0.05), transparent 60%),
|
||
radial-gradient(950px 460px at 104% -4%, rgba(22, 119, 255, 0.055), transparent 58%);
|
||
}
|
||
html.dark body {
|
||
background-image:
|
||
radial-gradient(1100px 480px at 10% -8%, rgba(236, 65, 65, 0.07), transparent 60%),
|
||
radial-gradient(950px 460px at 104% -4%, rgba(22, 104, 220, 0.09), transparent 58%);
|
||
}
|
||
|
||
/* ---------- 卡片:顶部内高光,浅色主题更挺括 ---------- */
|
||
.card { box-shadow: var(--color-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.65); }
|
||
html.dark .card { box-shadow: var(--color-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); }
|
||
|
||
/* ---------- 账号栏与音质栏之间补一条分隔线(音质栏自带下边框,上下呼应) ---------- */
|
||
.account-bar { border-bottom: 1px solid var(--color-border-split); }
|
||
|
||
/* ---------- Tab 药丸化:下划线 → 软色块,hover 有底、active 有片 ----------
|
||
用 .tabs .tab 权重(0-2-0)压过基础层 .tab 与 v10 超小屏的同权重规则;
|
||
::after 下划线整体隐藏,active 态由药丸底色表达 */
|
||
.tabs .tab {
|
||
padding: 8px 16px;
|
||
margin-right: 6px;
|
||
border-radius: 999px;
|
||
transition: color 0.2s, background 0.2s, transform 0.15s;
|
||
}
|
||
.tabs .tab:hover { background: var(--color-fill); }
|
||
.tabs .tab:active { transform: scale(0.96); }
|
||
.tabs .tab.active { background: var(--color-primary-bg); }
|
||
.tabs .tab::after { display: none; }
|
||
|
||
/* ---------- 主按钮:与 success/danger 一致的顶部高光渐变 ---------- */
|
||
.btn-primary {
|
||
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%);
|
||
}
|
||
|
||
/* ---------- 空状态图标:v4 层已有 floatY 浮动,此处不再重复 ---------- */
|
||
|
||
/* ---------- 页脚字距 ---------- */
|
||
.footer { letter-spacing: 0.3px; }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 兼容层 v9+:IE/360 兼容模式等极老内核阻断提示(JS 检测后显示;现代浏览器不可见) -->
|
||
<div id="legacy-browser-blocker" role="alert">
|
||
<div class="lb-icon">⚠️</div>
|
||
<h2>当前浏览器内核过旧,无法运行云村小助手</h2>
|
||
<p>您可能正在使用 IE 内核浏览器,或浏览器的「兼容模式」。</p>
|
||
<p>本站基于现代浏览器技术构建,请切换或更换浏览器后访问:</p>
|
||
<div class="lb-list">
|
||
① 360 / QQ / 搜狗浏览器:点击地址栏旁的「闪电/极速模式」图标切换<br>
|
||
② Windows:使用自带 Microsoft Edge,或安装 Chrome / 360 极速版<br>
|
||
③ 安卓手机:使用系统自带浏览器、微信内置浏览器或 Chrome<br>
|
||
④ 苹果设备:Safari、微信内置浏览器均可正常使用
|
||
</div>
|
||
</div>
|
||
<!-- 页面加载动画 -->
|
||
<div class="page-loading" id="page-loading">
|
||
<div class="ring"></div>
|
||
<span>正在加载云村小助手...</span>
|
||
</div>
|
||
|
||
<!-- 顶部导航 -->
|
||
<header class="app-header">
|
||
<div class="app-header-inner">
|
||
<div class="brand" id="brand-home" title="返回主页面">
|
||
<div class="brand-logo">♪</div>
|
||
<div>
|
||
<div class="brand-title">云村小助手</div>
|
||
<div class="brand-sub">实时解析网易云音乐单曲、获取真实下载地址及封面信息</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<div class="auth-area" id="auth-guest-area">
|
||
<button class="btn btn-primary btn-sm" id="btn-open-login" type="button">登录</button>
|
||
<button class="btn btn-sm" id="btn-open-register" type="button">注册</button>
|
||
</div>
|
||
<div class="auth-area" id="auth-user-area" hidden>
|
||
<span class="tag-pill ok" id="auth-duration-pill" title="剩余次数"></span>
|
||
<div class="user-avatar" id="user-avatar" title="个人中心">U</div>
|
||
</div>
|
||
<button class="icon-btn" id="btn-settings" type="button">⚙️ <span>设置</span></button>
|
||
<button class="icon-btn" id="btn-changelog" type="button">📋 <span>更新日志</span></button>
|
||
<button class="icon-btn" id="theme-toggle" type="button">🌙 <span>深色</span></button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main-container">
|
||
<!-- 微信/QQ 内置浏览器引导(UA 检测后显示,JS 控制) -->
|
||
<div class="webview-guide" id="webview-guide" hidden>
|
||
<span class="wg-icon">💬</span>
|
||
<span class="wg-text">当前在微信 / QQ 内置浏览器中,<b>音乐下载可能受限</b>。建议点击右上角「···」→「在浏览器中打开」,获得完整体验。</span>
|
||
<button class="wg-close" id="webview-guide-close" type="button">知道了</button>
|
||
</div>
|
||
|
||
<!-- 功能卡片 -->
|
||
<div class="card">
|
||
<!-- 当前解析账号信息 -->
|
||
<div class="account-bar" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding-bottom:12px;">
|
||
<span class="gq-label">👤 解析账号</span>
|
||
<span class="badge" id="account-vip-badge" style="border-color:var(--color-text-tertiary);color:var(--color-text-secondary);">获取中...</span>
|
||
<span class="account-quality-list" id="account-qualities" style="font-size:12px;color:var(--color-text-secondary);"></span>
|
||
<button class="btn btn-sm btn-link" id="account-refresh" type="button" style="height:26px;">刷新</button>
|
||
</div>
|
||
<div class="hint" id="account-note" style="margin:0 0 14px;">
|
||
💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。
|
||
</div>
|
||
|
||
<!-- 全局音质选择(对所有模块生效) -->
|
||
<div class="global-quality-bar">
|
||
<span class="gq-label">🎧 全局音质</span>
|
||
<select class="select" id="global-level" title="全局音质选择">
|
||
<option value="auto" selected>最高音质(自动)</option>
|
||
<option value="jymaster">超清母带</option>
|
||
<option value="jyeffect">高清环绕声</option>
|
||
<option value="sky">沉浸环绕声</option>
|
||
<option value="hires">Hi-Res音质</option>
|
||
<option value="lossless">无损音质</option>
|
||
<option value="exhigh">极高音质</option>
|
||
<option value="standard">标准音质</option>
|
||
<option value="dolby">杜比全景声</option>
|
||
</select>
|
||
<button class="btn btn-sm" id="quality-save" type="button" style="height:30px;">保存</button>
|
||
<span class="gq-hint">全局生效:单曲解析、音乐下载、批量下载均使用该音质;「最高音质(自动)」会按账号会员类型自动尝试最高可解析音质。</span>
|
||
</div>
|
||
|
||
<div class="tabs" id="tabs">
|
||
<div class="tab active" data-tab="search">搜索</div>
|
||
<div class="tab" data-tab="song">单曲</div>
|
||
<div class="tab" data-tab="playlist">歌单</div>
|
||
<div class="tab" data-tab="album">专辑</div>
|
||
<div class="tab" data-tab="podcast">播客</div>
|
||
<div class="tab" data-tab="admin" id="tab-admin" hidden>管理员设置</div>
|
||
<div class="tab" data-tab="global" id="tab-global" hidden>全局账号歌单</div>
|
||
<div class="tab" data-tab="artist">歌手</div>
|
||
<div class="tab" data-tab="toplist">榜单</div>
|
||
<div class="tab" data-tab="personal">个人歌单</div>
|
||
<div class="tab" data-tab="usercenter" id="tab-usercenter" hidden>个人中心</div>
|
||
<div class="tab" data-tab="useradmin" id="tab-useradmin" hidden>管理后台</div>
|
||
<div class="tab" data-tab="about">关于网站</div>
|
||
</div>
|
||
|
||
<!-- 搜索面板 -->
|
||
<div class="panel active" data-panel="search">
|
||
<div class="search-header" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<div style="flex:none;width:36px;height:36px;border-radius:10px;background:var(--color-fill);display:flex;align-items:center;justify-content:center;font-size:17px;">🔍</div>
|
||
<div style="flex:1;min-width:220px;display:flex;gap:12px;flex-wrap:wrap;">
|
||
<input class="input" id="search-input" placeholder="在此输入搜索关键词" autocomplete="off" style="flex:1;min-width:200px;" />
|
||
<select class="select search-header-select" id="search-type" title="搜索类型" style="width:110px;">
|
||
<option value="1">歌曲</option>
|
||
<option value="1000">歌单</option>
|
||
<option value="10">专辑</option>
|
||
<option value="100">歌手</option>
|
||
</select>
|
||
<select class="select" id="search-limit" title="返回数量" style="width:90px;">
|
||
<option value="10">10 条</option>
|
||
<option value="20">20 条</option>
|
||
<option value="30" selected>30 条</option>
|
||
<option value="50">50 条</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary" id="search-btn" type="button">搜索</button>
|
||
</div>
|
||
<div class="hint">支持搜索歌曲、歌单、专辑、歌手;结果点击即可跳转对应功能解析。</div>
|
||
</div>
|
||
|
||
<!-- 单曲面板 -->
|
||
<div class="panel" data-panel="song">
|
||
<div class="input-row">
|
||
<input class="input" id="song-input" placeholder="将网易云音乐的分享链接或歌曲ID粘贴到这里,点击右侧按钮即可解析" autocomplete="off" />
|
||
<button class="btn btn-primary" id="parse-btn" type="button">开始解析</button>
|
||
</div>
|
||
<div class="history-row" id="history-row" hidden>
|
||
<span class="history-label">解析历史:</span>
|
||
<span id="history-list"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌单面板 -->
|
||
<div class="panel" data-panel="playlist">
|
||
<div class="input-row">
|
||
<input class="input" id="playlist-input" placeholder="网易云音乐的分享链接或歌单ID,例如 https://music.163.com/playlist?id=123456" autocomplete="off" />
|
||
<button class="btn btn-primary" id="playlist-btn" type="button">解析歌单</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 专辑面板 -->
|
||
<div class="panel" data-panel="album">
|
||
<div class="input-row">
|
||
<input class="input" id="album-input" placeholder="网易云音乐的分享链接或专辑ID,例如 https://music.163.com/album?id=123456" autocomplete="off" />
|
||
<button class="btn btn-primary" id="album-btn" type="button">解析专辑</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播客面板 -->
|
||
<div class="panel" data-panel="podcast">
|
||
<div class="input-row">
|
||
<input class="input" id="podcast-input" placeholder="播客电台/单集分享链接或ID,例如 https://music.163.com/djradio?id=1212051560" autocomplete="off" />
|
||
<select class="select" id="podcast-quality" title="单集音质" style="width:130px;">
|
||
<option value="auto" selected>最高音质(自动)</option>
|
||
<option value="exhigh">极高 320K</option>
|
||
<option value="standard">标准 128K</option>
|
||
</select>
|
||
<button class="btn btn-primary" id="podcast-btn" type="button">解析</button>
|
||
</div>
|
||
<div class="input-row" style="margin-top:10px;">
|
||
<input class="input" id="podcast-search-input" placeholder="搜索播客电台关键词,例如:故事 / 脱口秀 / 有声书" autocomplete="off" />
|
||
<button class="btn" id="podcast-search-btn" type="button">🔍 搜索播客</button>
|
||
</div>
|
||
<div class="hint">支持 电台链接(djradio?id=)、单集链接(program?id= / sound?id=)、163cn.tv 短链或纯数字ID;解析后可在线试听、获取直链、下载单集、整台打包 ZIP。</div>
|
||
|
||
<div id="podcast-loading" class="empty-state" hidden><div class="empty-icon">🎙️</div><div>解析中...</div></div>
|
||
|
||
<!-- 电台解析结果:电台信息 + 单集列表 -->
|
||
<div id="podcast-radio-result" hidden>
|
||
<div class="collection-header">
|
||
<img class="collection-cover" id="podcast-radio-cover" src="" alt="cover" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="podcast-radio-name"></div>
|
||
<div class="collection-meta" id="podcast-radio-meta"></div>
|
||
<div class="collection-meta podcast-desc" id="podcast-radio-desc"></div>
|
||
<div class="collection-actions" style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<button class="btn btn-success btn-sm" id="podcast-radio-zip" type="button">📦 打包整个播客</button>
|
||
<button class="btn btn-sm" id="podcast-radio-order" type="button">⇅ 最新在前</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="card-title">单集列表 <span class="count" id="podcast-episode-count"></span></div>
|
||
<div class="song-list" id="podcast-episodes"></div>
|
||
<div class="comment-more" id="podcast-more-wrap" hidden>
|
||
<button class="btn btn-sm" id="podcast-more" type="button">加载更多单集</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 单集解析结果:详情 + 播放器 + 直链/下载 -->
|
||
<div id="podcast-program-result" hidden>
|
||
<div class="song-detail">
|
||
<img class="song-detail-cover" id="podcast-program-cover" src="" alt="cover" />
|
||
<div class="song-detail-info">
|
||
<div class="detail-name" id="podcast-program-name"></div>
|
||
<div class="detail-meta"><b>播客</b><span id="podcast-program-radio"></span></div>
|
||
<div class="detail-meta"><b>主播</b><span id="podcast-program-dj"></span></div>
|
||
<div class="detail-meta"><b>信息</b><span id="podcast-program-meta"></span></div>
|
||
<div class="detail-badges" id="podcast-program-badges"></div>
|
||
<div class="detail-actions" style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<button class="btn btn-success btn-sm" id="podcast-program-download" type="button">⬇ 下载单集</button>
|
||
<a class="btn btn-sm" id="podcast-program-open" href="#" target="_blank" rel="noopener" style="text-decoration:none;">打开直链</a>
|
||
</div>
|
||
<div class="hint" id="podcast-program-tip" hidden style="margin-top:10px;"></div>
|
||
<audio controls id="podcast-audio" style="width:100%;margin-top:14px;" preload="none"></audio>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播客搜索结果 -->
|
||
<div id="podcast-search-result" hidden>
|
||
<div class="divider"></div>
|
||
<div class="card-title">播客搜索结果 <span class="count" id="podcast-search-count"></span></div>
|
||
<div class="song-list" id="podcast-search-list"></div>
|
||
<div class="hint" id="podcast-search-tip" hidden style="margin-top:8px;"></div>
|
||
</div>
|
||
|
||
<!-- 热门播客(分类卡片墙) -->
|
||
<div id="podcast-hot-wrap" style="margin-top:10px;">
|
||
<div class="divider"></div>
|
||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;">
|
||
<div class="card-title" style="margin:0;">热门播客</div>
|
||
<div class="podcast-cate-bar" id="podcast-cate-bar"></div>
|
||
</div>
|
||
<div class="toplist-grid" id="podcast-hot-grid"></div>
|
||
<div class="comment-more" id="podcast-hot-more-wrap" hidden style="margin-top:14px;">
|
||
<button class="btn btn-sm" id="podcast-hot-more" type="button">加载更多热门播客</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 管理员设置面板 -->
|
||
<div class="panel" data-panel="admin">
|
||
<!-- 密码门禁 -->
|
||
<div id="admin-gate">
|
||
<div class="input-row">
|
||
<input class="input" id="admin-password" type="password" placeholder="请输入管理员密码" autocomplete="off" />
|
||
<button class="btn btn-primary" id="admin-enter" type="button">验证并进入</button>
|
||
</div>
|
||
<div class="hint">管理员设置:全局二维码登录与 Cookie 管理。登录后 Cookie 自动保存,无需重复扫码。</div>
|
||
</div>
|
||
|
||
<!-- 管理员内容 -->
|
||
<div id="admin-content" hidden>
|
||
<div class="card" style="box-shadow:none;background:var(--color-fill);margin-bottom:16px;padding:16px 20px;">
|
||
<div class="setting-item" style="padding:6px 0;">
|
||
<div>
|
||
<div class="setting-label">登录状态</div>
|
||
<div class="setting-desc" id="admin-status-desc">获取中...</div>
|
||
</div>
|
||
<span class="badge" id="admin-status-badge">-</span>
|
||
</div>
|
||
<div class="setting-item" style="padding:6px 0;">
|
||
<div>
|
||
<div class="setting-label">Cookie 文件</div>
|
||
<div class="setting-desc" id="admin-cookie-file">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="setting-item" style="padding:6px 0;">
|
||
<div>
|
||
<div class="setting-label">Cookie 数量 / 最后更新</div>
|
||
<div class="setting-desc" id="admin-cookie-time">-</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<button class="btn btn-primary btn-sm" id="admin-qr-btn" type="button">📱 生成登录二维码</button>
|
||
<button class="btn btn-danger btn-sm" id="admin-cookie-delete" type="button">🗑 删除 Cookie(重新扫码)</button>
|
||
<button class="btn btn-sm" id="admin-status-refresh" type="button">刷新状态</button>
|
||
</div>
|
||
<div id="admin-qr-area" hidden>
|
||
<div style="text-align:center;padding:16px 0;">
|
||
<img id="admin-qr-img" alt="登录二维码" style="width:200px;height:200px;border-radius:12px;box-shadow:var(--color-shadow-heavy);background:#fff;" />
|
||
<div class="hint" id="admin-qr-status" style="text-align:center;margin-top:12px;">请使用网易云音乐手机 APP 扫描二维码登录</div>
|
||
<div class="hint" style="text-align:center;">二维码有效期 3 分钟,登录成功后 Cookie 自动保存,下次无需重复扫码。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户 IP 解析/下载记录 -->
|
||
<div class="settings-section" style="margin-top:20px;">
|
||
<div class="settings-section-head">
|
||
<div class="settings-section-title">用户 IP 解析/下载记录</div>
|
||
<button class="btn btn-mini" id="admin-logs-refresh" type="button">刷新</button>
|
||
</div>
|
||
<div id="admin-logs-list" style="max-height:320px;overflow-y:auto;"></div>
|
||
<div class="comment-more" id="admin-logs-pager" hidden>
|
||
<button class="btn btn-sm" id="admin-logs-prev" type="button">‹ 上一页</button>
|
||
<span id="admin-logs-info" style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="admin-logs-next" type="button">下一页 ›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 全局账号歌单面板 -->
|
||
<div class="panel" data-panel="global">
|
||
<!-- 全局账号未登录 -->
|
||
<div id="global-gate">
|
||
<div style="display:flex;flex-direction:column;gap:12px;align-items:stretch;">
|
||
<div class="hint" id="global-gate-hint" style="margin:0;">
|
||
全局账号歌单展示系统中全局账号(管理员 <code>cookie.txt</code>)收藏的歌单与「我喜欢的音乐」。
|
||
当前全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录全局账号。
|
||
</div>
|
||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<button class="btn btn-primary" id="global-goto-admin" type="button">🔑 前往管理员设置登录</button>
|
||
<button class="btn" id="global-refresh" type="button">刷新状态</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已登录:账号信息 + 歌单列表 -->
|
||
<div id="global-content" hidden>
|
||
<div class="collection-header">
|
||
<img class="collection-cover collection-cover-round" id="global-avatar" src="" alt="avatar" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="global-nickname" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"></div>
|
||
<div class="collection-meta" id="global-signature"></div>
|
||
<div class="collection-actions" style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<button class="btn btn-sm" id="global-playlists-refresh" type="button">刷新歌单</button>
|
||
<button class="btn btn-sm" id="global-api-open" type="button">🔗 公开播放 API 说明</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
|
||
<div class="card-title">❤️ 我喜欢的音乐 <span class="count" id="global-fav-count"></span></div>
|
||
<div class="song-list" id="global-fav-playlists"></div>
|
||
|
||
<div class="card-title">收藏的歌单 <span class="count" id="global-collect-count"></span></div>
|
||
<div class="song-list" id="global-collect-playlists"></div>
|
||
|
||
<div id="global-tracks-wrap" hidden>
|
||
<div class="divider"></div>
|
||
<div class="card-title" id="global-tracks-title">歌单歌曲</div>
|
||
<div class="song-list" id="global-tracks"></div>
|
||
<div class="comment-more" id="global-pager" hidden>
|
||
<button class="btn btn-sm" id="global-prev" type="button">‹ 上一页</button>
|
||
<span style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;" id="global-page-info">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="global-next" type="button">下一页 ›</button>
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn btn-success btn-sm" id="global-download-all" type="button">⬇ 批量下载全部</button>
|
||
<button class="btn btn-sm btn-primary" id="global-play-playlist" type="button" hidden>▶ 播放歌单(公开 API)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌手面板 -->
|
||
<div class="panel" data-panel="artist">
|
||
<div class="input-row">
|
||
<input class="input" id="artist-input" placeholder="网易云音乐的分享链接或歌手ID,例如 https://music.163.com/artist?id=6452" autocomplete="off" />
|
||
<button class="btn btn-primary" id="artist-btn" type="button">解析歌手</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 榜单面板 -->
|
||
<div class="panel" data-panel="toplist">
|
||
<div class="input-row" style="margin-bottom:16px;">
|
||
<span style="color:var(--color-text-secondary);">网易云官方实时排行榜,点击卡片即可查看榜单歌曲</span>
|
||
</div>
|
||
<div id="toplist-loading" class="empty-state"><div class="empty-icon">📊</div><div>加载中...</div></div>
|
||
<div class="toplist-grid" id="toplist-grid" hidden></div>
|
||
</div>
|
||
|
||
<!-- 个人歌单面板 -->
|
||
<div class="panel" data-panel="personal">
|
||
<!-- 未登录:扫码登录自己的账号 -->
|
||
<div id="personal-gate">
|
||
<div style="display:flex;flex-direction:column;gap:12px;align-items:stretch;">
|
||
<div class="hint" id="personal-gate-hint" style="margin:0;">
|
||
扫码登录你自己的网易云账号,即可查看收藏的歌单(包括「我喜欢的音乐」)。
|
||
个人登录仅用于获取你的歌单,<b>解析与下载音乐仍使用管理员全局账号</b>。
|
||
</div>
|
||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<button class="btn btn-primary" id="personal-qr-btn" type="button">📱 扫码登录我的账号</button>
|
||
<button class="btn" id="personal-refresh" type="button">刷新状态</button>
|
||
</div>
|
||
</div>
|
||
<div id="personal-qr-area" hidden>
|
||
<div style="text-align:center;padding:16px 0;">
|
||
<img id="personal-qr-img" alt="登录二维码" style="width:200px;height:200px;border-radius:12px;box-shadow:var(--color-shadow-heavy);background:#fff;" />
|
||
<div class="hint" id="personal-qr-status" style="text-align:center;margin-top:12px;">请使用网易云音乐手机 APP 扫描二维码登录</div>
|
||
<div class="hint" style="text-align:center;">二维码有效期 3 分钟,登录后自动保存,下次无需重复扫码。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已登录:账号信息 + 歌单列表 -->
|
||
<div id="personal-content" hidden>
|
||
<div class="collection-header">
|
||
<img class="collection-cover collection-cover-round" id="personal-avatar" src="" alt="avatar" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="personal-nickname" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"></div>
|
||
<div class="collection-meta" id="personal-signature"></div>
|
||
<div class="collection-actions" style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<button class="btn btn-sm" id="personal-toggle-account" type="button">切换为个人账号解析</button>
|
||
<button class="btn btn-danger btn-sm" id="personal-logout" type="button">退出登录</button>
|
||
<button class="btn btn-sm" id="personal-playlists-refresh" type="button">刷新歌单</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="card-title">我的歌单 <span class="count" id="personal-playlist-count"></span></div>
|
||
<div class="song-list" id="personal-playlists"></div>
|
||
|
||
<div id="personal-tracks-wrap" hidden>
|
||
<div class="divider"></div>
|
||
<div class="card-title" id="personal-tracks-title">歌单歌曲</div>
|
||
<div class="song-list" id="personal-tracks"></div>
|
||
<div class="comment-more" id="personal-pager" hidden>
|
||
<button class="btn btn-sm" id="personal-prev" type="button">‹ 上一页</button>
|
||
<span style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;" id="personal-page-info">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="personal-next" type="button">下一页 ›</button>
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn btn-success btn-sm" id="personal-download-all" type="button">⬇ 批量下载全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 关于网站面板 -->
|
||
<div class="panel" data-panel="about">
|
||
<div class="about-hero">
|
||
<div class="about-logo">♪</div>
|
||
<div>
|
||
<div class="about-title">云村小助手</div>
|
||
<div class="about-sub">基于 Flask + 原生前端的云村小助手 · 当前版本 <b id="about-version">v2.1.0</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider" style="margin-top:16px;"></div>
|
||
<div class="card-title">项目介绍</div>
|
||
<p class="about-text">
|
||
本项目是一个开箱即用的云村小助手,支持<b>歌曲搜索、单曲 / 歌单 / 专辑 / 歌手解析、官方榜单、个人歌单、播客解析</b>等完整功能,
|
||
提供多音质试听与下载(标准 / 极高 / 无损 / Hi-Res / 沉浸环绕声等)、批量下载与 ZIP 打包、元数据写入、歌词下载(LRC / SRT)、
|
||
自定义播放器与逐字歌词、评论区、多账号体系(全局账号 + 个人账号)与下载任务进度管理。
|
||
服务端基于 Flask 与网易云官方接口实现,前端零框架原生实现,支持深色模式与响应式布局,可通过 Docker 一键部署。
|
||
</p>
|
||
|
||
<div class="divider"></div>
|
||
<div class="card-title">功能说明</div>
|
||
<div class="about-grid">
|
||
<div class="about-card">
|
||
<div class="about-card-title">🔍 多类型搜索</div>
|
||
<div class="about-card-desc">支持按歌曲、歌单、专辑、歌手搜索,结果一键跳转对应功能解析。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">🎵 单曲 / 歌单 / 专辑 / 歌手解析</div>
|
||
<div class="about-card-desc">输入链接或ID即可解析,自动按账号实测最高可解析音质输出,含封面、歌词与直链。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">🎙️ 播客解析</div>
|
||
<div class="about-card-desc">支持播客电台与单集解析:在线试听、音频直链、单集下载、整台打包 ZIP,热门播客分类浏览。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">📊 官方榜单</div>
|
||
<div class="about-card-desc">63 个网易云官方实时排行榜卡片墙,点击即看榜单歌曲。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">⬇ 下载能力</div>
|
||
<div class="about-card-desc">单曲 / 批量下载、ZIP 打包、文件命名格式、ffmpeg 元数据写入、LRC / SRT 歌词。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">🎧 播放器与歌词</div>
|
||
<div class="about-card-desc">自定义深色播放器、SMTC 系统媒体控制、逐字 KTV 歌词高亮、翻译双行、偏移校准。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">👥 多账号体系</div>
|
||
<div class="about-card-desc">全局账号负责解析下载;个人账号扫码登录查看自己的歌单,Cookie 保存在本机浏览器(站点账号登录时管理后台可按需查看)。</div>
|
||
</div>
|
||
<div class="about-card">
|
||
<div class="about-card-title">🔒 隐私与任务管理</div>
|
||
<div class="about-card-desc">隐私协议 Cookie 偏好、下载任务进度悬浮窗、断线续传与离线暂停恢复、接口统计。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<div class="card-title">更新日志</div>
|
||
<div id="about-changelog"></div>
|
||
|
||
<div class="hint" style="margin-top:14px;">本工具仅供个人学习交流使用,音频内容版权归网易云音乐及内容创作者所有,请勿用于商业用途。</div>
|
||
</div>
|
||
|
||
<!-- 个人中心面板 -->
|
||
<div class="panel" data-panel="usercenter">
|
||
<div id="uc-gate" class="empty-state"><div class="empty-icon">🔒</div><div>请先登录后查看个人中心</div></div>
|
||
<div id="uc-content" hidden>
|
||
<div class="collection-header">
|
||
<div class="user-avatar" id="uc-avatar" style="width:56px;height:56px;font-size:24px;">U</div>
|
||
<div class="collection-info">
|
||
<!-- 退出登录与用户名同行右侧对齐(垂直居中),窄屏长名自动换行 -->
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<div class="collection-name" id="uc-username" style="min-width:0;">-</div>
|
||
<button class="btn" id="uc-logout" type="button" style="margin-left:auto;flex-shrink:0;border-color:var(--color-error);color:var(--color-error);">🚪 退出登录</button>
|
||
</div>
|
||
<div class="collection-meta" id="uc-role">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="uc-grid" style="margin-top:14px;">
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">⏳ 剩余次数</div>
|
||
<div class="uc-card-value" id="uc-duration">-</div>
|
||
<div class="hint" style="margin:6px 0 0;font-size:12px;">按次计费:一首歌消耗 1 次,歌单打包按歌曲数扣减</div>
|
||
</div>
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">📮 注册邮箱</div>
|
||
<div class="uc-card-value" id="uc-email" style="font-size:14px;">-</div>
|
||
</div>
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">📅 注册时间</div>
|
||
<div class="uc-card-value" id="uc-created" style="font-size:14px;">-</div>
|
||
</div>
|
||
</div>
|
||
<div class="uc-section">
|
||
<div class="card-title">🎟️ 卡密充值</div>
|
||
<div class="input-row">
|
||
<input class="input" id="uc-card-input" placeholder="输入次数卡密,例如 VIP-XXXXXXXXXX" autocomplete="off" />
|
||
<button class="btn btn-primary" id="uc-card-redeem" type="button">充值</button>
|
||
</div>
|
||
</div>
|
||
<div class="uc-section">
|
||
<div class="card-title">📢 系统公告</div>
|
||
<button class="btn btn-sm" id="uc-ann-view" type="button">查看最新公告</button>
|
||
</div>
|
||
<div class="uc-section">
|
||
<div class="card-title">📋 版本信息</div>
|
||
<div id="uc-changelog"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 管理后台面板 -->
|
||
<div class="panel" data-panel="useradmin">
|
||
<div id="ua-gate" class="empty-state"><div class="empty-icon">🔒</div><div>仅管理员可访问管理后台</div></div>
|
||
<div id="ua-content" hidden>
|
||
<div class="admin-layout">
|
||
<div class="admin-side" id="admin-side">
|
||
<div class="admin-side-item active" data-view="users">👥 用户管理</div>
|
||
<div class="admin-side-item" data-view="cookie">🎵 全局Cookie</div>
|
||
<div class="admin-side-item" data-view="smtp">📧 邮箱与SMTP</div>
|
||
<div class="admin-side-item" data-view="logs">📝 日志管理</div>
|
||
<div class="admin-side-item" data-view="cards">🎟️ 次数卡密</div>
|
||
<div class="admin-side-item" data-view="announce">📢 公告设置</div>
|
||
<div class="admin-side-item" data-view="database">🗄️ 数据库管理</div>
|
||
<div class="admin-side-item" data-view="playkey">🔑 播放API密钥</div>
|
||
<div class="admin-side-sep"></div>
|
||
<div class="admin-side-item" id="ua-back">🎧 返回解析</div>
|
||
<div class="admin-side-item" id="ua-logout" style="color:var(--color-error);">🚪 退出登录</div>
|
||
</div>
|
||
<div class="admin-main">
|
||
<!-- 用户管理 -->
|
||
<div class="admin-view active" data-aview="users">
|
||
<div class="admin-toolbar">
|
||
<button class="btn btn-sm" id="ua-users-refresh" type="button">刷新用户列表</button>
|
||
<span class="hint" style="margin:0;">共 <b id="ua-users-count">0</b> 个用户</span>
|
||
</div>
|
||
<div style="overflow-x:auto;">
|
||
<table class="site-table">
|
||
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>角色</th><th>剩余次数</th><th>注册IP / 设备</th><th>网易云Cookie</th><th>操作</th></tr></thead>
|
||
<tbody id="ua-users-body"><tr><td colspan="8">加载中...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<!-- 全局Cookie -->
|
||
<div class="admin-view" data-aview="cookie">
|
||
<div class="hint" style="margin-top:0;">全局网易云账号(管理员账号)Cookie 管理:用于全站解析与下载。原「管理员设置」页仍可用。</div>
|
||
<div class="uc-grid" style="margin-top:12px;">
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">当前状态</div>
|
||
<div class="uc-card-value" id="ua-cookie-status" style="font-size:14px;">获取中...</div>
|
||
<button class="btn btn-sm" id="ua-cookie-refresh" type="button" style="margin-top:10px;">刷新状态</button>
|
||
</div>
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">扫码登录全局账号</div>
|
||
<div id="ua-qr-box" style="text-align:center;min-height:60px;"></div>
|
||
<div class="hint" id="ua-qr-tip" style="margin:8px 0 0;text-align:center;">点击下方按钮生成二维码</div>
|
||
<button class="btn btn-primary btn-sm" id="ua-qr-create" type="button" style="margin-top:10px;width:100%;">📱 生成登录二维码</button>
|
||
</div>
|
||
<div class="uc-card">
|
||
<div class="uc-card-title">危险操作</div>
|
||
<div class="hint" style="margin:0 0 10px;">删除后需重新扫码登录才能解析。</div>
|
||
<button class="btn btn-sm" id="ua-cookie-delete" type="button" style="border-color:var(--color-error);color:var(--color-error);">🗑 删除全局 Cookie</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 邮箱与SMTP -->
|
||
<div class="admin-view" data-aview="smtp">
|
||
<div class="hint" style="margin-top:0;">配置 SMTP 后用户注册需邮箱验证码。常用端口:587(STARTTLS)/ 465(SSL)。邮箱密码一般为「授权码」。</div>
|
||
<div class="auth-form" style="max-width:460px;">
|
||
<input class="input" id="ua-smtp-host" placeholder="SMTP 服务器,例如 smtp.qq.com" />
|
||
<div class="auth-form-row">
|
||
<input class="input" id="ua-smtp-port" placeholder="端口(默认587)" type="number" />
|
||
<input class="input" id="ua-smtp-sender" placeholder="发件邮箱" />
|
||
</div>
|
||
<input class="input" id="ua-smtp-pass" placeholder="邮箱密码 / 授权码" type="password" />
|
||
<div class="auth-form-row">
|
||
<input class="input" id="ua-smtp-test-to" placeholder="测试收件邮箱" />
|
||
<button class="btn" id="ua-smtp-test" type="button" style="flex:none;">发送测试邮件</button>
|
||
</div>
|
||
<button class="btn btn-primary" id="ua-smtp-save" type="button">保存配置</button>
|
||
</div>
|
||
</div>
|
||
<!-- 日志管理 -->
|
||
<div class="admin-view" data-aview="logs">
|
||
<div class="admin-toolbar">
|
||
<button class="btn btn-sm" id="ua-log-tab-ok" type="button">✔ 成功日志</button>
|
||
<button class="btn btn-sm" id="ua-log-tab-fail" type="button">✖ 失败日志</button>
|
||
<button class="btn btn-sm" id="ua-log-refresh" type="button">刷新</button>
|
||
<span class="hint" style="margin:0;">第 <b id="ua-log-page">1</b> / <b id="ua-log-pages">1</b> 页 · 共 <b id="ua-log-total">0</b> 条</span>
|
||
<button class="btn btn-sm" id="ua-log-prev" type="button">‹ 上一页</button>
|
||
<button class="btn btn-sm" id="ua-log-next" type="button">下一页 ›</button>
|
||
<button class="btn btn-sm" id="ua-log-clear" type="button" style="color:var(--color-error);">🗑 清除当前日志</button>
|
||
</div>
|
||
<div class="code-box log-list" id="ua-log-list">加载中...</div>
|
||
</div>
|
||
<!-- 次数卡密 -->
|
||
<div class="admin-view" data-aview="cards">
|
||
<div class="admin-toolbar">
|
||
<input class="input" id="ua-card-count" type="number" value="10" min="1" max="500" placeholder="张数" title="生成数量(张)" style="width:90px;" />
|
||
<input class="input" id="ua-card-prefix" placeholder="卡密前缀" value="VIP" title="卡密前缀" style="width:90px;" />
|
||
<select class="select" id="ua-card-minutes" title="每张卡密的下载次数">
|
||
<option value="3">体验卡 · 3次</option>
|
||
<option value="10" selected>次卡 · 10次</option>
|
||
<option value="50">次卡 · 50次</option>
|
||
<option value="100">次卡 · 100次</option>
|
||
<option value="500">次卡 · 500次</option>
|
||
<option value="2000">次卡 · 2000次</option>
|
||
<option value="custom">⚙ 自定义次数</option>
|
||
</select>
|
||
<input class="input" id="ua-card-custom" type="number" min="1" placeholder="自定义次数" hidden style="width:100px;" />
|
||
<input class="input" id="ua-card-days" type="number" value="0" min="0" placeholder="有效天数" title="有效天数(0=永久有效)" style="width:100px;" />
|
||
<input class="input" id="ua-card-note" placeholder="备注(如:渠道 / 活动名)" style="flex:1;min-width:140px;" />
|
||
<button class="btn btn-primary btn-sm" id="ua-card-gen" type="button">生成卡密</button>
|
||
</div>
|
||
<div class="code-box" id="ua-card-codes" hidden></div>
|
||
<div style="overflow-x:auto;margin-top:12px;">
|
||
<table class="site-table">
|
||
<thead><tr><th>卡密</th><th>次数</th><th>状态</th><th>使用者</th><th>备注</th><th>有效期至</th><th>生成日期</th><th>操作</th></tr></thead>
|
||
<tbody id="ua-cards-body"><tr><td colspan="8">加载中...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<!-- 卡密分页条(后端 limit/offset,前端每次 100 张) -->
|
||
<div id="ua-cards-pager" hidden style="display:flex;gap:12px;align-items:center;margin-top:10px;"></div>
|
||
</div>
|
||
<!-- 公告设置 -->
|
||
<div class="admin-view" data-aview="announce">
|
||
<div class="auth-form" style="max-width:560px;">
|
||
<input class="input" id="ua-ann-title" placeholder="公告标题" />
|
||
<textarea class="input" id="ua-ann-content" rows="6" placeholder="公告内容(支持换行)" style="height:auto;resize:vertical;line-height:1.6;"></textarea>
|
||
<select class="select" id="ua-ann-mode">
|
||
<option value="off">不弹窗(仅个人中心可看)</option>
|
||
<option value="every" selected>每次登录弹出</option>
|
||
<option value="once">仅弹出一次(更新后再次弹出)</option>
|
||
</select>
|
||
<button class="btn btn-primary" id="ua-ann-save" type="button">保存公告</button>
|
||
</div>
|
||
</div>
|
||
<!-- 数据库管理 -->
|
||
<div class="admin-view" data-aview="database">
|
||
<div class="admin-toolbar">
|
||
<button class="btn btn-primary btn-sm" id="ua-db-backup" type="button">立即备份</button>
|
||
<input class="input" id="ua-db-hours" type="number" min="0" step="0.5" style="width:110px;" title="自动备份间隔(小时)" />
|
||
<button class="btn btn-sm" id="ua-db-auto" type="button">保存自动备份间隔</button>
|
||
<label class="btn btn-sm" style="cursor:pointer;">上传备份<input type="file" id="ua-db-upload" accept=".db" hidden></label>
|
||
<span class="hint" style="margin:0;">数据库大小:<b id="ua-db-size">-</b></span>
|
||
</div>
|
||
<div style="overflow-x:auto;">
|
||
<table class="site-table">
|
||
<thead><tr><th>备份文件</th><th>大小</th><th>时间</th><th>操作</th></tr></thead>
|
||
<tbody id="ua-db-body"><tr><td colspan="4">加载中...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<!-- 播放API密钥 -->
|
||
<div class="admin-view" data-aview="playkey">
|
||
<div class="hint" style="margin-top:0;">
|
||
「公开播放 API」(<code>/api/playlist/play</code>)的访问密钥。无密钥的请求一律拒绝;
|
||
<b>刷新后旧密钥立即全部失效</b>(已签发的音频地址按 36 分钟自然过期)。
|
||
</div>
|
||
<div class="admin-toolbar" style="align-items:center;">
|
||
<input class="input" id="ua-playkey-value" readonly
|
||
style="flex:1;min-width:220px;font-family:monospace;letter-spacing:1px;"
|
||
placeholder="点击「显示密钥」查看" />
|
||
<button class="btn btn-sm" id="ua-playkey-show" type="button">显示密钥</button>
|
||
<button class="btn btn-sm" id="ua-playkey-copy" type="button">复制</button>
|
||
<button class="btn btn-primary btn-sm" id="ua-playkey-rotate" type="button">刷新密钥</button>
|
||
</div>
|
||
<div class="hint" style="margin:4px 0 0;">
|
||
外部调用示例:<code id="ua-playkey-example">/api/playlist/play?id=歌单ID&key=你的密钥</code>
|
||
。刷新密钥前请确认外部使用方能及时更新(旧地址会立即打不开)。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索结果 -->
|
||
<div class="card" id="search-result" hidden>
|
||
<div class="card-title">搜索结果 <span class="count" id="search-count"></span></div>
|
||
<div class="song-list" id="search-list"></div>
|
||
</div>
|
||
|
||
<!-- 单曲解析结果 -->
|
||
<div class="card" id="song-result" hidden>
|
||
<div class="card-title">单曲解析结果</div>
|
||
<div class="song-detail">
|
||
<img class="song-detail-cover" id="detail-cover" src="" alt="cover" />
|
||
<div class="song-detail-info">
|
||
<div class="detail-name" id="detail-name"></div>
|
||
<div class="detail-meta"><b>歌手</b><span id="detail-artist"></span></div>
|
||
<div class="detail-meta"><b>专辑</b><span id="detail-album"></span></div>
|
||
<div class="detail-badges">
|
||
<span class="badge" id="detail-level"></span>
|
||
<span class="badge badge-green" id="detail-size"></span>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<button class="btn btn-success btn-sm" id="detail-download" type="button">⬇ 下载音乐文件</button>
|
||
<button class="btn btn-sm" id="detail-lyric-download" type="button">下载歌词</button>
|
||
</div>
|
||
<div class="hint" id="detail-tip" hidden></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自定义播放器 -->
|
||
<div class="player" id="player" hidden>
|
||
<div class="player-top">
|
||
<img class="player-cover" id="player-cover" src="" alt="" />
|
||
<div class="player-track">
|
||
<div class="player-name" id="player-name"></div>
|
||
<div class="player-artist" id="player-artist"></div>
|
||
</div>
|
||
<div class="player-controls">
|
||
<button class="player-ctrl player-ctrl-sm" id="player-restart" type="button" title="重新播放">↺</button>
|
||
<button class="player-ctrl" id="player-toggle" type="button" title="播放/暂停">▶</button>
|
||
</div>
|
||
</div>
|
||
<div class="player-bottom">
|
||
<span class="player-time" id="player-current">00:00</span>
|
||
<div class="progress" id="player-progress">
|
||
<div class="progress-fill" id="player-progress-fill"></div>
|
||
</div>
|
||
<span class="player-time" id="player-duration">00:00</span>
|
||
<div class="volume-wrap">
|
||
<button class="volume-btn" id="player-mute" type="button" title="静音">🔊</button>
|
||
<div class="volume-slider" id="player-volume">
|
||
<div class="volume-fill" id="player-volume-fill"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌词面板 -->
|
||
<div class="lyric-panel" id="lyric-panel" hidden>
|
||
<div class="lyric-panel-title" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;row-gap:6px;">
|
||
<span>歌词</span>
|
||
<span class="lyric-calib">
|
||
<button class="btn btn-mini" id="lyric-fontsize" type="button" title="歌词字号" aria-haspopup="true" aria-expanded="false">A±</button>
|
||
<span class="lyric-fs-pop" id="lyric-fs-pop" hidden>
|
||
<button class="btn lyric-fs-btn" id="lyric-fs-dec" type="button" title="缩小歌词字号">A−</button>
|
||
<span class="lyric-fs-val" id="lyric-fs-val">100%</span>
|
||
<button class="btn lyric-fs-btn" id="lyric-fs-inc" type="button" title="放大歌词字号">A+</button>
|
||
<button class="btn lyric-fs-btn" id="lyric-fs-reset" type="button" title="恢复默认字号">↺</button>
|
||
</span>
|
||
<button class="btn btn-mini" id="lyric-advance" type="button" title="歌词提前 0.25 秒">⏱ 提前</button>
|
||
<button class="btn btn-mini" id="lyric-delay" type="button" title="歌词延后 0.25 秒">⏱ 延后</button>
|
||
<span class="lyric-offset-label" id="lyric-offset-label">偏移 0.00s</span>
|
||
</span>
|
||
</div>
|
||
<div class="lyric-box" id="lyric-box"></div>
|
||
</div>
|
||
|
||
<!-- 评论区 -->
|
||
<div class="comment-panel" id="comment-panel" hidden>
|
||
<div class="comment-panel-title">评论 <span class="count" id="comment-count"></span></div>
|
||
<!-- 评论输入框(个人账号登录后可用) -->
|
||
<div id="comment-composer" hidden>
|
||
<textarea class="input" id="comment-input" rows="2" placeholder="登录个人账号后,说点什么吧...(140字以内)" style="width:100%;height:auto;resize:vertical;line-height:1.5;"></textarea>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;gap:8px;flex-wrap:wrap;">
|
||
<span class="hint" id="comment-reply-to" hidden style="margin:0;">回复 @<span id="comment-reply-name" style="color:var(--color-primary);"></span>
|
||
<button class="btn-link" id="comment-reply-cancel" type="button" style="border:none;background:none;color:var(--color-error);cursor:pointer;font-size:12px;padding:0;margin-left:6px;">取消</button>
|
||
</span>
|
||
<button class="btn btn-primary btn-sm" id="comment-submit" type="button">发布评论</button>
|
||
</div>
|
||
</div>
|
||
<div id="comment-hot-title" class="hint" style="margin:0 0 4px 8px;font-weight:500;color:var(--color-text-secondary);">热门评论</div>
|
||
<div class="comment-list" id="comment-hot-list"></div>
|
||
<div id="comment-all-title" class="hint" style="margin:12px 0 4px 8px;font-weight:500;color:var(--color-text-secondary);">全部评论</div>
|
||
<div class="comment-list" id="comment-all-list"></div>
|
||
<div class="comment-more" id="comment-pager" hidden>
|
||
<button class="btn btn-sm" id="comment-prev" type="button">‹ 上一页</button>
|
||
<span class="comment-page-info" id="comment-page-info" style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="comment-next" type="button">下一页 ›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌单结果 -->
|
||
<div class="card" id="playlist-result" hidden>
|
||
<div class="card-title">歌单解析结果</div>
|
||
<div class="collection-header">
|
||
<img class="collection-cover" id="playlist-cover" src="" alt="cover" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="playlist-name"></div>
|
||
<div class="collection-meta" id="playlist-creator"></div>
|
||
<div class="collection-meta">共 <b id="playlist-count"></b> 首歌</div>
|
||
<div class="collection-desc" id="playlist-desc" hidden></div>
|
||
<div class="collection-actions">
|
||
<button class="btn btn-sm" id="collect-playlist-btn" type="button" hidden>♡ 收藏歌单</button>
|
||
<button class="btn btn-primary btn-sm" id="playlist-api-btn" type="button">🔗 公开播放 API</button>
|
||
<button class="btn btn-success btn-sm" id="playlist-download-all" type="button">⬇ 批量下载全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="song-list" id="playlist-tracks"></div>
|
||
<div class="comment-more" id="playlist-pager" hidden>
|
||
<button class="btn btn-sm" id="playlist-prev" type="button">‹ 上一页</button>
|
||
<span style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;" id="playlist-page-info">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="playlist-next" type="button">下一页 ›</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 专辑结果 -->
|
||
<div class="card" id="album-result" hidden>
|
||
<div class="card-title">专辑解析结果</div>
|
||
<div class="collection-header">
|
||
<img class="collection-cover" id="album-cover" src="" alt="cover" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="album-name"></div>
|
||
<div class="collection-meta" id="album-artist"></div>
|
||
<div class="collection-meta">共 <b id="album-count"></b> 首歌</div>
|
||
<div class="collection-desc" id="album-desc" hidden></div>
|
||
<div class="collection-actions">
|
||
<button class="btn btn-sm" id="collect-album-btn" type="button" hidden>♡ 收藏专辑</button>
|
||
<button class="btn btn-success btn-sm" id="album-download-all" type="button">⬇ 批量下载全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="song-list" id="album-tracks"></div>
|
||
<div class="comment-more" id="album-pager" hidden>
|
||
<button class="btn btn-sm" id="album-prev" type="button">‹ 上一页</button>
|
||
<span style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;" id="album-page-info">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="album-next" type="button">下一页 ›</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌手结果 -->
|
||
<div class="card" id="artist-result" hidden>
|
||
<div class="card-title">歌手解析结果</div>
|
||
<div class="collection-header">
|
||
<img class="collection-cover collection-cover-round" id="artist-cover" src="" alt="avatar" />
|
||
<div class="collection-info">
|
||
<div class="collection-name" id="artist-name"></div>
|
||
<div class="collection-meta" id="artist-alias"></div>
|
||
<div class="collection-meta">共 <b id="artist-count"></b> 首热门歌曲</div>
|
||
<div class="collection-desc" id="artist-desc" hidden></div>
|
||
<div class="collection-actions">
|
||
<button class="btn btn-success btn-sm" id="artist-download-all" type="button">⬇ 批量下载全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="song-list" id="artist-tracks"></div>
|
||
<div class="comment-more" id="artist-pager" hidden>
|
||
<button class="btn btn-sm" id="artist-prev" type="button">‹ 上一页</button>
|
||
<span style="margin:0 12px;color:var(--color-text-secondary);font-size:13px;" id="artist-page-info">第 1 / 1 页</span>
|
||
<button class="btn btn-sm" id="artist-next" type="button">下一页 ›</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 设置抽屉 -->
|
||
<div class="drawer-mask" id="drawer-mask"></div>
|
||
<aside class="drawer" id="settings-drawer">
|
||
<div class="drawer-header">
|
||
<div class="drawer-title">个性化设置</div>
|
||
<button class="drawer-close" id="drawer-close" type="button">×</button>
|
||
</div>
|
||
<div class="drawer-body">
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">外观模式</div>
|
||
<div class="mode-seg" id="mode-seg">
|
||
<div class="seg-item" tabindex="0" role="button" data-mode="light">浅色</div>
|
||
<div class="seg-item" tabindex="0" role="button" data-mode="dark">深色</div>
|
||
<div class="seg-item" tabindex="0" role="button" data-mode="system">跟随系统</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">主题颜色</div>
|
||
<div class="color-palette" id="color-palette"></div>
|
||
</div>
|
||
|
||
<!-- 下载配置 -->
|
||
<div class="settings-section">
|
||
<div class="settings-section-head">
|
||
<div class="settings-section-title">下载配置</div>
|
||
<button class="btn btn-mini" id="cfg-save-download" type="button">保存</button>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">文件命名格式</div>
|
||
<div class="setting-desc">单曲下载的文件名格式</div>
|
||
</div>
|
||
<select class="select" id="cfg-filename-format" style="width:150px;height:30px;">
|
||
<option value="artist_song">歌手 - 歌曲名</option>
|
||
<option value="song_artist">歌曲名 - 歌手</option>
|
||
<option value="song">仅歌曲名</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">写入元数据</div>
|
||
<div class="setting-desc">写入歌曲名、歌手、专辑、封面等信息</div>
|
||
</div>
|
||
<div class="switch" id="cfg-write-meta"></div>
|
||
</div>
|
||
<div class="check-grid" id="cfg-meta-fields-wrap" style="margin:6px 0 10px;">
|
||
<label class="check-item"><input type="checkbox" data-field="name" /> 歌曲名</label>
|
||
<label class="check-item"><input type="checkbox" data-field="artist" /> 歌手</label>
|
||
<label class="check-item"><input type="checkbox" data-field="album" /> 专辑</label>
|
||
<label class="check-item"><input type="checkbox" data-field="cover" /> 封面</label>
|
||
<label class="check-item"><input type="checkbox" data-field="lyrics" /> 歌词</label>
|
||
<label class="check-item"><input type="checkbox" data-field="date" /> 发布日期</label>
|
||
</div>
|
||
<div class="setting-desc" style="font-size:12px;color:var(--color-text-tertiary);margin-bottom:6px;">
|
||
仅在「写入元数据」开启时生效。未勾选字段将保持原音频文件中的值不变。批量下载同样遵循此设置。
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">启用ZIP打包</div>
|
||
<div class="setting-desc">打包音频、歌词、封面、信息文件为一个压缩包</div>
|
||
</div>
|
||
<div class="switch" id="cfg-zip"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量下载配置 -->
|
||
<div class="settings-section">
|
||
<div class="settings-section-head">
|
||
<div class="settings-section-title">批量下载配置</div>
|
||
<button class="btn btn-mini" id="cfg-save-batch" type="button">保存</button>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">写入元数据</div>
|
||
<div class="setting-desc">为批量下载的歌曲写入歌曲名、歌手、封面等信息</div>
|
||
</div>
|
||
<div class="switch" id="cfg-batch-meta"></div>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">启用ZIP打包</div>
|
||
<div class="setting-desc">批量下载时将所有文件打包为一个压缩包,否则将逐个下载</div>
|
||
</div>
|
||
<div class="switch" id="cfg-batch-zip"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌词 -->
|
||
<div class="settings-section">
|
||
<div class="settings-section-head">
|
||
<div class="settings-section-title">歌词</div>
|
||
<button class="btn btn-mini" id="cfg-save-lyric" type="button">保存</button>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">歌词格式</div>
|
||
<div class="setting-desc">下载歌词 / 打包中歌词的格式</div>
|
||
</div>
|
||
<select class="select" id="cfg-lyric-format" style="width:110px;height:30px;">
|
||
<option value="lrc">LRC</option>
|
||
<option value="srt">SRT</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">翻译歌词分两行显示</div>
|
||
<div class="setting-desc">开启后播放器歌词原文在上、翻译在下分行展示</div>
|
||
</div>
|
||
<div class="switch" id="switch-lyric2line"></div>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">逐字歌词(KTV 模式)</div>
|
||
<div class="setting-desc">当前行歌词逐字渐变高亮,无逐字歌词时自动回退普通 LRC</div>
|
||
</div>
|
||
<div class="switch" id="switch-ktv"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播放 -->
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">播放</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">播放进度记忆</div>
|
||
<div class="setting-desc">记住上次播放位置,再次打开时续播</div>
|
||
</div>
|
||
<div class="switch" id="switch-progress"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 其他配置 -->
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">其他配置</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">启用SMTC</div>
|
||
<div class="setting-desc">在系统媒体控制面板(如 Windows 锁屏/音量面板)显示播放信息</div>
|
||
</div>
|
||
<div class="switch" id="switch-smtc"></div>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">显示评论区</div>
|
||
<div class="setting-desc">关闭后单曲信息下方将不再显示评论区</div>
|
||
</div>
|
||
<div class="switch" id="switch-comments"></div>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">接口请求统计</div>
|
||
<div class="setting-desc">联动后端 /api/topen 实时获取各接口请求统计,每 10 秒自动刷新</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex:none;">
|
||
<button class="btn btn-mini" id="cfg-stats-view" type="button">查看统计</button>
|
||
<button class="btn btn-mini" id="cfg-stats-refresh" type="button">刷新</button>
|
||
</div>
|
||
</div>
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">隐私与协议</div>
|
||
<div class="setting-desc">查看或修改《用户协议》《隐私政策》以及 Cookie 启用情况</div>
|
||
</div>
|
||
<button class="btn btn-mini" id="cfg-cookie-prefs" type="button">管理Cookie偏好</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="drawer-footer">
|
||
<button class="btn btn-primary" id="settings-save" type="button">保存设置</button>
|
||
<button class="btn" id="settings-reset" type="button">恢复默认</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 更新日志弹窗 -->
|
||
<div class="modal-mask" id="changelog-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">更新日志</div>
|
||
<button class="drawer-close" id="changelog-close" type="button">×</button>
|
||
</div>
|
||
<div class="modal-body" id="changelog-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 接口请求统计弹窗 -->
|
||
<div class="modal-mask" id="stats-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">接口请求统计</div>
|
||
<div style="display:flex;gap:8px;align-items:center;">
|
||
<button class="btn btn-mini" id="stats-refresh-btn" type="button">刷新</button>
|
||
<button class="drawer-close" id="stats-close" type="button">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="stat-grid">
|
||
<div class="stat-card"><div class="stat-num" id="stat-total">-</div><div class="stat-label">总请求数</div></div>
|
||
<div class="stat-card"><div class="stat-num" id="stat-endpoints">-</div><div class="stat-label">不同端点数</div></div>
|
||
<div class="stat-card"><div class="stat-num" id="stat-max">-</div><div class="stat-label">最高端点请求数</div></div>
|
||
</div>
|
||
<div class="settings-section-title" style="margin-top:16px;">按接口端点明细(按请求数倒序)</div>
|
||
<div id="stats-list"></div>
|
||
<div class="stat-refresh-info">数据来源:GET /api/topen · 每 10 秒自动刷新 · 最近刷新:<span id="stats-refresh-time">-</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌单播放 API 说明弹窗 -->
|
||
<div class="modal-mask" id="api-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔗 公开歌单播放 API</div>
|
||
<button class="drawer-close" id="api-close" type="button">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="hint" style="margin:0 0 12px;">公开接口,无需登录即可调用:返回歌单信息 + 每首歌的可播放直链(使用全局账号解析音质)。</div>
|
||
|
||
<div class="setting-item" style="padding:6px 0;">
|
||
<div style="flex:1;">
|
||
<div class="setting-label">接口地址</div>
|
||
<div class="setting-desc" style="word-break:break-all;" id="api-url">-</div>
|
||
</div>
|
||
<button class="btn btn-mini" id="api-copy-url" type="button">复制</button>
|
||
</div>
|
||
<div class="setting-item" style="padding:6px 0;">
|
||
<div>
|
||
<div class="setting-label">请求方式</div>
|
||
<div class="setting-desc">GET / POST(均支持表单或 JSON 参数)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section-title" style="margin-top:14px;">请求参数</div>
|
||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:2;">
|
||
<div>· <code>id</code> <span style="color:var(--color-error);">必填</span>:歌单 ID 或网易云分享链接</div>
|
||
<div>· <code>level</code> <span style="color:var(--color-text-tertiary);">选填</span>:音质等级,默认 <code>lossless</code>(standard/exhigh/lossless/hires/sky/jyeffect/jymaster/dolby)</div>
|
||
<div>· <code>limit</code> <span style="color:var(--color-text-tertiary);">选填</span>:最多返回歌曲数,默认 <code>100</code>,最大 500(0 按封顶 500 计)</div>
|
||
</div>
|
||
|
||
<div class="settings-section-title" style="margin-top:14px;">请求示例</div>
|
||
<div style="background:var(--color-fill);border-radius:8px;padding:10px 12px;font-size:12px;word-break:break-all;line-height:1.7;" id="api-example">-</div>
|
||
<div style="margin-top:8px;">
|
||
<button class="btn btn-mini" id="api-copy-example" type="button">复制示例</button>
|
||
</div>
|
||
|
||
<div class="settings-section-title" style="margin-top:14px;">响应字段</div>
|
||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:2;">
|
||
<div>· <code>playlist</code>:歌单信息(id / name / coverImgUrl / creator / trackCount)</div>
|
||
<div>· <code>tracks[]</code>:可播放歌曲列表(id / name / artists / album / url / level / size_formatted)</div>
|
||
<div>· <code>total / playable / skipped</code>:总歌曲数 / 可播放数 / 跳过数(版权或无法解析)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 隐私与协议设置弹窗 -->
|
||
<div class="modal-mask" id="cookie-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">隐私与协议设置</div>
|
||
<button class="drawer-close" id="cookie-close" type="button">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="hint" style="margin:0 0 12px;">请阅读并同意用户协议与隐私政策,并选择您希望启用的 Cookie 类型。必要 Cookie 无法禁用。</div>
|
||
|
||
<details style="margin-bottom:10px;border:1px solid var(--color-border-split);border-radius:8px;padding:10px 14px;">
|
||
<summary style="cursor:pointer;font-weight:500;color:var(--color-primary);">查看《用户协议》</summary>
|
||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.8;margin-top:8px;max-height:220px;overflow-y:auto;">
|
||
<p>欢迎使用云村小助手(以下简称"本工具")。在使用本工具之前,请您仔细阅读以下条款。点击"我已阅读"即表示您完全理解并接受本协议全部内容。</p>
|
||
<p><b>1. 用途限制</b>:本工具仅供个人学习、研究与交流使用。所有解析的音频及相关数据版权归原作者及网易云音乐所有,未经授权不得用于任何公开传播或商业用途。</p>
|
||
<p><b>2. 禁止商用</b>:严禁将本工具及解析出的任何内容用于任何形式的商业用途或非法传播,包括但不限于:转售、收费下载、公开播放、二次分发等。</p>
|
||
<p><b>3. 免责声明</b>:本工具不存储任何音频文件,仅作为网络数据接口的调用和本地展示。因使用本工具而引发的任何版权纠纷或法律责任,由使用者自行承担,工具作者不承担任何连带责任。</p>
|
||
<p><b>4. 服务变更</b>:我们保留随时修改或中断本工具服务的权利,恕不另行通知。接口可用性、音质支持范围、相关功能可能随时调整。</p>
|
||
</div>
|
||
</details>
|
||
|
||
<details style="margin-bottom:12px;border:1px solid var(--color-border-split);border-radius:8px;padding:10px 14px;">
|
||
<summary style="cursor:pointer;font-weight:500;color:var(--color-primary);">查看《隐私政策》</summary>
|
||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.8;margin-top:8px;max-height:220px;overflow-y:auto;">
|
||
<p>本工具(以下简称"我们")非常重视您的隐私。本政策说明我们如何收集、使用、存储和保护您的个人信息。请仔细阅读,点击"我已阅读"即表示您完全理解并接受本政策全部内容。</p>
|
||
<p><b>1. 数据收集</b>:本工具不存储任何音频文件,也不上传您的个人隐私信息。所有解析操作仅作为网络数据接口的调用和本地展示,您输入的链接 / 歌曲 ID 仅用于本次请求。</p>
|
||
<p><b>2. 本地存储</b>:以下信息仅保存在您的本地浏览器中(localStorage / IndexedDB),不会上传至我们的服务器:主题模式、主题色、下载格式等偏好设置;历史搜索 / 解析记录;隐私与 Cookie 偏好选择。</p>
|
||
<p><b>3. Cookie 使用</b>:根据您的选择,我们可能会使用以下类型的 Cookie:必要 Cookie(CDN 性能监控等保证基本功能所必需,无法禁用);功能性 Cookie(用于解析与下载服务、播放进度记忆、历史记录等)。您可以随时在设置中关闭,关闭后将无法解析与下载音乐。</p>
|
||
<p><b>4. 第三方服务</b>:本工具调用的所有数据接口由第三方提供,其隐私实践遵循各自的政策。本工具不对第三方服务的隐私行为承担责任。</p>
|
||
<p><b>5. 数据安全</b>:由于部分数据仅存储在您的本地浏览器中,请妥善保管您的设备,避免他人未经授权访问。建议在公共设备上使用后清除浏览器数据。</p>
|
||
<p><b>6. 您的权利</b>:您可以随时通过浏览器设置清除本工具存储的所有本地数据,或在设置中修改您的 Cookie 偏好与隐私选择。</p>
|
||
<p><b>7. 政策变更</b>:本政策可能根据实际需要随时更新,更新后的政策将在本工具中重新发布。继续使用本工具即视为接受变更后的政策。</p>
|
||
</div>
|
||
</details>
|
||
|
||
<label class="check-item" style="font-size:13px;margin-bottom:10px;display:flex;gap:6px;">
|
||
<input type="checkbox" id="cookie-agree" /> 我已完整阅读并同意《用户协议》和《隐私政策》
|
||
</label>
|
||
<div class="settings-section" style="margin-top:8px;">
|
||
<div class="setting-item" style="border-bottom:1px solid var(--color-border-split);">
|
||
<div>
|
||
<div class="setting-label">必要 Cookie <span class="tag" style="margin-left:4px;">始终启用</span></div>
|
||
<div class="setting-desc">这些 Cookie 对于网站的基本功能是必需的,无法禁用。</div>
|
||
</div>
|
||
<div class="switch on" style="opacity:0.6;cursor:not-allowed;"></div>
|
||
</div>
|
||
<div class="setting-desc" style="margin:8px 0 4px;">CDN 性能监控 - 用于资源加载性能统计</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="setting-item">
|
||
<div>
|
||
<div class="setting-label">功能性 Cookie <span class="tag" id="cookie-func-tag" style="margin-left:4px;">已启用</span></div>
|
||
<div class="setting-desc">这些 Cookie 用于增强网站功能和个性化体验。</div>
|
||
</div>
|
||
<div class="switch on" id="switch-cookie-func" tabindex="0" role="switch" aria-checked="true"></div>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--color-text-secondary);line-height:1.9;">
|
||
<div>🔓 解析与下载服务 - 启用后才能解析与下载音乐</div>
|
||
<div>▶️ 播放进度记忆 - 记录上次播放位置</div>
|
||
<div>🕘 历史记录 - 保存搜索/解析历史</div>
|
||
</div>
|
||
<div class="setting-desc" style="margin-top:8px;color:var(--color-error);">关闭「功能性 Cookie」将无法解析与下载音乐。</div>
|
||
</div>
|
||
</div>
|
||
<div class="drawer-footer">
|
||
<button class="btn btn-danger" id="cookie-withdraw" type="button">撤回同意</button>
|
||
<button class="btn btn-primary" id="cookie-save" type="button">保存设置</button>
|
||
<!-- 注册门槛模式专用——16 秒强制阅读计时 + 勾选同意后才可点 -->
|
||
<button class="btn btn-primary" id="cookie-agree-continue" type="button" hidden style="flex:1;">同意并继续注册</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 登录弹窗 -->
|
||
<div class="modal-mask" id="login-modal">
|
||
<div class="modal" style="width:400px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">登录账号</div>
|
||
<button class="icon-btn" id="login-close" type="button" style="padding:4px 10px;">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="auth-brand">
|
||
<div class="auth-brand-logo">♪</div>
|
||
<div class="auth-brand-name">云村小助手</div>
|
||
<div class="auth-brand-sub">登录后继续解析与下载</div>
|
||
</div>
|
||
<div class="auth-form">
|
||
<input class="input" id="login-username" placeholder="用户名或邮箱" autocomplete="username" />
|
||
<div style="position:relative;">
|
||
<input class="input" id="login-password" placeholder="密码" type="password" autocomplete="current-password" style="padding-right:44px;" />
|
||
<button class="btn btn-sm" id="login-pwd-eye" type="button"
|
||
style="position:absolute;right:6px;top:50%;transform:translateY(-50%);padding:2px 8px;font-size:12px;">显示</button>
|
||
</div>
|
||
<button class="btn btn-primary" id="login-submit" type="button">登 录</button>
|
||
<div class="auth-switch">还没有账号?<a id="goto-register">立即注册</a> · <a id="goto-forgot" style="cursor:pointer;">忘记密码?</a></div>
|
||
<div class="auth-trial-box">🎁 新用户?<a id="goto-register2" style="color:var(--color-primary);cursor:pointer;">注册账号</a> 注册即送 <span class="reg-gift-num">3</span> 次免费下载次数</div>
|
||
<!-- 找回密码视图(复用验证码通道),默认隐藏 -->
|
||
<div id="forgot-view" hidden>
|
||
<div class="auth-switch" style="margin:4px 0;">通过注册邮箱验证码重置密码</div>
|
||
<input class="input" id="forgot-email" placeholder="注册邮箱" type="email" />
|
||
<div class="auth-form-row">
|
||
<input class="input" id="forgot-code" placeholder="邮箱验证码" />
|
||
<button class="btn" id="forgot-send-code" type="button" style="flex:none;">发送验证码</button>
|
||
</div>
|
||
<div style="position:relative;">
|
||
<input class="input" id="forgot-password" placeholder="新密码(至少8位)" type="password" autocomplete="new-password" style="padding-right:44px;" />
|
||
<button class="btn btn-sm" id="forgot-pwd-eye" type="button"
|
||
style="position:absolute;right:6px;top:50%;transform:translateY(-50%);padding:2px 8px;font-size:12px;">显示</button>
|
||
</div>
|
||
<button class="btn btn-primary" id="forgot-submit" type="button" style="width:100%;margin-top:8px;">重置密码</button>
|
||
<div class="hint" id="forgot-error-line" hidden style="margin:2px 0 0;color:var(--color-error,#e0483f);"></div>
|
||
<div class="auth-switch" style="margin-top:8px;"><a id="forgot-back-login">返回登录</a></div>
|
||
</div>
|
||
<!-- 管理员两步验证视图(密码验证通过后:绑定邮箱 → 邮箱验证码) -->
|
||
<div id="admin2fa-view" hidden>
|
||
<div class="auth-switch" style="margin:4px 0;">管理员两步验证,绑定邮箱:<b id="a2fa-email-mask">-</b></div>
|
||
<input class="input" id="a2fa-email" placeholder="输入管理员绑定邮箱" type="email" autocomplete="off" />
|
||
<div class="auth-form-row">
|
||
<input class="input" id="a2fa-code" placeholder="邮箱验证码" autocomplete="off" />
|
||
<button class="btn" id="a2fa-send" type="button" style="flex:none;">发送验证码</button>
|
||
</div>
|
||
<button class="btn btn-primary" id="a2fa-submit" type="button" style="width:100%;margin-top:8px;">验证并登录</button>
|
||
<div class="hint" id="a2fa-error-line" hidden style="margin:2px 0 0;color:var(--color-error,#e0483f);"></div>
|
||
<div class="auth-switch" style="margin-top:8px;"><a id="a2fa-back">返回重新登录</a></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 注册弹窗 -->
|
||
<div class="modal-mask" id="register-modal">
|
||
<div class="modal" style="width:420px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">注册账号</div>
|
||
<button class="icon-btn" id="register-close" type="button" style="padding:4px 10px;">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 邮箱验证码注册 -->
|
||
<div class="auth-brand">
|
||
<div class="auth-brand-logo">♪</div>
|
||
<div class="auth-brand-name">创建账号</div>
|
||
<div class="auth-brand-sub">注册即送 <span class="reg-gift-num">3</span> 次免费下载次数</div>
|
||
</div>
|
||
<div class="auth-form" id="reg-mail-form">
|
||
<input class="input" id="reg-username" placeholder="用户名(3-20位,中英文/数字/下划线)" autocomplete="username" />
|
||
<div style="position:relative;">
|
||
<input class="input" id="reg-password" placeholder="密码(至少8位)" type="password" autocomplete="new-password" style="padding-right:44px;" />
|
||
<button class="btn btn-sm" id="reg-pwd-eye" type="button"
|
||
style="position:absolute;right:6px;top:50%;transform:translateY(-50%);padding:2px 8px;font-size:12px;">显示</button>
|
||
</div>
|
||
<div class="auth-form-row">
|
||
<input class="input" id="reg-email" placeholder="邮箱地址" type="email" />
|
||
<button class="btn" id="reg-send-code" type="button" style="flex:none;">发送验证码</button>
|
||
</div>
|
||
<input class="input" id="reg-code" placeholder="邮箱验证码" autocomplete="one-time-code" />
|
||
<div class="hint" id="reg-error-line" hidden style="margin:2px 0 0;color:var(--color-error,#e0483f);"></div>
|
||
<button class="btn btn-primary" id="reg-submit" type="button">注 册</button>
|
||
<div class="auth-switch">已有账号?<a id="goto-login">直接登录</a></div>
|
||
<div class="auth-trial-box">🎁 注册成功即赠送 <b><span class="reg-gift-num">3</span> 次免费下载次数</b>(解析与在线试听免费)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 公告弹窗 -->
|
||
<div class="modal-mask" id="announce-modal">
|
||
<div class="modal" style="width:480px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="announce-title">系统公告</div>
|
||
<button class="icon-btn" id="announce-close" type="button" style="padding:4px 10px;">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="announce-body" id="announce-content"></div>
|
||
</div>
|
||
<div class="drawer-footer" style="border-top:1px solid var(--color-border-split);">
|
||
<button class="btn btn-primary" id="announce-ok" type="button">我知道了</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast-wrap" id="toast-wrap"></div>
|
||
|
||
<!-- 实时排队 / 预计等待 悬浮框 -->
|
||
<div class="queue-float" id="queue-float">
|
||
<div class="queue-float-head"><span class="queue-float-dot"></span><span>实时排队</span></div>
|
||
<div class="queue-float-num" id="queue-num">0</div>
|
||
<div class="queue-float-sub" id="queue-est">预计等待:--</div>
|
||
<div class="queue-float-sub">下载 <span id="queue-download">-</span> · 打包 <span id="queue-batch">-</span></div>
|
||
</div>
|
||
|
||
<!-- 下载任务悬浮窗(批量打包 + 单曲下载,可取消) -->
|
||
<div class="task-panel" id="task-panel">
|
||
<div class="task-panel-head">
|
||
<span class="tp-title">📥 下载任务 <span class="tp-count" id="task-count">0</span></span>
|
||
<button class="tp-close" id="task-panel-close" type="button">✕</button>
|
||
</div>
|
||
<div class="task-panel-list" id="task-list"></div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* Promise.finally 老内核 polyfill(MDN 标准实现,ES5 语法)。
|
||
finally 需 Chrome 63+;支持矩阵承诺 Chromium 57-85 可用降级,
|
||
20 处业务代码的 .finally 依赖此垫片在 57-62 上正常工作 */
|
||
if (!Promise.prototype.finally) {
|
||
Promise.prototype.finally = function (cb) {
|
||
var P = this.constructor || Promise;
|
||
return this.then(
|
||
function (v) { return P.resolve(cb()).then(function () { return v; }); },
|
||
function (e) { return P.resolve(cb()).then(function () { throw e; }); }
|
||
);
|
||
};
|
||
}
|
||
|
||
var $ = function (s) { return document.querySelector(s); };
|
||
var $$ = function (s) { return Array.prototype.slice.call(document.querySelectorAll(s)); };
|
||
|
||
/* ================= 全局状态 ================= */
|
||
var state = {
|
||
currentSong: null, // 当前解析的歌曲数据
|
||
lyrics: [], // 解析后的 LRC 时间轴 [{time, text}]
|
||
lyricsTrans: [], // 翻译歌词时间轴
|
||
rawLyric: null, // 原始 LRC 歌词(用于重新合并翻译)
|
||
rawTlyric: null, // 原始翻译歌词
|
||
currentTab: 'search',
|
||
commentOffset: 0,
|
||
commentTotal: 0,
|
||
commentHasMore: false,
|
||
settings: {
|
||
primaryColor: '#1677ff',
|
||
themeMode: 'system', // light | dark | system
|
||
ktv: false,
|
||
lyric2line: true,
|
||
progressMemory: true,
|
||
showComments: true,
|
||
smtc: true, // 启用SMTC
|
||
filenameFormat: 'artist_song', // song | artist_song | song_artist
|
||
writeMeta: true,
|
||
metaFields: { name: true, artist: true, album: true, cover: true, lyrics: false, date: false },
|
||
zipEnabled: false,
|
||
batchWriteMeta: true,
|
||
batchZip: true, // 批量下载默认 ZIP 打包(任务面板有进度,一次下载一个包)
|
||
lyricFormat: 'lrc', // lrc | srt
|
||
lyricFont: 1, // 歌词字号倍数(0.7–1.6,#lyric-fontsize 调节)
|
||
cookiePrefs: { functional: true, accepted: false } // 新用户默认未同意
|
||
},
|
||
adminAuthed: false, // 管理员是否已通过密码验证
|
||
adminPassword: '', // 已废弃(旧口令 2026-09-15 废除;清理后前端不再发送)
|
||
adminQrTimer: null, // 二维码轮询定时器
|
||
adminUnikey: '',
|
||
personal: { // 个人歌单(游客扫码登录自己的账号,Cookie 保存在本机浏览器)
|
||
authed: false,
|
||
qrTimer: null,
|
||
unikey: '',
|
||
user: null,
|
||
playlists: [],
|
||
current: null, // 当前查看的个人歌单 {id, name}
|
||
cookie: '' // 个人账号 Cookie(保存在本机浏览器;站点账号登录时后端同步一份供管理后台查看)
|
||
},
|
||
global: { // 全局账号歌单(管理员 cookie.txt)
|
||
user: null,
|
||
playlists: [],
|
||
current: null, // 当前查看的全局歌单 {id, name}
|
||
playQueue: [] // 公开播放 API 返回的可播放歌曲(用于播放歌单)
|
||
},
|
||
parseAccount: 'admin', // 解析账号:admin=全局账号 / user=个人账号
|
||
levelsOk: [], // 当前解析账号实测可解析的音质(从高到低)
|
||
lyricOffset: 0, // 歌词偏移校准(秒)
|
||
currentCollection: null, // 当前展示的歌单/专辑 {type, id, name}
|
||
podcast: { // 播客模块状态
|
||
hotLoaded: false, // 热门播客是否已加载
|
||
hotCate: null, // 当前热门分类 ID
|
||
hotOffset: 0, // 热门列表分页偏移
|
||
radio: null, // 当前解析的电台详情
|
||
radioId: null, // 当前电台 ID
|
||
program: null, // 当前解析的单集详情
|
||
offset: 0, // 单集列表分页偏移
|
||
count: 0, // 单集总数
|
||
asc: true // 单集排序:true=正序 / false=倒序
|
||
},
|
||
auth: { // 站点账号状态
|
||
user: null, // 当前登录用户 {id, username, email, role, ...}
|
||
checked: false, // 是否已完成 /auth/me 检查
|
||
annSeen: '' // 已读公告标记
|
||
}
|
||
};
|
||
|
||
/* VIP 类型 → 名称 / 可解析音质 */
|
||
var VIP_NAME = { 0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP' };
|
||
var VIP_QUALITIES = {
|
||
0: [['standard', '标准音质'], ['exhigh', '极高音质']],
|
||
1: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']],
|
||
11: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']],
|
||
100: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']],
|
||
10: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质'],
|
||
['sky', '沉浸环绕声'], ['jyeffect', '高清环绕声'], ['jymaster', '超清母带'], ['dolby', '杜比全景声']],
|
||
110: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质'],
|
||
['sky', '沉浸环绕声'], ['jyeffect', '高清环绕声'], ['jymaster', '超清母带'], ['dolby', '杜比全景声']]
|
||
};
|
||
var AUTO_LADDER = ['jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard'];
|
||
|
||
/* ================= 设置 ================= */
|
||
var THEME_COLORS = [
|
||
{ name: '默认蓝', color: '#1677ff' },
|
||
{ name: '红色', color: '#f5222d' },
|
||
{ name: '橙色', color: '#fa8c16' },
|
||
{ name: '火山', color: '#d4380d' },
|
||
{ name: '青色', color: '#13c2c2' },
|
||
{ name: '绿色', color: '#52c41a' },
|
||
{ name: '粉红', color: '#eb2f96' },
|
||
{ name: '紫罗兰', color: '#722ed1' }
|
||
];
|
||
|
||
function loadSettings() {
|
||
// 可读部分先应用;单条目损坏不阻断整体恢复;
|
||
// primaryColor 校验为合法 #rrggbb(数值等坏值不再炸 applyTheme)
|
||
var s = {};
|
||
try { s = JSON.parse(localStorage.getItem('netease_settings') || '{}') || {}; } catch (e) { s = {}; }
|
||
if (typeof s !== 'object') s = {};
|
||
// 一次性迁移:批量下载默认改为 ZIP 打包(老用户未主动开启过打包也升级,之后可在设置中关闭)
|
||
// 迁移结果必须持久化(原实现只写完成标志,第二次加载回退旧值)
|
||
try {
|
||
if (!localStorage.getItem('netease_batchzip_migrated')) {
|
||
if (s.batchZip !== true) { delete s.batchZip; }
|
||
localStorage.setItem('netease_settings', JSON.stringify(s));
|
||
localStorage.setItem('netease_batchzip_migrated', '1');
|
||
}
|
||
} catch (e) {}
|
||
if (typeof s.primaryColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(s.primaryColor)) {
|
||
state.settings.primaryColor = s.primaryColor;
|
||
}
|
||
if (s.themeMode) state.settings.themeMode = s.themeMode;
|
||
['ktv', 'lyric2line', 'progressMemory', 'showComments', 'smtc', 'writeMeta',
|
||
'batchWriteMeta', 'batchZip', 'zipEnabled'].forEach(function (k) {
|
||
if (typeof s[k] === 'boolean') state.settings[k] = s[k];
|
||
});
|
||
if (s.filenameFormat) state.settings.filenameFormat = s.filenameFormat;
|
||
if (s.lyricFormat) state.settings.lyricFormat = s.lyricFormat;
|
||
if (typeof s.lyricFont === 'number' && isFinite(s.lyricFont)) {
|
||
// 歌词字号:限幅 0.7–1.6 并按 5% 步进取整(坏值/超范围自动收回)
|
||
state.settings.lyricFont = Math.max(0.7, Math.min(1.6, Math.round(s.lyricFont * 20) / 20));
|
||
}
|
||
if (s.metaFields && typeof s.metaFields === 'object') {
|
||
Object.keys(state.settings.metaFields).forEach(function (k) {
|
||
if (typeof s.metaFields[k] === 'boolean') state.settings.metaFields[k] = s.metaFields[k];
|
||
});
|
||
}
|
||
if (s.cookiePrefs && typeof s.cookiePrefs === 'object') {
|
||
if (typeof s.cookiePrefs.functional === 'boolean') state.settings.cookiePrefs.functional = s.cookiePrefs.functional;
|
||
if (typeof s.cookiePrefs.accepted === 'boolean') state.settings.cookiePrefs.accepted = s.cookiePrefs.accepted;
|
||
}
|
||
}
|
||
|
||
function saveSettings() {
|
||
// 返回是否真的持久化成功(调用方据此给出准确提示,不再一律"已保存")
|
||
try {
|
||
localStorage.setItem('netease_settings', JSON.stringify(state.settings));
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
function applyTheme() {
|
||
var root = document.documentElement;
|
||
// 主题切换平滑过渡:切换期间短暂启用全变量颜色过渡
|
||
root.classList.add('theme-fading');
|
||
setTimeout(function () { root.classList.remove('theme-fading'); }, 320);
|
||
var mode = state.settings.themeMode;
|
||
var dark = mode === 'dark' || (mode === 'system' &&
|
||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
root.classList.toggle('dark', dark);
|
||
root.style.setProperty('--color-primary', state.settings.primaryColor);
|
||
root.style.setProperty('--color-primary-bg', mixRgba(state.settings.primaryColor, 0.12));
|
||
root.style.setProperty('--color-primary-hover', mixRgba(state.settings.primaryColor, 0.9));
|
||
root.style.setProperty('--color-primary-active', mixRgba(state.settings.primaryColor, 0.7));
|
||
$('#theme-toggle').innerHTML = dark ? '☀️ <span>浅色</span>' : '🌙 <span>深色</span>';
|
||
}
|
||
|
||
function mixRgba(hex, alpha) {
|
||
var r = parseInt(hex.slice(1, 3), 16);
|
||
var g = parseInt(hex.slice(3, 5), 16);
|
||
var b = parseInt(hex.slice(5, 7), 16);
|
||
// alpha > 0.5 视为向白色混合得到 hover/active 色,否则返回半透明色
|
||
if (alpha > 0.5) {
|
||
var k = 1 - alpha;
|
||
return 'rgb(' + Math.round(r + (255 - r) * k) + ',' +
|
||
Math.round(g + (255 - g) * k) + ',' +
|
||
Math.round(b + (255 - b) * k) + ')';
|
||
}
|
||
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
||
}
|
||
|
||
function renderSettingsUI() {
|
||
// 主题色
|
||
var palette = $('#color-palette');
|
||
palette.innerHTML = THEME_COLORS.map(function (c) {
|
||
return '<div class="color-dot' + (state.settings.primaryColor === c.color ? ' active' : '') +
|
||
'" data-color="' + c.color + '" title="' + c.name + '" style="background:' + c.color + ';"></div>';
|
||
}).join('');
|
||
// 外观模式
|
||
$$('#mode-seg .seg-item').forEach(function (el) {
|
||
el.classList.toggle('active', el.getAttribute('data-mode') === state.settings.themeMode);
|
||
});
|
||
// 开关
|
||
$('#switch-ktv').classList.toggle('on', state.settings.ktv);
|
||
$('#switch-lyric2line').classList.toggle('on', state.settings.lyric2line);
|
||
$('#switch-progress').classList.toggle('on', state.settings.progressMemory);
|
||
$('#switch-comments').classList.toggle('on', state.settings.showComments);
|
||
$('#switch-smtc').classList.toggle('on', state.settings.smtc);
|
||
// 下载配置
|
||
$('#cfg-filename-format').value = state.settings.filenameFormat;
|
||
$('#cfg-write-meta').classList.toggle('on', state.settings.writeMeta);
|
||
$('#cfg-zip').classList.toggle('on', state.settings.zipEnabled);
|
||
$('#cfg-batch-meta').classList.toggle('on', state.settings.batchWriteMeta);
|
||
$('#cfg-batch-zip').classList.toggle('on', state.settings.batchZip);
|
||
$('#cfg-lyric-format').value = state.settings.lyricFormat;
|
||
// 写入字段复选框
|
||
$$('#cfg-meta-fields-wrap input[type=checkbox]').forEach(function (cb) {
|
||
cb.checked = !!state.settings.metaFields[cb.getAttribute('data-field')];
|
||
cb.disabled = !state.settings.writeMeta;
|
||
});
|
||
// 功能性 Cookie
|
||
$('#switch-cookie-func').classList.toggle('on', state.settings.cookiePrefs.functional);
|
||
$('#switch-cookie-func').setAttribute('aria-checked', state.settings.cookiePrefs.functional ? 'true' : 'false');
|
||
$('#cookie-func-tag').textContent = state.settings.cookiePrefs.functional ? '已启用' : '已禁用';
|
||
// 主题色点键盘可达(同型——动态生成后统一补属性)
|
||
$$('#color-palette .color-dot').forEach(function (el) {
|
||
el.setAttribute('tabindex', '0');
|
||
el.setAttribute('role', 'button');
|
||
});
|
||
}
|
||
|
||
$('#color-palette').addEventListener('click', function (e) {
|
||
var dot = e.target.closest('.color-dot');
|
||
if (!dot) return;
|
||
state.settings.primaryColor = dot.getAttribute('data-color');
|
||
renderSettingsUI();
|
||
applyTheme();
|
||
});
|
||
// 主题色点 Enter/空格激活
|
||
$('#color-palette').addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
var dot = e.target.closest ? e.target.closest('.color-dot') : null;
|
||
if (!dot) return;
|
||
e.preventDefault();
|
||
dot.click();
|
||
});
|
||
|
||
$('#mode-seg').addEventListener('click', function (e) {
|
||
var item = e.target.closest('.seg-item');
|
||
if (!item) return;
|
||
state.settings.themeMode = item.getAttribute('data-mode');
|
||
renderSettingsUI();
|
||
applyTheme();
|
||
});
|
||
// 外观模式分段键盘可达
|
||
$('#mode-seg').addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
var item = e.target.closest ? e.target.closest('.seg-item') : null;
|
||
if (!item) return;
|
||
e.preventDefault();
|
||
item.click();
|
||
});
|
||
|
||
function bindSwitch(id, key) {
|
||
var el = $(id);
|
||
if (!el) return;
|
||
// 自定义开关补全键盘语义(tabindex/role/aria + Enter/空格激活)
|
||
el.setAttribute('tabindex', '0');
|
||
el.setAttribute('role', 'switch');
|
||
var activate = function () {
|
||
state.settings[key] = !state.settings[key];
|
||
renderSettingsUI();
|
||
applySettingsEffects();
|
||
};
|
||
el.addEventListener('click', activate);
|
||
el.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
activate();
|
||
}
|
||
});
|
||
}
|
||
bindSwitch('#switch-ktv', 'ktv');
|
||
bindSwitch('#switch-lyric2line', 'lyric2line');
|
||
bindSwitch('#switch-progress', 'progressMemory');
|
||
bindSwitch('#switch-comments', 'showComments');
|
||
bindSwitch('#switch-smtc', 'smtc');
|
||
|
||
// 配置区开关同样支持键盘操作
|
||
function bindCfgSwitch(id, fn) {
|
||
var el = $(id);
|
||
if (!el) return;
|
||
el.setAttribute('tabindex', '0');
|
||
el.setAttribute('role', 'switch');
|
||
el.addEventListener('click', fn);
|
||
el.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
fn();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 下载/批量/歌词 配置控件
|
||
$('#cfg-filename-format').addEventListener('change', function () {
|
||
state.settings.filenameFormat = this.value;
|
||
});
|
||
bindCfgSwitch('#cfg-write-meta', function () {
|
||
state.settings.writeMeta = !state.settings.writeMeta;
|
||
renderSettingsUI();
|
||
});
|
||
bindCfgSwitch('#cfg-zip', function () {
|
||
state.settings.zipEnabled = !state.settings.zipEnabled;
|
||
renderSettingsUI();
|
||
});
|
||
bindCfgSwitch('#cfg-batch-meta', function () {
|
||
state.settings.batchWriteMeta = !state.settings.batchWriteMeta;
|
||
renderSettingsUI();
|
||
});
|
||
bindCfgSwitch('#cfg-batch-zip', function () {
|
||
state.settings.batchZip = !state.settings.batchZip;
|
||
renderSettingsUI();
|
||
});
|
||
$('#cfg-lyric-format').addEventListener('change', function () {
|
||
state.settings.lyricFormat = this.value;
|
||
});
|
||
$('#cfg-meta-fields-wrap').addEventListener('change', function (e) {
|
||
var cb = e.target;
|
||
if (cb.getAttribute('type') !== 'checkbox') return;
|
||
state.settings.metaFields[cb.getAttribute('data-field')] = cb.checked;
|
||
});
|
||
// 各分区保存按钮
|
||
// 保存提示反映真实持久化结果(存储被禁/配额满时不再谎报成功)
|
||
function saveSettingsToast(okMsg) {
|
||
toast(saveSettings() ? okMsg : '设置未能保存(浏览器存储不可用),仅本次生效', 'warning');
|
||
}
|
||
$('#cfg-save-download').addEventListener('click', function () { saveSettingsToast('下载配置已保存'); });
|
||
$('#cfg-save-batch').addEventListener('click', function () { saveSettingsToast('批量下载配置已保存'); });
|
||
$('#cfg-save-lyric').addEventListener('click', function () { saveSettingsToast('歌词配置已保存'); });
|
||
|
||
// 下载配置参数构建(供解析/下载请求使用)
|
||
function downloadConfig() {
|
||
var fields = Object.keys(state.settings.metaFields).filter(function (k) {
|
||
return state.settings.metaFields[k];
|
||
});
|
||
return {
|
||
filename_format: state.settings.filenameFormat,
|
||
write_meta: state.settings.writeMeta ? 'true' : 'false',
|
||
meta_fields: state.settings.writeMeta && fields.length ? fields.join(',') : '__none__',
|
||
lyric_format: state.settings.lyricFormat,
|
||
zip: state.settings.zipEnabled ? 'true' : 'false'
|
||
};
|
||
}
|
||
|
||
function applySettingsEffects() {
|
||
// 评论区显隐
|
||
$('#comment-panel').hidden = !(state.settings.showComments && state.currentSong);
|
||
updateCommentComposer();
|
||
// 评论从关到开时,为当前歌曲补加载评论(原来只显示面板,
|
||
// 可能展示旧歌评论或空白)
|
||
if (state.settings.showComments && state.currentSong
|
||
&& !state.commentTotal && !state._commentsLoading) {
|
||
loadComments(state.currentSong.id, 1, true);
|
||
}
|
||
// KTV 从开到关时终止已启动的动画帧循环(原来旧帧继续更新并排下一帧)
|
||
if (!state.settings.ktv && state.ktvRunning) {
|
||
state.ktvRunning = false;
|
||
}
|
||
// 重绘歌词(KTV / 翻译分行模式)
|
||
if (state.rawLyric != null) {
|
||
state.lyrics = buildLyricLines();
|
||
renderLyrics();
|
||
}
|
||
}
|
||
|
||
$('#settings-save').addEventListener('click', function () {
|
||
saveSettingsToast('设置已保存');
|
||
closeDrawer();
|
||
});
|
||
$('#settings-reset').addEventListener('click', function () {
|
||
state.settings = {
|
||
primaryColor: '#1677ff',
|
||
themeMode: 'system',
|
||
ktv: false,
|
||
lyric2line: true,
|
||
progressMemory: true,
|
||
showComments: true,
|
||
smtc: true,
|
||
filenameFormat: 'artist_song',
|
||
writeMeta: true,
|
||
metaFields: { name: true, artist: true, album: true, cover: true, lyrics: false, date: false },
|
||
zipEnabled: false,
|
||
batchWriteMeta: true,
|
||
batchZip: true, // 批量下载默认 ZIP 打包(任务面板有进度,一次下载一个包)
|
||
lyricFormat: 'lrc',
|
||
lyricFont: 1, // 恢复默认设置时字号一并回 100%
|
||
cookiePrefs: { functional: true, accepted: false } // 新用户默认未同意
|
||
};
|
||
renderSettingsUI();
|
||
applyTheme();
|
||
applySettingsEffects();
|
||
applyLyricFont();
|
||
saveSettings();
|
||
toast('已恢复默认设置', 'info');
|
||
});
|
||
|
||
/* ================= 接口请求统计 ================= */
|
||
var statsTimer = null;
|
||
function loadStats() {
|
||
apiPost('/api/topen', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取统计失败');
|
||
var d = data.data;
|
||
$('#stat-total').textContent = fmtCount(d.total);
|
||
$('#stat-endpoints').textContent = d.endpoint_count;
|
||
$('#stat-max').textContent = fmtCount(d.max_count);
|
||
var now = new Date();
|
||
$('#stats-refresh-time').textContent =
|
||
String(now.getHours()).padStart(2, '0') + ':' +
|
||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||
String(now.getSeconds()).padStart(2, '0');
|
||
var list = d.endpoints || [];
|
||
$('#stats-list').innerHTML = list.map(function (ep, i) {
|
||
return '<div class="stat-list-item">' +
|
||
'<span class="stat-rank">#' + (i + 1) + '</span>' +
|
||
'<div class="stat-endpoint">' +
|
||
'<div class="ep-name">' + escapeHtml(ep.name) + '</div>' +
|
||
'<div class="ep-path">' + escapeHtml(ep.path) + '</div>' +
|
||
'</div>' +
|
||
'<span class="stat-count">' + escapeHtml(String(fmtCount(ep.count))) + '次 (' + escapeHtml(String(ep.percent)) + '%)</span>' +
|
||
'</div>';
|
||
}).join('') || '<div class="comment-empty">暂无请求记录</div>';
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取统计失败', 'error');
|
||
});
|
||
}
|
||
function openStatsModal() {
|
||
$('#stats-modal').classList.add('show');
|
||
loadStats();
|
||
if (statsTimer) clearInterval(statsTimer);
|
||
statsTimer = setInterval(loadStats, 10000);
|
||
}
|
||
function closeStatsModal() {
|
||
$('#stats-modal').classList.remove('show');
|
||
if (statsTimer) { clearInterval(statsTimer); statsTimer = null; }
|
||
}
|
||
$('#cfg-stats-view').addEventListener('click', openStatsModal);
|
||
$('#cfg-stats-refresh').addEventListener('click', openStatsModal);
|
||
$('#stats-refresh-btn').addEventListener('click', loadStats);
|
||
$('#stats-close').addEventListener('click', closeStatsModal);
|
||
$('#stats-modal').addEventListener('click', function (e) { if (e.target === this) closeStatsModal(); });
|
||
|
||
/* ================= 隐私与协议(Cookie 偏好) ================= */
|
||
// mode='register' 为注册门槛模式——打开即自动计时 16 秒强制阅读,
|
||
// 计时归零且勾选同意后才允许「同意并继续注册」进入注册弹窗(同意一次永久记住,
|
||
// 撤回/清浏览器数据后重新计时)。普通模式(首访/设置入口)行为不变。
|
||
var _cookieRegMode = false;
|
||
var _cookieReadTimer = null;
|
||
var _cookieReadLeft = 0;
|
||
var _cookieTitleDefault = null;
|
||
function _cookieStopReadTimer() {
|
||
if (_cookieReadTimer) { clearInterval(_cookieReadTimer); _cookieReadTimer = null; }
|
||
}
|
||
function _cookieUpdateContinueBtn() {
|
||
var btn = $('#cookie-agree-continue');
|
||
if (!btn) return;
|
||
if (_cookieReadLeft > 0) {
|
||
btn.disabled = true;
|
||
btn.textContent = '请阅读协议(' + _cookieReadLeft + 's)';
|
||
return;
|
||
}
|
||
btn.textContent = '同意并继续注册';
|
||
btn.disabled = !$('#cookie-agree').checked;
|
||
}
|
||
function _cookieSetRegMode(isReg) {
|
||
_cookieRegMode = isReg;
|
||
$('#cookie-withdraw').hidden = isReg;
|
||
$('#cookie-save').hidden = isReg;
|
||
var cont = $('#cookie-agree-continue');
|
||
if (cont) cont.hidden = !isReg;
|
||
var title = $('#cookie-modal .modal-title');
|
||
if (title) {
|
||
if (_cookieTitleDefault === null) _cookieTitleDefault = title.textContent;
|
||
title.textContent = isReg ? '注册前请阅读并同意协议' : _cookieTitleDefault;
|
||
}
|
||
}
|
||
function openCookieModal(mode) {
|
||
// 反向叠窗守卫——公告先开时 Cookie 窗(强制同意门)
|
||
// 优先,收起公告弹窗再开(游客 once 模式已在打开时记已读,属可接受取舍)
|
||
var _am = document.getElementById('announce-modal');
|
||
if (_am && _am.classList.contains('show')) _am.classList.remove('show');
|
||
var isReg = mode === 'register' && !state.settings.cookiePrefs.accepted;
|
||
$('#cookie-agree').checked = state.settings.cookiePrefs.accepted;
|
||
renderSettingsUI();
|
||
_cookieSetRegMode(isReg);
|
||
var m = $('#cookie-modal');
|
||
m.classList.add('show');
|
||
_cookieStopReadTimer();
|
||
if (isReg) {
|
||
// 16 秒强制阅读:弹窗被任何方式关闭(Escape/遮罩/关闭钮)由 tick
|
||
// 自检停止——不碰 MutationObserver(渲染器活锁教训)
|
||
_cookieReadLeft = 16;
|
||
_cookieUpdateContinueBtn();
|
||
_cookieReadTimer = setInterval(function () {
|
||
if (!m.classList.contains('show')) { _cookieStopReadTimer(); return; }
|
||
_cookieReadLeft = Math.max(0, _cookieReadLeft - 1);
|
||
_cookieUpdateContinueBtn();
|
||
if (_cookieReadLeft <= 0) _cookieStopReadTimer();
|
||
}, 1000);
|
||
}
|
||
}
|
||
function closeCookieModal() {
|
||
_cookieStopReadTimer();
|
||
_cookieReadLeft = 0;
|
||
$('#cookie-modal').classList.remove('show');
|
||
_cookieSetRegMode(false); // 复位为常规 footer,普通入口打开状态正确
|
||
}
|
||
$('#cfg-cookie-prefs').addEventListener('click', function () { openCookieModal(); }); // 显式包裹,避免事件对象被误当 mode 实参
|
||
$('#cookie-close').addEventListener('click', closeCookieModal);
|
||
$('#cookie-modal').addEventListener('click', function (e) { if (e.target === this) closeCookieModal(); });
|
||
$('#cookie-agree').addEventListener('change', function () {
|
||
if (_cookieRegMode) _cookieUpdateContinueBtn();
|
||
});
|
||
|
||
var _toggleCookieFunc = function () {
|
||
state.settings.cookiePrefs.functional = !state.settings.cookiePrefs.functional;
|
||
renderSettingsUI();
|
||
toast(state.settings.cookiePrefs.functional ? '功能性 Cookie 已启用' : '功能性 Cookie 已禁用(解析与下载将不可用)', 'warning');
|
||
};
|
||
$('#switch-cookie-func').addEventListener('click', _toggleCookieFunc);
|
||
// 功能性 Cookie 开关键盘可达
|
||
$('#switch-cookie-func').addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggleCookieFunc(); }
|
||
});
|
||
|
||
$('#cookie-save').addEventListener('click', function () {
|
||
var agreed = $('#cookie-agree').checked;
|
||
if (!agreed) {
|
||
toast('请先勾选已阅读并同意《用户协议》和《隐私政策》', 'warning');
|
||
return;
|
||
}
|
||
state.settings.cookiePrefs.accepted = true;
|
||
saveSettings();
|
||
closeCookieModal();
|
||
toast('Cookie 偏好已保存', 'success');
|
||
});
|
||
// 注册门槛模式的「同意并继续注册」——保存同意与功能性偏好,
|
||
// 关闭本弹窗并放行进入注册弹窗(聚焦用户名框)
|
||
$('#cookie-agree-continue').addEventListener('click', function () {
|
||
if (this.disabled) return;
|
||
if (!$('#cookie-agree').checked) {
|
||
toast('请先勾选已阅读并同意《用户协议》和《隐私政策》', 'warning');
|
||
return;
|
||
}
|
||
state.settings.cookiePrefs.accepted = true;
|
||
saveSettings();
|
||
renderSettingsUI();
|
||
closeCookieModal();
|
||
toast('已同意《用户协议》与《隐私政策》,请继续注册', 'success');
|
||
var rm = $('#register-modal');
|
||
if (rm) {
|
||
rm.classList.add('show');
|
||
var un = $('#reg-username');
|
||
if (un) { try { un.focus(); } catch (e) {} }
|
||
}
|
||
});
|
||
$('#cookie-withdraw').addEventListener('click', function () {
|
||
state.settings.cookiePrefs = { functional: true, accepted: false };
|
||
saveSettings();
|
||
renderSettingsUI();
|
||
closeCookieModal();
|
||
toast('已撤回同意', 'info');
|
||
});
|
||
|
||
// 功能性 Cookie 关闭时禁止解析/下载;mode='register' 时只强制协议同意
|
||
// (功能性 Cookie 不拦注册——注册本身不依赖它,拦解析/下载时再报)
|
||
function cookieGate(mode) {
|
||
if (!state.settings.cookiePrefs.accepted) {
|
||
var reg = mode === 'register';
|
||
toast(reg ? '注册前请先阅读并同意《用户协议》与《隐私政策》' : '请先阅读并同意《用户协议》和《隐私政策》', 'warning');
|
||
openCookieModal(reg ? 'register' : undefined);
|
||
return false;
|
||
}
|
||
if (mode !== 'register' && !state.settings.cookiePrefs.functional) {
|
||
toast('功能性 Cookie 已关闭,无法解析与下载音乐,请在「设置 → 隐私与协议」中开启', 'warning');
|
||
openCookieModal();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
// 暴露给 site_auth.js——注册提交前强制隐私同意(未同意则弹协议窗并中止)
|
||
window.SITE_COOKIE_GATE = cookieGate;
|
||
|
||
var _drawerPrevFocus = null;
|
||
function openDrawer() {
|
||
// 打开抽屉时把焦点移入抽屉(键盘/读屏用户不再滞留在页面背后)
|
||
_drawerPrevFocus = document.activeElement;
|
||
$('#settings-drawer').classList.add('show');
|
||
$('#drawer-mask').classList.add('show');
|
||
var first = $('#settings-drawer').querySelector('button, [href], input, select, [tabindex]:not([tabindex="-1"])');
|
||
if (first) try { first.focus(); } catch (e) {}
|
||
}
|
||
function closeDrawer() {
|
||
$('#settings-drawer').classList.remove('show');
|
||
$('#drawer-mask').classList.remove('show');
|
||
// 关闭后把焦点还给触发控件
|
||
if (_drawerPrevFocus && typeof _drawerPrevFocus.focus === 'function') {
|
||
try { _drawerPrevFocus.focus(); } catch (e) {}
|
||
}
|
||
_drawerPrevFocus = null;
|
||
}
|
||
// Escape 关闭抽屉/弹窗(键盘可达性)
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Escape') return;
|
||
// 先关视觉最上层的弹窗(z-index 300 > 抽屉 201)——
|
||
// 抽屉内打开统计/Cookie 偏好弹窗时,旧顺序会关掉下层抽屉留下上层弹窗
|
||
var openModalMask = document.querySelector('.modal-mask.show');
|
||
if (openModalMask) {
|
||
/* 取最后一个 .show = 视觉最上层 */
|
||
var _stack = document.querySelectorAll('.modal-mask.show');
|
||
if (_stack.length > 1) openModalMask = _stack[_stack.length - 1];
|
||
// 统计弹窗的 10 秒轮询定时器只有 closeStatsModal 会清——
|
||
// Escape 走通用分支时必须先走专用关闭,否则定时器泄漏持续后台请求
|
||
if (openModalMask.id === 'stats-modal') {
|
||
closeStatsModal();
|
||
return;
|
||
}
|
||
// 登录/协议弹窗 Escape 走专用关闭——login-modal 需复位
|
||
// 找回/两步验证视图,cookie-modal 需复位注册门槛模式的 footer/标题
|
||
if (openModalMask.id === 'login-modal') {
|
||
if (window.resetAuthViews) window.resetAuthViews();
|
||
}
|
||
if (openModalMask.id === 'cookie-modal') {
|
||
closeCookieModal();
|
||
return;
|
||
}
|
||
openModalMask.classList.remove('show');
|
||
// 动态创建的弹窗(data-dynamic)随 Escape 一并移除 DOM:
|
||
// 静态弹窗是复用的不能删,动态弹窗残留会导致重复堆叠/占用 id
|
||
if (openModalMask.getAttribute('data-dynamic') === '1') openModalMask.remove();
|
||
return;
|
||
}
|
||
// 无弹窗时 Escape 仍关抽屉(弹窗优先于抽屉的顺序修正)
|
||
if ($('#settings-drawer').classList.contains('show')) closeDrawer();
|
||
});
|
||
$('#btn-settings').addEventListener('click', openDrawer);
|
||
$('#drawer-close').addEventListener('click', closeDrawer);
|
||
$('#drawer-mask').addEventListener('click', closeDrawer);
|
||
|
||
/* ================= 更新日志 ================= */
|
||
var CHANGELOG = [
|
||
{ v: '2.4.0', date: '2026-09-21', items: ['修复:解析结果在快速连续解析两首歌时可能互相覆盖(串台)的问题', '修复:超长曲目(100 分钟以上)歌词第 100 分钟起丢失的问题', '修复:连播切歌后歌词/评论区仍显示上一首的问题', '修复:歌名含特殊字符时下载领取失败的问题', '修复:老内核浏览器(Chrome 57-62)操作按钮卡加载的问题', '安全:登录失败次数过多临时锁定;公网错误信息不再暴露内部细节', '优化:下载结果保留期与「领取」入口统一为 35 分钟'] },
|
||
{ v: '2.1.0', date: '2026-08-28', items: ['调整:计费改为按次计费,一首歌消耗 1 次,歌单打包下载按歌曲数量扣减(如 100 首歌单打包 = 100 次)', '调整:免费试用改为 3 次(3 首歌),注册页领取,每台设备限领一次', '调整:卡密升级为次数卡,后台可生成 3/10/50/100/500/2000 次卡'] },
|
||
{ v: '1.6.0', date: '2026-08-28', items: ['新增:账号系统(强制登录):邮箱验证码注册、免费体验5分钟(设备识别防重复)、使用时长管理', '新增:管理后台:用户管理(删除/时长修改/网易云Cookie查看)、全局Cookie管理、SMTP邮箱配置、成功/失败日志、次数卡密生成、公告设置、数据库备份/下载/上传恢复/定时备份', '新增:个人中心:剩余时长、卡密充值、公告查看、版本日志、联系方式、退出登录', '新增:导航栏登录/注册按钮,登录后显示头像,管理员/普通用户角色识别与定向跳转', '调整:全局账号歌单仅管理员可见;解析功能需登录后使用'] },
|
||
{ v: '1.5.0', date: '2026-08-28', items: ['新增:播客解析模块,支持电台/单集链接与ID解析、在线试听、音频直链获取', '新增:播客下载方式:单集服务器下载(ZIP 可选)、整台播客打包下载,接入任务进度悬浮窗', '新增:播客搜索与热门播客分类卡片墙', '新增:「关于网站」菜单:项目介绍、功能说明、更新日志与作者信息'] },
|
||
{ v: '1.4.8', date: '2026-08-05', items: ['新增:单曲解析结果下方新增「下载歌词」按钮', '新增:播放器新增「播放进度记忆」功能', '优化:歌词下载自动过滤 YRC/LRC 头部的 JSON 元数据行'] },
|
||
{ v: '1.4.7', date: '2026-07-22', items: ['新增:设置「下载配置」分类中加入「写入字段」勾选区', '优化:未勾选字段不会覆盖原音频文件中的值'] },
|
||
{ v: '1.4.6', date: '2026-07-21', items: ['新增:单曲解析结果新增「自定义元数据」开关', '优化:每次解析新歌曲时自动重置自定义元数据'] },
|
||
{ v: '1.4.5', date: '2026-07-20', items: ['新增:逐字歌词(KTV 模式),支持逐字渐变高亮', '优化:歌词头部元数据(作词/作曲/编曲等)自动识别展示'] },
|
||
{ v: '1.4.4', date: '2026-07-19', items: ['新增:沉浸环绕声(sky)支持子类型 c51 / ste / aac', '优化:兼容网易云逐字歌词(YRC)'] },
|
||
{ v: '1.4.3', date: '2026-07-13', items: ['新增:歌手解析 Tab,支持热门 / 全部歌曲', '优化:更新日志改为独立页面'] },
|
||
{ v: '1.4.2', date: '2026-07-13', items: ['新增:8 种主题颜色,应用到全站强调色', '新增:独立「歌词」分类设置', '优化:榜单改为响应式卡片网格'] },
|
||
{ v: '1.4.1', date: '2026-07-05', items: ['新增:GitHub Issues 同步页', '新增:API 请求失败通知'] },
|
||
{ v: '1.4.0', date: '2026-07-02', items: ['重构:设置 / 统计由右侧 Drawer 改为独立页面'] },
|
||
{ v: '1.3.9', date: '2026-06-21', items: ['重构:移除 APlayer,自研全新 AudioPlayer', '优化:LRC 歌词毫秒精度高亮、点击歌词行跳转'] },
|
||
{ v: '1.3.8', date: '2026-06-20', items: ['新增:单曲信息下方评论区,支持分页加载', '优化:评论显示 VIP / SVIP / 音乐包标识、IP 归属地'] },
|
||
{ v: '1.3.6', date: '2026-05-24', items: ['新增:所有官方榜单全量支持', '新增:歌单 / 专辑批量下载本页'] },
|
||
{ v: '1.2.0', date: '2026-03-29', items: ['修复部分歌曲解析失败的问题', '优化界面交互体验'] }
|
||
];
|
||
|
||
function renderChangelog() {
|
||
$('#changelog-body').innerHTML = CHANGELOG.map(function (v) {
|
||
return '<div class="version-item">' +
|
||
'<div class="version-head"><span class="version-num">v' + v.v + '</span>' +
|
||
'<span class="version-date">' + v.date + '</span></div>' +
|
||
'<ul class="version-items">' + v.items.map(function (i) {
|
||
return '<li>' + escapeHtml(i) + '</li>';
|
||
}).join('') + '</ul></div>';
|
||
}).join('');
|
||
}
|
||
function openChangelog() { renderChangelog(); $('#changelog-modal').classList.add('show'); }
|
||
$('#btn-changelog').addEventListener('click', openChangelog);
|
||
$('#changelog-close').addEventListener('click', function () { $('#changelog-modal').classList.remove('show'); });
|
||
$('#changelog-modal').addEventListener('click', function (e) {
|
||
if (e.target === this) this.classList.remove('show');
|
||
});
|
||
|
||
/* ================= 页面加载 ================= */
|
||
window.addEventListener('load', function () {
|
||
setTimeout(function () {
|
||
var el = $('#page-loading');
|
||
if (el) el.remove();
|
||
}, 300);
|
||
});
|
||
|
||
/* ================= Toast ================= */
|
||
function toast(msg, type) {
|
||
type = type || 'info';
|
||
var wrap = $('#toast-wrap');
|
||
var el = document.createElement('div');
|
||
el.className = 'toast toast-' + type;
|
||
el.textContent = msg;
|
||
wrap.appendChild(el);
|
||
requestAnimationFrame(function () { el.classList.add('show'); });
|
||
setTimeout(function () {
|
||
el.classList.remove('show');
|
||
setTimeout(function () { el.remove(); }, 300);
|
||
}, 2600);
|
||
}
|
||
|
||
/* ================= 深色模式(顶部按钮) ================= */
|
||
$('#theme-toggle').addEventListener('click', function () {
|
||
state.settings.themeMode = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||
renderSettingsUI();
|
||
applyTheme();
|
||
saveSettings();
|
||
});
|
||
|
||
/* ================= 解析账号栏 ================= */
|
||
var _acctGen = 0; // 账号栏代次令牌——连点刷新/快速切账号时迟到响应不再覆盖新状态
|
||
function renderAccountBar() {
|
||
// 个人账号时携带本机保存的个人 Cookie(原实现只发 account:user,
|
||
// 后端拿不到个人凭据,永远显示"未登录")
|
||
var gen = ++_acctGen;
|
||
apiPost('/account/vip', {
|
||
account: state.parseAccount,
|
||
user_cookie: state.parseAccount === 'user' ? state.personal.cookie : ''
|
||
})
|
||
.then(function (data) {
|
||
if (gen !== _acctGen) return; // 已有新一轮刷新,丢弃迟到响应
|
||
if (data.status !== 200) throw new Error(data.message || '获取账号信息失败');
|
||
var d = data.data || {};
|
||
var badge = $('#account-vip-badge');
|
||
var isUser = d.account === 'user';
|
||
var label = (isUser ? '个人账号' : '全局账号') + ' · ' + (d.vip_name || '未知');
|
||
badge.textContent = label;
|
||
if (isUser) {
|
||
badge.style.borderColor = 'var(--color-warning)';
|
||
badge.style.color = 'var(--color-warning)';
|
||
badge.style.background = 'rgba(250,173,20,0.12)';
|
||
} else {
|
||
badge.style.borderColor = 'var(--color-primary)';
|
||
badge.style.color = 'var(--color-primary)';
|
||
badge.style.background = 'var(--color-primary-bg)';
|
||
}
|
||
// 可解析音质列表:优先使用接口实测结果
|
||
var quals = [];
|
||
if (d.levels_ok && d.levels_ok.length) {
|
||
state.levelsOk = d.levels_ok;
|
||
quals = d.levels_ok.map(function (lv) { return [lv, levelName(lv)]; });
|
||
} else {
|
||
state.levelsOk = [];
|
||
quals = VIP_QUALITIES[d.vip_type] || VIP_QUALITIES[0];
|
||
}
|
||
$('#account-qualities').textContent = '可解析:' + quals.map(function (q) { return q[1]; }).join('、');
|
||
// 提示
|
||
var note = $('#account-note');
|
||
if (isUser && !d.logged_in) {
|
||
note.innerHTML = '⚠️ 个人账号未登录,当前仍使用全局账号解析。<b>可在「个人歌单」扫码登录你自己的账号。</b>';
|
||
} else if (isUser) {
|
||
note.innerHTML = '💡 当前使用<b>个人账号</b>解析与下载(' + escapeHtml(d.nickname || '') + ' · ' + escapeHtml(d.vip_name || '') + ')。可在「个人歌单」随时切换回全局账号。';
|
||
} else if (!d.logged_in) {
|
||
note.innerHTML = '💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。' +
|
||
(d.note ? '<br>⚠️ ' + escapeHtml(d.note) +
|
||
';当前检测为「' + escapeHtml(d.vip_name || '普通用户') + '」。可在「管理员设置」重新扫码登录全局账号。'
|
||
: '<br>⚠️ 全局 Cookie 未关联有效账号信息,可在「管理员设置」重新扫码登录。');
|
||
} else {
|
||
note.innerHTML = '💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。';
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== _acctGen) return;
|
||
$('#account-vip-badge').textContent = '获取失败';
|
||
// 探测失败清空 levelsOk——避免 auto 音质阶梯继续用
|
||
// 旧账号(全局↔个人切换窗口)的能力列表逐级白试
|
||
state.levelsOk = [];
|
||
toast(err.message || '获取账号信息失败', 'error');
|
||
});
|
||
}
|
||
$('#account-refresh').addEventListener('click', renderAccountBar);
|
||
|
||
/* ================= Tab 切换 ================= */
|
||
var tabs = $$('#tabs .tab');
|
||
tabs.forEach(function (tab) {
|
||
tab.addEventListener('click', function () {
|
||
switchTab(tab.getAttribute('data-tab'));
|
||
});
|
||
});
|
||
|
||
function switchTab(name) {
|
||
state.currentTab = name;
|
||
// 切页签(含键盘回车激活,不产生 click)时关掉字号弹层,
|
||
// 否则 fs-pop-open 卡在 html 上,排队/任务浮窗在其他页签一直隐藏
|
||
if (typeof setLyricFsPop === 'function') setLyricFsPop(false);
|
||
tabs.forEach(function (t) {
|
||
t.classList.toggle('active', t.getAttribute('data-tab') === name);
|
||
});
|
||
$$('.panel').forEach(function (p) {
|
||
p.classList.toggle('active', p.getAttribute('data-panel') === name);
|
||
});
|
||
setTimeout(function () {
|
||
var input = {
|
||
search: '#search-input',
|
||
song: '#song-input',
|
||
playlist: '#playlist-input',
|
||
album: '#album-input',
|
||
artist: '#artist-input',
|
||
admin: '#admin-password'
|
||
}[name];
|
||
if (input) {
|
||
var el = $(input);
|
||
if (el && !el.value) el.focus();
|
||
}
|
||
}, 60);
|
||
// 进入榜单 Tab 时按需加载
|
||
if (name === 'toplist' && !state.toplistLoaded) loadToplists();
|
||
// 进入管理员设置:未验证则停留在密码门禁
|
||
if (name === 'admin' && !state.adminAuthed) {
|
||
$('#admin-gate').hidden = false;
|
||
$('#admin-content').hidden = true;
|
||
}
|
||
// 进入个人歌单:检查登录状态
|
||
if (name === 'personal') personalCheckLogin();
|
||
// 进入全局账号歌单:检查登录状态
|
||
if (name === 'global') globalCheckLogin();
|
||
// 进入播客:首次加载热门播客
|
||
if (name === 'podcast' && !state.podcast.hotLoaded) loadPodcastHot();
|
||
// 进入关于网站:渲染更新日志
|
||
if (name === 'about' && !$('#about-changelog').innerHTML) renderAboutChangelog();
|
||
// 进入个人中心:需登录
|
||
if (name === 'usercenter' && window.renderSITEUserCenter) window.renderSITEUserCenter();
|
||
// 进入管理后台:权限门由 site_auth.js 的 uaShowGate 处理
|
||
//(原这里调用不存在的 renderUserAdmin(),浏览器抛 ReferenceError)
|
||
if (name === 'useradmin' && window.renderSITEUserCenter) window.renderSITEUserCenter();
|
||
}
|
||
|
||
$('#brand-home').addEventListener('click', function () {
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
switchTab('search');
|
||
});
|
||
|
||
/* ================= 工具函数 ================= */
|
||
function extractId(text) {
|
||
text = (text || '').trim();
|
||
if (!text) return '';
|
||
if (text.indexOf('163cn.tv') !== -1) return text;
|
||
var m = text.match(/[?&]id=(\d+)/);
|
||
if (m) return m[1];
|
||
var n = text.match(/\b\d{4,}\b/);
|
||
if (n) return n[0];
|
||
return text;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function fmtTime(sec) {
|
||
if (isNaN(sec) || sec < 0) sec = 0;
|
||
var m = Math.floor(sec / 60);
|
||
var s = Math.floor(sec % 60);
|
||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||
}
|
||
|
||
function fmtCount(n) {
|
||
n = n || 0;
|
||
if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
|
||
if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
|
||
return String(n);
|
||
}
|
||
|
||
function fmtRelative(ts) {
|
||
if (!ts) return '';
|
||
var diff = Math.floor(Date.now() / 1000 - ts / 1000);
|
||
if (diff < 60) return '刚刚';
|
||
if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前';
|
||
if (diff < 86400 * 30) return Math.floor(diff / 86400) + ' 天前';
|
||
var d = new Date(ts);
|
||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(d.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
function levelName(level) {
|
||
if (level === 'auto') return '最高音质(自动)';
|
||
var map = {
|
||
standard: '标准音质', exhigh: '极高音质', lossless: '无损音质',
|
||
hires: 'Hi-Res音质', sky: '沉浸环绕声', jyeffect: '高清环绕声',
|
||
jymaster: '超清母带', dolby: '杜比全景声'
|
||
};
|
||
return map[level] || level;
|
||
}
|
||
|
||
function apiPost(url, params) {
|
||
var body = Object.keys(params).map(function (k) {
|
||
return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]);
|
||
}).join('&');
|
||
var attempt = function () {
|
||
// 请求超时:常规 15 秒;歌单/专辑/歌手/播客解析这类重端点放宽到 45 秒
|
||
// (大歌单服务端处理较久);AbortController 老内核缺失时优雅降级为无超时
|
||
var heavy = /\/(playlist|album|artist_songs|podcast\/parse)$/.test(url);
|
||
var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
var timer = null;
|
||
if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, heavy ? 45000 : 15000);
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||
body: body,
|
||
signal: ctl ? ctl.signal : undefined
|
||
}).then(function (r) {
|
||
// 超时计时器在"响应正文读完"之后再清除
|
||
//(原实现在 r.json() 之前 clear,正文读取阶段失去超时保护)
|
||
return r.json().catch(function () { throw new Error('服务响应异常'); })
|
||
.then(function (data) { if (timer) clearTimeout(timer); return data; },
|
||
function (err) { if (timer) clearTimeout(timer); throw err; });
|
||
}, function (err) {
|
||
if (timer) clearTimeout(timer);
|
||
throw err;
|
||
}).then(function (data) {
|
||
// 统一处理强制登录:未登录 / 次数耗尽时弹出登录框
|
||
if (data && data.status === 401 && (data.error_code === 'unauthorized' || data.error_code === 'expired')) {
|
||
if (url.indexOf('/auth/') !== 0) {
|
||
handleAuthExpired(data.message || '请先登录', data.error_code === 'expired');
|
||
}
|
||
}
|
||
return data;
|
||
});
|
||
};
|
||
// 网络层失败自动重试 1 次(丢包/断流/超时):
|
||
// 仅限幂等查询端点——下载提交/任务操作/评论/收藏/Cookie/扫码/登录一律不重试,
|
||
// 防止写操作重复执行;服务端有明确响应(4xx/5xx)不重试。
|
||
var retriable = url.indexOf('download') === -1
|
||
&& url.indexOf('/auth/') !== 0
|
||
&& url.indexOf('/api/task') === -1
|
||
&& url.indexOf('/user/') === -1
|
||
&& url.indexOf('/comments') === -1;
|
||
return attempt().catch(function (err) {
|
||
var networkLevel = err && (err.name === 'TypeError' || err.name === 'AbortError' ||
|
||
err.message === '服务响应异常');
|
||
if (retriable && networkLevel) {
|
||
return new Promise(function (res) { setTimeout(res, 700); }).then(attempt);
|
||
}
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
/* 登录前置检查:未登录时弹窗提示并打开登录框(状态由 site_auth.js 同步) */
|
||
function requireLogin() {
|
||
if (window.SITE_LOGGED_IN) return true;
|
||
if (window.handleAuthExpired) {
|
||
window.handleAuthExpired('解析功能需要登录后才能使用', false);
|
||
} else {
|
||
toast('解析功能需要登录后才能使用', 'warning');
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setBtnLoading(btn, loading, loadingText) {
|
||
if (!btn) return;
|
||
if (loading) {
|
||
// 重入保护——已在加载中不覆盖原始标签(重复提交后原标签丢失)
|
||
if (!btn.dataset.originText) btn.dataset.originText = btn.textContent;
|
||
btn.dataset.loadingCount = String((parseInt(btn.dataset.loadingCount, 10) || 0) + 1);
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> ' + escapeHtml(loadingText || '处理中...');
|
||
} else {
|
||
var n = (parseInt(btn.dataset.loadingCount, 10) || 1) - 1;
|
||
if (n > 0) { btn.dataset.loadingCount = String(n); return; } // 还有在途请求,保持加载态
|
||
btn.disabled = false;
|
||
delete btn.dataset.loadingCount;
|
||
if (btn.dataset.originText) {
|
||
btn.textContent = btn.dataset.originText;
|
||
delete btn.dataset.originText;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ================= 解析历史 ================= */
|
||
var HISTORY_KEY = 'netease_parse_history';
|
||
function getHistory() {
|
||
// 结构校验——null/对象/坏成员一律回退为空数组,
|
||
// 损坏历史不再中断页面初始化或播放器更新
|
||
var raw = null;
|
||
try { raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch (e) { return []; }
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw.filter(function (h) {
|
||
return h && typeof h === 'object' && h.id != null && typeof h.name !== 'undefined';
|
||
}).map(function (h) {
|
||
return { id: String(h.id), name: String(h.name == null ? h.id : h.name), t: Number(h.t) || 0 };
|
||
});
|
||
}
|
||
function addHistory(id, name) {
|
||
var list = getHistory();
|
||
list = list.filter(function (h) { return String(h.id) !== String(id); });
|
||
list.unshift({ id: String(id), name: name || String(id), t: Date.now() });
|
||
if (list.length > 8) list.length = 8;
|
||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); } catch (e) {}
|
||
renderHistory();
|
||
}
|
||
function renderHistory() {
|
||
var list = getHistory();
|
||
var row = $('#history-row');
|
||
var box = $('#history-list');
|
||
if (!list.length) { row.hidden = true; return; }
|
||
row.hidden = false;
|
||
box.innerHTML = list.map(function (h) {
|
||
return '<span class="tag" data-id="' + escapeHtml(h.id) + '" title="点击重新解析">' +
|
||
'<span class="tag-text">' + escapeHtml(h.name) + '</span>' +
|
||
'<span class="tag-close" data-close="' + escapeHtml(h.id) + '" title="删除">×</span></span>';
|
||
}).join(' ');
|
||
}
|
||
$('#history-list').addEventListener('click', function (e) {
|
||
var close = e.target.closest('.tag-close');
|
||
if (close) {
|
||
var id = close.getAttribute('data-close');
|
||
var list = getHistory().filter(function (h) { return String(h.id) !== id; });
|
||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); } catch (err) {}
|
||
renderHistory();
|
||
return;
|
||
}
|
||
var tag = e.target.closest('.tag');
|
||
if (tag) {
|
||
$('#song-input').value = tag.getAttribute('data-id');
|
||
switchTab('song');
|
||
parseSong();
|
||
}
|
||
});
|
||
renderHistory();
|
||
|
||
/* ================= 搜索 ================= */
|
||
var _searchGen = 0;
|
||
function doSearch() {
|
||
if (!requireLogin()) return;
|
||
var keyword = $('#search-input').value.trim();
|
||
if (!keyword) { toast('请输入搜索关键词', 'warning'); $('#search-input').focus(); return; }
|
||
var btn = $('#search-btn');
|
||
var gen = ++_searchGen; // 代次守卫——回车入口绕过按钮禁用,迟到响应不得覆盖新结果
|
||
setBtnLoading(btn, true, '搜索中...');
|
||
apiPost('/search', {
|
||
keyword: keyword,
|
||
limit: $('#search-limit').value,
|
||
type: $('#search-type').value
|
||
}).then(function (data) {
|
||
if (gen !== _searchGen) return; // 迟到响应丢弃
|
||
if (data.status !== 200) throw new Error(data.message || '搜索失败');
|
||
renderSearchList(data.data || []);
|
||
}).catch(function (err) {
|
||
if (gen !== _searchGen) return;
|
||
toast(err.message || '搜索失败,请重试', 'error');
|
||
}).finally(function () {
|
||
// setBtnLoading 是重入计数器——Enter 连发时两次
|
||
// 调用各自 +1,finally 必须无条件配对复位,否则旧代次被 gen 拦下后
|
||
// 计数永远无法归零、按钮永久卡死;代次条件只保留在 then/catch 的状态写入
|
||
setBtnLoading(btn, false);
|
||
});
|
||
}
|
||
|
||
function renderSearchList(list) {
|
||
var box = $('#search-list');
|
||
var result = $('#search-result');
|
||
if (!list || !list.length) {
|
||
result.hidden = false;
|
||
$('#search-count').textContent = '共 0 条';
|
||
box.innerHTML = '<div class="empty-state"><div class="empty-icon">🎵</div>未找到相关内容</div>';
|
||
return;
|
||
}
|
||
$('#search-count').textContent = '共 ' + list.length + ' 条';
|
||
box.innerHTML = list.map(function (item) {
|
||
var html = '';
|
||
if (item.type === 'song') {
|
||
html = '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(item.picUrl || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(item.name) +
|
||
'</span><span class="tag">' + escapeHtml(item.album || '') + '</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(item.artists || '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm btn-link act-parse" data-id="' + escapeHtml(item.id) + '" data-name="' + escapeHtml(item.name) + '" type="button">解析</button>' +
|
||
'<button class="btn btn-sm act-download" data-id="' + escapeHtml(item.id) + '" data-name="' + escapeHtml(item.name) + '" type="button">下载</button>' +
|
||
'</div></div>';
|
||
} else if (item.type === 'playlist') {
|
||
html = '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(item.picUrl || '') + '" loading="lazy" alt="" style="border-radius:12px;" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(item.name) + '</span><span class="tag">歌单</span></div>' +
|
||
'<div class="song-meta">by ' + escapeHtml(item.creator || '未知') + ' · ' + fmtCount(item.trackCount) + ' 首</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions"><button class="btn btn-sm btn-link act-goto-playlist" data-id="' + escapeHtml(item.id) + '" type="button">查看歌单</button></div></div>';
|
||
} else if (item.type === 'album') {
|
||
html = '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(item.picUrl || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(item.name) + '</span><span class="tag">专辑</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(item.artists || '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions"><button class="btn btn-sm btn-link act-goto-album" data-id="' + escapeHtml(item.id) + '" type="button">查看专辑</button></div></div>';
|
||
} else if (item.type === 'artist') {
|
||
html = '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(item.picUrl || '') + '" loading="lazy" alt="" style="border-radius:50%;" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(item.name) + '</span><span class="tag">歌手</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(item.alias || '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions"><button class="btn btn-sm btn-link act-goto-artist" data-id="' + escapeHtml(item.id) + '" type="button">查看歌手</button></div></div>';
|
||
}
|
||
return html;
|
||
}).join('');
|
||
result.hidden = false;
|
||
}
|
||
|
||
$('#search-btn').addEventListener('click', doSearch);
|
||
$('#search-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doSearch(); });
|
||
|
||
/* ================= 单曲解析 ================= */
|
||
function requestSong(params) {
|
||
// 附带解析账号;个人账号失效时自动回退全局账号
|
||
params.account = state.parseAccount;
|
||
if (state.parseAccount === 'user') params.user_cookie = state.personal.cookie;
|
||
return apiPost('/song', params).then(function (data) {
|
||
if (data.status === 401 && state.parseAccount === 'user') {
|
||
state.parseAccount = 'admin';
|
||
try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {}
|
||
renderAccountBar();
|
||
renderPersonalToggle();
|
||
toast('个人账号未登录或已失效,已自动切换为全局账号解析', 'warning');
|
||
params.account = 'admin';
|
||
return apiPost('/song', params);
|
||
}
|
||
return data;
|
||
});
|
||
}
|
||
|
||
function apiResolveLevel(idInput) {
|
||
// 'auto':按账号实测可解析列表从高到低尝试,返回首个可解析的音质
|
||
var lvl = $('#global-level').value;
|
||
if (lvl !== 'auto') return Promise.resolve(lvl);
|
||
var ladder = state.levelsOk && state.levelsOk.length ? state.levelsOk : AUTO_LADDER;
|
||
var idx = 0;
|
||
return new Promise(function (resolve) {
|
||
(function tryNext() {
|
||
if (idx >= ladder.length) { resolve('standard'); return; }
|
||
requestSong({ url: idInput, level: ladder[idx], type: 'url' })
|
||
.then(function (d) {
|
||
// 返回的音质必须与请求一致才算真正解析成功(否则视为降级,继续尝试)
|
||
if (d.status === 200 && d.data && d.data.url && d.data.level === ladder[idx]) {
|
||
resolve(ladder[idx]);
|
||
} else { idx++; tryNext(); }
|
||
})
|
||
.catch(function () { idx++; tryNext(); });
|
||
})();
|
||
});
|
||
}
|
||
|
||
var _parseGen = 0; // 单曲解析代次令牌(家族补漏——回车/搜索行/历史标签
|
||
// 三个入口绕过按钮禁用,连点两首歌时迟到响应覆盖新结果)
|
||
function parseSong() {
|
||
if (!requireLogin()) return;
|
||
var raw = $('#song-input').value.trim();
|
||
if (!raw) { toast('请输入歌曲ID或网易云音乐链接', 'warning'); $('#song-input').focus(); return; }
|
||
if (!cookieGate()) return;
|
||
var btn = $('#parse-btn');
|
||
var gen = ++_parseGen;
|
||
setBtnLoading(btn, true, '解析中...');
|
||
// 新解析一旦提交即收起上一首的结果——但 #player/#lyric-panel/
|
||
// #comment-panel 都嵌套在 #song-result 卡片内部,整卡隐藏会把正在播放的
|
||
// 旧歌播放器条一起藏掉(失败路径音频"隐身"继续播)。改为只藏 .song-detail
|
||
// 信息块(旧歌封面/名称/下载按钮),播放器照常可见可操作,歌词/评论照旧收起
|
||
$('#song-result .song-detail').hidden = true;
|
||
$('#lyric-panel').hidden = true;
|
||
$('#comment-panel').hidden = true;
|
||
state.lyrics = [];
|
||
apiResolveLevel(raw)
|
||
.then(function (level) {
|
||
return requestSong({ url: raw, level: level, type: 'json' });
|
||
})
|
||
.then(function (data) {
|
||
if (gen !== _parseGen) return; // 已有更新的解析,丢弃迟到响应
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '解析失败');
|
||
renderSongResult(data.data);
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== _parseGen) return;
|
||
toast(err.message || '解析失败,请检查歌曲ID或链接', 'error');
|
||
})
|
||
.then(function () { setBtnLoading(btn, false); },
|
||
function () { setBtnLoading(btn, false); });
|
||
}
|
||
|
||
function parseSongById(id, name) {
|
||
$('#song-input').value = id;
|
||
switchTab('song');
|
||
$('#song-input').focus();
|
||
if (name) toast('正在解析:' + name, 'info');
|
||
parseSong();
|
||
}
|
||
|
||
function renderSongResult(d) {
|
||
state.currentSong = d;
|
||
state.global.playQueue = []; // 单曲解析时清空播放歌单队列,避免串歌
|
||
state.global.playQueueIndex = 0;
|
||
state.lyrics = [];
|
||
state.rawLyric = null;
|
||
state.rawTlyric = null;
|
||
state.commentPage = 1;
|
||
state.commentTotal = 0;
|
||
state.commentReply = null; // 切歌清掉旧评论回复对象(防跨歌错配)
|
||
$('#detail-cover').src = d.pic || '';
|
||
$('#detail-name').textContent = d.name || '未知歌曲';
|
||
$('#detail-artist').textContent = d.ar_name || '未知歌手';
|
||
$('#detail-album').textContent = d.al_name || '未知专辑';
|
||
$('#detail-level').textContent = '音质:' + levelName(d.level);
|
||
$('#detail-size').textContent = d.size || '大小未知';
|
||
|
||
var tip = $('#detail-tip');
|
||
if (!d.url) {
|
||
tip.hidden = false;
|
||
tip.textContent = '⚠️ 当前音质无法获取下载链接,可能是版权限制或音质不支持,请尝试其他音质。';
|
||
} else {
|
||
tip.hidden = true;
|
||
}
|
||
|
||
$('#song-result').hidden = false;
|
||
$('#song-result .song-detail').hidden = false; // 恢复提交时收起的信息块
|
||
window.scrollTo({ top: $('#song-result').offsetTop - 80, behavior: 'smooth' });
|
||
|
||
// 重置歌词偏移校准
|
||
state.lyricOffset = 0;
|
||
$('#lyric-offset-label').textContent = '偏移 0.00s';
|
||
|
||
addHistory(d.id, d.name);
|
||
|
||
// 歌词
|
||
initLyrics(d.lyric || '', d.tlyric || '', d.yrc || '');
|
||
|
||
// 播放器
|
||
if (d.url) {
|
||
initPlayer(d.url, d.name || '', d.ar_name || '', d.pic || '');
|
||
} else {
|
||
stopPlayer();
|
||
}
|
||
|
||
// 评论区
|
||
if (state.settings.showComments) {
|
||
$('#comment-panel').hidden = false;
|
||
updateCommentComposer();
|
||
loadComments(d.id, 1, true);
|
||
} else {
|
||
$('#comment-panel').hidden = true;
|
||
}
|
||
}
|
||
|
||
$('#parse-btn').addEventListener('click', parseSong);
|
||
$('#song-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') parseSong(); });
|
||
|
||
/* ================= 歌词处理 ================= */
|
||
function lrctrim(lyrics) {
|
||
var lines = String(lyrics || '').split('\n');
|
||
var data = [];
|
||
lines.forEach(function (line, index) {
|
||
// 分钟放宽到 3 位(同型漏网——后端 SRT 当年修了,
|
||
// 前端没修:超 100 分钟曲目第 100 分钟起歌词行全部丢失)
|
||
var matches = line.match(/\[(\d{1,3}):(\d{1,2}[.:]?\d*)]/);
|
||
if (matches) {
|
||
var minutes = parseInt(matches[1], 10);
|
||
var seconds = parseFloat(matches[2].replace(':', '.')) || 0;
|
||
var timestamp = minutes * 60 + seconds;
|
||
var text = line.replace(/\[\d{1,3}:\d{1,2}[.:]?\d*]/g, '').trim();
|
||
text = text.replace(/\s\s+/g, ' ');
|
||
if (text) data.push({ time: timestamp, text: text, index: index });
|
||
}
|
||
});
|
||
data.sort(function (a, b) { return a.time - b.time; });
|
||
return data;
|
||
}
|
||
|
||
function mergeTranslation(lyric, tlyric) {
|
||
var trans = lrctrim(tlyric);
|
||
if (!trans.length) return lyric;
|
||
// 精确匹配优先,其次取时间最近者;每条译文只配一行原文,
|
||
// 不再被"第一个邻近"遮蔽精确配对或重复使用
|
||
var usedTrans = {};
|
||
return lyric.map(function (l) {
|
||
var best = null, bestDt = Infinity, bestIdx = -1;
|
||
for (var i = 0; i < trans.length; i++) {
|
||
if (usedTrans[i]) continue;
|
||
var dt = Math.abs(trans[i].time - l.time);
|
||
if (dt > 0.5) {
|
||
if (trans[i].time > l.time + 0.5) break;
|
||
continue;
|
||
}
|
||
if (dt < bestDt - 1e-9) { bestDt = dt; best = trans[i]; bestIdx = i; }
|
||
if (dt === 0) break; // 精确命中立即采用
|
||
}
|
||
if (best && best.text) {
|
||
usedTrans[bestIdx] = true;
|
||
// 保留纯原文——渲染层还原原文时不再依赖对拼接串做正则截断
|
||
//(原文/译文本身含 " (翻译:" 时贪婪正则会吃掉原文尾部)
|
||
var merged = { time: l.time, end: l.end, words: l.words, trans: best.text, index: l.index, orig: l.text };
|
||
if (state.settings.lyric2line) {
|
||
merged.text = l.text + '\n' + best.text;
|
||
} else {
|
||
merged.text = l.text + ' (翻译:' + best.text + ')';
|
||
}
|
||
return merged;
|
||
}
|
||
return l;
|
||
});
|
||
}
|
||
|
||
// 解析网易云 YRC 逐字歌词:行头 [startMs,durMs],每字 (startMs,durMs,0)字
|
||
// 字与字之间的正文允许包含半角括号——只把"合法时间标记"当分隔符,
|
||
// 不再把 (world) 这类正文当标记吃掉
|
||
function parseYrc(text) {
|
||
var out = [];
|
||
String(text || '').split('\n').forEach(function (line) {
|
||
var m = line.match(/^\[(\d+),(\d+)\]/);
|
||
if (!m) return;
|
||
var start = parseInt(m[1], 10) / 1000, dur = parseInt(m[2], 10) / 1000;
|
||
if (dur <= 0) return; // [0,0] 元数据行
|
||
var rest = line.slice(m[0].length);
|
||
var marks = [];
|
||
var re = /\((\d+),(\d+)(?:,\d+)?\)/g, w;
|
||
while ((w = re.exec(rest)) !== null) {
|
||
marks.push({ s: parseInt(w[1], 10) / 1000, d: parseInt(w[2], 10) / 1000, start: w.index, end: w.index + w[0].length });
|
||
}
|
||
var words = [], chars = '';
|
||
for (var i = 0; i < marks.length; i++) {
|
||
// 字的正文 = 本标记之后到下一标记之前(保留其中的一切字符,含括号)
|
||
var segEnd = (i + 1 < marks.length) ? marks[i + 1].start : rest.length;
|
||
var wordText = rest.slice(marks[i].end, segEnd);
|
||
if (!wordText) continue;
|
||
words.push({ c: wordText, s: marks[i].s, d: marks[i].d });
|
||
chars += wordText;
|
||
}
|
||
chars = chars.replace(/\s\s+/g, ' ').trim();
|
||
if (!words.length || !chars) return;
|
||
out.push({ time: start, end: start + dur, text: chars, words: words, index: out.length });
|
||
});
|
||
// YRC 时间轴排序,覆盖已排序 LRC 前先规范时间轴
|
||
out.sort(function (a, b) { return a.time - b.time; });
|
||
out.forEach(function (l, i) { l.index = i; });
|
||
return out;
|
||
}
|
||
|
||
// 由原始歌词构建行数据:LRC 为底,YRC 逐字轴覆盖过半时替换,
|
||
// 未被覆盖的 LRC 行保留(不再整体丢弃剩余歌词行),再合并翻译
|
||
function buildLyricLines() {
|
||
var lines = lrctrim(state.rawLyric || '');
|
||
var wordLines = parseYrc(state.rawYrc || '');
|
||
if (wordLines.length && wordLines.length >= Math.max(1, Math.ceil(lines.length * 0.5))) {
|
||
// 把 LRC 里没有被 YRC 覆盖(±1s 内无对应行)的行补回来
|
||
var merged = wordLines.slice();
|
||
lines.forEach(function (l) {
|
||
var covered = wordLines.some(function (w) { return Math.abs(w.time - l.time) <= 1; });
|
||
if (!covered) merged.push(l);
|
||
});
|
||
merged.sort(function (a, b) { return a.time - b.time; });
|
||
merged.forEach(function (l, i) { l.index = i; });
|
||
lines = merged;
|
||
}
|
||
if (state.rawTlyric) lines = mergeTranslation(lines, state.rawTlyric);
|
||
return lines;
|
||
}
|
||
|
||
function initLyrics(lrc, tlrc, yrc) {
|
||
state.rawLyric = lrc || '';
|
||
state.rawTlyric = tlrc || '';
|
||
state.rawYrc = yrc || '';
|
||
state.lyrics = buildLyricLines();
|
||
renderLyrics();
|
||
var panel = $('#lyric-panel');
|
||
panel.hidden = false;
|
||
if (!state.lyrics.length) {
|
||
$('#lyric-box').innerHTML = '<div class="lyric-empty">暂无歌词</div>';
|
||
}
|
||
}
|
||
|
||
function renderLyrics() {
|
||
var box = $('#lyric-box');
|
||
if (!state.lyrics.length) {
|
||
box.innerHTML = '<div class="lyric-empty">暂无歌词</div>';
|
||
return;
|
||
}
|
||
var twoLine = state.settings.lyric2line;
|
||
box.innerHTML = state.lyrics.map(function (l, i) {
|
||
var text = l.text || '♪';
|
||
var cls = state.settings.ktv ? 'lyric-line ktv' : 'lyric-line';
|
||
var inner;
|
||
if (state.settings.ktv && l.words && l.words.length) {
|
||
// 逐字行:每字一个 span,由 rAF 按真实字级时间轴推进高亮
|
||
cls += ' kw-line';
|
||
inner = l.words.map(function (w) { return '<span class="kw">' + escapeHtml(w.c) + '</span>'; }).join('');
|
||
if (twoLine && l.trans) inner += '<br><span class="ktr">' + escapeHtml(l.trans) + '</span>';
|
||
else if (l.trans) inner += '<span class="ktr"> (翻译:' + escapeHtml(l.trans) + ')</span>';
|
||
} else {
|
||
// 译文统一走 .ktr 弱化样式(与 KTV 分支同构,所有模式层级一致)。
|
||
// 数据层把译文并进了 text(双行"原文\n译文"/单行"原文 (翻译:…)"),
|
||
// 优先用 mergeTranslation 保留的纯原文 orig,避免正则截断的边角误伤
|
||
if (l.trans) {
|
||
var base = l.orig || text;
|
||
var orig = twoLine ? base.split('\n')[0] : base.replace(/ \(翻译:.*\)$/, '');
|
||
inner = escapeHtml(orig);
|
||
if (twoLine) inner += '<br><span class="ktr">' + escapeHtml(l.trans) + '</span>';
|
||
else inner += '<span class="ktr"> (翻译:' + escapeHtml(l.trans) + ')</span>';
|
||
} else {
|
||
inner = twoLine ? escapeHtml(text).replace(/\n/g, '<br>') : escapeHtml(text);
|
||
}
|
||
}
|
||
return '<div class="' + cls + '" data-i="' + i + '">' + inner + '</div>';
|
||
}).join('');
|
||
// 新歌歌词从顶部开始,避免残留上一首歌的滚动位置
|
||
box.scrollTop = 0;
|
||
// 重绘后行元素已重建,KTV 填充缓存与行高亮状态一并重置
|
||
// 行元素随重绘缓存——applyActiveLyric 由 rAF 以 60fps 调用,
|
||
// 原实现每帧 querySelectorAll 全部歌词行(百行 × 60fps),低端机无谓 CPU
|
||
state.lastActiveLine = -1;
|
||
state.ktvFillIdx = -1;
|
||
state.ktvWordEls = null;
|
||
state.ktvLineEls = Array.prototype.slice.call(box.children);
|
||
}
|
||
|
||
$('#lyric-box').addEventListener('click', function (e) {
|
||
var line = e.target.closest('.lyric-line');
|
||
if (!line) return;
|
||
var i = parseInt(line.getAttribute('data-i'), 10);
|
||
var audio = $('#player audio');
|
||
if (audio && audio.src && state.lyrics[i]) {
|
||
// 点击定位与高亮使用互逆变换——高亮是 currentTime+offset,
|
||
// 定位就必须 currentTime = 行时间 - offset(原实现同向相加,点哪句跳错句)
|
||
audio.currentTime = Math.max(0, state.lyrics[i].time - state.lyricOffset);
|
||
// 裸 play() 的 Promise 拒绝会喂错误上报(token 过期后点歌词必现)
|
||
if (audio.paused) audio.play().catch(function () {});
|
||
}
|
||
});
|
||
|
||
// 歌词偏移校准
|
||
function applyLyricOffset(delta) {
|
||
state.lyricOffset = Math.max(-30, Math.min(30, Math.round((state.lyricOffset + delta) * 100) / 100));
|
||
$('#lyric-offset-label').textContent = '偏移 ' + (state.lyricOffset > 0 ? '+' : '') + state.lyricOffset.toFixed(2) + 's';
|
||
toast('歌词偏移:' + (state.lyricOffset > 0 ? '+' : '') + state.lyricOffset.toFixed(2) + ' 秒', 'info');
|
||
var audio = $('#player audio');
|
||
if (audio && !audio.paused) updateActiveLyric(audio.currentTime);
|
||
}
|
||
$('#lyric-delay').addEventListener('click', function () { applyLyricOffset(0.25); });
|
||
$('#lyric-advance').addEventListener('click', function () { applyLyricOffset(-0.25); });
|
||
|
||
// 歌词字号调节(A± 弹层):0.7–1.6 倍、5% 步进,立即生效并持久化;
|
||
// 缩放通过 CSS 变量 --lyric-fs 挂在 #lyric-box 上,歌词重绘(切歌/切开关)后依然有效
|
||
var LYRIC_FONT_MIN = 0.7, LYRIC_FONT_MAX = 1.6, LYRIC_FONT_STEP = 0.05;
|
||
function clampLyricFont(v) {
|
||
if (typeof v !== 'number' || !isFinite(v)) return 1;
|
||
return Math.max(LYRIC_FONT_MIN, Math.min(LYRIC_FONT_MAX, Math.round(v * 20) / 20));
|
||
}
|
||
function applyLyricFont() {
|
||
state.settings.lyricFont = clampLyricFont(state.settings.lyricFont);
|
||
$('#lyric-box').style.setProperty('--lyric-fs', String(state.settings.lyricFont));
|
||
$('#lyric-fs-val').textContent = Math.round(state.settings.lyricFont * 100) + '%';
|
||
$('#lyric-fs-dec').disabled = state.settings.lyricFont <= LYRIC_FONT_MIN;
|
||
$('#lyric-fs-inc').disabled = state.settings.lyricFont >= LYRIC_FONT_MAX;
|
||
}
|
||
function lyricFsPopOpen() { return !$('#lyric-fs-pop').hidden; }
|
||
function setLyricFsPop(open) {
|
||
$('#lyric-fs-pop').hidden = !open;
|
||
$('#lyric-fontsize').setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
// 弹层开着时隐藏排队/任务浮窗,贴顶场景不再被盖住
|
||
document.documentElement.classList.toggle('fs-pop-open', open);
|
||
if (open) applyLyricFont();
|
||
}
|
||
function nudgeLyricFont(dir) {
|
||
state.settings.lyricFont = clampLyricFont(state.settings.lyricFont + dir * LYRIC_FONT_STEP);
|
||
applyLyricFont();
|
||
saveSettings();
|
||
}
|
||
$('#lyric-fontsize').addEventListener('click', function () { setLyricFsPop(lyricFsPopOpen() ? false : true); });
|
||
$('#lyric-fs-inc').addEventListener('click', function () { nudgeLyricFont(1); });
|
||
$('#lyric-fs-dec').addEventListener('click', function () { nudgeLyricFont(-1); });
|
||
$('#lyric-fs-reset').addEventListener('click', function () {
|
||
state.settings.lyricFont = 1;
|
||
applyLyricFont();
|
||
saveSettings();
|
||
toast('歌词字号已恢复默认', 'info');
|
||
});
|
||
// 点弹层与触发按钮以外的任意位置关闭(捕获期,不干扰歌词行点击等其他处理器)
|
||
document.addEventListener('click', function (e) {
|
||
if (!lyricFsPopOpen()) return;
|
||
if (e.target.closest('#lyric-fs-pop') || e.target.closest('#lyric-fontsize')) return;
|
||
setLyricFsPop(false);
|
||
}, true);
|
||
// 捕获期监听(先于冒泡处理器执行)——抽屉/模态开着时让上层
|
||
// 先消费 Escape 并跳过,避免同一次按键把键盘焦点偷换到遮罩后面的 A± 按钮
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && lyricFsPopOpen()) {
|
||
if (document.querySelector('.drawer.show, .modal-mask.show')) return;
|
||
setLyricFsPop(false);
|
||
$('#lyric-fontsize').focus();
|
||
}
|
||
}, true);
|
||
|
||
function findActiveLyric(t) {
|
||
var active = -1;
|
||
for (var i = 0; i < state.lyrics.length; i++) {
|
||
if (state.lyrics[i].time <= t + 0.2) active = i;
|
||
else break;
|
||
}
|
||
return active;
|
||
}
|
||
|
||
// KTV 填充:有逐字轴的行按字推进(唱到哪个字填到哪个字);
|
||
// 无 YRC 的行不再做仿逐字整行渐变(owner 2026-09-22 拍板撤销),仅普通高亮,无需逐帧写 --p
|
||
function fillKtvLine(el, idx, t) {
|
||
var line = state.lyrics[idx];
|
||
if (!el || !line) return;
|
||
if (line.words && line.words.length) {
|
||
if (state.ktvFillIdx !== idx) {
|
||
state.ktvFillIdx = idx;
|
||
state.ktvWordEls = el.querySelectorAll('.kw');
|
||
}
|
||
var spans = state.ktvWordEls;
|
||
for (var i = 0; i < line.words.length; i++) {
|
||
var w = line.words[i], sp = spans[i];
|
||
if (!sp) break;
|
||
if (t >= w.s + w.d) {
|
||
if (!sp.classList.contains('on')) { sp.classList.add('on'); sp.classList.remove('cur'); }
|
||
} else if (t >= w.s) {
|
||
if (!sp.classList.contains('cur')) { sp.classList.add('cur'); sp.classList.remove('on'); }
|
||
sp.style.setProperty('--pw', Math.min(100, (t - w.s) / w.d * 100) + '%');
|
||
} else if (sp.classList.contains('on') || sp.classList.contains('cur')) {
|
||
sp.classList.remove('on');
|
||
sp.classList.remove('cur');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyActiveLyric(active, t) {
|
||
// 优先用缓存行(renderLyrics 时重建),缓存缺失(外部清空 lyric-box 等)才回退查询
|
||
var lines = state.ktvLineEls && state.ktvLineEls.length ? state.ktvLineEls
|
||
: Array.prototype.slice.call($('#lyric-box').children);
|
||
for (var j = 0; j < lines.length; j++) {
|
||
lines[j].classList.toggle('active', j === active);
|
||
}
|
||
if (active >= 0 && state.settings.ktv) fillKtvLine(lines[active], active, t);
|
||
if (active >= 0 && active !== state.lastActiveLine) {
|
||
state.lastActiveLine = active;
|
||
var box = $('#lyric-box');
|
||
var el = lines[active];
|
||
if (el) {
|
||
var top = el.offsetTop - box.clientHeight / 2 + el.clientHeight / 2;
|
||
box.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateActiveLyric(time) {
|
||
var t = time + state.lyricOffset;
|
||
applyActiveLyric(findActiveLyric(t), t);
|
||
}
|
||
|
||
// KTV 帧循环:播放中按 60fps 平滑推进填充(timeupdate 仅约 4Hz,直接驱动会显得卡顿)
|
||
function ktvFrame() {
|
||
if (!state.ktvRunning) return;
|
||
var audio = $('#player audio');
|
||
if (!audio || audio.paused) { state.ktvRunning = false; return; }
|
||
var t = audio.currentTime + state.lyricOffset;
|
||
applyActiveLyric(findActiveLyric(t), t);
|
||
requestAnimationFrame(ktvFrame);
|
||
}
|
||
function ktvStart() {
|
||
if (!state.ktvRunning && state.settings.ktv) {
|
||
state.ktvRunning = true;
|
||
requestAnimationFrame(ktvFrame);
|
||
}
|
||
}
|
||
|
||
/* ================= 评论区(分页,10条/页) ================= */
|
||
function loadComments(songId, page, reset) {
|
||
if (reset) {
|
||
page = 1;
|
||
state.commentPage = 1;
|
||
state.commentTotal = 0;
|
||
}
|
||
var offset = (page - 1) * 10;
|
||
state._commentsLoading = true; // 防止评论开关快速切换时重复加载
|
||
// 评论翻页代次守卫——快速连点下一页时迟到旧页响应
|
||
// 不得把页码与内容一起回滚(只防了切歌,这里补同歌翻页)
|
||
state._commentGen = (state._commentGen || 0) + 1;
|
||
var gen = state._commentGen;
|
||
apiPost('/comments', { id: songId, limit: 10, offset: offset })
|
||
.then(function (data) {
|
||
if (gen !== state._commentGen) return; // 迟到响应丢弃
|
||
if (data.status !== 200) throw new Error(data.message || '获取评论失败');
|
||
// 迟到响应不得覆盖已切歌的评论区(评论对象以 songId 为准)
|
||
if (state.currentSong && String(state.currentSong.id) !== String(songId)) return;
|
||
var d = data.data;
|
||
state.commentTotal = d.total || 0;
|
||
$('#comment-count').textContent = state.commentTotal ? ('(' + fmtCount(state.commentTotal) + ')') : '';
|
||
|
||
if (page === 1) {
|
||
var hot = d.hotComments || [];
|
||
$('#comment-hot-title').hidden = !hot.length;
|
||
$('#comment-hot-list').innerHTML = hot.length ? hot.map(commentHtml).join('') :
|
||
'<div class="comment-empty">暂无热门评论</div>';
|
||
}
|
||
|
||
var list = d.comments || [];
|
||
$('#comment-all-list').innerHTML = list.length ? list.map(commentHtml).join('') :
|
||
'<div class="comment-empty">暂无评论</div>';
|
||
|
||
state.commentPage = page;
|
||
renderCommentPager();
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== state._commentGen) return; // 迟到响应丢弃(与 then 同口径)
|
||
toast(err.message || '获取评论失败', 'error');
|
||
})
|
||
.then(function () { if (gen === state._commentGen) state._commentsLoading = false; },
|
||
function () { if (gen === state._commentGen) state._commentsLoading = false; });
|
||
}
|
||
|
||
function renderCommentPager() {
|
||
var pages = Math.max(1, Math.ceil(state.commentTotal / 10));
|
||
var cur = state.commentPage;
|
||
$('#comment-page-info').textContent = '第 ' + cur + ' / ' + pages + ' 页';
|
||
$('#comment-prev').disabled = cur <= 1;
|
||
$('#comment-next').disabled = cur >= pages;
|
||
$('#comment-pager').hidden = state.commentTotal <= 10;
|
||
}
|
||
|
||
$('#comment-prev').addEventListener('click', function () {
|
||
if (state.currentSong && state.commentPage > 1) {
|
||
loadComments(state.currentSong.id, state.commentPage - 1, false);
|
||
}
|
||
});
|
||
$('#comment-next').addEventListener('click', function () {
|
||
if (state.currentSong) {
|
||
loadComments(state.currentSong.id, state.commentPage + 1, false);
|
||
}
|
||
});
|
||
|
||
/* ================= 发布评论 / 回复评论(个人账号) ================= */
|
||
state.commentReply = null; // {id, nickname}
|
||
|
||
function updateCommentComposer() {
|
||
var composer = $('#comment-composer');
|
||
if (!composer) return;
|
||
composer.hidden = !state.personal.authed;
|
||
if (!state.personal.authed) {
|
||
state.commentReply = null;
|
||
$('#comment-reply-to').hidden = true;
|
||
return;
|
||
}
|
||
$('#comment-input').placeholder = state.commentReply
|
||
? '回复 @' + state.commentReply.nickname + '...' : '说点什么吧...(140字以内)';
|
||
}
|
||
|
||
function setCommentReply(id, nickname) {
|
||
state.commentReply = { id: id, nickname: nickname };
|
||
$('#comment-reply-name').textContent = nickname;
|
||
$('#comment-reply-to').hidden = false;
|
||
updateCommentComposer();
|
||
$('#comment-input').focus();
|
||
}
|
||
|
||
$('#comment-reply-cancel').addEventListener('click', function () {
|
||
state.commentReply = null;
|
||
$('#comment-reply-to').hidden = true;
|
||
updateCommentComposer();
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var rb = e.target.closest('.comment-reply-btn');
|
||
if (rb) {
|
||
setCommentReply(rb.getAttribute('data-id'), rb.getAttribute('data-nickname'));
|
||
}
|
||
// 回复内容「详情」按钮:展开/收起完整内容
|
||
var detail = e.target.closest('.cr-detail');
|
||
if (detail) {
|
||
var contentEl = detail.closest('.comment-reply').querySelector('.cr-content');
|
||
if (!contentEl) return;
|
||
if (detail.classList.contains('expanded')) {
|
||
detail.classList.remove('expanded');
|
||
detail.textContent = '详情';
|
||
contentEl.textContent = contentEl.getAttribute('data-short');
|
||
contentEl.style.whiteSpace = 'nowrap';
|
||
contentEl.style.overflow = 'hidden';
|
||
} else {
|
||
detail.classList.add('expanded');
|
||
detail.textContent = '收起';
|
||
contentEl.textContent = contentEl.getAttribute('data-full');
|
||
contentEl.style.whiteSpace = 'normal';
|
||
contentEl.style.overflow = 'visible';
|
||
contentEl.style.wordBreak = 'break-word';
|
||
}
|
||
}
|
||
});
|
||
|
||
$('#comment-submit').addEventListener('click', function () {
|
||
if (!state.personal.authed || !state.personal.cookie) {
|
||
toast('请先在「个人歌单」扫码登录你的账号后再评论', 'warning');
|
||
switchTab('personal');
|
||
return;
|
||
}
|
||
if (!state.currentSong) { toast('请先解析一首歌曲', 'warning'); return; }
|
||
var content = $('#comment-input').value.trim();
|
||
if (!content) { toast('评论内容不能为空', 'warning'); $('#comment-input').focus(); return; }
|
||
if (content.length > 140) { toast('评论内容不能超过 140 字', 'warning'); return; }
|
||
|
||
var btn = $('#comment-submit');
|
||
var isReply = !!state.commentReply;
|
||
var oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = '发布中...';
|
||
apiPost('/user/comment', {
|
||
song_id: state.currentSong.id,
|
||
content: content,
|
||
action: isReply ? 2 : 1,
|
||
comment_id: isReply ? state.commentReply.id : '',
|
||
user_cookie: state.personal.cookie
|
||
})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '评论失败');
|
||
toast(isReply ? '✅ 回复成功' : '✅ 评论成功', 'success');
|
||
$('#comment-input').value = '';
|
||
state.commentReply = null;
|
||
$('#comment-reply-to').hidden = true;
|
||
updateCommentComposer();
|
||
loadComments(state.currentSong.id, 1, true); // 刷新评论
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '评论失败', 'error');
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
});
|
||
});
|
||
|
||
function commentHtml(c) {
|
||
var u = c.user || {};
|
||
var vip = '';
|
||
if (u.vipType === 11 || u.vipType === 100) vip = '<span class="vip-badge vip-vip">黑胶VIP</span>';
|
||
else if (u.vipType === 10 || u.vipType === 110) vip = '<span class="vip-badge vip-svip">黑胶SVIP</span>';
|
||
else if (u.vipType === 1) vip = '<span class="vip-badge vip-pkg">音乐包</span>';
|
||
var location = u.location ? (' · ' + escapeHtml(u.location)) : '';
|
||
// 被回复信息显示(昵称蓝色、内容10字内省略 + 详情按钮悬浮/点击查看完整)
|
||
var reply = '';
|
||
if (c.beReplied && c.beReplied.length) {
|
||
var br = c.beReplied[0] || {};
|
||
var rNick = br.nickname || '';
|
||
var rContent = br.content || '';
|
||
if (rNick || rContent) {
|
||
var rDisplay = rNick || '已删除用户';
|
||
var rShort = rContent.length > 10 ? rContent.slice(0, 10) + '…' : rContent;
|
||
reply = '<div class="comment-reply" style="display:flex;align-items:center;gap:6px;padding:5px 10px;">' +
|
||
'<span style="color:var(--color-text-tertiary);flex:none;font-size:12px;">回复</span>' +
|
||
'<span class="cr-nick" style="color:var(--color-primary);flex:none;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;">@' + escapeHtml(rDisplay) + '</span>' +
|
||
'<span class="cr-content" data-short="' + escapeHtml(rShort) + '" data-full="' + escapeHtml(rContent) + '" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;" title="' + escapeHtml(rContent || rDisplay) + '">' + escapeHtml(rShort) + '</span>' +
|
||
(rContent.length > 10
|
||
? '<button class="cr-detail" type="button" style="border:none;background:none;color:var(--color-primary);cursor:pointer;font-size:12px;padding:0;flex:none;">详情</button>'
|
||
: '') +
|
||
'</div>';
|
||
}
|
||
}
|
||
// 回复按钮(个人账号登录后显示)
|
||
var replyBtn = state.personal.authed
|
||
? '<button class="btn-link comment-reply-btn" data-id="' + escapeHtml(c.id) +
|
||
'" data-nickname="' + escapeHtml(u.nickname || '') + '" type="button" style="border:none;background:none;color:var(--color-text-tertiary);cursor:pointer;font-size:12px;padding:0;">回复</button>'
|
||
: '';
|
||
return '<div class="comment-item">' +
|
||
'<img class="comment-avatar" src="' + escapeHtml(u.avatarUrl || '') + '" loading="lazy" alt="" onerror="this.style.visibility=\'hidden\'" />' +
|
||
'<div class="comment-body">' +
|
||
'<div class="comment-head">' +
|
||
'<span class="comment-nickname">' + escapeHtml(u.nickname || '匿名') + '</span>' + vip +
|
||
'<span class="comment-meta">' + fmtRelative(c.time) + location + '</span>' +
|
||
'</div>' +
|
||
'<div class="comment-content">' + escapeHtml(c.content || '') + '</div>' + reply +
|
||
'<div class="comment-actions"><span class="comment-like">👍 ' + fmtCount(c.likedCount) + '</span>' +
|
||
replyBtn + '</div>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
|
||
/* ================= 播放器 ================= */
|
||
var playerState = {};
|
||
|
||
function initPlayer(url, name, artist, cover) {
|
||
var player = $('#player');
|
||
player.hidden = false;
|
||
// 与播客试听互斥——开播音乐先停播客音频(反向在 podcastRenderProgram 处理)
|
||
try {
|
||
var pa = $('#podcast-audio');
|
||
if (pa && !pa.paused) pa.pause();
|
||
} catch (e) {}
|
||
// 关键:先停止并清空上一个音频,避免播放旧歌曲导致歌词与歌曲不对应
|
||
var oldAudio = player.querySelector('audio');
|
||
if (oldAudio) {
|
||
oldAudio.pause();
|
||
oldAudio.removeAttribute('src');
|
||
oldAudio.load();
|
||
}
|
||
$('#player-cover').src = cover;
|
||
$('#player-name').textContent = name;
|
||
$('#player-artist').textContent = artist;
|
||
$('#player-toggle').textContent = '▶';
|
||
$('#player-duration').textContent = '00:00';
|
||
$('#player-current').textContent = '00:00';
|
||
$('#player-progress-fill').style.width = '0%';
|
||
playerState.url = url;
|
||
// 保留用户上一次的音量/静音选择(原实现每次切歌都重置为 0.9/取消静音)
|
||
if (typeof playerState.volume !== 'number') { playerState.volume = 0.9; playerState.muted = false; }
|
||
updateVolumeUI();
|
||
updateMuteUI();
|
||
// SMTC:系统媒体控制面板显示播放信息
|
||
if (state.settings.smtc && 'mediaSession' in navigator && 'MediaMetadata' in window) {
|
||
try {
|
||
navigator.mediaSession.metadata = new MediaMetadata({
|
||
title: name,
|
||
artist: artist,
|
||
album: (state.currentSong && state.currentSong.al_name) || '',
|
||
artwork: cover ? [{ src: cover, sizes: '512x512' }] : []
|
||
});
|
||
// 系统媒体的 play/pause 分别对应明确的播放/暂停(toggle 不幂等,
|
||
// 面板点"暂停"曾被切换成继续播放)
|
||
navigator.mediaSession.setActionHandler('play', function () {
|
||
var a = ensureAudio();
|
||
if (a && a.paused) togglePlay();
|
||
});
|
||
navigator.mediaSession.setActionHandler('pause', function () {
|
||
var a = ensureAudio();
|
||
if (a && !a.paused) togglePlay();
|
||
});
|
||
navigator.mediaSession.setActionHandler('seekto', function (d) {
|
||
var a = ensureAudio();
|
||
if (a && d.seekTime != null) a.currentTime = d.seekTime;
|
||
});
|
||
} catch (e) {}
|
||
} else if ('mediaSession' in navigator) {
|
||
// 关闭 SMTC 设置时撤销既有处理器
|
||
try {
|
||
navigator.mediaSession.setActionHandler('play', null);
|
||
navigator.mediaSession.setActionHandler('pause', null);
|
||
navigator.mediaSession.setActionHandler('seekto', null);
|
||
} catch (e) {}
|
||
}
|
||
$('#player').onclick = function (e) {
|
||
if (e.target.closest('.player-ctrl') || e.target.closest('.progress') ||
|
||
e.target.closest('.volume-btn') || e.target.closest('.volume-slider')) return;
|
||
togglePlay();
|
||
};
|
||
}
|
||
|
||
function stopPlayer() {
|
||
var player = $('#player');
|
||
player.hidden = true;
|
||
var audio = player.querySelector('audio');
|
||
if (audio) { audio.pause(); audio.removeAttribute('src'); audio.load(); }
|
||
}
|
||
|
||
function ensureAudio() {
|
||
var player = $('#player');
|
||
var audio = player.querySelector('audio');
|
||
if (!audio) {
|
||
audio = document.createElement('audio');
|
||
audio.preload = 'metadata';
|
||
player.appendChild(audio);
|
||
|
||
audio.addEventListener('timeupdate', function () {
|
||
$('#player-current').textContent = fmtTime(audio.currentTime);
|
||
if (audio.duration) {
|
||
$('#player-progress-fill').style.width =
|
||
(audio.currentTime / audio.duration * 100) + '%';
|
||
}
|
||
updateActiveLyric(audio.currentTime);
|
||
ktvStart(); // 兜底:中途开启 KTV 开关也能启动帧循环
|
||
if (state.settings.progressMemory && state.currentSong) {
|
||
try {
|
||
var key = 'netease_progress_' + state.currentSong.id;
|
||
var last = parseFloat(localStorage.getItem(key) || '0');
|
||
if (Math.abs(audio.currentTime - last) > 5) {
|
||
localStorage.setItem(key, audio.currentTime);
|
||
// 进度键无界清理(跳过场景永不删,长期用户无限累积)
|
||
// 超过 100 个键时清掉当前曲之外的全部(进度记忆为锦上添花)
|
||
var pk = [];
|
||
for (var i = 0; i < localStorage.length; i++) {
|
||
var k = localStorage.key(i);
|
||
if (k && k.indexOf('netease_progress_') === 0 && k !== key) pk.push(k);
|
||
}
|
||
if (pk.length >= 100) {
|
||
pk.forEach(function (k2) { localStorage.removeItem(k2); });
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
audio.addEventListener('play', function () { ktvStart(); });
|
||
|
||
audio.addEventListener('loadedmetadata', function () {
|
||
$('#player-duration').textContent = fmtTime(audio.duration);
|
||
try {
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.setPositionState({
|
||
duration: audio.duration || 0,
|
||
playbackRate: 1,
|
||
position: audio.currentTime || 0
|
||
});
|
||
}
|
||
} catch (e) {}
|
||
});
|
||
audio.addEventListener('ended', function () {
|
||
$('#player-toggle').textContent = '▶';
|
||
$('#player-progress-fill').style.width = '0%';
|
||
$('#player-current').textContent = '00:00';
|
||
try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; } catch (e) {}
|
||
if (state.currentSong) {
|
||
try { localStorage.removeItem('netease_progress_' + state.currentSong.id); } catch (e) {}
|
||
}
|
||
// 播放歌单(公开播放 API 队列)自动连播下一首
|
||
var gq = state.global && state.global.playQueue;
|
||
if (gq && gq.length) {
|
||
state.global.playQueueIndex = (state.global.playQueueIndex || 0) + 1;
|
||
if (state.global.playQueueIndex < gq.length) {
|
||
var nx = gq[state.global.playQueueIndex];
|
||
// 连播切歌同步 currentSong/进度键——原实现 currentSong
|
||
// 仍是上一首,B 的播放进度会写进 A 的存储键
|
||
state.currentSong = { id: nx.id, name: nx.name, ar_name: nx.artists || '', al_name: nx.album || '' };
|
||
initPlayer(nx.url || '', nx.name || '', nx.artists || '', nx.picUrl || '');
|
||
// 连播不带歌词/评论数据——清空旧歌词与评论区(原实现
|
||
// 第二首起歌词还是上一首且 KTV 错位高亮、评论区不换)
|
||
state.rawLyric = null;
|
||
state.rawTlyric = null;
|
||
state.rawYrc = null;
|
||
state.lyrics = [];
|
||
renderLyrics();
|
||
$('#comment-panel').hidden = true;
|
||
// 连播换歌同样清评论计数并重置歌词偏移——否则 A 的旧
|
||
// commentTotal 挡住补加载(开关一扳就复活 A 的评论区),
|
||
// A 校准过的偏移也会带进 B 造成整片高亮错位
|
||
state.commentTotal = 0;
|
||
state.commentPage = 1;
|
||
$('#comment-count').textContent = '';
|
||
$('#comment-hot-list').innerHTML = '<div class="comment-empty">暂无热门评论</div>';
|
||
$('#comment-all-list').innerHTML = '<div class="comment-empty">暂无评论</div>';
|
||
state.lyricOffset = 0;
|
||
$('#lyric-offset-label').textContent = '偏移 0.00s';
|
||
// 连播语义=顺序完整播放,跳过该曲的历史进度续播
|
||
// (一次性标志,togglePlay 内消费;用户进度键保留不动)
|
||
state._queueNoResume = true;
|
||
toast('▶ 正在播放:' + (nx.name || ''), 'info');
|
||
togglePlay();
|
||
state._queueNoResume = false;
|
||
} else {
|
||
state.global.playQueue = [];
|
||
state.global.playQueueIndex = 0;
|
||
}
|
||
}
|
||
});
|
||
audio.addEventListener('play', function () {
|
||
$('#player-toggle').textContent = '⏸';
|
||
try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; } catch (e) {}
|
||
});
|
||
audio.addEventListener('pause', function () {
|
||
$('#player-toggle').textContent = '▶';
|
||
try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; } catch (e) {}
|
||
});
|
||
audio.addEventListener('error', function () {
|
||
toast('音频播放失败:可能是链接失效或该格式不支持在线播放', 'error');
|
||
$('#player-toggle').textContent = '▶';
|
||
});
|
||
}
|
||
return audio;
|
||
}
|
||
|
||
function togglePlay() {
|
||
var audio = ensureAudio();
|
||
var key = 'netease_progress_' + (state.currentSong ? state.currentSong.id : '');
|
||
if (audio.paused && !audio.src) {
|
||
audio.src = playerState.url;
|
||
if (state.settings.progressMemory && !state._queueNoResume) {
|
||
// 连播场景(_queueNoResume)跳过续播,从头完整播放
|
||
try {
|
||
var saved = parseFloat(localStorage.getItem(key));
|
||
if (saved > 0) audio.currentTime = saved; // 续播:恢复到上次播放位置
|
||
} catch (e) {}
|
||
}
|
||
audio.volume = playerState.volume;
|
||
audio.muted = playerState.muted;
|
||
}
|
||
if (audio.paused) {
|
||
audio.play().catch(function () { toast('播放失败,请稍后重试', 'error'); });
|
||
} else {
|
||
audio.pause();
|
||
if (state.settings.progressMemory) {
|
||
try { localStorage.setItem(key, audio.currentTime); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
$('#player-toggle').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
togglePlay();
|
||
});
|
||
|
||
$('#player-restart').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
var audio = ensureAudio();
|
||
if (audio.src) {
|
||
audio.currentTime = 0;
|
||
// 裸 play() 同样会喂 unhandledrejection 上报
|
||
if (audio.paused) audio.play().catch(function () {});
|
||
} else {
|
||
togglePlay();
|
||
}
|
||
});
|
||
|
||
$('#player-progress').addEventListener('click', function (e) {
|
||
var rect = this.getBoundingClientRect();
|
||
var ratio = (e.clientX - rect.left) / rect.width;
|
||
var audio = ensureAudio();
|
||
if (audio.src && audio.duration) {
|
||
audio.currentTime = ratio * audio.duration;
|
||
}
|
||
});
|
||
|
||
function updateVolumeUI() {
|
||
$('#player-volume-fill').style.width = (playerState.volume * 100) + '%';
|
||
}
|
||
function updateMuteUI() {
|
||
$('#player-mute').textContent = playerState.muted ? '🔇' : '🔊';
|
||
var audio = $('#player audio');
|
||
if (audio) { audio.muted = playerState.muted; audio.volume = playerState.volume; }
|
||
}
|
||
$('#player-volume').addEventListener('click', function (e) {
|
||
var rect = this.getBoundingClientRect();
|
||
playerState.volume = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
playerState.muted = false;
|
||
updateVolumeUI();
|
||
updateMuteUI();
|
||
});
|
||
$('#player-mute').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
playerState.muted = !playerState.muted;
|
||
updateMuteUI();
|
||
});
|
||
|
||
/* ================= 下载 ================= */
|
||
function downloadSong(id, name) {
|
||
if (!id) { toast('无法获取音乐ID', 'error'); return; }
|
||
if (!cookieGate()) return;
|
||
apiResolveLevel(id)
|
||
.then(function (level) {
|
||
toast('正在准备下载:' + (name || id) + '(' + levelName(level) + ')...', 'info');
|
||
doDownload(id, name, level);
|
||
});
|
||
}
|
||
|
||
function doDownload(id, name, level) {
|
||
// 同曲在途去重——双击「下载」会提交两个任务扣两次费
|
||
// (批量入口当年做了去重,单曲入口漏了同型)
|
||
var inFlight = tasks.some(function (t) {
|
||
return t.label === ('单曲 · ' + (name || id)) &&
|
||
(t.status === 'queued' || t.status === 'running' || t.status === 'paused');
|
||
});
|
||
if (inFlight) { toast('这首歌已在下载队列中,请勿重复提交', 'warning'); return; }
|
||
var cfg = downloadConfig();
|
||
var taskId = genTaskId();
|
||
apiPost('/download', {
|
||
id: id,
|
||
quality: level,
|
||
task_id: taskId,
|
||
device_id: getDeviceId(),
|
||
account: state.parseAccount,
|
||
user_cookie: state.parseAccount === 'user' ? state.personal.cookie : '',
|
||
filename_format: cfg.filename_format,
|
||
write_meta: cfg.write_meta,
|
||
meta_fields: cfg.meta_fields,
|
||
lyric_format: cfg.lyric_format,
|
||
zip: cfg.zip
|
||
}).then(function (data) {
|
||
if (data.status === 401 && state.parseAccount === 'user') {
|
||
state.parseAccount = 'admin';
|
||
try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {}
|
||
renderAccountBar();
|
||
renderPersonalToggle();
|
||
toast('个人账号未登录或已失效,已自动切换为全局账号下载', 'warning');
|
||
doDownload(id, name, level);
|
||
return;
|
||
}
|
||
if (data.status !== 200) {
|
||
toast(data.message || '下载提交失败', 'error');
|
||
return;
|
||
}
|
||
// 加入任务悬浮窗,完成后自动下载文件并通知
|
||
addTask({
|
||
id: taskId, type: 'single',
|
||
label: '单曲 · ' + (name || id),
|
||
total: 1, done: 0, status: 'queued'
|
||
});
|
||
}).catch(function () {
|
||
toast('下载提交失败,请检查网络', 'error');
|
||
});
|
||
}
|
||
|
||
/* ================= 下载歌词 ================= */
|
||
function lyricToFormat(lines, fmt) {
|
||
// lines: [{time, text}];fmt: lrc | srt
|
||
if (fmt === 'srt') {
|
||
function srtTime(t) {
|
||
var h = Math.floor(t / 3600);
|
||
var m = Math.floor((t % 3600) / 60);
|
||
var s = Math.floor(t % 60);
|
||
var ms = Math.round((t - Math.floor(t)) * 1000);
|
||
if (ms >= 1000) ms = 999;
|
||
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' +
|
||
String(s).padStart(2, '0') + ',' + String(ms).padStart(3, '0');
|
||
}
|
||
return lines.map(function (l, i) {
|
||
var end = lines[i + 1] ? lines[i + 1].time : l.time + 5;
|
||
// 同时间多行给 1 秒最小区间(前端同型漏网——
|
||
// 后端当年修了倒挂区间,前端 lyricToFormat 没修,部分播放器拒收)
|
||
if (end <= l.time) end = l.time + 1;
|
||
// SRT 支持块内多行——双行模式原文/译文各占一行,不再删换行粘连
|
||
return (i + 1) + '\n' + srtTime(l.time) + ' --> ' + srtTime(end) + '\n' + l.text + '\n';
|
||
}).join('\n');
|
||
}
|
||
return lines.map(function (l) {
|
||
var m = Math.floor(l.time / 60);
|
||
var s = l.time - m * 60;
|
||
var ms = Math.round((s - Math.floor(s)) * 1000);
|
||
// 双行模式换行改为" / "分隔(原实现直接删除,原文译文无缝粘连)
|
||
return '[' + String(m).padStart(2, '0') + ':' + String(Math.floor(s)).padStart(2, '0') +
|
||
'.' + String(Math.min(ms, 999)).padStart(3, '0') + ']' + l.text.replace(/\n/g, ' / ');
|
||
}).join('\n');
|
||
}
|
||
|
||
$('#detail-lyric-download').addEventListener('click', function () {
|
||
if (!state.currentSong || !state.lyrics.length) {
|
||
toast('暂无歌词可下载', 'warning');
|
||
return;
|
||
}
|
||
var fmt = state.settings.lyricFormat === 'srt' ? 'srt' : 'lrc';
|
||
var content = lyricToFormat(state.lyrics, fmt);
|
||
var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (state.currentSong.name || 'lyric') + '.' + fmt;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(function () { URL.revokeObjectURL(url); }, 3000);
|
||
toast('歌词已下载(' + fmt.toUpperCase() + ')', 'success');
|
||
});
|
||
|
||
function fallbackCopy(text) {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
// execCommand 返回 false 也是失败(原实现只在抛异常时报错,静默失败仍提示成功)
|
||
try {
|
||
var ok = document.execCommand('copy');
|
||
if (ok) toast('已复制到剪贴板', 'success');
|
||
else toast('复制失败,请手动复制', 'error');
|
||
}
|
||
catch (e) { toast('复制失败,请手动复制', 'error'); }
|
||
document.body.removeChild(ta);
|
||
}
|
||
|
||
/* ================= 歌单解析 ================= */
|
||
// 合集渲染代次守卫——快速切换解析目标时,迟到的旧响应不再覆盖新页面
|
||
function _collGen(type) {
|
||
state.collGen = state.collGen || {};
|
||
state.collGen[type] = (state.collGen[type] || 0) + 1;
|
||
return state.collGen[type];
|
||
}
|
||
function _collStale(type, gen) {
|
||
return state.collGen[type] !== gen;
|
||
}
|
||
function doPlaylist() {
|
||
if (!requireLogin()) return;
|
||
var raw = $('#playlist-input').value.trim();
|
||
var id = extractId(raw);
|
||
if (!id) { toast('请输入歌单ID或链接', 'warning'); $('#playlist-input').focus(); return; }
|
||
var btn = $('#playlist-btn');
|
||
var gen = _collGen('playlist');
|
||
setBtnLoading(btn, true, '解析中...');
|
||
apiPost('/playlist', { id: id })
|
||
.then(function (data) {
|
||
if (_collStale('playlist', gen)) return;
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '歌单解析失败');
|
||
renderCollection('playlist', data.data.playlist);
|
||
})
|
||
.catch(function (err) {
|
||
if (_collStale('playlist', gen)) return;
|
||
toast(err.message || '歌单解析失败', 'error');
|
||
})
|
||
.then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); });
|
||
}
|
||
|
||
function loadPlaylistById(id, name) {
|
||
$('#playlist-input').value = id;
|
||
switchTab('playlist');
|
||
$('#playlist-input').focus();
|
||
if (name) toast('正在解析歌单:' + name, 'info');
|
||
doPlaylist();
|
||
}
|
||
|
||
/* ================= 专辑解析 ================= */
|
||
function doAlbum() {
|
||
if (!requireLogin()) return;
|
||
var raw = $('#album-input').value.trim();
|
||
var id = extractId(raw);
|
||
if (!id) { toast('请输入专辑ID或链接', 'warning'); $('#album-input').focus(); return; }
|
||
var btn = $('#album-btn');
|
||
var gen = _collGen('album');
|
||
setBtnLoading(btn, true, '解析中...');
|
||
apiPost('/album', { id: id })
|
||
.then(function (data) {
|
||
if (_collStale('album', gen)) return;
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '专辑解析失败');
|
||
renderCollection('album', data.data.album);
|
||
})
|
||
.catch(function (err) {
|
||
if (_collStale('album', gen)) return;
|
||
toast(err.message || '专辑解析失败', 'error');
|
||
})
|
||
.then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); });
|
||
}
|
||
|
||
function loadAlbumById(id, name) {
|
||
$('#album-input').value = id;
|
||
switchTab('album');
|
||
$('#album-input').focus();
|
||
if (name) toast('正在解析专辑:' + name, 'info');
|
||
doAlbum();
|
||
}
|
||
|
||
/* 歌曲行 HTML(absIndex 为绝对序号) */
|
||
function songRowHtml(song, absIndex) {
|
||
return '<div class="song-row">' +
|
||
'<span class="song-meta" style="width:32px;text-align:right;">' + absIndex + '</span>' +
|
||
'<img class="song-cover" src="' + escapeHtml(song.picUrl || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(song.name) +
|
||
'</span><span class="tag">' + escapeHtml(song.album || '') + '</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(song.artists || '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm btn-link act-parse" data-id="' + escapeHtml(song.id) + '" data-name="' + escapeHtml(song.name) + '" type="button">解析</button>' +
|
||
'<button class="btn btn-sm act-download" data-id="' + escapeHtml(song.id) + '" data-name="' + escapeHtml(song.name) + '" type="button">下载</button>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
/* 通用歌曲列表分页(10条/页),prefix 对应各区域 id 前缀 */
|
||
var PAGE_SIZE = 10;
|
||
function renderPagedTracks(prefix, allTracks, page) {
|
||
allTracks = allTracks || [];
|
||
var total = allTracks.length;
|
||
var pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
page = Math.max(1, Math.min(page || 1, pages));
|
||
var start = (page - 1) * PAGE_SIZE;
|
||
var slice = allTracks.slice(start, start + PAGE_SIZE);
|
||
|
||
var box = $('#' + prefix + '-tracks');
|
||
if (!slice.length) {
|
||
box.innerHTML = '<div class="empty-state"><div class="empty-icon">📄</div>暂无歌曲(可能为受版权保护的歌曲)</div>';
|
||
} else {
|
||
box.innerHTML = slice.map(function (song, i) {
|
||
return songRowHtml(song, start + i + 1);
|
||
}).join('');
|
||
}
|
||
|
||
var pager = $('#' + prefix + '-pager');
|
||
if (pager) {
|
||
pager.hidden = total <= PAGE_SIZE;
|
||
$('#' + prefix + '-page-info').textContent = '第 ' + page + ' / ' + pages + ' 页';
|
||
$('#' + prefix + '-prev').disabled = page <= 1;
|
||
$('#' + prefix + '-next').disabled = page >= pages;
|
||
}
|
||
var dlBtn = $('#' + prefix + '-download-all');
|
||
if (dlBtn && total) dlBtn.textContent = '⬇ 批量下载全部(共 ' + total + ' 首)';
|
||
state['page_' + prefix] = page;
|
||
state['tracks_' + prefix] = allTracks;
|
||
return page;
|
||
}
|
||
|
||
/* 绑定各区域分页按钮 */
|
||
['playlist', 'album', 'artist', 'personal', 'global'].forEach(function (p) {
|
||
$('#' + p + '-prev').addEventListener('click', function () {
|
||
renderPagedTracks(p, state['tracks_' + p], state['page_' + p] - 1);
|
||
});
|
||
$('#' + p + '-next').addEventListener('click', function () {
|
||
renderPagedTracks(p, state['tracks_' + p], state['page_' + p] + 1);
|
||
});
|
||
});
|
||
|
||
function renderCollection(type, info) {
|
||
if (!info) { toast('解析结果为空', 'error'); return; }
|
||
var prefix = type;
|
||
['playlist', 'album', 'artist'].forEach(function (t) { $('#' + t + '-result').hidden = true; });
|
||
var card = $('#' + prefix + '-result');
|
||
card.hidden = false;
|
||
|
||
var tracks = info.tracks || info.songs || [];
|
||
var isPlaylist = prefix === 'playlist';
|
||
// 残缺歌单显式提示(后端标记 trackIds 与实际拿到的曲目数不一致)
|
||
if (info.incomplete && info.missing) {
|
||
toast('注意:该歌单不完整,缺少 ' + info.missing + ' 首(可能版权受限或接口失败)', 'warning');
|
||
}
|
||
|
||
// 记录当前歌单/专辑(供收藏按钮使用)
|
||
state.currentCollection = { type: prefix, id: info.id, name: info.name };
|
||
|
||
$('#' + prefix + '-cover').src = info.coverImgUrl || '';
|
||
$('#' + prefix + '-name').textContent = info.name || '未知';
|
||
if (isPlaylist) {
|
||
$('#playlist-creator').textContent = 'by ' + (info.creator || '未知');
|
||
$('#playlist-count').textContent = info.trackCount != null ? info.trackCount : tracks.length;
|
||
$('#playlist-desc').hidden = !(info.description && info.description.trim());
|
||
if (!$('#playlist-desc').hidden) $('#playlist-desc').textContent = info.description;
|
||
} else {
|
||
$('#album-artist').textContent = info.artist ? ('歌手:' + info.artist) : '';
|
||
$('#album-count').textContent = tracks.length;
|
||
$('#album-desc').hidden = !(info.description && info.description.trim());
|
||
if (!$('#album-desc').hidden) $('#album-desc').textContent = info.description;
|
||
}
|
||
|
||
renderPagedTracks(prefix, tracks, 1);
|
||
updateCollectButtons();
|
||
window.scrollTo({ top: card.offsetTop - 80, behavior: 'smooth' });
|
||
}
|
||
|
||
$('#playlist-btn').addEventListener('click', doPlaylist);
|
||
$('#playlist-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doPlaylist(); });
|
||
$('#album-btn').addEventListener('click', doAlbum);
|
||
$('#album-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doAlbum(); });
|
||
|
||
/* ================= 歌手解析 ================= */
|
||
function doArtist() {
|
||
if (!requireLogin()) return;
|
||
var raw = $('#artist-input').value.trim();
|
||
var id = extractId(raw);
|
||
if (!id) { toast('请输入歌手ID或链接', 'warning'); $('#artist-input').focus(); return; }
|
||
var btn = $('#artist-btn');
|
||
var gen = _collGen('artist');
|
||
setBtnLoading(btn, true, '解析中...');
|
||
apiPost('/artist_songs', { id: id })
|
||
.then(function (data) {
|
||
if (_collStale('artist', gen)) return;
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '歌手解析失败');
|
||
renderArtist(data.data);
|
||
})
|
||
.catch(function (err) {
|
||
if (_collStale('artist', gen)) return;
|
||
toast(err.message || '歌手解析失败', 'error');
|
||
})
|
||
.then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); });
|
||
}
|
||
|
||
function loadArtistById(id, name) {
|
||
$('#artist-input').value = id;
|
||
switchTab('artist');
|
||
$('#artist-input').focus();
|
||
if (name) toast('正在解析歌手:' + name, 'info');
|
||
doArtist();
|
||
}
|
||
|
||
function renderArtist(data) {
|
||
['playlist', 'album', 'artist'].forEach(function (t) { $('#' + t + '-result').hidden = true; });
|
||
var card = $('#artist-result');
|
||
card.hidden = false;
|
||
var artist = data.artist || {};
|
||
var songs = data.songs || [];
|
||
$('#artist-cover').src = artist.picUrl || '';
|
||
$('#artist-name').textContent = artist.name || '未知歌手';
|
||
$('#artist-alias').textContent = artist.alias || '';
|
||
$('#artist-count').textContent = artist.musicSize != null ? artist.musicSize : songs.length;
|
||
$('#artist-desc').hidden = !(artist.briefDesc && artist.briefDesc.trim());
|
||
if (!$('#artist-desc').hidden) $('#artist-desc').textContent = artist.briefDesc;
|
||
|
||
if (!songs.length) {
|
||
$('#artist-tracks').innerHTML = '<div class="empty-state"><div class="empty-icon">🎤</div>该歌手暂无热门歌曲</div>';
|
||
} else {
|
||
renderPagedTracks('artist', songs, 1);
|
||
}
|
||
window.scrollTo({ top: card.offsetTop - 80, behavior: 'smooth' });
|
||
}
|
||
|
||
$('#artist-btn').addEventListener('click', doArtist);
|
||
$('#artist-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doArtist(); });
|
||
|
||
/* ================= 榜单 ================= */
|
||
state.toplistLoaded = false;
|
||
|
||
function loadToplists() {
|
||
if (!requireLogin()) {
|
||
var _tl = document.getElementById('toplist-loading');
|
||
if (_tl) { _tl.innerHTML = '<div class="empty-icon">🔒</div><div>请先登录后查看榜单</div>'; }
|
||
return;
|
||
}
|
||
if (state.toplistLoaded) return;
|
||
$('#toplist-loading').hidden = false;
|
||
$('#toplist-grid').hidden = true;
|
||
apiPost('/toplist', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取榜单失败');
|
||
var list = data.data || [];
|
||
// 空结果也标记为已加载(原文案"暂无榜单"可见即可),
|
||
// 但 loading/error 节点在完成后不再被 finally 一律隐藏
|
||
state.toplistLoaded = true;
|
||
renderToplists(list);
|
||
})
|
||
.catch(function (err) {
|
||
// 失败态保持可见(错误信息 + 重试按钮),不被 finally 藏起来
|
||
$('#toplist-loading').hidden = false;
|
||
$('#toplist-loading').innerHTML = '<div class="empty-icon">📊</div><div>' + escapeHtml(err.message || '获取榜单失败') + '</div><div style="margin-top:8px;"><button class="btn btn-sm" id="toplist-retry" type="button">重试</button></div>';
|
||
});
|
||
}
|
||
|
||
function renderToplists(list) {
|
||
var grid = $('#toplist-grid');
|
||
if (!list || !list.length) {
|
||
grid.hidden = true;
|
||
$('#toplist-loading').hidden = false;
|
||
$('#toplist-loading').innerHTML = '<div class="empty-icon">📊</div><div>暂无榜单数据</div>';
|
||
return;
|
||
}
|
||
$('#toplist-loading').hidden = true; // 有数据时才隐藏提示区
|
||
grid.hidden = false;
|
||
grid.innerHTML = list.map(function (t) {
|
||
return '<div class="toplist-card" data-id="' + escapeHtml(t.id) + '" data-name="' + escapeHtml(t.name) + '" title="' + escapeHtml(t.description || t.name) + '">' +
|
||
'<img class="tl-cover" src="' + escapeHtml(t.coverImgUrl || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="tl-name">' + escapeHtml(t.name) + '</div>' +
|
||
'<div class="tl-meta">' + escapeHtml(t.updateFrequency || '') +
|
||
(t.playCount ? ' · 播放 ' + fmtCount(t.playCount) : '') + '</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
$('#toplist-grid').addEventListener('click', function (e) {
|
||
var card = e.target.closest('.toplist-card');
|
||
if (!card) return;
|
||
var id = card.getAttribute('data-id');
|
||
var name = card.getAttribute('data-name');
|
||
toast('正在加载榜单:' + name, 'info');
|
||
$('#playlist-input').value = id;
|
||
switchTab('playlist');
|
||
doPlaylist();
|
||
});
|
||
document.addEventListener('click', function (e) {
|
||
if (e.target.closest('#toplist-retry')) {
|
||
// 重试必须重置"已加载"标志(原实现标志仍真,重试是空操作)
|
||
state.toplistLoaded = false;
|
||
$('#toplist-loading').innerHTML = '';
|
||
loadToplists();
|
||
}
|
||
});
|
||
|
||
/* ================= 播客(电台 / 单集声音) ================= */
|
||
var podcastHotCache = { categories: [] }; // 播客分类缓存
|
||
|
||
function podcastRadioCardHtml(r) {
|
||
return '<div class="toplist-card" data-radio-id="' + escapeHtml(r.id) + '" data-radio-name="' + escapeHtml(r.name) + '" title="' + escapeHtml(r.name) + '">' +
|
||
'<img class="tl-cover" src="' + escapeHtml(r.pic_url || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="tl-name">' + escapeHtml(r.name) + '</div>' +
|
||
'<div class="tl-meta">' + escapeHtml(r.category || '播客') +
|
||
(r.program_count ? ' · ' + escapeHtml(r.program_count) + ' 集' : '') +
|
||
(r.sub_count_text && r.sub_count_text !== '0' ? ' · 订阅 ' + escapeHtml(r.sub_count_text) : '') + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderPodcastCates() {
|
||
var bar = $('#podcast-cate-bar');
|
||
var cats = [{ id: null, name: '热门推荐' }].concat(podcastHotCache.categories);
|
||
bar.innerHTML = cats.map(function (c) {
|
||
var active = (c.id == null && !state.podcast.hotCate) || String(c.id) === String(state.podcast.hotCate || '');
|
||
return '<span class="podcast-cate-item' + (active ? ' active' : '') + '" data-cate-id="' + escapeHtml(c.id == null ? '' : c.id) + '">' + escapeHtml(c.name) + '</span>';
|
||
}).join('');
|
||
}
|
||
|
||
var _podcastHotBusy = false;
|
||
function loadPodcastHot(cateId, append) {
|
||
var grid = $('#podcast-hot-grid');
|
||
// 在途请求锁——双击"加载更多"不再用同一 offset 重复请求
|
||
if (append && _podcastHotBusy) return;
|
||
var loadingHtml = '<div class="empty-state" style="grid-column:1/-1;"><div class="empty-icon">🎙️</div><div>加载中...</div></div>';
|
||
if (!append) grid.innerHTML = loadingHtml;
|
||
_podcastHotBusy = true;
|
||
apiPost('/podcast/hot', { limit: 30, offset: append ? state.podcast.hotOffset : 0, cate_id: cateId || '' })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取热门播客失败');
|
||
var d = data.data || {};
|
||
if (d.categories && d.categories.length) podcastHotCache.categories = d.categories;
|
||
state.podcast.hotLoaded = true;
|
||
state.podcast.hotCate = cateId || null;
|
||
state.podcast.hotOffset = (append ? state.podcast.hotOffset : 0) + (d.radios || []).length;
|
||
renderPodcastCates();
|
||
var html = (d.radios || []).map(podcastRadioCardHtml).join('');
|
||
if (append) grid.insertAdjacentHTML('beforeend', html);
|
||
else grid.innerHTML = html || '<div class="empty-state" style="grid-column:1/-1;"><div class="empty-icon">🎙️</div><div>该分类暂无热门播客</div></div>';
|
||
// 按服务端 has_more 显隐(空页/短页没有更多)
|
||
$('#podcast-hot-more-wrap').hidden = !(d.radios || []).length || !d.has_more;
|
||
})
|
||
.catch(function (err) {
|
||
if (!append) grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1;"><div class="empty-icon">😕</div><div>' + escapeHtml(err.message || '加载失败') + '</div></div>';
|
||
toast(err.message || '获取热门播客失败', 'error');
|
||
})
|
||
.then(function () { _podcastHotBusy = false; }, function () { _podcastHotBusy = false; });
|
||
}
|
||
|
||
$('#podcast-cate-bar').addEventListener('click', function (e) {
|
||
var item = e.target.closest('.podcast-cate-item');
|
||
if (!item) return;
|
||
var cid = item.getAttribute('data-cate-id');
|
||
loadPodcastHot(cid || null, false);
|
||
});
|
||
$('#podcast-hot-more').addEventListener('click', function () {
|
||
loadPodcastHot(state.podcast.hotCate, true);
|
||
});
|
||
$('#podcast-hot-grid').addEventListener('click', function (e) {
|
||
var card = e.target.closest('.toplist-card');
|
||
if (!card) return;
|
||
$('#podcast-input').value = card.getAttribute('data-radio-id');
|
||
podcastParse();
|
||
});
|
||
|
||
/* ---- 解析入口 ---- */
|
||
var _podcastGen = 0;
|
||
function podcastParse() {
|
||
if (!requireLogin()) return;
|
||
if (!cookieGate()) return; // 播客入口补齐撤回闸(原音乐四入口有、播客三入口漏)
|
||
var raw = $('#podcast-input').value.trim();
|
||
if (!raw) { toast('请输入播客电台/单集链接或ID', 'warning'); return; }
|
||
var btn = $('#podcast-btn');
|
||
var gen = ++_podcastGen; // 代次守卫——回车绕过按钮禁用,迟到响应不得覆盖新解析
|
||
setBtnLoading(btn, true, '解析中...');
|
||
podcastShowLoading(true);
|
||
$('#podcast-radio-result').hidden = true;
|
||
$('#podcast-program-result').hidden = true;
|
||
apiPost('/podcast/parse', { id: raw, quality: $('#podcast-quality').value })
|
||
.then(function (data) {
|
||
if (gen !== _podcastGen) return; // 迟到响应丢弃
|
||
if (data.status !== 200) throw new Error(data.message || '解析失败');
|
||
var d = data.data || {};
|
||
if (d.kind === 'radio') podcastRenderRadio(d);
|
||
else podcastRenderProgram(d.program, true);
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== _podcastGen) return;
|
||
toast(err.message || '解析失败', 'error');
|
||
})
|
||
.finally(function () {
|
||
// 按钮无条件配对复位(重入计数器,见 doSearch);
|
||
// 全局 spinner 保持代次门——旧请求不提前收起新请求的 loading
|
||
setBtnLoading(btn, false);
|
||
if (gen === _podcastGen) podcastShowLoading(false);
|
||
});
|
||
}
|
||
function podcastShowLoading(show) { $('#podcast-loading').hidden = !show; }
|
||
|
||
$('#podcast-btn').addEventListener('click', podcastParse);
|
||
$('#podcast-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') podcastParse(); });
|
||
|
||
/* ---- 电台渲染 ---- */
|
||
function podcastRenderRadio(d) {
|
||
var radio = d.radio || {};
|
||
state.podcast.radio = radio;
|
||
state.podcast.radioId = radio.id;
|
||
state.podcast.offset = (d.programs || []).length;
|
||
state.podcast.count = d.count || 0;
|
||
state.podcast.asc = true;
|
||
$('#podcast-radio-cover').src = radio.pic_url || '';
|
||
$('#podcast-radio-name').textContent = radio.name || '';
|
||
$('#podcast-radio-meta').textContent =
|
||
'主播:' + (radio.dj_name || '未知') +
|
||
(radio.category ? ' · 分类:' + radio.category : '') +
|
||
(radio.program_count ? ' · 共 ' + radio.program_count + ' 集' : '') +
|
||
(radio.sub_count_text && radio.sub_count_text !== '0' ? ' · 订阅 ' + radio.sub_count_text : '');
|
||
$('#podcast-radio-desc').textContent = radio.desc || '';
|
||
$('#podcast-radio-desc').hidden = !radio.desc;
|
||
$('#podcast-radio-order').textContent = '⇅ 最新在前';
|
||
renderPodcastEpisodes(d.programs || [], false);
|
||
$('#podcast-radio-result').hidden = false;
|
||
$('#podcast-program-result').hidden = true;
|
||
$('#podcast-search-result').hidden = true;
|
||
}
|
||
|
||
function podcastEpisodeRowHtml(p, idx) {
|
||
var serial = p.serial_num || (idx + 1);
|
||
var meta = [];
|
||
if (p.duration_text) meta.push(p.duration_text);
|
||
if (p.create_date) meta.push(p.create_date);
|
||
if (p.listener_count_text && p.listener_count_text !== '0') meta.push('收听 ' + p.listener_count_text);
|
||
return '<div class="song-row" data-ep-id="' + escapeHtml(p.id) + '">' +
|
||
'<div class="podcast-ep-index">' + escapeHtml(serial) + '</div>' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(p.name) + '</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(meta.join(' · ')) + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm" data-act="detail" data-id="' + escapeHtml(p.id) + '" data-name="' + escapeHtml(p.name) + '">试听</button>' +
|
||
'<button class="btn btn-sm" data-act="direct" data-id="' + escapeHtml(p.id) + '" data-name="' + escapeHtml(p.name) + '">直链</button>' +
|
||
'<button class="btn btn-success btn-sm" data-act="download" data-id="' + escapeHtml(p.id) + '" data-name="' + escapeHtml(p.name) + '">下载</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderPodcastEpisodes(programs, append) {
|
||
var box = $('#podcast-episodes');
|
||
var html = programs.map(podcastEpisodeRowHtml).join('');
|
||
if (append) box.insertAdjacentHTML('beforeend', html);
|
||
else box.innerHTML = html || '<div class="tag-empty">该电台暂无单集</div>';
|
||
$('#podcast-episode-count').textContent = state.podcast.count ? state.podcast.count + ' 集' : '';
|
||
$('#podcast-more-wrap').hidden = !state.podcast.count || state.podcast.offset >= state.podcast.count;
|
||
}
|
||
|
||
var _podcastMoreBusy = false;
|
||
$('#podcast-more').addEventListener('click', function () {
|
||
// 在途请求锁——双击"加载更多"不再用同一 offset 发两次请求
|
||
//(原实现两次 append 相同页后 offset 跳两页,漏掉一页)
|
||
if (_podcastMoreBusy) return;
|
||
_podcastMoreBusy = true;
|
||
apiPost('/podcast/programs', {
|
||
radio_id: state.podcast.radioId,
|
||
limit: 30, offset: state.podcast.offset, asc: state.podcast.asc
|
||
}).then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '加载失败');
|
||
var d = data.data || {};
|
||
state.podcast.offset += (d.programs || []).length;
|
||
renderPodcastEpisodes(d.programs || [], true);
|
||
}).catch(function (err) { toast(err.message || '加载更多失败', 'error'); })
|
||
.then(function () { _podcastMoreBusy = false; }, function () { _podcastMoreBusy = false; });
|
||
});
|
||
|
||
$('#podcast-radio-order').addEventListener('click', function () {
|
||
var prevAsc = state.podcast.asc, prevOffset = state.podcast.offset;
|
||
state.podcast.asc = !state.podcast.asc;
|
||
this.textContent = state.podcast.asc ? '⇅ 最新在前' : '⇅ 第1集在前';
|
||
state.podcast.offset = 0;
|
||
apiPost('/podcast/programs', {
|
||
radio_id: state.podcast.radioId,
|
||
limit: 30, offset: 0, asc: state.podcast.asc
|
||
}).then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '加载失败');
|
||
var d = data.data || {};
|
||
state.podcast.offset = (d.programs || []).length;
|
||
renderPodcastEpisodes(d.programs || [], false);
|
||
}).catch(function (err) {
|
||
// 排序失败回滚状态与按钮文案(原实现失败后界面方向与数据不一致)
|
||
state.podcast.asc = prevAsc;
|
||
state.podcast.offset = prevOffset;
|
||
// 原实现的 [0] 取值恒为 undefined 导致短路,恢复文案从未生效
|
||
$('#podcast-radio-order').textContent =
|
||
state.podcast.asc ? '⇅ 最新在前' : '⇅ 第1集在前';
|
||
toast(err.message || '切换排序失败', 'error');
|
||
});
|
||
});
|
||
|
||
/* ---- 整台打包 ---- */
|
||
$('#podcast-radio-zip').addEventListener('click', function () {
|
||
if (!cookieGate()) return; // 整台打包补齐撤回闸
|
||
if (!state.podcast.radioId) return;
|
||
var name = (state.podcast.radio && state.podcast.radio.name) || state.podcast.radioId;
|
||
var credits = (window.getSITECredits && window.getSITECredits()) || 0;
|
||
// 预检上限取 min(实际集数, 200)——原实现固定按 200 集比较,
|
||
// 3 集的小电台也会误报"不足以打包全部单集"
|
||
var limit = Math.min(500, state.podcast.count || 200); // 与后端 500 上限对齐(原 200 使 201-500 集电台永远打不满)
|
||
if (credits < 0) {
|
||
// 未登录明确拦截(原实现游客 -1 穿过预检,提交后才吃 401)
|
||
if (window.handleAuthExpired) window.handleAuthExpired('请先登录后再使用打包下载', false);
|
||
return;
|
||
}
|
||
if (credits >= 0) {
|
||
if (credits <= 0) { if (window.handleAuthExpired) window.handleAuthExpired('', true); return; }
|
||
if (credits < limit) {
|
||
if (!window.confirm('您的剩余下载次数为 ' + credits + ' 次,不足以打包全部单集。\n确定后将仅打包前 ' + credits + ' 集,是否继续?')) return;
|
||
limit = credits;
|
||
}
|
||
}
|
||
if (!window.confirm('将下载「' + name + '」的单集(最多 ' + limit + ' 集)并打包为 ZIP,大电台耗时较长,确认开始?')) return;
|
||
var taskId = genTaskId();
|
||
apiPost('/podcast/download/batch_zip', {
|
||
radio_id: state.podcast.radioId,
|
||
limit: limit,
|
||
quality: $('#podcast-quality').value,
|
||
task_id: taskId,
|
||
device_id: getDeviceId()
|
||
}).then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '提交失败');
|
||
addTask({ id: taskId, type: 'batch', label: '播客打包 · ' + name, total: 0, done: 0, status: 'queued' });
|
||
toast('打包任务已提交,可在进度悬浮窗查看', 'success');
|
||
}).catch(function (err) { toast(err.message || '打包提交失败', 'error'); });
|
||
});
|
||
|
||
/* ---- 单集列表操作(事件委托) ---- */
|
||
$('#podcast-episodes').addEventListener('click', function (e) {
|
||
var btn = e.target.closest('button[data-act]');
|
||
if (!btn) return;
|
||
var act = btn.getAttribute('data-act');
|
||
var pid = btn.getAttribute('data-id');
|
||
var name = btn.getAttribute('data-name');
|
||
if (act === 'detail') podcastOpenProgram(pid, name, true, btn);
|
||
else if (act === 'direct') podcastCopyDirectLink(pid, name, btn);
|
||
else if (act === 'download') podcastDownloadEpisode(pid, name);
|
||
});
|
||
|
||
/* ---- 单集详情 / 试听 ---- */
|
||
function podcastRenderProgram(program, autoplay) {
|
||
state.podcast.program = program;
|
||
$('#podcast-program-cover').src = program.cover_url || '';
|
||
$('#podcast-program-name').textContent = program.name || '';
|
||
$('#podcast-program-radio').textContent = program.radio_name || '-';
|
||
$('#podcast-program-dj').textContent = program.dj_name || '-';
|
||
var meta = [];
|
||
if (program.duration_text) meta.push('时长 ' + program.duration_text);
|
||
if (program.create_date) meta.push('发布 ' + program.create_date);
|
||
if (program.serial_num) meta.push('第 ' + program.serial_num + ' 集');
|
||
$('#podcast-program-meta').textContent = meta.join(' · ') || '-';
|
||
var audio = program.audio;
|
||
var badges = $('#podcast-program-badges');
|
||
var openBtn = $('#podcast-program-open');
|
||
var canPlay = audio && audio.url;
|
||
badges.innerHTML = canPlay
|
||
? '<span class="badge">' + escapeHtml(audio.level_name || audio.level || '标准') + '</span>' +
|
||
'<span class="badge badge-green">' + escapeHtml((audio.type || 'mp3').toUpperCase()) + ' · ' + escapeHtml(audio.size_formatted || '') + '</span>' +
|
||
(audio.br ? '<span class="badge">' + Math.round(audio.br / 1000) + ' kbps</span>' : '')
|
||
: '<span class="badge" style="border-color:var(--color-warning);color:var(--color-warning);">未获取到音频直链</span>';
|
||
var player = $('#podcast-audio');
|
||
if (canPlay) {
|
||
// 与音乐播放器互斥——开播播客先暂停音乐
|
||
try {
|
||
var ma = $('#player audio');
|
||
if (ma && !ma.paused) ma.pause();
|
||
} catch (e) {}
|
||
player.src = audio.url;
|
||
player.hidden = false;
|
||
// 试听音量随主播放器(原实现原生控件独立,主播放器调低音量后
|
||
// 切播客试听仍 100% 首响)
|
||
try {
|
||
if (typeof playerState.volume === 'number') {
|
||
player.volume = playerState.volume;
|
||
player.muted = !!playerState.muted;
|
||
}
|
||
} catch (e) {}
|
||
openBtn.href = audio.url;
|
||
openBtn.hidden = false;
|
||
// 试听链接 36 分钟过期(=35 分钟保留期+60s),失效时给出明确指引(重新解析即可刷新)
|
||
player.onerror = function () {
|
||
toast('试听链接已过期或失效,请重新点击「试听」刷新', 'warning');
|
||
};
|
||
if (autoplay) { player.play().catch(function () { /* 自动播放被拦截时忽略 */ }); }
|
||
} else {
|
||
player.removeAttribute('src');
|
||
player.hidden = true;
|
||
openBtn.hidden = true;
|
||
}
|
||
$('#podcast-program-tip').hidden = !!canPlay;
|
||
$('#podcast-program-tip').textContent = '该单集暂无可用音频(可能已下架或受版权限制)';
|
||
$('#podcast-program-result').hidden = false;
|
||
$('#podcast-radio-result').hidden = true;
|
||
}
|
||
|
||
function podcastOpenProgram(pid, name, autoplay, btn) {
|
||
var restore = function () { if (btn) setBtnLoading(btn, false); };
|
||
if (btn) setBtnLoading(btn, true, '...');
|
||
podcastShowLoading(true);
|
||
apiPost('/podcast/parse', { id: pid, quality: $('#podcast-quality').value })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '解析失败');
|
||
podcastRenderProgram((data.data || {}).program, autoplay);
|
||
})
|
||
.catch(function (err) { toast(err.message || '解析失败', 'error'); })
|
||
.finally(function () { podcastShowLoading(false); restore(); });
|
||
}
|
||
|
||
/* ---- 直链复制 ---- */
|
||
function podcastCopyDirectLink(pid, name, btn) {
|
||
var restore = function () { if (btn) setBtnLoading(btn, false); };
|
||
if (btn) setBtnLoading(btn, true, '...');
|
||
apiPost('/podcast/parse', { id: pid, quality: $('#podcast-quality').value })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '解析失败');
|
||
var audio = ((data.data || {}).program || {}).audio;
|
||
if (!audio || !audio.url) throw new Error('未获取到音频直链');
|
||
try {
|
||
navigator.clipboard.writeText(audio.url)
|
||
.then(function () { toast('直链已复制:' + name, 'success'); })
|
||
.catch(function () { fallbackCopy(audio.url); });
|
||
} catch (e2) { fallbackCopy(audio.url); }
|
||
})
|
||
.catch(function (err) { toast(err.message || '获取直链失败', 'error'); })
|
||
.finally(restore);
|
||
}
|
||
|
||
/* ---- 单集下载 ---- */
|
||
function podcastDownloadEpisode(pid, name) {
|
||
if (!requireLogin()) return;
|
||
if (!cookieGate()) return; // 单集下载补齐撤回闸
|
||
var taskId = genTaskId();
|
||
apiPost('/podcast/download', {
|
||
id: pid,
|
||
quality: $('#podcast-quality').value,
|
||
task_id: taskId,
|
||
device_id: getDeviceId()
|
||
}).then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '提交失败');
|
||
addTask({ id: taskId, type: 'single', label: '播客 · ' + (name || pid), total: 1, done: 0, status: 'queued' });
|
||
toast('下载任务已提交:' + name, 'success');
|
||
}).catch(function (err) { toast(err.message || '下载提交失败', 'error'); });
|
||
}
|
||
|
||
/* ---- 单集详情卡片按钮 ---- */
|
||
$('#podcast-program-download').addEventListener('click', function () {
|
||
var p = state.podcast.program;
|
||
if (!p) return;
|
||
podcastDownloadEpisode(p.id, p.name);
|
||
});
|
||
|
||
/* ---- 播客搜索 ---- */
|
||
function podcastSearch() {
|
||
if (!requireLogin()) return;
|
||
var kw = $('#podcast-search-input').value.trim();
|
||
if (!kw) { toast('请输入搜索关键词', 'warning'); return; }
|
||
var btn = $('#podcast-search-btn');
|
||
setBtnLoading(btn, true, '搜索中...');
|
||
apiPost('/podcast/search', { keyword: kw, limit: 20, type: 'all' })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '搜索失败');
|
||
var d = data.data || {};
|
||
var radios = d.radios || [];
|
||
var voices = d.voices || [];
|
||
var html = radios.map(function (r) {
|
||
var meta = [];
|
||
if (r.dj_name) meta.push('主播 ' + r.dj_name);
|
||
if (r.program_count) meta.push(r.program_count + ' 集');
|
||
if (r.sub_count_text && r.sub_count_text !== '0') meta.push('订阅 ' + r.sub_count_text);
|
||
return '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(r.pic_url || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(r.name) + '</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(meta.join(' · ')) + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-primary btn-sm" data-search-radio="' + escapeHtml(r.id) + '" data-radio-name="' + escapeHtml(r.name) + '">解析电台</button>' +
|
||
'</div></div>';
|
||
}).join('');
|
||
html += voices.map(function (v) {
|
||
var meta = [];
|
||
if (v.radio_name) meta.push('来自「' + v.radio_name + '」');
|
||
if (v.duration_text) meta.push(v.duration_text);
|
||
if (v.create_date) meta.push(v.create_date);
|
||
return '<div class="song-row">' +
|
||
'<img class="song-cover" src="' + escapeHtml(v.cover_url || '') + '" loading="lazy" alt="" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(v.name) + '</span></div>' +
|
||
'<div class="song-meta">' + escapeHtml(meta.join(' · ')) + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm" data-search-voice="' + escapeHtml(v.id) + '" data-voice-name="' + escapeHtml(v.name) + '">解析单集</button>' +
|
||
'</div></div>';
|
||
}).join('');
|
||
$('#podcast-search-list').innerHTML = html ||
|
||
'<div class="tag-empty">未找到相关播客</div>';
|
||
$('#podcast-search-count').textContent = (radios.length + voices.length) + ' 条';
|
||
var tip = $('#podcast-search-tip');
|
||
if (!voices.length && radios.length) {
|
||
tip.textContent = '💡 受网易接口限制,单集(声音)搜索可能无结果;可先解析电台后浏览全部单集。';
|
||
tip.hidden = false;
|
||
} else tip.hidden = true;
|
||
$('#podcast-search-result').hidden = false;
|
||
})
|
||
.catch(function (err) { toast(err.message || '搜索失败', 'error'); })
|
||
.finally(function () { setBtnLoading(btn, false); });
|
||
}
|
||
$('#podcast-search-btn').addEventListener('click', podcastSearch);
|
||
$('#podcast-search-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') podcastSearch(); });
|
||
$('#podcast-search-list').addEventListener('click', function (e) {
|
||
var radioBtn = e.target.closest('button[data-search-radio]');
|
||
if (radioBtn) {
|
||
$('#podcast-input').value = radioBtn.getAttribute('data-search-radio');
|
||
podcastParse();
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
return;
|
||
}
|
||
var voiceBtn = e.target.closest('button[data-search-voice]');
|
||
if (voiceBtn) podcastOpenProgram(voiceBtn.getAttribute('data-search-voice'), voiceBtn.getAttribute('data-voice-name'), true, voiceBtn);
|
||
});
|
||
|
||
/* ================= 关于网站 ================= */
|
||
function renderAboutChangelog() {
|
||
// 版本号与更新日志联动(原实现写死 v1.5.0,与日志最新 2.1.0 不一致)
|
||
var ver = $('#about-version');
|
||
if (ver && CHANGELOG[0]) ver.textContent = 'v' + CHANGELOG[0].v;
|
||
$('#about-changelog').innerHTML = CHANGELOG.map(function (v) {
|
||
return '<div class="version-item">' +
|
||
'<div class="version-head"><span class="version-num">v' + v.v + '</span>' +
|
||
'<span class="version-date">' + v.date + '</span></div>' +
|
||
'<ul class="version-items">' + v.items.map(function (i) {
|
||
return '<li>' + escapeHtml(i) + '</li>';
|
||
}).join('') + '</ul></div>';
|
||
}).join('');
|
||
}
|
||
|
||
/* ================= 管理员设置(二维码登录 / Cookie 管理) ================= */
|
||
// 旧版前端写死口令已废除——本面板全部接口仅认站点管理员会话;
|
||
// 入口改为直接用会话探测(管理员已登录 site_uid 即放行,否则引导去管理后台)
|
||
|
||
function adminEnter() {
|
||
apiPost('/admin/cookie/status', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '需要管理员权限');
|
||
state.adminAuthed = true;
|
||
$('#admin-gate').hidden = true;
|
||
$('#admin-content').hidden = false;
|
||
toast('验证成功,欢迎进入管理员设置', 'success');
|
||
adminLoadStatus();
|
||
loadAdminLogs();
|
||
})
|
||
.catch(function (err) {
|
||
toast('请先用管理员账号登录站点,管理功能已并入「管理后台」页签', 'error');
|
||
});
|
||
}
|
||
|
||
function adminLoadStatus() {
|
||
apiPost('/admin/cookie/status', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取状态失败');
|
||
var info = data.data || {};
|
||
var badge = $('#admin-status-badge');
|
||
var desc = $('#admin-status-desc');
|
||
if (info.is_valid) {
|
||
badge.textContent = '有效';
|
||
badge.style.borderColor = 'var(--color-success)';
|
||
badge.style.color = 'var(--color-success)';
|
||
badge.style.background = 'rgba(82,196,26,0.1)';
|
||
desc.textContent = '已登录,Cookie 自动保存,无需重复扫码。';
|
||
} else {
|
||
badge.textContent = '未登录';
|
||
badge.style.borderColor = 'var(--color-error)';
|
||
badge.style.color = 'var(--color-error)';
|
||
badge.style.background = 'rgba(255,77,79,0.1)';
|
||
desc.textContent = info.file_exists ?
|
||
'Cookie 文件存在但缺少关键字段,建议重新扫码登录。' :
|
||
'尚未配置 Cookie,请扫码登录。';
|
||
}
|
||
$('#admin-cookie-file').textContent = info.file_path || '-';
|
||
var parts = [];
|
||
if (info.cookie_count != null) parts.push('Cookie ' + info.cookie_count + ' 个');
|
||
if (info.last_modified) parts.push('最后更新 ' + info.last_modified);
|
||
if (info.important_cookies_present && info.important_cookies_present.length) {
|
||
parts.push('包含 ' + info.important_cookies_present.join(', '));
|
||
}
|
||
$('#admin-cookie-time').textContent = parts.join(' · ') || '-';
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取 Cookie 状态失败', 'error');
|
||
});
|
||
}
|
||
|
||
function adminStopQrPoll() {
|
||
if (state.adminQrTimer) {
|
||
clearInterval(state.adminQrTimer);
|
||
state.adminQrTimer = null;
|
||
}
|
||
}
|
||
|
||
function adminQrCreate() {
|
||
if (!state.adminAuthed) return;
|
||
setBtnLoading($('#admin-qr-btn'), true, '生成中...');
|
||
apiPost('/admin/qr/create', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '生成二维码失败');
|
||
var d = data.data;
|
||
state.adminUnikey = d.unikey;
|
||
var svg = d.qr_svg || '';
|
||
var dataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||
$('#admin-qr-img').src = dataUri;
|
||
$('#admin-qr-area').hidden = false;
|
||
$('#admin-qr-status').textContent = '请使用网易云音乐手机 APP 扫描二维码登录';
|
||
$('#admin-qr-status').style.color = '';
|
||
toast('二维码已生成,请用手机扫码', 'info');
|
||
adminStopQrPoll();
|
||
// 换新码即作废旧 unikey 的在途 check 响应(代次令牌)
|
||
state.adminQrGen = (state.adminQrGen || 0) + 1;
|
||
state.adminQrTimer = setInterval(adminQrPoll, 3000);
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '生成二维码失败', 'error');
|
||
})
|
||
.finally(function () { setBtnLoading($('#admin-qr-btn'), false); });
|
||
}
|
||
|
||
function adminQrPoll() {
|
||
if (!state.adminUnikey) { adminStopQrPoll(); return; }
|
||
// 代次令牌——删除 Cookie/重新生成二维码后,旧 unikey 的
|
||
// 在途 check 响应不得杀掉新一轮轮询或误报「登录成功,Cookie 已自动保存」
|
||
state.adminQrGen = (state.adminQrGen || 0) + 1;
|
||
var gen = state.adminQrGen;
|
||
apiPost('/admin/qr/check', { unikey: state.adminUnikey })
|
||
.then(function (data) {
|
||
if (gen !== state.adminQrGen) return; // 迟到响应丢弃
|
||
if (data.status !== 200) {
|
||
adminStopQrPoll();
|
||
$('#admin-qr-status').textContent = data.message || '检查登录状态失败';
|
||
$('#admin-qr-status').style.color = 'var(--color-error)';
|
||
return;
|
||
}
|
||
var st = data.data.status;
|
||
if (st === 'waiting') {
|
||
$('#admin-qr-status').textContent = '等待扫码...';
|
||
$('#admin-qr-status').style.color = '';
|
||
} else if (st === 'scanned') {
|
||
$('#admin-qr-status').textContent = '✅ 已扫码,请在手机上确认登录';
|
||
$('#admin-qr-status').style.color = 'var(--color-warning)';
|
||
} else if (st === 'success') {
|
||
adminStopQrPoll();
|
||
$('#admin-qr-status').textContent = '🎉 登录成功,Cookie 已自动保存';
|
||
$('#admin-qr-status').style.color = 'var(--color-success)';
|
||
toast('登录成功,Cookie 已自动保存', 'success');
|
||
adminLoadStatus();
|
||
renderAccountBar();
|
||
} else if (st === 'expired') {
|
||
adminStopQrPoll();
|
||
$('#admin-qr-status').textContent = '二维码已过期,请点击「生成登录二维码」重新获取';
|
||
$('#admin-qr-status').style.color = 'var(--color-error)';
|
||
} else {
|
||
adminStopQrPoll();
|
||
$('#admin-qr-status').textContent = data.message || '登录状态异常';
|
||
$('#admin-qr-status').style.color = 'var(--color-error)';
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== state.adminQrGen) return; // 迟到响应丢弃
|
||
adminStopQrPoll();
|
||
$('#admin-qr-status').textContent = err.message || '检查登录状态失败';
|
||
$('#admin-qr-status').style.color = 'var(--color-error)';
|
||
});
|
||
}
|
||
|
||
function adminCookieDelete() {
|
||
if (!state.adminAuthed) return;
|
||
if (!window.confirm('确定要删除当前 Cookie 吗?\n删除后将需要重新扫码登录。')) return;
|
||
setBtnLoading($('#admin-cookie-delete'), true, '删除中...');
|
||
apiPost('/admin/cookie/delete', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '删除失败');
|
||
adminStopQrPoll();
|
||
// 删除后作废在途 check 响应(代次令牌)
|
||
state.adminQrGen = (state.adminQrGen || 0) + 1;
|
||
$('#admin-qr-area').hidden = true;
|
||
state.adminUnikey = '';
|
||
toast('Cookie 已删除,可重新扫码登录', 'success');
|
||
adminLoadStatus();
|
||
renderAccountBar();
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '删除 Cookie 失败', 'error');
|
||
})
|
||
.finally(function () { setBtnLoading($('#admin-cookie-delete'), false); });
|
||
}
|
||
|
||
$('#admin-enter').addEventListener('click', adminEnter);
|
||
$('#admin-password').addEventListener('keydown', function (e) { if (e.key === 'Enter') adminEnter(); });
|
||
$('#admin-qr-btn').addEventListener('click', adminQrCreate);
|
||
$('#admin-cookie-delete').addEventListener('click', adminCookieDelete);
|
||
$('#admin-status-refresh').addEventListener('click', adminLoadStatus);
|
||
|
||
/* ================= 管理员:用户 IP 解析/下载记录 ================= */
|
||
var adminLogsPage = 1;
|
||
function loadAdminLogs() {
|
||
if (!state.adminAuthed) return;
|
||
apiPost('/admin/logs', { page: adminLogsPage, page_size: 10 })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取记录失败');
|
||
var d = data.data;
|
||
var box = $('#admin-logs-list');
|
||
if (!d.items || !d.items.length) {
|
||
box.innerHTML = '<div class="comment-empty">暂无解析/下载记录</div>';
|
||
} else {
|
||
box.innerHTML = d.items.map(function (it) {
|
||
var tagCls = it.action === '下载' ? ' style="border-color:var(--color-success);color:var(--color-success);background:rgba(82,196,26,0.1);"' :
|
||
' style="border-color:var(--color-primary);color:var(--color-primary);background:var(--color-primary-bg);"';
|
||
return '<div class="stat-list-item">' +
|
||
'<span class="badge" ' + tagCls + '>' + escapeHtml(it.action || '-') + '</span>' +
|
||
'<span style="font-family:monospace;color:var(--color-text-secondary);flex:none;">' + escapeHtml(it.ip || '-') + '</span>' +
|
||
'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(it.song || '') + '</span>' +
|
||
'<span style="flex:none;color:var(--color-text-tertiary);font-size:12px;">' + escapeHtml(it.time || '') + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
var pages = Math.max(1, d.pages || 1);
|
||
$('#admin-logs-info').textContent = '第 ' + d.page + ' / ' + pages + ' 页';
|
||
$('#admin-logs-prev').disabled = d.page <= 1;
|
||
$('#admin-logs-next').disabled = d.page >= pages;
|
||
$('#admin-logs-pager').hidden = d.total <= 10;
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取记录失败', 'error');
|
||
});
|
||
}
|
||
$('#admin-logs-refresh').addEventListener('click', loadAdminLogs);
|
||
$('#admin-logs-prev').addEventListener('click', function () {
|
||
if (adminLogsPage > 1) { adminLogsPage--; loadAdminLogs(); }
|
||
});
|
||
$('#admin-logs-next').addEventListener('click', function () {
|
||
adminLogsPage++; loadAdminLogs();
|
||
});
|
||
|
||
/* ================= 个人歌单(游客扫码登录自己的账号) ================= */
|
||
function personalCheckLogin() {
|
||
apiPost('/user/status', { user_cookie: state.personal.cookie })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取登录状态失败');
|
||
var d = data.data || {};
|
||
var info = d.cookie || {};
|
||
if (info.is_valid && d.user) {
|
||
state.personal.authed = true;
|
||
state.personal.user = d.user;
|
||
$('#personal-gate').hidden = true;
|
||
$('#personal-content').hidden = false;
|
||
personalRenderUser();
|
||
personalLoadPlaylists();
|
||
renderAccountBar();
|
||
updateCollectButtons();
|
||
updateCommentComposer();
|
||
} else {
|
||
personalResetGate(info.is_valid);
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取登录状态失败', 'error');
|
||
personalResetGate(false);
|
||
});
|
||
}
|
||
|
||
function personalResetGate(hasCookie) {
|
||
state.personal.authed = false;
|
||
$('#personal-content').hidden = true;
|
||
$('#personal-gate').hidden = false;
|
||
$('#personal-qr-area').hidden = true;
|
||
$('#personal-refresh').hidden = !hasCookie;
|
||
updateCollectButtons();
|
||
updateCommentComposer();
|
||
if (hasCookie) {
|
||
$('#personal-gate-hint').textContent = '检测到登录信息已失效(或未关联有效账号),请重新扫码登录。';
|
||
$('#personal-refresh').hidden = false;
|
||
} else {
|
||
$('#personal-gate-hint').innerHTML = '扫码登录你自己的网易云账号,即可查看收藏的歌单(包括「我喜欢的音乐」)。' +
|
||
'个人登录仅用于获取你的歌单,<b>解析与下载音乐仍使用管理员全局账号</b>。';
|
||
$('#personal-refresh').hidden = true;
|
||
}
|
||
}
|
||
|
||
function vipBadgeHtml(vipType) {
|
||
if (vipType === 11 || vipType === 100) return '<span class="vip-badge vip-vip">黑胶VIP</span>';
|
||
if (vipType === 10 || vipType === 110) return '<span class="vip-badge vip-svip">黑胶SVIP</span>';
|
||
if (vipType === 1) return '<span class="vip-badge vip-pkg">音乐包</span>';
|
||
return '<span class="vip-badge" style="background:var(--color-fill);color:var(--color-text-secondary);border:1px solid var(--color-border);">普通用户</span>';
|
||
}
|
||
|
||
function personalRenderUser() {
|
||
var u = state.personal.user || {};
|
||
$('#personal-avatar').src = u.avatarUrl || '';
|
||
$('#personal-nickname').innerHTML = escapeHtml(u.nickname || '未命名用户') + ' ' + vipBadgeHtml(u.vipType);
|
||
$('#personal-signature').textContent = u.signature || '';
|
||
renderPersonalToggle();
|
||
}
|
||
|
||
function renderPersonalToggle() {
|
||
var btn = $('#personal-toggle-account');
|
||
if (state.parseAccount === 'user') {
|
||
btn.textContent = '切换为全局账号解析';
|
||
btn.className = 'btn btn-sm btn-primary';
|
||
} else {
|
||
btn.textContent = '切换为个人账号解析';
|
||
btn.className = 'btn btn-sm';
|
||
}
|
||
}
|
||
|
||
$('#personal-toggle-account').addEventListener('click', function () {
|
||
if (state.parseAccount === 'admin') {
|
||
if (!state.personal.authed) {
|
||
toast('请先在「个人歌单」扫码登录你自己的账号', 'warning');
|
||
return;
|
||
}
|
||
state.parseAccount = 'user';
|
||
toast('已切换为个人账号解析与下载(全局菜单均生效)', 'success');
|
||
} else {
|
||
state.parseAccount = 'admin';
|
||
toast('已切换为全局账号解析与下载', 'success');
|
||
}
|
||
try { localStorage.setItem('netease_parse_account', state.parseAccount); } catch (e) {}
|
||
renderPersonalToggle();
|
||
renderAccountBar();
|
||
});
|
||
|
||
function personalLoadPlaylists() {
|
||
apiPost('/user/playlists', { user_cookie: state.personal.cookie })
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取歌单失败');
|
||
state.personal.user = data.data.user;
|
||
personalRenderUser();
|
||
personalRenderPlaylists(data.data.playlists || []);
|
||
})
|
||
.catch(function (err) {
|
||
if (err.message && err.message.indexOf('请先扫码') !== -1) {
|
||
toast(err.message, 'warning');
|
||
personalResetGate(false);
|
||
} else {
|
||
toast(err.message || '获取歌单失败', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
function personalRenderPlaylists(list) {
|
||
state.personal.playlists = list;
|
||
var box = $('#personal-playlists');
|
||
$('#personal-playlist-count').textContent = '共 ' + list.length + ' 个';
|
||
if (!list.length) {
|
||
box.innerHTML = '<div class="empty-state"><div class="empty-icon">📂</div>暂无歌单</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = list.map(function (p) {
|
||
var badge = '';
|
||
if (p.specialType === 5) {
|
||
badge = '<span class="badge" style="border-color:var(--color-error);color:var(--color-error);background:rgba(255,77,79,0.1);">❤️ 我喜欢的音乐</span>';
|
||
} else if (p.is_own) {
|
||
badge = '<span class="badge" style="border-color:var(--color-primary);color:var(--color-primary);">我创建的</span>';
|
||
} else {
|
||
badge = '<span class="badge badge-green">收藏的</span>';
|
||
}
|
||
return '<div class="song-row personal-pl" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" style="cursor:pointer;">' +
|
||
'<img class="song-cover" src="' + escapeHtml(p.coverImgUrl || '') + '" loading="lazy" alt="" style="border-radius:12px;" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(p.name) + '</span> ' + badge + '</div>' +
|
||
'<div class="song-meta">' + fmtCount(p.trackCount) + ' 首 · 播放 ' + fmtCount(p.playCount) +
|
||
(p.creator ? ' · by ' + escapeHtml(p.creator) : '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm act-uncollect" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" type="button">取消收藏</button>' +
|
||
'<button class="btn btn-sm btn-link act-personal-open" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" type="button">查看歌曲</button></div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
function personalOpenPlaylist(id, name) {
|
||
if (!id) return;
|
||
state.personal.current = { id: id, name: name };
|
||
var gen = _collGen('personal'); // 迟到响应不得覆盖新打开的歌单
|
||
$('#personal-tracks-wrap').hidden = false;
|
||
$('#personal-tracks-title').textContent = '歌单歌曲:' + (name || id);
|
||
$('#personal-tracks').innerHTML = '<div class="empty-state"><div class="empty-icon">⏳</div><div>加载中...</div></div>';
|
||
window.scrollTo({ top: $('#personal-tracks-wrap').offsetTop - 80, behavior: 'smooth' });
|
||
apiPost('/user/playlist', { id: id, user_cookie: state.personal.cookie })
|
||
.then(function (data) {
|
||
if (_collStale('personal', gen)) return;
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '获取歌单失败');
|
||
personalRenderTracks(data.data.playlist || {});
|
||
})
|
||
.catch(function (err) {
|
||
if (_collStale('personal', gen)) return;
|
||
$('#personal-tracks').innerHTML = '<div class="empty-state"><div class="empty-icon">❌</div><div>' + escapeHtml(err.message || '获取歌单失败') + '</div></div>';
|
||
});
|
||
}
|
||
|
||
function personalRenderTracks(info) {
|
||
var tracks = info.tracks || [];
|
||
// 空列表同样走 renderPagedTracks——清空 tracks_personal 状态与分页条,
|
||
// 原实现提前 return,旧歌单的下载数据残留(批量下载会下到旧歌)
|
||
renderPagedTracks('personal', tracks, 1);
|
||
}
|
||
|
||
function personalStopQrPoll() {
|
||
if (state.personal.qrTimer) {
|
||
clearInterval(state.personal.qrTimer);
|
||
state.personal.qrTimer = null;
|
||
}
|
||
}
|
||
|
||
function personalQrCreate() {
|
||
setBtnLoading($('#personal-qr-btn'), true, '生成中...');
|
||
apiPost('/user/qr/create', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '生成二维码失败');
|
||
var d = data.data;
|
||
state.personal.unikey = d.unikey;
|
||
var dataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(d.qr_svg || '')));
|
||
$('#personal-qr-img').src = dataUri;
|
||
$('#personal-qr-area').hidden = false;
|
||
$('#personal-qr-status').textContent = '请使用网易云音乐手机 APP 扫描二维码登录';
|
||
$('#personal-qr-status').style.color = '';
|
||
toast('二维码已生成,请用手机扫码', 'info');
|
||
personalStopQrPoll();
|
||
state.personal.qrGen = (state.personal.qrGen || 0) + 1; // 新轮询代次
|
||
state.personal.qrTimer = setInterval(personalQrPoll, 3000);
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '生成二维码失败', 'error');
|
||
})
|
||
.finally(function () { setBtnLoading($('#personal-qr-btn'), false); });
|
||
}
|
||
|
||
function personalQrPoll() {
|
||
if (!state.personal.unikey) { personalStopQrPoll(); return; }
|
||
var myGen = state.personal.qrGen; // 本次轮询所属代次,退出后作废
|
||
apiPost('/user/qr/check', { unikey: state.personal.unikey })
|
||
.then(function (data) {
|
||
if (myGen !== state.personal.qrGen) return; // 期间已退出/重开,丢弃迟到响应
|
||
if (data.status !== 200) {
|
||
personalStopQrPoll();
|
||
$('#personal-qr-status').textContent = data.message || '检查登录状态失败';
|
||
$('#personal-qr-status').style.color = 'var(--color-error)';
|
||
return;
|
||
}
|
||
var st = data.data.status;
|
||
if (st === 'waiting') {
|
||
$('#personal-qr-status').textContent = '等待扫码...';
|
||
$('#personal-qr-status').style.color = '';
|
||
} else if (st === 'scanned') {
|
||
$('#personal-qr-status').textContent = '✅ 已扫码,请在手机上确认登录';
|
||
$('#personal-qr-status').style.color = 'var(--color-warning)';
|
||
} else if (st === 'success') {
|
||
personalStopQrPoll();
|
||
// 个人登录 Cookie 保存在本机浏览器;
|
||
// 站点账号登录时后端会同步一份到站点库供管理后台查看(口径对齐)
|
||
if (data.data && data.data.cookie) {
|
||
state.personal.cookie = data.data.cookie;
|
||
try { localStorage.setItem('netease_user_cookie', state.personal.cookie); } catch (e) {}
|
||
} $('#personal-qr-status').textContent = '🎉 登录成功,正在加载你的歌单...';
|
||
$('#personal-qr-status').style.color = 'var(--color-success)';
|
||
toast('登录成功,正在加载你的歌单', 'success');
|
||
personalCheckLogin();
|
||
} else if (st === 'expired') {
|
||
personalStopQrPoll();
|
||
$('#personal-qr-status').textContent = '二维码已过期,请点击「扫码登录我的账号」重新获取';
|
||
$('#personal-qr-status').style.color = 'var(--color-error)';
|
||
} else {
|
||
personalStopQrPoll();
|
||
$('#personal-qr-status').textContent = data.message || '登录状态异常';
|
||
$('#personal-qr-status').style.color = 'var(--color-error)';
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
personalStopQrPoll();
|
||
$('#personal-qr-status').textContent = err.message || '检查登录状态失败';
|
||
$('#personal-qr-status').style.color = 'var(--color-error)';
|
||
});
|
||
}
|
||
|
||
function personalLogout() {
|
||
if (!window.confirm('确定要退出登录当前网易云账号吗?\n退出后需要重新扫码才能查看个人歌单。')) return;
|
||
apiPost('/user/logout', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '退出失败');
|
||
personalStopQrPoll();
|
||
state.personal.qrGen = (state.personal.qrGen || 0) + 1; // 作废在途轮询响应
|
||
state.personal.authed = false;
|
||
state.personal.user = null;
|
||
state.personal.playlists = [];
|
||
state.personal.current = null;
|
||
// 清除本机浏览器的个人 Cookie
|
||
state.personal.cookie = '';
|
||
try { localStorage.removeItem('netease_user_cookie'); } catch (e) {}
|
||
$('#personal-tracks-wrap').hidden = true;
|
||
// 若正在使用个人账号解析,退出后回退全局账号
|
||
if (state.parseAccount === 'user') {
|
||
state.parseAccount = 'admin';
|
||
try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {}
|
||
toast('已退出个人账号,解析账号已切换为全局账号', 'warning');
|
||
} else {
|
||
toast('已退出登录', 'success');
|
||
}
|
||
renderPersonalToggle();
|
||
renderAccountBar();
|
||
personalResetGate(false);
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '退出登录失败', 'error');
|
||
});
|
||
}
|
||
|
||
$('#personal-qr-btn').addEventListener('click', personalQrCreate);
|
||
$('#personal-refresh').addEventListener('click', personalCheckLogin);
|
||
$('#personal-logout').addEventListener('click', personalLogout);
|
||
$('#personal-playlists-refresh').addEventListener('click', personalLoadPlaylists);
|
||
|
||
// 歌单列表点击查看
|
||
$('#personal-playlists').addEventListener('click', function (e) {
|
||
// 取消收藏按钮的点击不触发"打开歌单"(两条无关操作不再同时发生)
|
||
if (e.target.closest('.act-uncollect')) return;
|
||
var btn = e.target.closest('.act-personal-open');
|
||
var row = e.target.closest('.personal-pl');
|
||
if (btn) {
|
||
personalOpenPlaylist(btn.getAttribute('data-id'), btn.getAttribute('data-name'));
|
||
} else if (row) {
|
||
personalOpenPlaylist(row.getAttribute('data-id'), row.getAttribute('data-name'));
|
||
}
|
||
});
|
||
|
||
$('#personal-download-all').addEventListener('click', function () {
|
||
batchDownloadAll(this, 'personal');
|
||
});
|
||
|
||
/* ================= 全局账号歌单(管理员 cookie.txt) ================= */
|
||
function globalCheckLogin() {
|
||
apiPost('/global/playlists', {})
|
||
.then(function (data) {
|
||
if (data.status === 401) {
|
||
globalRenderGate(false);
|
||
return;
|
||
}
|
||
if (data.status !== 200) throw new Error(data.message || '获取全局账号歌单失败');
|
||
var d = data.data || {};
|
||
state.global.user = d.user || null;
|
||
state.global.playlists = d.playlists || [];
|
||
$('#global-gate').hidden = true;
|
||
$('#global-content').hidden = false;
|
||
globalRenderUser();
|
||
globalRenderLists();
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取全局账号歌单失败', 'error');
|
||
globalRenderGate(false);
|
||
});
|
||
}
|
||
|
||
function globalRenderGate(hasCookie) {
|
||
$('#global-content').hidden = true;
|
||
$('#global-gate').hidden = false;
|
||
$('#global-refresh').hidden = !hasCookie;
|
||
}
|
||
|
||
function globalRenderUser() {
|
||
var u = state.global.user || {};
|
||
$('#global-avatar').src = u.avatarUrl || '';
|
||
$('#global-nickname').innerHTML = escapeHtml(u.nickname || '未命名用户') + ' ' + vipBadgeHtml(u.vipType);
|
||
$('#global-signature').textContent = u.signature || '';
|
||
}
|
||
|
||
function globalLoadPlaylists() {
|
||
apiPost('/global/playlists', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取全局账号歌单失败');
|
||
state.global.user = data.data.user || null;
|
||
state.global.playlists = data.data.playlists || [];
|
||
globalRenderUser();
|
||
globalRenderLists();
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || '获取全局账号歌单失败', 'error');
|
||
});
|
||
}
|
||
|
||
/* 歌单行 HTML(isFav 区分「我喜欢的音乐」列表) */
|
||
function globalPlaylistRowHtml(p) {
|
||
var badge = '';
|
||
if (p.specialType === 5) {
|
||
badge = '<span class="badge" style="border-color:var(--color-error);color:var(--color-error);background:rgba(255,77,79,0.1);">❤️ 我喜欢的音乐</span>';
|
||
} else if (p.is_own) {
|
||
badge = '<span class="badge" style="border-color:var(--color-primary);color:var(--color-primary);">我创建的</span>';
|
||
} else {
|
||
badge = '<span class="badge badge-green">收藏的</span>';
|
||
}
|
||
return '<div class="song-row global-pl" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" style="cursor:pointer;">' +
|
||
'<img class="song-cover" src="' + escapeHtml(p.coverImgUrl || '') + '" loading="lazy" alt="" style="border-radius:12px;" />' +
|
||
'<div class="song-info">' +
|
||
'<div class="song-name"><span class="n">' + escapeHtml(p.name) + '</span> ' + badge + '</div>' +
|
||
'<div class="song-meta">' + fmtCount(p.trackCount) + ' 首 · 播放 ' + fmtCount(p.playCount) +
|
||
(p.creator ? ' · by ' + escapeHtml(p.creator) : '') + '</div>' +
|
||
'</div>' +
|
||
'<div class="song-actions">' +
|
||
'<button class="btn btn-sm btn-primary act-global-play" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" type="button">▶ 播放</button>' +
|
||
'<button class="btn btn-sm act-global-api" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" type="button">🔗 API</button>' +
|
||
'<button class="btn btn-sm btn-link act-global-open" data-id="' + escapeHtml(p.id) +
|
||
'" data-name="' + escapeHtml(p.name) + '" type="button">查看歌曲</button></div></div>';
|
||
}
|
||
|
||
function globalRenderLists() {
|
||
var list = state.global.playlists;
|
||
var fav = list.filter(function (p) { return p.specialType === 5; });
|
||
var collect = list.filter(function (p) { return p.specialType !== 5; });
|
||
|
||
$('#global-fav-count').textContent = '共 ' + fav.length + ' 个';
|
||
$('#global-collect-count').textContent = '共 ' + collect.length + ' 个';
|
||
|
||
$('#global-fav-playlists').innerHTML = fav.length
|
||
? fav.map(globalPlaylistRowHtml).join('')
|
||
: '<div class="empty-state"><div class="empty-icon">📂</div>暂无「我喜欢的音乐」</div>';
|
||
$('#global-collect-playlists').innerHTML = collect.length
|
||
? collect.map(globalPlaylistRowHtml).join('')
|
||
: '<div class="empty-state"><div class="empty-icon">📂</div>暂无收藏的歌单</div>';
|
||
}
|
||
|
||
function globalOpenPlaylist(id, name) {
|
||
if (!id) return;
|
||
state.global.current = { id: id, name: name };
|
||
var gen = _collGen('global'); // 迟到响应不得覆盖新打开的歌单
|
||
$('#global-tracks-wrap').hidden = false;
|
||
$('#global-tracks-title').textContent = '歌单歌曲:' + (name || id);
|
||
$('#global-tracks').innerHTML = '<div class="empty-state"><div class="empty-icon">⏳</div><div>加载中...</div></div>';
|
||
$('#global-play-playlist').hidden = false;
|
||
$('#global-play-playlist').setAttribute('data-id', id);
|
||
$('#global-play-playlist').setAttribute('data-name', name || id);
|
||
window.scrollTo({ top: $('#global-tracks-wrap').offsetTop - 80, behavior: 'smooth' });
|
||
apiPost('/global/playlist', { id: id })
|
||
.then(function (data) {
|
||
if (_collStale('global', gen)) return;
|
||
if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '获取歌单失败');
|
||
globalRenderTracks(data.data.playlist || {});
|
||
})
|
||
.catch(function (err) {
|
||
if (_collStale('global', gen)) return;
|
||
$('#global-tracks').innerHTML = '<div class="empty-state"><div class="empty-icon">❌</div><div>' + escapeHtml(err.message || '获取歌单失败') + '</div></div>';
|
||
});
|
||
}
|
||
|
||
function globalRenderTracks(info) {
|
||
var tracks = info.tracks || [];
|
||
// 空列表同样走 renderPagedTracks,清空 tracks_global 状态(同 personal 侧)
|
||
renderPagedTracks('global', tracks, 1);
|
||
}
|
||
|
||
/* 播放歌单:调用公开播放 API 获取可播放直链,加载第一首进播放器并支持自动连播。
|
||
端点强制校验 `key`——原实现不带
|
||
key,管理员点「播放」100% 被 401 拒绝。此处先取播放密钥再附带请求。 */
|
||
function globalPlayPlaylist(id, name, btn) {
|
||
if (!id) { toast('缺少歌单 ID', 'warning'); return; }
|
||
var lvl = $('#global-level').value;
|
||
if (lvl === 'auto') lvl = (state.levelsOk && state.levelsOk.length ? state.levelsOk[0] : 'lossless');
|
||
// 代次守卫——行内 ▶ 与底部播放按钮双入口可并发,
|
||
// 迟到旧队列不得覆盖 playQueue(否则连播接着播错歌单的歌)
|
||
state._globalPlayGen = (state._globalPlayGen || 0) + 1;
|
||
var gen = state._globalPlayGen;
|
||
if (btn) setBtnLoading(btn, true, '解析中...');
|
||
apiPost('/admin/site/play_key/get', {}).then(function (kd) {
|
||
if (gen !== state._globalPlayGen) throw new Error('__stale__');
|
||
if (kd.status !== 200) throw new Error(kd.message || '获取播放密钥失败');
|
||
var key = (kd.data || {}).key || '';
|
||
return apiPost('/api/playlist/play', { id: id, level: lvl, limit: 100, key: key });
|
||
}).then(function (data) {
|
||
if (gen !== state._globalPlayGen) return;
|
||
if (data.status !== 200) throw new Error(data.message || '获取播放列表失败');
|
||
var d = data.data || {};
|
||
var tracks = d.tracks || [];
|
||
if (!tracks.length) { toast('该歌单暂无可以播放的歌曲(可能均为版权受限)', 'warning'); return; }
|
||
state.global.playQueue = tracks;
|
||
state.global.playQueueIndex = 0;
|
||
var first = tracks[0];
|
||
initPlayer(first.url || '', first.name || '', first.artists || '', first.picUrl || '');
|
||
toast('✅ 已获取 ' + tracks.length + ' 首可播放歌曲(' + (d.level_name || lvl) + '),请点击播放器 ▶ 开始播放:' + first.name, 'success');
|
||
if (state.global.current && String(state.global.current.id) === String(id)) {
|
||
$('#global-tracks-title').textContent = '歌单歌曲:' + (name || id) + '(播放 API 返回 ' + tracks.length + ' 首可播放)';
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
if (gen !== state._globalPlayGen || err.message === '__stale__') return;
|
||
toast(err.message || '获取播放列表失败', 'error');
|
||
})
|
||
.finally(function () {
|
||
// 双入口是不同按钮——每个调用无条件复位自己的
|
||
// 按钮(重入计数器配对),否则被作废旧入口的按钮永久卡"解析中..."
|
||
if (btn) setBtnLoading(btn, false);
|
||
});
|
||
}
|
||
|
||
/* ================= 公开播放 API 说明弹窗 ================= */
|
||
function openApiModal(playlistId) {
|
||
var base = (location.origin + location.pathname).replace(/\/+$/, '');
|
||
var apiUrl = base + '/api/playlist/play';
|
||
var url = apiUrl + '?id=' + encodeURIComponent(playlistId || '');
|
||
// 代次守卫——快速连开两个歌单的 API 弹窗时,迟到响应
|
||
// 不得把复制地址写回旧歌单(复制出去即错链)
|
||
state._apiModalGen = (state._apiModalGen || 0) + 1;
|
||
var gen = state._apiModalGen;
|
||
// 密钥实时取(管理端点仅站点管理员可达),弹窗里的地址总是可直接使用
|
||
apiPost('/admin/site/play_key/get', {}).then(function (d) {
|
||
if (gen !== state._apiModalGen) return; // 迟到响应丢弃
|
||
var key = (d && d.data && d.data.key) || '';
|
||
var withKey = key ? url + '&key=' + encodeURIComponent(key) : url;
|
||
$('#api-url').textContent = withKey;
|
||
$('#api-example').textContent =
|
||
'curl "' + withKey + '&level=lossless&limit=100"\n' +
|
||
'// POST 表单:id=' + (playlistId || '') + '&level=lossless&limit=100&key=' + key;
|
||
}).catch(function () {
|
||
if (gen !== state._apiModalGen) return;
|
||
$('#api-url').textContent = url;
|
||
});
|
||
$('#api-modal').classList.add('show');
|
||
}
|
||
|
||
function closeApiModal() { $('#api-modal').classList.remove('show'); }
|
||
|
||
function copyText(text, tip) {
|
||
if (!text) return;
|
||
function fallback() {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
// execCommand 返回 false 也是失败,不再谎报已复制
|
||
try {
|
||
var ok = document.execCommand('copy');
|
||
if (ok) toast(tip || '已复制', 'success');
|
||
else toast('复制失败,请手动选择文本复制', 'error');
|
||
}
|
||
catch (e) { toast('复制失败,请手动选择文本复制', 'error'); }
|
||
document.body.removeChild(ta);
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(function () { toast(tip || '已复制', 'success'); })
|
||
.catch(fallback);
|
||
} else { fallback(); }
|
||
}
|
||
|
||
$('#global-goto-admin').addEventListener('click', function () { switchTab('admin'); });
|
||
$('#global-refresh').addEventListener('click', globalCheckLogin);
|
||
$('#global-playlists-refresh').addEventListener('click', globalLoadPlaylists);
|
||
$('#global-api-open').addEventListener('click', function () { openApiModal(''); });
|
||
$('#global-play-playlist').addEventListener('click', function () {
|
||
globalPlayPlaylist(this.getAttribute('data-id'), this.getAttribute('data-name'), this);
|
||
});
|
||
$('#global-download-all').addEventListener('click', function () { batchDownloadAll(this, 'global'); });
|
||
|
||
// 歌单列表点击查看 / 播放 / API(收藏歌单与「我喜欢的音乐」共用)
|
||
function globalPlListClick(e) {
|
||
var btn = e.target.closest('.act-global-play, .act-global-open, .act-global-api');
|
||
var row = e.target.closest('.global-pl');
|
||
var id = btn ? btn.getAttribute('data-id') : (row ? row.getAttribute('data-id') : '');
|
||
var name = btn ? btn.getAttribute('data-name') : (row ? row.getAttribute('data-name') : '');
|
||
if (!id) return;
|
||
if (btn && btn.classList.contains('act-global-play')) {
|
||
globalPlayPlaylist(id, name, btn);
|
||
} else if (btn && btn.classList.contains('act-global-api')) {
|
||
openApiModal(id);
|
||
} else {
|
||
globalOpenPlaylist(id, name);
|
||
}
|
||
}
|
||
$('#global-fav-playlists').addEventListener('click', globalPlListClick);
|
||
$('#global-collect-playlists').addEventListener('click', globalPlListClick);
|
||
|
||
// 歌单解析结果卡片中的「公开播放 API」入口
|
||
$('#playlist-api-btn').addEventListener('click', function () {
|
||
var c = state.currentCollection;
|
||
openApiModal(c && c.type === 'playlist' ? c.id : '');
|
||
});
|
||
|
||
$('#api-close').addEventListener('click', closeApiModal);
|
||
$('#api-modal').addEventListener('click', function (e) { if (e.target === this) closeApiModal(); });
|
||
$('#api-copy-url').addEventListener('click', function () { copyText($('#api-url').textContent, '接口地址已复制'); });
|
||
$('#api-copy-example').addEventListener('click', function () { copyText($('#api-example').textContent, '请求示例已复制'); });
|
||
|
||
/* ================= 收藏功能(个人账号登录后显示) ================= */
|
||
function updateCollectButtons() {
|
||
var show = state.personal.authed;
|
||
var pb = $('#collect-playlist-btn');
|
||
var ab = $('#collect-album-btn');
|
||
if (pb) pb.hidden = !show;
|
||
if (ab) ab.hidden = !show;
|
||
if (show && state.currentCollection) {
|
||
// 已收藏的歌单显示「已收藏」
|
||
var c = state.currentCollection;
|
||
var inList = c.type === 'playlist' && (state.personal.playlists || []).some(function (p) {
|
||
return String(p.id) === String(c.id);
|
||
});
|
||
if (c.type === 'album') {
|
||
// 专辑收藏状态依据本会话收藏结果,不再无条件还原为未收藏
|
||
if (c.albumCollected) {
|
||
$('#collect-album-btn').textContent = '✓ 已收藏专辑';
|
||
$('#collect-album-btn').classList.add('btn-success');
|
||
} else {
|
||
$('#collect-album-btn').textContent = '♡ 收藏专辑';
|
||
$('#collect-album-btn').classList.remove('btn-success');
|
||
}
|
||
} else {
|
||
if (inList) {
|
||
$('#collect-playlist-btn').textContent = '✓ 已收藏';
|
||
$('#collect-playlist-btn').classList.add('btn-success');
|
||
} else {
|
||
$('#collect-playlist-btn').textContent = '♡ 收藏歌单';
|
||
$('#collect-playlist-btn').classList.remove('btn-success');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function collectTarget(type, id, action) {
|
||
if (!state.personal.authed || !state.personal.cookie) {
|
||
toast('请先在「个人歌单」扫码登录你的账号', 'warning');
|
||
switchTab('personal');
|
||
return;
|
||
}
|
||
var tip = action === 1 ? '收藏' : '取消收藏';
|
||
apiPost('/user/collect', {
|
||
id: id, type: type, action: action,
|
||
user_cookie: state.personal.cookie
|
||
})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || tip + '失败');
|
||
toast(action === 1 ? '✅ 已收藏到你的个人歌单' : '已取消收藏', 'success');
|
||
// 专辑收藏结果记入 currentCollection,供 updateCollectButtons 正确渲染
|
||
if (state.currentCollection && String(state.currentCollection.id) === String(id)) {
|
||
state.currentCollection.albumCollected = (type === 'album' && action === 1);
|
||
}
|
||
// 按钮状态即时更新
|
||
var btn = type === 'album' ? $('#collect-album-btn') : $('#collect-playlist-btn');
|
||
if (btn) {
|
||
if (action === 1) {
|
||
btn.textContent = type === 'album' ? '✓ 已收藏专辑' : '✓ 已收藏';
|
||
btn.classList.add('btn-success');
|
||
} else {
|
||
btn.textContent = type === 'album' ? '♡ 收藏专辑' : '♡ 收藏歌单';
|
||
btn.classList.remove('btn-success');
|
||
}
|
||
}
|
||
// 收藏后自动刷新个人歌单
|
||
if (state.personal.authed) personalLoadPlaylists();
|
||
updateCollectButtons();
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || tip + '失败', 'error');
|
||
});
|
||
}
|
||
|
||
$('#collect-playlist-btn').addEventListener('click', function () {
|
||
if (state.currentCollection) {
|
||
var inList = (state.personal.playlists || []).some(function (p) {
|
||
return String(p.id) === String(state.currentCollection.id);
|
||
});
|
||
collectTarget('playlist', state.currentCollection.id, inList ? 2 : 1);
|
||
}
|
||
});
|
||
$('#collect-album-btn').addEventListener('click', function () {
|
||
if (state.currentCollection) collectTarget('album', state.currentCollection.id, 1);
|
||
});
|
||
|
||
/* ================= 列表内按钮事件委托 ================= */
|
||
document.addEventListener('click', function (e) {
|
||
var parseBtn = e.target.closest('.act-parse');
|
||
if (parseBtn) {
|
||
parseSongById(parseBtn.getAttribute('data-id'), parseBtn.getAttribute('data-name'));
|
||
return;
|
||
}
|
||
var dlBtn = e.target.closest('.act-download');
|
||
if (dlBtn) {
|
||
downloadSong(dlBtn.getAttribute('data-id'), dlBtn.getAttribute('data-name'));
|
||
return;
|
||
}
|
||
var gp = e.target.closest('.act-goto-playlist');
|
||
if (gp) { loadPlaylistById(gp.getAttribute('data-id'), gp.getAttribute('data-name')); return; }
|
||
var ga = e.target.closest('.act-goto-album');
|
||
if (ga) { loadAlbumById(ga.getAttribute('data-id'), ga.getAttribute('data-name')); return; }
|
||
var gs = e.target.closest('.act-goto-artist');
|
||
if (gs) { loadArtistById(gs.getAttribute('data-id'), gs.getAttribute('data-name')); return; }
|
||
var unc = e.target.closest('.act-uncollect');
|
||
if (unc) {
|
||
collectTarget('playlist', unc.getAttribute('data-id'), 2);
|
||
return;
|
||
}
|
||
});
|
||
|
||
/* ================= 批量下载 ================= */
|
||
/* 次数不足时的选择框:仅下载前 credits 首 / 取消 */
|
||
function confirmPartialDownload(need, credits, cb) {
|
||
// 先移除残留实例——连点两次下载会叠两个弹窗,$('#cpd-ok')
|
||
// 永远命中第一个,第二个弹窗按钮失效(遮罩还盖着页面)
|
||
var old = document.querySelector('.modal-mask[data-dynamic="1"].show');
|
||
if (old) old.remove();
|
||
var mask = document.createElement('div');
|
||
mask.className = 'modal-mask show';
|
||
mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除
|
||
mask.innerHTML =
|
||
'<div class="modal" style="width:400px;">' +
|
||
' <div class="modal-header"><div class="modal-title">⏳ 下载次数不足</div></div>' +
|
||
' <div class="modal-body" style="font-size:13px;line-height:1.9;">' +
|
||
' 本次共需 <b>' + need + '</b> 次下载,您的剩余次数为 <b style="color:var(--color-error);">' + credits + '</b> 次。<br>' +
|
||
' 可选择仅下载前 <b>' + credits + '</b> 首,或取消下载后前往充值。' +
|
||
' </div>' +
|
||
' <div style="display:flex;gap:10px;padding:0 22px 20px;">' +
|
||
' <button class="btn btn-primary" id="cpd-ok" type="button" style="flex:1;">仅下载前 ' + credits + ' 首</button>' +
|
||
' <button class="btn" id="cpd-cancel" type="button" style="flex:1;">取消下载</button>' +
|
||
' </div>' +
|
||
'</div>';
|
||
document.body.appendChild(mask);
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) { mask.remove(); cb(false); } });
|
||
$('#cpd-ok').addEventListener('click', function () { mask.remove(); cb(true); });
|
||
$('#cpd-cancel').addEventListener('click', function () { mask.remove(); cb(false); });
|
||
}
|
||
|
||
function checkDownloadQuota(list, btn, cb) {
|
||
/* 返回 true 表示次数充足直接继续;不足时弹选择框后按结果回调截断列表 */
|
||
var credits = (window.getSITECredits && window.getSITECredits()) || 0;
|
||
if (credits < 0) { if (window.handleAuthExpired) window.handleAuthExpired(); cb(null); return; }
|
||
if (list.length <= credits) { cb(list); return; }
|
||
if (credits <= 0) { if (window.handleAuthExpired) window.handleAuthExpired('', true); cb(null); return; }
|
||
setBtnLoading(btn, false);
|
||
confirmPartialDownload(list.length, credits, function (ok) {
|
||
cb(ok ? list.slice(0, credits) : null);
|
||
});
|
||
}
|
||
|
||
function batchDownload(btn, tracks, _prechecked) {
|
||
// 入口按 ID 去重(服务端同样去重计费,预检与实际口径一致)
|
||
var seen = {};
|
||
var list = (tracks || []).filter(function (t) {
|
||
if (!t || !t.id || seen[t.id]) return false;
|
||
seen[t.id] = true;
|
||
return true;
|
||
});
|
||
if (!list.length) { toast('暂无可以下载的歌曲', 'warning'); return; }
|
||
if (!cookieGate()) { setBtnLoading(btn, false); return; }
|
||
if (!_prechecked) {
|
||
checkDownloadQuota(list, btn, function (allowed) {
|
||
if (allowed) batchDownload(btn, allowed, true);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 批量 ZIP 打包模式(后台任务:提交即返回 task_id,任务面板显示进度/取消,完成后自动下载)
|
||
if (state.settings.batchZip) {
|
||
setBtnLoading(btn, true, '打包中...');
|
||
var cfg = downloadConfig();
|
||
// 批量模式的元数据字段跟随"批量写入"开关,不受单曲开关影响
|
||
//(原实现单曲关闭时这里会提交 __none__,批量开启也写不进任何字段)
|
||
var batchFields = Object.keys(state.settings.metaFields).filter(function (k) {
|
||
return state.settings.metaFields[k];
|
||
});
|
||
var batchMetaFields = state.settings.batchWriteMeta && batchFields.length
|
||
? batchFields.join(',') : '__none__';
|
||
var lvl = $('#global-level').value;
|
||
if (lvl === 'auto') lvl = (state.levelsOk && state.levelsOk[0]) || 'lossless';
|
||
var ids = list.map(function (t) { return t.id; }).join(',');
|
||
var taskId = genTaskId();
|
||
apiPost('/download/batch_zip', {
|
||
ids: ids,
|
||
quality: lvl,
|
||
task_id: taskId,
|
||
device_id: getDeviceId(),
|
||
account: state.parseAccount,
|
||
user_cookie: state.parseAccount === 'user' ? state.personal.cookie : '',
|
||
filename_format: cfg.filename_format,
|
||
write_meta: state.settings.batchWriteMeta ? 'true' : 'false',
|
||
meta_fields: batchMetaFields,
|
||
lyric_format: cfg.lyric_format
|
||
}).then(function (data) {
|
||
setBtnLoading(btn, false);
|
||
if (data.status !== 200) {
|
||
toast(data.message || '批量打包提交失败', 'error');
|
||
return;
|
||
}
|
||
addTask({
|
||
id: taskId, type: 'batch',
|
||
label: '批量打包 · ' + list.length + ' 首',
|
||
total: list.length, done: 0, status: 'queued'
|
||
});
|
||
if (window.refreshSITECredits) window.refreshSITECredits(); // 立即同步顶栏剩余次数
|
||
}).catch(function () {
|
||
setBtnLoading(btn, false);
|
||
toast('批量打包提交失败,请检查网络', 'error');
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 逐个下载
|
||
setBtnLoading(btn, true, '批量下载中...');
|
||
var i = 0, okCount = 0, failCount = 0, unknownCount = 0; // 逐首结果如实汇总
|
||
function next() {
|
||
if (i >= list.length) {
|
||
setBtnLoading(btn, false);
|
||
var parts = [];
|
||
if (okCount) parts.push('成功 ' + okCount + ' 首');
|
||
if (failCount) parts.push('失败 ' + failCount + ' 首');
|
||
if (unknownCount) parts.push(unknownCount + ' 首结果未知(任务过期,请到任务面板重试)');
|
||
if (failCount && !okCount && !unknownCount) toast('批量下载全部失败(' + failCount + ' 首)', 'error');
|
||
else if (failCount || unknownCount) toast('批量下载结束:' + parts.join(','), 'warning');
|
||
else toast('批量下载完成,共 ' + list.length + ' 首', 'success');
|
||
return;
|
||
}
|
||
var song = list[i];
|
||
toast('正在下载:' + (song.name || song.id) + '(' + (i + 1) + '/' + list.length + ')', 'info');
|
||
downloadOne(song, function (success) {
|
||
i++;
|
||
if (success === false) failCount++;
|
||
else if (success === 'unknown') unknownCount++;
|
||
else okCount++;
|
||
next();
|
||
});
|
||
}
|
||
next();
|
||
}
|
||
|
||
function downloadOne(song, done) {
|
||
if (!cookieGate()) { done(); return; }
|
||
apiResolveLevel(song.id)
|
||
.then(function (level) {
|
||
var cfg = downloadConfig();
|
||
// 逐首分支的元数据字段同样跟随批量开关(与 ZIP 分支同口径)
|
||
var oneFields = Object.keys(state.settings.metaFields).filter(function (k) {
|
||
return state.settings.metaFields[k];
|
||
});
|
||
var oneMetaFields = state.settings.batchWriteMeta && oneFields.length
|
||
? oneFields.join(',') : '__none__';
|
||
var taskId = genTaskId();
|
||
apiPost('/download', {
|
||
id: song.id,
|
||
quality: level,
|
||
task_id: taskId,
|
||
device_id: getDeviceId(),
|
||
account: state.parseAccount,
|
||
user_cookie: state.parseAccount === 'user' ? state.personal.cookie : '',
|
||
filename_format: cfg.filename_format,
|
||
write_meta: state.settings.batchWriteMeta ? 'true' : 'false',
|
||
meta_fields: oneMetaFields,
|
||
lyric_format: cfg.lyric_format,
|
||
zip: cfg.zip
|
||
}).then(function (data) {
|
||
if (data.status === 401 && state.parseAccount === 'user') {
|
||
state.parseAccount = 'admin';
|
||
try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {}
|
||
renderAccountBar();
|
||
renderPersonalToggle();
|
||
toast('个人账号未登录,已自动切换为全局账号下载', 'warning');
|
||
downloadOne(song, done);
|
||
return;
|
||
}
|
||
if (data.status !== 200) {
|
||
toast(data.message || ('下载失败:' + (song.name || song.id)), 'error');
|
||
done(false); // 提交失败如实计为失败
|
||
return;
|
||
}
|
||
// 任务加入悬浮窗;完成后自动下载该文件并继续下一首
|
||
addTask({
|
||
id: taskId, type: 'single',
|
||
label: '单曲 · ' + (song.name || song.id),
|
||
total: 1, done: 0, status: 'queued',
|
||
doneCb: function (success) { done(success); } // 透传 true/false/'unknown'
|
||
});
|
||
if (window.refreshSITECredits) window.refreshSITECredits(); // 立即同步顶栏剩余次数
|
||
}).catch(function () {
|
||
toast('下载提交失败:' + (song.name || song.id), 'error');
|
||
done(false);
|
||
});
|
||
})
|
||
.catch(function () { toast('下载失败:' + (song.name || song.id), 'error'); done(false); });
|
||
}
|
||
|
||
/* 批量下载全部歌曲(从 state 中的完整列表取,不分页) */
|
||
function batchDownloadAll(btn, prefix) {
|
||
var tracks = (state['tracks_' + prefix] || []).map(function (t) {
|
||
return { id: t.id, name: t.name };
|
||
});
|
||
batchDownload(btn, tracks);
|
||
}
|
||
$('#playlist-download-all').addEventListener('click', function () { batchDownloadAll(this, 'playlist'); });
|
||
$('#album-download-all').addEventListener('click', function () { batchDownloadAll(this, 'album'); });
|
||
$('#artist-download-all').addEventListener('click', function () { batchDownloadAll(this, 'artist'); });
|
||
|
||
/* ================= 下载按钮(单曲详情) ================= */
|
||
$('#detail-download').addEventListener('click', function () {
|
||
if (!state.currentSong) return;
|
||
downloadSong(state.currentSong.id, state.currentSong.name);
|
||
});
|
||
|
||
/* ================= 美化增强(纯观感,防御式,不影响任何功能) ================= */
|
||
// 顶栏滚动感知:页面下滑后加深顶栏阴影与发丝线
|
||
try {
|
||
var _hdr = document.querySelector('.app-header');
|
||
if (_hdr) {
|
||
var _hdrScroll = function () {
|
||
if (window.scrollY > 8) _hdr.classList.add('scrolled');
|
||
else _hdr.classList.remove('scrolled');
|
||
};
|
||
window.addEventListener('scroll', _hdrScroll, { passive: true });
|
||
_hdrScroll();
|
||
}
|
||
} catch (e) {}
|
||
// 播放态光晕:audio 播放/暂停时给播放器挂 .playing(media 事件不冒泡,用捕获)
|
||
try {
|
||
document.addEventListener('play', function (e) {
|
||
if (e.target && e.target.tagName === 'AUDIO') {
|
||
var p = $('#player');
|
||
if (p) p.classList.add('playing');
|
||
}
|
||
}, true);
|
||
document.addEventListener('pause', function (e) {
|
||
if (e.target && e.target.tagName === 'AUDIO') {
|
||
var p = $('#player');
|
||
if (p) p.classList.remove('playing');
|
||
}
|
||
}, true);
|
||
} catch (e) {}
|
||
// 弹窗/抽屉打开时锁定背景滚动(防移动端滚动穿透;监听 class 变化,避开 rAF 高频 style 写入)
|
||
// 同时挂 body.overlay-open 类:隐藏实时排队/任务面板浮窗(它们 z-index 高于弹层,
|
||
// 否则会盖住设置抽屉/弹窗内容——owner 实测报障)
|
||
try {
|
||
var _scrollLockMO = new MutationObserver(function () {
|
||
var locked = document.querySelector('.modal-mask.show, .drawer.show');
|
||
document.body.style.overflow = locked ? 'hidden' : '';
|
||
// 状态类挂 <html> 而非 <body>:本观察器监听 body 子树的 class 变化,
|
||
// 回调若改 body 自身 class 会形成自反馈,真实鼠标输入下引发渲染器活锁(实测卡死)
|
||
if (locked) document.documentElement.classList.add('overlay-open');
|
||
else document.documentElement.classList.remove('overlay-open');
|
||
});
|
||
_scrollLockMO.observe(document.body, { attributes: true, attributeFilter: ['class'], subtree: true });
|
||
} catch (e) {}
|
||
// 删除注册四框的 Enter→提交冗余绑定——site_auth.js:988 已对
|
||
// 全部四框绑 Enter→doRegister,此处旧绑定只挂 #reg-code,两处叠加导致
|
||
// 一次回车 doRegister 执行两次(第二请求被验证码原子消费挡下但报错闪烁)
|
||
// 播放器缓冲预览:进度条底层显示已缓冲长度(CSS 变量 --buffered,纯读取 audio.buffered,不改播放逻辑)
|
||
try {
|
||
var _updBuf = function (a) {
|
||
var p = document.getElementById('player-progress');
|
||
if (!p) return;
|
||
if (!a || !a.duration || !a.buffered || !a.buffered.length) {
|
||
p.style.setProperty('--buffered', '0%'); return;
|
||
}
|
||
var end = a.buffered.end(a.buffered.length - 1);
|
||
p.style.setProperty('--buffered', Math.min(100, end / a.duration * 100) + '%');
|
||
};
|
||
document.addEventListener('progress', function (e) {
|
||
if (e.target && e.target.tagName === 'AUDIO') _updBuf(e.target);
|
||
}, true);
|
||
['timeupdate', 'loadstart', 'emptied'].forEach(function (ev) {
|
||
document.addEventListener(ev, function (e) {
|
||
if (e.target && e.target.tagName === 'AUDIO' && ev !== 'timeupdate') _updBuf(null);
|
||
else if (e.target && e.target.tagName === 'AUDIO') _updBuf(e.target);
|
||
}, true);
|
||
});
|
||
} catch (e) {}
|
||
// 键盘快捷键:空格 播放/暂停、←/→ 快退/快进 5 秒(输入框聚焦时不拦截)
|
||
try {
|
||
document.addEventListener('keydown', function (e) {
|
||
var t = e.target;
|
||
// 排除输入控件与可点击元素(BUTTON/A/[role=button]):空格在按钮上的默认行为是"按下按钮",
|
||
// 不能被快捷键抢占,否则键盘用户聚焦按钮后按空格会变成切歌
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' ||
|
||
t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable ||
|
||
t.getAttribute && t.getAttribute('role') === 'button')) return;
|
||
if (e.key !== ' ' && e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||
var audio = document.querySelector('#player audio');
|
||
if (!audio || !audio.src) return;
|
||
if (e.key === ' ') {
|
||
e.preventDefault();
|
||
var b = document.getElementById('player-toggle');
|
||
if (b) b.click();
|
||
} else if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
audio.currentTime = Math.max(0, audio.currentTime - 5);
|
||
} else {
|
||
e.preventDefault();
|
||
audio.currentTime = Math.min(audio.duration || Infinity, audio.currentTime + 5);
|
||
}
|
||
});
|
||
} catch (e) {}
|
||
// 播放时标签页标题显示当前内容,暂停/停止恢复站名
|
||
try {
|
||
var _baseTitle = document.title;
|
||
var _nowName = function (t) {
|
||
var name = '';
|
||
if (t && t.id === 'podcast-audio') {
|
||
var pn = document.getElementById('podcast-program-name');
|
||
name = pn ? pn.textContent : '';
|
||
} else {
|
||
var n = document.getElementById('player-name');
|
||
name = n ? n.textContent : '';
|
||
}
|
||
return name;
|
||
};
|
||
document.addEventListener('play', function (e) {
|
||
if (!e.target || e.target.tagName !== 'AUDIO') return;
|
||
var nm = _nowName(e.target);
|
||
document.title = nm ? '▶ ' + nm + ' - ' + _baseTitle : _baseTitle;
|
||
}, true);
|
||
document.addEventListener('pause', function (e) {
|
||
if (!e.target || e.target.tagName !== 'AUDIO') return;
|
||
document.title = _baseTitle;
|
||
}, true);
|
||
} catch (e) {}
|
||
// 兼容层 v9+(2026-09-19):① 极老内核(IE/360 兼容模式等,无 fetch)阻断提示;
|
||
// ② 键盘导航焦点环 polyfill(:focus-visible 需 Chromium 86+,老内核由 Tab 键挂类激活)
|
||
try {
|
||
(function () {
|
||
var uaL = (navigator.userAgent || '');
|
||
var isTrident = /MSIE|Trident\//.test(uaL);
|
||
var isTooOld = !window.fetch || !window.Promise || !document.addEventListener;
|
||
if (isTrident || isTooOld) {
|
||
var el = document.getElementById('legacy-browser-blocker');
|
||
if (el) el.className = 'show';
|
||
var pl = document.getElementById('page-loading');
|
||
if (pl) pl.style.display = 'none';
|
||
try { document.documentElement.style.background = '#1f2329'; } catch (e2) {}
|
||
return; // 阻断页已接管,不再执行后续增强逻辑
|
||
}
|
||
// 键盘焦点环:Tab 导航时挂 kbd-nav 类(激活 :focus 规则),鼠标按下时移除
|
||
document.addEventListener('keydown', function (e) {
|
||
if ((e.key === 'Tab' || e.keyCode === 9) &&
|
||
!document.documentElement.className.match(/\bkbd-nav\b/)) {
|
||
document.documentElement.className += ' kbd-nav';
|
||
}
|
||
}, true);
|
||
document.addEventListener('mousedown', function () {
|
||
document.documentElement.className =
|
||
document.documentElement.className.replace(/\s*kbd-nav\b/, '');
|
||
}, true);
|
||
})();
|
||
} catch (e) {}
|
||
// 微信/QQ 内置浏览器:显示下载受限引导条 + 点击下载按钮时 toast 提醒(不拦截下载本身)
|
||
// v9+ 扩展:支付宝/钉钉/微博/企业微信等 App 内置浏览器同样提示(企业微信 UA 含 MicroMessenger 自动覆盖)
|
||
try {
|
||
var _ua = navigator.userAgent || '';
|
||
var _inWeChat = _ua.indexOf('MicroMessenger') !== -1;
|
||
var _inQQWV = _ua.indexOf(' QQ/') !== -1 && _ua.indexOf('MQQBrowser') === -1;
|
||
var _otherAppWV = /AlipayClient|DingTalk|Weibo|HeyTapBrowser|HiSpace|HMSCore/.test(_ua);
|
||
if (_inWeChat || _inQQWV || _otherAppWV) {
|
||
var _seen = false;
|
||
try { _seen = sessionStorage.getItem('netease_wv_guide_closed') === '1'; } catch (e2) {}
|
||
var _guide = document.getElementById('webview-guide');
|
||
if (_guide && !_seen) {
|
||
if (_otherAppWV && !_inWeChat && !_inQQWV) {
|
||
var _wgText = _guide.querySelector('.wg-text');
|
||
if (_wgText) _wgText.innerHTML = '💬 当前在 App 内置浏览器中,<b>音乐下载可能受限</b>。建议复制链接到系统浏览器打开,获得完整体验。';
|
||
}
|
||
_guide.hidden = false;
|
||
}
|
||
var _gclose = document.getElementById('webview-guide-close');
|
||
if (_gclose) _gclose.addEventListener('click', function () {
|
||
var g = document.getElementById('webview-guide');
|
||
if (g) g.hidden = true;
|
||
try { sessionStorage.setItem('netease_wv_guide_closed', '1'); } catch (e2) {}
|
||
});
|
||
document.addEventListener('click', function (e) {
|
||
var btn = e.target && e.target.closest ? e.target.closest('button, a') : null;
|
||
if (!btn) return;
|
||
var sig = ((btn.id || '') + ' ' + (btn.className || '')).toLowerCase();
|
||
var txt = btn.textContent || '';
|
||
if (/download|zip/.test(sig) || txt.indexOf('⬇') !== -1 || txt.indexOf('下载') !== -1 || txt.indexOf('打包') !== -1) {
|
||
toast((_inWeChat || _inQQWV)
|
||
? '微信/QQ 内置浏览器可能无法下载文件,建议点右上角「···」→「在浏览器中打开」'
|
||
: '当前 App 内置浏览器可能无法下载文件,建议复制链接到系统浏览器打开', 'warning');
|
||
}
|
||
}, true);
|
||
}
|
||
} catch (e) {}
|
||
// 前端错误日志上报:JS 运行错误 / Promise 拒绝 / 资源加载失败 → /api/fe_error
|
||
// 用于排查老内核浏览器"API 行为不一致 / 样式错位"类问题。去重 + 限频,任何异常静默。
|
||
try {
|
||
var _feSeen = {};
|
||
var _feCount = 0;
|
||
var _feSend = function (type, msg, stack) {
|
||
try {
|
||
if (_feCount >= 8) return; // 单会话最多 8 条
|
||
var key = type + '|' + msg;
|
||
if (_feSeen[key]) return; // 同一错误只报一次
|
||
_feSeen[key] = 1;
|
||
_feCount++;
|
||
var body = new URLSearchParams({
|
||
type: type, msg: msg, stack: stack || '',
|
||
page: location.href.slice(0, 120),
|
||
env: (navigator.userAgent || '').slice(0, 180) +
|
||
' | ' + window.innerWidth + 'x' + window.innerHeight +
|
||
' | dpr' + (window.devicePixelRatio || 1)
|
||
});
|
||
if (navigator.sendBeacon) {
|
||
navigator.sendBeacon('/api/fe_error', body);
|
||
} else {
|
||
fetch('/api/fe_error', { method: 'POST', body: body, keepalive: true })
|
||
.catch(function () {});
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
window.addEventListener('error', function (e) {
|
||
// 资源错误只报静态资源(IMG/SCRIPT/LINK);AUDIO 的加载错误多为
|
||
// 流媒体 seek 时的瞬时抖动,用户在播放器上即可感知,上报只会产生噪音
|
||
if (e.target && e.target !== window && (e.target.tagName === 'IMG' || e.target.tagName === 'SCRIPT' || e.target.tagName === 'LINK')) {
|
||
// 空 src 的占位封面(初始 src="")在部分内核触发 error 事件且
|
||
// src 属性解析为页面 URL,属噪音(实测曾占满上报通道),跳过
|
||
if (e.target.tagName === 'IMG' && !(e.target.getAttribute('src') || '').trim()) return;
|
||
_feSend('resource', (e.target.tagName || '') + ' 加载失败: ' + ((e.target.src || e.target.href || '').slice(-80)));
|
||
return;
|
||
}
|
||
_feSend('error', e.message || '未知错误', (e.filename || '') + ':' + (e.lineno || 0));
|
||
}, true);
|
||
window.addEventListener('unhandledrejection', function (e) {
|
||
var r = e.reason;
|
||
_feSend('promise', (r && (r.message || String(r))) || '未处理的 Promise 拒绝',
|
||
(r && r.stack ? String(r.stack).slice(0, 300) : ''));
|
||
});
|
||
} catch (e) {}
|
||
// 键盘可达性:Tab 页签可用 Tab 键聚焦、回车激活(Enter 触发既有点击处理,零逻辑改动)
|
||
try {
|
||
$$('#tabs .tab').forEach(function (t) {
|
||
t.setAttribute('tabindex', '0');
|
||
t.setAttribute('role', 'button');
|
||
t.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); t.click(); }
|
||
});
|
||
});
|
||
} catch (e) {}
|
||
|
||
/* ================= 初始化 ================= */
|
||
// 全局音质:恢复上次选择(默认最高音质自动)
|
||
var VALID_LEVELS = ['auto', 'jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard', 'dolby'];
|
||
try {
|
||
var savedLevel = localStorage.getItem('netease_global_level');
|
||
if (savedLevel && VALID_LEVELS.indexOf(savedLevel) !== -1) $('#global-level').value = savedLevel;
|
||
} catch (e) {}
|
||
$('#global-level').addEventListener('change', function () {
|
||
try { localStorage.setItem('netease_global_level', $('#global-level').value); } catch (e) {}
|
||
});
|
||
$('#quality-save').addEventListener('click', function () {
|
||
try { localStorage.setItem('netease_global_level', $('#global-level').value); } catch (e) {}
|
||
toast('全局音质已保存:' + levelName($('#global-level').value), 'success');
|
||
});
|
||
|
||
// 解析账号:恢复上次选择(admin 全局 / user 个人)
|
||
try {
|
||
var savedAccount = localStorage.getItem('netease_parse_account');
|
||
if (savedAccount === 'user' || savedAccount === 'admin') state.parseAccount = savedAccount;
|
||
} catch (e) {}
|
||
// 个人登录 Cookie:仅保存在本机浏览器
|
||
try {
|
||
var savedUserCookie = localStorage.getItem('netease_user_cookie');
|
||
if (savedUserCookie) state.personal.cookie = savedUserCookie;
|
||
} catch (e) {}
|
||
renderAccountBar();
|
||
|
||
loadSettings();
|
||
applyLyricFont(); // 启动即应用持久化的歌词字号(CSS 变量挂 #lyric-box,重绘不丢)
|
||
renderSettingsUI();
|
||
applyTheme();
|
||
renderHistory();
|
||
|
||
// 重新打开网站:找回本设备未结束的下载任务(断线自动恢复,接着下载)
|
||
// 登录后才会调用(游客请求该接口会 401 并误弹登录框);
|
||
// 登录成功由 site_auth 的 afterAuth 回调再触发一次
|
||
window.SITE_TASKS_BOOTSTRAP = loadMyTasks;
|
||
// 站点退出时连带清除个人网易云登录——共享电脑场景下,
|
||
// 下一位使用者不得沿用前一人的网易云身份(发评/收藏/看歌单)。
|
||
// 清理强度与 personalLogout 对齐(对抗复审):作废在途轮询(防 Cookie 写回)、
|
||
// 清个人面板数据与展示(防下一位用户闪现上一人的头像/歌单)
|
||
window.SITE_ON_LOGOUT = function () {
|
||
try { localStorage.removeItem('netease_user_cookie'); } catch (e) {}
|
||
personalStopQrPoll();
|
||
state.personal.qrGen = (state.personal.qrGen || 0) + 1;
|
||
state.personal.authed = false;
|
||
state.personal.user = null;
|
||
state.personal.playlists = [];
|
||
state.personal.current = null;
|
||
state.personal.cookie = '';
|
||
var _ptw = $('#personal-tracks-wrap');
|
||
if (_ptw) _ptw.hidden = true;
|
||
if (state.parseAccount === 'user') {
|
||
state.parseAccount = 'admin';
|
||
try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {}
|
||
} else {
|
||
try { localStorage.removeItem('netease_parse_account'); } catch (e) {}
|
||
}
|
||
renderPersonalToggle();
|
||
personalResetGate(false);
|
||
renderAccountBar();
|
||
};
|
||
if (window.SITE_LOGGED_IN) loadMyTasks();
|
||
|
||
// 新用户首次访问:弹出隐私与协议同意
|
||
if (!state.settings.cookiePrefs.accepted) {
|
||
setTimeout(function () {
|
||
openCookieModal();
|
||
toast('首次使用请阅读并同意《用户协议》与《隐私政策》', 'info');
|
||
}, 600);
|
||
}
|
||
|
||
/* ================= 实时排队 / 预计等待 悬浮框 ================= */
|
||
function formatWaitTime(sec) {
|
||
if (sec == null || !isFinite(sec) || sec <= 0) return '--';
|
||
if (sec < 60) return '约 ' + Math.max(1, Math.round(sec)) + ' 秒';
|
||
if (sec < 3600) return '约 ' + Math.ceil(sec / 60) + ' 分钟';
|
||
return '约 ' + (sec / 3600).toFixed(1) + ' 小时';
|
||
}
|
||
function loadQueue() {
|
||
apiPost('/api/queue', {})
|
||
.then(function (data) {
|
||
if (data.status !== 200) throw new Error(data.message || '获取排队状态失败');
|
||
var d = data.data || {};
|
||
var waiting = (d.batch_waiting || 0) + (d.download_waiting || 0);
|
||
$('#queue-num').textContent = waiting;
|
||
$('#queue-download').textContent =
|
||
(d.download_active || 0) + '/' + (d.download_capacity || '-');
|
||
$('#queue-batch').textContent =
|
||
(d.batch_active || 0) + '/' + (d.batch_capacity || '-');
|
||
// 预计等待 ≈ 批量排队人数 × 平均每批耗时
|
||
var avg = d.avg_batch_seconds;
|
||
var est = avg ? (d.batch_waiting || 0) * avg : null;
|
||
$('#queue-est').textContent = '预计等待:' + formatWaitTime(est);
|
||
$('#queue-float').classList.toggle('busy', waiting > 0);
|
||
})
|
||
.catch(function () { /* 拉取失败保持原状,不打扰用户 */ });
|
||
}
|
||
loadQueue();
|
||
setInterval(loadQueue, 3000);
|
||
|
||
/* ================= 下载任务悬浮窗(批量打包 + 单曲下载,可取消) ================= */
|
||
function getDeviceId() {
|
||
var key = 'netease_device_id';
|
||
// 页面内存缓存兜底——localStorage 不可写时,同一页面内标识保持稳定,
|
||
// 心跳/任务归属不再每次漂移
|
||
if (window.__deviceIdCache) return window.__deviceIdCache;
|
||
var id = null;
|
||
try { id = localStorage.getItem(key); } catch (e) {}
|
||
if (!id) {
|
||
id = 'd' + Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
|
||
try { localStorage.setItem(key, id); } catch (e) {}
|
||
}
|
||
window.__deviceIdCache = id;
|
||
return id;
|
||
}
|
||
function genTaskId() {
|
||
return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||
}
|
||
function fmtDur(sec) {
|
||
if (sec == null || !isFinite(sec) || sec <= 0) return '--';
|
||
sec = Math.max(0, Math.round(sec));
|
||
var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||
if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||
}
|
||
var tasks = []; // {id, type, label, total, done, status, statusText, doneCb}
|
||
var taskPollTimer = null;
|
||
|
||
function addTask(task) {
|
||
tasks.push(task);
|
||
renderTaskPanel();
|
||
$('#task-panel').classList.add('show');
|
||
if (!taskPollTimer) taskPollTimer = setInterval(pollTasks, 1500);
|
||
pollTasks();
|
||
}
|
||
function renderTaskPanel() {
|
||
var list = $('#task-list');
|
||
$('#task-count').textContent = tasks.length;
|
||
list.innerHTML = tasks.map(function (t) {
|
||
var pct;
|
||
if (t.status === 'done') pct = 100;
|
||
else if (t.status === 'running' && typeof t.livePct === 'number')
|
||
pct = Math.max(1, Math.min(99, Math.round(t.livePct))); // 服务端实时百分比(单曲为字节级)
|
||
else pct = (t.total ? Math.round((t.done || 0) / t.total * 100) : 0);
|
||
var statusText = t.statusText || (
|
||
t.status === 'done' ? '完成' :
|
||
t.status === 'error' ? '失败' :
|
||
t.status === 'canceled' ? '已取消' :
|
||
t.status === 'paused' ? '已暂停' : (pct + '%')
|
||
);
|
||
var bar = (t.status === 'done' || t.status === 'error' || t.status === 'canceled')
|
||
? '' : '<div class="task-item-bar"><div style="width:' + pct + '%"></div></div>';
|
||
var cancelBtn = (t.status === 'queued' || t.status === 'running' || t.status === 'paused')
|
||
? '<button class="task-item-cancel" data-id="' + escapeHtml(t.id) + '">取消</button>' : '';
|
||
// 已交付条目:领取(35 分钟保留期内可重复下载)+ 手动移除
|
||
var claimBtn = t.claimable
|
||
? '<button class="task-item-claim" data-id="' + escapeHtml(t.id) + '" title="重新下载该结果(服务端保留 35 分钟)">领取</button>' : '';
|
||
var dismissBtn = t.claimable
|
||
? '<button class="task-item-dismiss" data-id="' + escapeHtml(t.id) + '" title="移除记录">✕</button>' : '';
|
||
// 任务 ID 一律转义后再进属性(防属性注入)
|
||
return '<div class="task-item ' + (t.status === 'done' || t.status === 'error' || t.status === 'canceled' ? 'ended' : '') + '" data-id="' + escapeHtml(t.id) + '">' +
|
||
'<div class="task-item-top">' +
|
||
'<span class="task-item-name" title="' + escapeHtml(t.label) + '">' + escapeHtml(t.label) + '</span>' +
|
||
'<span class="task-item-status">' + escapeHtml(statusText) + '</span>' +
|
||
cancelBtn + claimBtn + dismissBtn +
|
||
'</div>' + bar + '</div>';
|
||
}).join('') || '<div class="comment-empty">暂无任务</div>';
|
||
$$('#task-list .task-item-cancel').forEach(function (btn) {
|
||
btn.addEventListener('click', function () { cancelTask(btn.getAttribute('data-id')); });
|
||
});
|
||
$$('#task-list .task-item-claim').forEach(function (btn) {
|
||
btn.addEventListener('click', function () { claimTask(btn.getAttribute('data-id')); });
|
||
});
|
||
$$('#task-list .task-item-dismiss').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var id = btn.getAttribute('data-id');
|
||
tasks = tasks.filter(function (t) { return t.id !== id; });
|
||
renderTaskPanel();
|
||
if (!tasks.length) $('#task-panel').classList.remove('show');
|
||
});
|
||
});
|
||
}
|
||
var _pollBusy = false; // 在途锁——15s 超时下轮询响应重叠时,同一终态会弹两次 toast
|
||
function pollTasks() {
|
||
if (_pollBusy) return;
|
||
var active = tasks.filter(function (t) {
|
||
return t.status === 'queued' || t.status === 'running' || t.status === 'paused';
|
||
});
|
||
if (!active.length) {
|
||
if (taskPollTimer) { clearInterval(taskPollTimer); taskPollTimer = null; }
|
||
return;
|
||
}
|
||
_pollBusy = true;
|
||
var doneCount = 0;
|
||
function _pollDone() { doneCount++; if (doneCount >= active.length) _pollBusy = false; }
|
||
active.forEach(function (t) {
|
||
apiPost('/api/batch_progress', { task_id: t.id })
|
||
.then(function (data) {
|
||
if (data.status === 404) {
|
||
// 任务已被服务端清理(过期):只收尾一次(404 重叠不再重复推进队列)
|
||
if (t._gone) return;
|
||
t._gone = true;
|
||
toast('任务已过期:' + t.label, 'info');
|
||
removeTaskLater(t.id, 500);
|
||
renderTaskPanel();
|
||
// 过期=交付结果未知,明确以 'unknown' 传给逐首队列(不再虚报成功)
|
||
if (typeof t.doneCb === 'function') { t.doneCb('unknown'); }
|
||
return;
|
||
}
|
||
if (data.status !== 200) return;
|
||
var d = data.data || {};
|
||
// 陈旧响应不得把终态任务拉回运行中(以 _done/_ended 为准);
|
||
// total 同步服务端(播客总数解析后进度条口径一致)
|
||
if (t._done || t._ended) return;
|
||
t.done = d.done || 0;
|
||
if (typeof d.total === 'number' && d.total > 0) t.total = d.total;
|
||
if (d.status === 'running') {
|
||
t.status = 'running';
|
||
var pct = Math.round(d.percent || 0);
|
||
t.livePct = (typeof d.percent === 'number') ? d.percent : t.livePct; // 进度条实时化
|
||
t.statusText = pct + '%' + (d.eta ? ' · 剩' + fmtDur(d.eta) : '');
|
||
t.statusText += (d.failed ? ' · 失败' + d.failed : '');
|
||
} else {
|
||
t.status = d.status || t.status;
|
||
t.statusText = d.msg || d.status;
|
||
}
|
||
if (d.status === 'done') {
|
||
if (d.refunded > 0) {
|
||
t.statusText = (t.statusText || '完成') + ' · 已退回 ' + d.refunded + ' 次';
|
||
toast('任务完成:' + t.label + ',失败部分已退回 ' + d.refunded + ' 次下载次数', 'info');
|
||
}
|
||
if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费)
|
||
onTaskDone(t);
|
||
}
|
||
else if (d.status === 'error') {
|
||
t._ended = true;
|
||
toast('下载失败:' + t.label + (d.msg ? '(' + d.msg + ')' : ''), 'error');
|
||
if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费)
|
||
// 失败但已有部分成果(has_result)→ 照常领取;
|
||
// 逐首队列的回调也要推进并如实报失败(原实现 error/canceled 不调 doneCb,队列卡死)
|
||
if (d.has_result) { onTaskDone(t); }
|
||
else if (typeof t.doneCb === 'function') { t.doneCb(false); removeTaskLater(t.id, 8000); }
|
||
else removeTaskLater(t.id, 8000);
|
||
} else if (d.status === 'canceled') {
|
||
t._ended = true;
|
||
if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费)
|
||
toast('已取消:' + t.label, 'info');
|
||
if (d.has_result) { onTaskDone(t); }
|
||
else if (typeof t.doneCb === 'function') { t.doneCb(false); removeTaskLater(t.id, 2500); }
|
||
else removeTaskLater(t.id, 2500);
|
||
}
|
||
renderTaskPanel();
|
||
})
|
||
.catch(function () {})
|
||
.then(function () { _pollDone(); }); // 链尾统一释放(成功/失败各恰一次)
|
||
});
|
||
}
|
||
// 触发结果文件下载(单曲走 /download/result,批量走 /download/batch_zip/result)
|
||
function triggerTaskDownload(t) {
|
||
var url = (t.type === 'batch' ? '/download/batch_zip/result' : '/download/result') +
|
||
'?task_id=' + encodeURIComponent(t.id) +
|
||
'&device_id=' + encodeURIComponent(getDeviceId());
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
// 手动重领:浏览器拦截自动下载时,35 分钟保留期内可反复领取
|
||
function claimTask(id) {
|
||
var target = null;
|
||
tasks.forEach(function (x) { if (x.id === id) target = x; });
|
||
if (!target) return;
|
||
triggerTaskDownload(target);
|
||
toast('正在重新领取:' + target.label, 'info');
|
||
}
|
||
// 已结束条目最多保留 max 条(防止任务面板无界增长)
|
||
function trimEndedTasks(max) {
|
||
var ended = tasks.filter(function (x) { return x.claimable || x._ended; });
|
||
var extra = ended.length - max;
|
||
for (var i = 0; i < extra; i++) {
|
||
(function (eid) {
|
||
tasks = tasks.filter(function (t) { return t.id !== eid; });
|
||
})(ended[i].id);
|
||
}
|
||
}
|
||
function onTaskDone(t) {
|
||
if (t._done) return; // 幂等:防止并发轮询重复触发下载
|
||
t._done = true;
|
||
toast('✅ 下载完成:' + t.label, 'success');
|
||
triggerTaskDownload(t);
|
||
// 完成条目保留「领取」入口(原实现 4 秒即移除——浏览器拦截自动下载时
|
||
// 用户毫无补救办法,文件在服务端留 35 分钟却取不到)
|
||
t.claimable = true;
|
||
removeTaskLater(t.id, 35 * 60 * 1000); // 与服务端 35 分钟保留期完全一致(原 30 分钟末尾领取必 404)
|
||
trimEndedTasks(20);
|
||
renderTaskPanel();
|
||
if (t.doneCb) t.doneCb(t);
|
||
}
|
||
function cancelTask(id) {
|
||
apiPost('/api/task/cancel', { task_id: id, device_id: getDeviceId() })
|
||
.then(function (data) {
|
||
if (data.status !== 200) { toast(data.message || '取消失败', 'error'); return; }
|
||
toast('已请求取消任务', 'info');
|
||
})
|
||
.catch(function () { toast('取消失败,请检查网络', 'error'); });
|
||
}
|
||
function removeTaskLater(id, ms) {
|
||
setTimeout(function () {
|
||
tasks = tasks.filter(function (t) { return t.id !== id; });
|
||
renderTaskPanel();
|
||
if (!tasks.length) $('#task-panel').classList.remove('show');
|
||
}, ms);
|
||
}
|
||
/* 设备心跳:保持任务活跃;离线超时任务会被暂停让位,重新打开页面自动恢复 */
|
||
function sendHeartbeat() {
|
||
var hasTasks = tasks.some(function (t) {
|
||
return t.status === 'queued' || t.status === 'running' || t.status === 'paused';
|
||
});
|
||
if (!hasTasks) return;
|
||
apiPost('/api/task/heartbeat', { device_id: getDeviceId() })
|
||
.then(function (data) {
|
||
if (data.status === 200 && data.data && data.data.resumed > 0) {
|
||
toast('任务已恢复,继续下载', 'info');
|
||
pollTasks();
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
setInterval(sendHeartbeat, 15000);
|
||
/* 重新打开网站:找回本设备未结束的任务,重新进入排队列表接着下载 */
|
||
function loadMyTasks() {
|
||
apiPost('/api/task/list', { device_id: getDeviceId() })
|
||
.then(function (data) {
|
||
if (data.status !== 200) return; var my = data.data.tasks || [];
|
||
if (!my.length) return;
|
||
var added = false;
|
||
my.forEach(function (t) {
|
||
if (tasks.some(function (x) { return x.id === t.task_id; })) return;
|
||
tasks.push({
|
||
id: t.task_id,
|
||
type: t.type,
|
||
label: t.kind === 'podcast' ? '播客打包 · ' + t.total + ' 集' : '批量打包 · ' + t.total + ' 首',
|
||
total: t.total,
|
||
done: t.done || 0,
|
||
status: t.status,
|
||
statusText: t.status === 'paused' ? '已暂停(恢复中...)' : (t.msg || '')
|
||
});
|
||
added = true;
|
||
});
|
||
if (added) {
|
||
renderTaskPanel();
|
||
$('#task-panel').classList.add('show');
|
||
// 恢复任务后启动持续轮询(原实现只查一次,
|
||
// 刷新页面恢复的任务永远等不到 done)
|
||
if (!taskPollTimer) taskPollTimer = setInterval(pollTasks, 1500);
|
||
pollTasks();
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
$('#task-panel-close').addEventListener('click', function () {
|
||
$('#task-panel').classList.remove('show');
|
||
});
|
||
})();
|
||
</script>
|
||
<!-- 站点账号系统前端逻辑(按次计费) -->
|
||
<script src="/static/site_auth.js?v=2.3.21"></script>
|
||
<footer style="text-align:center;padding:18px 0 26px;font-size:12px;opacity:.55;">
|
||
云村小助手
|
||
</footer>
|
||
</body>
|
||
</html>
|