`;CSS 选择器也随之用类选择器而不是标签选择器。
+ 另外滑杆元素本身高度设 32px(触控目标够大),视觉细轨道由
+ `::-webkit-slider-runnable-track` / `::-moz-range-track` 绘制,滑块 22px + 5px 边框。
+ `attachThumbOnlyDrag()` 仍作为第二道防线(命中滑块才放行原生拖动,容差按 32px 滑块算)
+- **远程搜索性能**:曲库 DB 有 **67 万首 / 约 1GB**(`muse.db`),搜索是 I/O 密集操作。
+ 实测(同一台机器直连该 DB,`rec_score` 复合索引齐全):
+ - 中文 `name_trim LIKE '%kw%'`:热态 **2~280ms**(走 `idx_rec_score` 顺序扫描 + 早停,无临时排序)
+ - 拼音 `name_cap/name_full GLOB 'kw*'`:热态 **0.2~150ms**(前缀范围扫描 + 临时 B-tree 排序;
+ `A*` 这类高频前缀会扫很多行),冷态(首次读 1GB 文件)可达 **1~15s**
+ - 歌手检索(`EXISTS` + singers LIKE):热态 **34~70ms**
+ 结论与对策:**瓶颈在冷态 I/O 与重复查询**,因此
+ 1) 设备端加 `remoteSearchCache`(LRU 24 条 = 最近 4 个查询 × 6 页,TTL 60s,曲库刷新时清空),
+ 同一「类型+关键词+页码」直接复用 JSON,翻页与重复搜索不再查库;
+ 2) 手机端:搜索请求用 `AbortController` 取消旧请求、同关键词不重复请求、
+ 首页返回后**预热第 2 页**(把设备缓存捂热)、搜索期间暂停 1s 的状态轮询避免抢 I/O。
+ ⚠️ 不要试图用「一次多取几页」来省请求:实测 `LIMIT 201` 的代价远高于 `LIMIT 21`
+ (`name_full GLOB` 301 行 6.2s vs 21 行 0.7s,冷态),会把体验做得更差
- **焦点填充与「纯色遮罩层」**:`TvFocusStyler.install()` 会把背景整体换成**不透明**红→橙渐变,
作为遥控器焦点/按下的反馈(`prepareTvFocusableTree(window.decorView)` 与对话框的
`installTree(decorView)` 会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但
@@ -154,9 +177,10 @@ $BT/apksigner verify 目标名.apk
统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` /
`selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。
**严禁调用 `AudioManager.setStreamVolume` / `adjustStreamVolume` 修改系统音量**。
- 三条入口必须全部收敛到 `applyPlaybackMode()`:**「调音」弹窗**(`showTuningDialog()`,播放界面与
- 主界面右上角共用同一个弹窗)的音量横向控制杆、声音设置-歌曲最大音量、远程控制-音乐音量
- (`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
+ 入口只有两处,且都必须收敛到 `applyPlaybackMode()`:**「调音」弹窗**(`showTuningDialog()`,
+ 播放界面与主界面右上角共用同一个弹窗)的音量横向控制杆、远程控制-音乐音量
+ (`remoteAudio()` 的 `musicVolume`)。**声音设置弹窗里不再有音量滑杆与均衡器入口**
+ (避免同一功能两套入口),它只保留「切到点播歌曲时音量/原伴唱」这类策略选择。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
设备最终响度仍取决于系统音量,App 不再替用户改动它
- **音频均衡器**:内核 `KtvEqualizer`(参考开源项目 OpenEQ,`Turbofan3360/OpenEQ`,GPL-3.0 的
`EQUtils.kt`)——直接用系统 `android.media.audiofx.Equalizer`,**按设备真实频段数与增益范围自适应**
@@ -167,8 +191,7 @@ $BT/apksigner verify 目标名.apk
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」
- 是同一个弹窗;另有声音设置入口与
- 远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
+ 是同一个弹窗;另有远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
diff --git a/app/build.gradle b/app/build.gradle
index 6fb124a..8d7dba1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
- versionCode 14
- versionName "1.3.8"
+ versionCode 15
+ versionName "1.3.9"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
diff --git a/app/src/main/assets/mobile/app.css b/app/src/main/assets/mobile/app.css
index 8f0306d..2a6e846 100644
--- a/app/src/main/assets/mobile/app.css
+++ b/app/src/main/assets/mobile/app.css
@@ -1,4 +1,4 @@
-:root{color-scheme:dark;--gold:#ffd56a;--gold2:#ffb23f;--violet:#9c4dff;--magenta:#ec5acc;--bg:#10031f;--glass:rgba(255,255,255,.075);--glass2:rgba(255,255,255,.115);--line:rgba(255,255,255,.12);--muted:#b9a9c9;--green:#62e6a5;--danger:#ff769a}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html{background:#10031f}body{margin:0;min-height:100vh;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(155deg,#260943 0,#10031f 56%,#080110 100%);overflow-x:hidden}button,input{font:inherit}button{border:0;color:inherit;cursor:pointer}.aurora{position:fixed;border-radius:50%;filter:blur(70px);opacity:.48;pointer-events:none}.aurora-one{width:280px;height:280px;background:#7d20d7;right:-110px;top:-80px}.aurora-two{width:260px;height:260px;background:#d82c9e;left:-150px;bottom:80px;opacity:.23}.mesh{position:fixed;inset:0;pointer-events:none;opacity:.22;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:28px 28px;mask-image:linear-gradient(to bottom,#000,transparent 80%)}.app-shell{position:relative;z-index:1;width:min(100%,520px);margin:auto;min-height:100vh;padding:env(safe-area-inset-top) 0 calc(92px + env(safe-area-inset-bottom))}.topbar{height:94px;padding:18px 20px 12px;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:11px}.brand-mark{position:relative;width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,#fff2aa,#ffba44);box-shadow:0 8px 25px rgba(255,190,65,.25);display:flex;align-items:end;justify-content:center;gap:3px;padding:10px}.brand-mark i{display:block;width:4px;border-radius:3px;background:#6a2383}.brand-mark i:nth-child(1){height:12px}.brand-mark i:nth-child(2){height:22px}.brand-mark i:nth-child(3){height:16px}.brand h1{margin:0;font-size:21px;letter-spacing:.5px}.brand h1 em{font-style:italic;color:var(--gold);font-size:15px}.brand p{margin:5px 0 0;color:#c9b9d6;font-size:11px}.brand p span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green);margin-right:5px}.queue-badge{display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,rgba(255,213,106,.16),rgba(255,165,55,.06));border:1px solid rgba(255,213,106,.2);padding:8px 11px;border-radius:16px}.queue-badge b{min-width:24px;height:24px;padding:0 5px;border-radius:9px;display:grid;place-items:center;background:linear-gradient(135deg,var(--gold),var(--gold2));color:#431440;font-size:12px}.queue-badge span{color:#f7dfaa;font-size:11px}.page{display:none;padding:8px 18px 22px;animation:fadeUp .25s ease}.page.active{display:block}@keyframes fadeUp{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}.hero-card{position:relative;display:flex;align-items:center;gap:18px;min-height:150px;padding:22px;overflow:hidden;border-radius:28px;background:linear-gradient(135deg,rgba(141,49,218,.64),rgba(95,28,147,.42) 56%,rgba(240,78,177,.23));border:1px solid rgba(255,255,255,.16);box-shadow:0 20px 50px rgba(3,0,12,.28),inset 0 1px rgba(255,255,255,.12)}.hero-glow{position:absolute;width:160px;height:160px;border-radius:50%;background:#f058bd;filter:blur(60px);right:-70px;top:-80px;opacity:.42}.album{position:relative;flex:0 0 94px;width:94px;height:94px;border-radius:23px;background:linear-gradient(145deg,#f1a059,#ce3ac1 48%,#6532c8);padding:7px;box-shadow:0 12px 30px rgba(20,0,35,.4)}.disc{height:100%;border-radius:18px;display:grid;place-items:center;background:repeating-radial-gradient(circle,#1d0e2b 0 4px,#2b143b 5px 8px)}.disc:after{content:"";position:absolute;width:24px;height:24px;border-radius:50%;background:linear-gradient(145deg,var(--gold),#e977ad)}.disc span{font-size:24px;color:white;z-index:2}.hero-copy{position:relative;min-width:0;flex:1}.eyebrow{font-size:11px;color:#eadff3;letter-spacing:1px}.playing-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);margin-right:6px}.hero-copy h2{font-size:25px;margin:10px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hero-copy p{margin:0;color:#d6c3e4;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sound-wave{display:flex;align-items:end;gap:3px;height:16px;margin-top:14px}.sound-wave i{width:3px;border-radius:3px;background:linear-gradient(to top,var(--gold2),#fff1a2);animation:wave .9s ease-in-out infinite alternate}.sound-wave i:nth-child(1),.sound-wave i:nth-child(5){height:5px}.sound-wave i:nth-child(2),.sound-wave i:nth-child(6){height:10px;animation-delay:.2s}.sound-wave i:nth-child(3){height:15px;animation-delay:.4s}.sound-wave i:nth-child(4),.sound-wave i:nth-child(7){height:8px;animation-delay:.1s}@keyframes wave{to{height:4px}}.controls{display:grid;grid-template-columns:1fr 1.18fr 1fr;align-items:center;gap:14px;padding:23px 8px 19px}.controls button{height:72px;border-radius:23px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;align-items:center;justify-content:center;color:#dcd0e7;box-shadow:inset 0 1px rgba(255,255,255,.05)}.controls .control-icon{font-size:23px;line-height:24px;font-weight:300}.controls button>span:last-child{font-size:11px;margin-top:7px}.controls .primary{height:82px;border-radius:27px;background:linear-gradient(145deg,#b551ff,#7028d8);color:#fff;box-shadow:0 13px 30px rgba(119,35,219,.4),inset 0 1px rgba(255,255,255,.25)}.glass-card{background:linear-gradient(145deg,rgba(255,255,255,.105),rgba(255,255,255,.05));border:1px solid var(--line);border-radius:24px;box-shadow:inset 0 1px rgba(255,255,255,.08)}.audio-panel{padding:18px}.panel-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.panel-title>div{display:flex;gap:9px;align-items:center}.panel-title b{font-size:15px}.panel-title small{color:#9f8eb0;font-size:10px}.tiny-icon{color:var(--gold);font-size:17px}.seg{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:5px;background:rgba(9,1,18,.38);border:1px solid rgba(255,255,255,.05);border-radius:17px}.seg button{height:46px;border-radius:13px;background:transparent;color:#a898b7}.seg button span{display:inline-grid;place-items:center;width:24px;height:24px;margin-right:7px;border-radius:8px;background:rgba(255,255,255,.08);font-size:11px}.seg button.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700;box-shadow:0 7px 18px rgba(255,184,74,.18)}.seg button.active span{background:rgba(62,13,57,.13)}.audio-panel label{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:19px;font-size:13px;color:#e9dfef}.audio-panel label>span{display:flex;align-items:center}.audio-panel label>b{color:var(--gold);font-size:12px}.audio-panel label b em{font-style:normal;font-size:15px}.volume-icon{display:inline-grid;place-items:center;width:27px;height:27px;margin-right:9px;border-radius:9px;background:rgba(168,77,255,.16);color:#ca8cff;font-style:normal}.volume-icon.mic{font-size:8px;color:#ff82cc}input[type=range]{grid-column:1/3;width:100%;height:4px;margin:15px 0 2px;appearance:none;border-radius:8px;background:linear-gradient(90deg,#a649f3,#e457c1);accent-color:var(--gold)}input[type=range]::-webkit-slider-thumb{appearance:none;width:19px;height:19px;border-radius:50%;background:#fff;border:5px solid var(--gold);box-shadow:0 3px 10px rgba(0,0,0,.35)}.next-card{display:flex;align-items:center;gap:12px;padding:15px 16px;margin-top:14px;border-radius:19px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.08)}.next-number{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;background:rgba(255,213,106,.12);color:var(--gold);font-size:11px}.next-card>div:nth-child(2){min-width:0;flex:1}.next-card small{display:block;color:#9885a8;font-size:10px;margin-bottom:4px}.next-card b{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.next-card button{background:transparent;color:var(--gold);font-size:11px}.next-card button span{font-size:17px;margin-left:3px}.page-heading{display:flex;align-items:end;justify-content:space-between;padding:7px 2px 18px}.page-heading p{margin:0 0 5px;color:#a378c7;letter-spacing:2px;font-size:9px}.page-heading h2{font-size:25px;margin:0}.page-heading>span{color:#a898b7;font-size:11px}.search-box{display:flex;align-items:center;gap:7px;height:58px;padding:6px 6px 6px 16px;border-radius:19px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.13);box-shadow:0 12px 28px rgba(0,0,0,.13)}.search-icon{width:16px;height:16px;border:2px solid #bfa9cf;border-radius:50%;position:relative}.search-icon:after{content:"";position:absolute;width:7px;height:2px;background:#bfa9cf;right:-5px;bottom:-3px;transform:rotate(45deg);border-radius:2px}.search-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:white;font-size:14px}.search-box input::placeholder{color:#9484a4}.search-box button,.more{height:45px;padding:0 20px;border-radius:14px;background:linear-gradient(135deg,#a74df5,#7130d5);font-weight:700;box-shadow:0 7px 17px rgba(120,45,210,.25)}.hot-block{margin-top:25px}.block-title{display:flex;align-items:center;justify-content:space-between}.block-title h3{font-size:16px;margin:0}.block-title span{font-size:10px;color:#907e9f}.chips{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.chips button{height:51px;padding:0 14px;text-align:left;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.08);color:#e7dcec}.chips button b{display:inline-block;margin-right:10px;color:#c17af6;font-size:10px}.list{margin-top:15px}.song{display:flex;align-items:center;gap:12px;min-height:70px;padding:11px 10px;margin-bottom:9px;border-radius:18px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.085)}.song-index{flex:0 0 37px;width:37px;height:37px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(156,77,255,.25),rgba(236,90,204,.13));color:#c991f3;font-size:11px}.song .info{flex:1;min-width:0}.song .info b{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .info p{margin:5px 0 0;color:#9e8cad;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .playing{display:inline-block;margin-bottom:5px;padding:3px 7px;border-radius:8px;color:#6ff0ac;background:rgba(76,225,150,.1);font-size:9px}.song button{height:38px;padding:0 13px;border-radius:12px;font-size:11px}.song .order,.song .top{background:linear-gradient(135deg,rgba(255,213,106,.2),rgba(255,176,57,.1));border:1px solid rgba(255,213,106,.2);color:var(--gold)}.song .order:disabled{color:var(--green);background:rgba(98,230,165,.1);border-color:rgba(98,230,165,.15)}.song .delete{width:38px;padding:0;background:rgba(255,118,154,.1);color:var(--danger)}.queue-tip{padding:11px 13px;border-radius:14px;background:rgba(255,213,106,.07);color:#c9b687;font-size:10px}.queue-tip span{display:inline-grid;place-items:center;width:18px;height:18px;margin-right:7px;border-radius:50%;background:rgba(255,213,106,.14);color:var(--gold)}.more{width:100%;margin:17px 0;color:#fff;border:0}.empty{padding:80px 15px;text-align:center;color:#927fa2}.empty:before{content:"♫";display:grid;place-items:center;width:58px;height:58px;margin:0 auto 14px;border-radius:19px;background:rgba(255,255,255,.06);color:#a970d3;font-size:25px}.hidden{display:none!important}nav{position:fixed;z-index:8;left:50%;bottom:0;transform:translateX(-50%);width:min(100%,520px);height:78px;padding:5px 15px env(safe-area-inset-bottom);display:grid;grid-template-columns:repeat(3,1fr);background:rgba(14,3,27,.9);backdrop-filter:blur(22px);border-top:1px solid rgba(255,255,255,.09)}nav button{position:relative;background:transparent;color:#887897;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:10px}nav button.active{color:var(--gold)}nav i{display:block;position:relative;width:21px;height:21px}.nav-remote:before{content:"";position:absolute;inset:2px 4px;border:2px solid currentColor;border-radius:6px}.nav-remote:after{content:"";position:absolute;width:4px;height:4px;border-radius:50%;background:currentColor;left:9px;top:8px}.nav-search{border:2px solid currentColor;border-radius:50%;width:16px;height:16px;margin-bottom:5px}.nav-search:after{content:"";position:absolute;width:8px;height:2px;background:currentColor;right:-6px;bottom:-4px;transform:rotate(45deg)}.nav-queue:before{content:"";position:absolute;inset:3px 2px;border-top:2px solid currentColor;border-bottom:2px solid currentColor;box-shadow:0 5px 0 -3px currentColor}.nav-queue:after{content:"";position:absolute;width:3px;height:3px;left:-3px;top:2px;background:currentColor;box-shadow:0 7px currentColor,0 14px currentColor}nav button>b{position:absolute;top:3px;left:calc(50% + 8px);min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:#ed4f86;color:#fff;font-size:9px;display:grid;place-items:center}.offline{position:fixed;z-index:20;top:0;left:0;right:0;padding:9px;background:rgba(190,43,86,.96);text-align:center;font-size:11px}.offline span{display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:50%;background:#fff}#toast{position:fixed;z-index:30;left:50%;bottom:93px;transform:translateX(-50%) translateY(18px);padding:11px 18px;border:1px solid rgba(255,255,255,.12);border-radius:18px;background:rgba(38,13,55,.96);box-shadow:0 12px 30px rgba(0,0,0,.3);opacity:0;transition:.22s;white-space:nowrap;font-size:12px}#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}@media(max-width:360px){.topbar{padding-left:15px;padding-right:15px}.page{padding-left:14px;padding-right:14px}.hero-card{padding:17px}.album{flex-basis:78px;width:78px;height:78px}.hero-copy h2{font-size:21px}.controls{gap:9px}.song{gap:8px}.song button{padding:0 9px}}
+:root{color-scheme:dark;--gold:#ffd56a;--gold2:#ffb23f;--violet:#9c4dff;--magenta:#ec5acc;--bg:#10031f;--glass:rgba(255,255,255,.075);--glass2:rgba(255,255,255,.115);--line:rgba(255,255,255,.12);--muted:#b9a9c9;--green:#62e6a5;--danger:#ff769a}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html{background:#10031f}body{margin:0;min-height:100vh;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(155deg,#260943 0,#10031f 56%,#080110 100%);overflow-x:hidden}button,input{font:inherit}button{border:0;color:inherit;cursor:pointer}.aurora{position:fixed;border-radius:50%;filter:blur(70px);opacity:.48;pointer-events:none}.aurora-one{width:280px;height:280px;background:#7d20d7;right:-110px;top:-80px}.aurora-two{width:260px;height:260px;background:#d82c9e;left:-150px;bottom:80px;opacity:.23}.mesh{position:fixed;inset:0;pointer-events:none;opacity:.22;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:28px 28px;mask-image:linear-gradient(to bottom,#000,transparent 80%)}.app-shell{position:relative;z-index:1;width:min(100%,520px);margin:auto;min-height:100vh;padding:env(safe-area-inset-top) 0 calc(92px + env(safe-area-inset-bottom))}.topbar{height:94px;padding:18px 20px 12px;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:11px}.brand-mark{position:relative;width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,#fff2aa,#ffba44);box-shadow:0 8px 25px rgba(255,190,65,.25);display:flex;align-items:end;justify-content:center;gap:3px;padding:10px}.brand-mark i{display:block;width:4px;border-radius:3px;background:#6a2383}.brand-mark i:nth-child(1){height:12px}.brand-mark i:nth-child(2){height:22px}.brand-mark i:nth-child(3){height:16px}.brand h1{margin:0;font-size:21px;letter-spacing:.5px}.brand h1 em{font-style:italic;color:var(--gold);font-size:15px}.brand p{margin:5px 0 0;color:#c9b9d6;font-size:11px}.brand p span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green);margin-right:5px}.queue-badge{display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,rgba(255,213,106,.16),rgba(255,165,55,.06));border:1px solid rgba(255,213,106,.2);padding:8px 11px;border-radius:16px}.queue-badge b{min-width:24px;height:24px;padding:0 5px;border-radius:9px;display:grid;place-items:center;background:linear-gradient(135deg,var(--gold),var(--gold2));color:#431440;font-size:12px}.queue-badge span{color:#f7dfaa;font-size:11px}.page{display:none;padding:8px 18px 22px;animation:fadeUp .25s ease}.page.active{display:block}@keyframes fadeUp{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}.hero-card{position:relative;display:flex;align-items:center;gap:18px;min-height:150px;padding:22px;overflow:hidden;border-radius:28px;background:linear-gradient(135deg,rgba(141,49,218,.64),rgba(95,28,147,.42) 56%,rgba(240,78,177,.23));border:1px solid rgba(255,255,255,.16);box-shadow:0 20px 50px rgba(3,0,12,.28),inset 0 1px rgba(255,255,255,.12)}.hero-glow{position:absolute;width:160px;height:160px;border-radius:50%;background:#f058bd;filter:blur(60px);right:-70px;top:-80px;opacity:.42}.album{position:relative;flex:0 0 94px;width:94px;height:94px;border-radius:23px;background:linear-gradient(145deg,#f1a059,#ce3ac1 48%,#6532c8);padding:7px;box-shadow:0 12px 30px rgba(20,0,35,.4)}.disc{height:100%;border-radius:18px;display:grid;place-items:center;background:repeating-radial-gradient(circle,#1d0e2b 0 4px,#2b143b 5px 8px)}.disc:after{content:"";position:absolute;width:24px;height:24px;border-radius:50%;background:linear-gradient(145deg,var(--gold),#e977ad)}.disc span{font-size:24px;color:white;z-index:2}.hero-copy{position:relative;min-width:0;flex:1}.eyebrow{font-size:11px;color:#eadff3;letter-spacing:1px}.playing-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);margin-right:6px}.hero-copy h2{font-size:25px;margin:10px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hero-copy p{margin:0;color:#d6c3e4;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sound-wave{display:flex;align-items:end;gap:3px;height:16px;margin-top:14px}.sound-wave i{width:3px;border-radius:3px;background:linear-gradient(to top,var(--gold2),#fff1a2);animation:wave .9s ease-in-out infinite alternate}.sound-wave i:nth-child(1),.sound-wave i:nth-child(5){height:5px}.sound-wave i:nth-child(2),.sound-wave i:nth-child(6){height:10px;animation-delay:.2s}.sound-wave i:nth-child(3){height:15px;animation-delay:.4s}.sound-wave i:nth-child(4),.sound-wave i:nth-child(7){height:8px;animation-delay:.1s}@keyframes wave{to{height:4px}}.controls{display:grid;grid-template-columns:1fr 1.18fr 1fr;align-items:center;gap:14px;padding:23px 8px 19px}.controls button{height:72px;border-radius:23px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;align-items:center;justify-content:center;color:#dcd0e7;box-shadow:inset 0 1px rgba(255,255,255,.05)}.controls .control-icon{font-size:23px;line-height:24px;font-weight:300}.controls button>span:last-child{font-size:11px;margin-top:7px}.controls .primary{height:82px;border-radius:27px;background:linear-gradient(145deg,#b551ff,#7028d8);color:#fff;box-shadow:0 13px 30px rgba(119,35,219,.4),inset 0 1px rgba(255,255,255,.25)}.glass-card{background:linear-gradient(145deg,rgba(255,255,255,.105),rgba(255,255,255,.05));border:1px solid var(--line);border-radius:24px;box-shadow:inset 0 1px rgba(255,255,255,.08)}.audio-panel{padding:18px}.panel-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.panel-title>div{display:flex;gap:9px;align-items:center}.panel-title b{font-size:15px}.panel-title small{color:#9f8eb0;font-size:10px}.tiny-icon{color:var(--gold);font-size:17px}.seg{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:5px;background:rgba(9,1,18,.38);border:1px solid rgba(255,255,255,.05);border-radius:17px}.seg button{height:46px;border-radius:13px;background:transparent;color:#a898b7}.seg button span{display:inline-grid;place-items:center;width:24px;height:24px;margin-right:7px;border-radius:8px;background:rgba(255,255,255,.08);font-size:11px}.seg button.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700;box-shadow:0 7px 18px rgba(255,184,74,.18)}.seg button.active span{background:rgba(62,13,57,.13)}.audio-row{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:19px;font-size:13px;color:#e9dfef}.audio-row>span{display:flex;align-items:center}.audio-row>b{color:var(--gold);font-size:12px}.audio-row b em{font-style:normal;font-size:15px}.volume-icon{display:inline-grid;place-items:center;width:27px;height:27px;margin-right:9px;border-radius:9px;background:rgba(168,77,255,.16);color:#ca8cff;font-style:normal}.volume-icon.mic{font-size:8px;color:#ff82cc}input[type=range]{grid-column:1/3;width:100%;height:32px;margin:4px 0 0;appearance:none;background:transparent;accent-color:var(--gold);touch-action:pan-y}input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:8px;background:linear-gradient(90deg,#a649f3,#e457c1)}input[type=range]::-moz-range-track{height:6px;border-radius:8px;background:linear-gradient(90deg,#a649f3,#e457c1)}input[type=range]::-webkit-slider-thumb{appearance:none;width:22px;height:22px;margin-top:-8px;border-radius:50%;background:#fff;border:5px solid var(--gold);box-shadow:0 3px 10px rgba(0,0,0,.35)}input[type=range]::-moz-range-thumb{width:22px;height:22px;border-radius:50%;background:#fff;border:5px solid var(--gold);box-shadow:0 3px 10px rgba(0,0,0,.35)}.next-card{display:flex;align-items:center;gap:12px;padding:15px 16px;margin-top:14px;border-radius:19px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.08)}.next-number{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;background:rgba(255,213,106,.12);color:var(--gold);font-size:11px}.next-card>div:nth-child(2){min-width:0;flex:1}.next-card small{display:block;color:#9885a8;font-size:10px;margin-bottom:4px}.next-card b{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.next-card button{background:transparent;color:var(--gold);font-size:11px}.next-card button span{font-size:17px;margin-left:3px}.page-heading{display:flex;align-items:end;justify-content:space-between;padding:7px 2px 18px}.page-heading p{margin:0 0 5px;color:#a378c7;letter-spacing:2px;font-size:9px}.page-heading h2{font-size:25px;margin:0}.page-heading>span{color:#a898b7;font-size:11px}.search-box{display:flex;align-items:center;gap:7px;height:58px;padding:6px 6px 6px 16px;border-radius:19px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.13);box-shadow:0 12px 28px rgba(0,0,0,.13)}.search-icon{width:16px;height:16px;border:2px solid #bfa9cf;border-radius:50%;position:relative}.search-icon:after{content:"";position:absolute;width:7px;height:2px;background:#bfa9cf;right:-5px;bottom:-3px;transform:rotate(45deg);border-radius:2px}.search-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:white;font-size:14px}.search-box input::placeholder{color:#9484a4}.search-box button,.more{height:45px;padding:0 20px;border-radius:14px;background:linear-gradient(135deg,#a74df5,#7130d5);font-weight:700;box-shadow:0 7px 17px rgba(120,45,210,.25)}.hot-block{margin-top:25px}.block-title{display:flex;align-items:center;justify-content:space-between}.block-title h3{font-size:16px;margin:0}.block-title span{font-size:10px;color:#907e9f}.chips{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.chips button{height:51px;padding:0 14px;text-align:left;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.08);color:#e7dcec}.chips button b{display:inline-block;margin-right:10px;color:#c17af6;font-size:10px}.list{margin-top:15px}.song{display:flex;align-items:center;gap:12px;min-height:70px;padding:11px 10px;margin-bottom:9px;border-radius:18px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.085)}.song-index{flex:0 0 37px;width:37px;height:37px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(156,77,255,.25),rgba(236,90,204,.13));color:#c991f3;font-size:11px}.song .info{flex:1;min-width:0}.song .info b{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .info p{margin:5px 0 0;color:#9e8cad;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .playing{display:inline-block;margin-bottom:5px;padding:3px 7px;border-radius:8px;color:#6ff0ac;background:rgba(76,225,150,.1);font-size:9px}.song button{height:38px;padding:0 13px;border-radius:12px;font-size:11px}.song .order,.song .top{background:linear-gradient(135deg,rgba(255,213,106,.2),rgba(255,176,57,.1));border:1px solid rgba(255,213,106,.2);color:var(--gold)}.song .order:disabled{color:var(--green);background:rgba(98,230,165,.1);border-color:rgba(98,230,165,.15)}.song .delete{width:38px;padding:0;background:rgba(255,118,154,.1);color:var(--danger)}.queue-tip{padding:11px 13px;border-radius:14px;background:rgba(255,213,106,.07);color:#c9b687;font-size:10px}.queue-tip span{display:inline-grid;place-items:center;width:18px;height:18px;margin-right:7px;border-radius:50%;background:rgba(255,213,106,.14);color:var(--gold)}.more{width:100%;margin:17px 0;color:#fff;border:0}.empty{padding:80px 15px;text-align:center;color:#927fa2}.empty:before{content:"♫";display:grid;place-items:center;width:58px;height:58px;margin:0 auto 14px;border-radius:19px;background:rgba(255,255,255,.06);color:#a970d3;font-size:25px}.hidden{display:none!important}nav{position:fixed;z-index:8;left:50%;bottom:0;transform:translateX(-50%);width:min(100%,520px);height:78px;padding:5px 15px env(safe-area-inset-bottom);display:grid;grid-template-columns:repeat(3,1fr);background:rgba(14,3,27,.9);backdrop-filter:blur(22px);border-top:1px solid rgba(255,255,255,.09)}nav button{position:relative;background:transparent;color:#887897;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:10px}nav button.active{color:var(--gold)}nav i{display:block;position:relative;width:21px;height:21px}.nav-remote:before{content:"";position:absolute;inset:2px 4px;border:2px solid currentColor;border-radius:6px}.nav-remote:after{content:"";position:absolute;width:4px;height:4px;border-radius:50%;background:currentColor;left:9px;top:8px}.nav-search{border:2px solid currentColor;border-radius:50%;width:16px;height:16px;margin-bottom:5px}.nav-search:after{content:"";position:absolute;width:8px;height:2px;background:currentColor;right:-6px;bottom:-4px;transform:rotate(45deg)}.nav-queue:before{content:"";position:absolute;inset:3px 2px;border-top:2px solid currentColor;border-bottom:2px solid currentColor;box-shadow:0 5px 0 -3px currentColor}.nav-queue:after{content:"";position:absolute;width:3px;height:3px;left:-3px;top:2px;background:currentColor;box-shadow:0 7px currentColor,0 14px currentColor}nav button>b{position:absolute;top:3px;left:calc(50% + 8px);min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:#ed4f86;color:#fff;font-size:9px;display:grid;place-items:center}.offline{position:fixed;z-index:20;top:0;left:0;right:0;padding:9px;background:rgba(190,43,86,.96);text-align:center;font-size:11px}.offline span{display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:50%;background:#fff}#toast{position:fixed;z-index:30;left:50%;bottom:93px;transform:translateX(-50%) translateY(18px);padding:11px 18px;border:1px solid rgba(255,255,255,.12);border-radius:18px;background:rgba(38,13,55,.96);box-shadow:0 12px 30px rgba(0,0,0,.3);opacity:0;transition:.22s;white-space:nowrap;font-size:12px}#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}@media(max-width:360px){.topbar{padding-left:15px;padding-right:15px}.page{padding-left:14px;padding-right:14px}.hero-card{padding:17px}.album{flex-basis:78px;width:78px;height:78px}.hero-copy h2{font-size:21px}.controls{gap:9px}.song{gap:8px}.song button{padding:0 9px}}
/* Mobile song search and hot-ranking additions. */
.search-mode{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:10px;padding:5px;border-radius:16px;background:rgba(9,1,18,.38);border:1px solid rgba(255,255,255,.06)}
@@ -16,6 +16,6 @@
.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px}
.empty.compact{padding:42px 15px}
-.audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)}.eq-panel{padding:18px;margin-top:14px}.eq-switch{grid-template-columns:1fr 1fr}.eq-bands{margin-top:6px}.eq-band{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:14px;font-size:12px;color:#e9dfef}.eq-band>span{color:#cbbad9}.eq-band>b{color:var(--gold);font-size:12px}.eq-band b em{font-style:normal;font-size:14px}.eq-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:17px}.eq-preset{height:40px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);color:#d9cce4;font-size:12px}.eq-preset.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700}.eq-hint{margin:14px 0 0;color:#9f8eb0;font-size:10px;line-height:1.5}
+.audio-row.mic-hw{opacity:.55}.audio-row.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-row.mic-hw input[type=range]::-webkit-slider-runnable-track{background:rgba(255,255,255,.14)}.eq-panel{padding:18px;margin-top:14px}.eq-switch{grid-template-columns:1fr 1fr}.eq-bands{margin-top:6px}.eq-band{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:14px;font-size:12px;color:#e9dfef}.eq-band>span{color:#cbbad9}.eq-band>b{color:var(--gold);font-size:12px}.eq-band b em{font-style:normal;font-size:14px}.eq-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:17px}.eq-preset{height:40px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);color:#d9cce4;font-size:12px}.eq-preset.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700}.eq-hint{margin:14px 0 0;color:#9f8eb0;font-size:10px;line-height:1.5}
.eq-title{width:100%;padding:0;margin:0;background:transparent;border:0;text-align:left;cursor:pointer}.eq-title-right{display:flex;align-items:center;gap:8px}.eq-chevron{font-style:normal;color:#c9a8e8;font-size:13px;transition:transform .2s}.eq-title[aria-expanded=true] .eq-chevron{transform:rotate(180deg)}.eq-body{margin-top:4px}
input[type=range]::-webkit-slider-thumb{cursor:grab}input[type=range]::-webkit-slider-thumb:active{cursor:grabbing}input[type=range]::-moz-range-thumb{cursor:grab}
diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js
index 89c93b0..958b4cc 100644
--- a/app/src/main/assets/mobile/app.js
+++ b/app/src/main/assets/mobile/app.js
@@ -1,9 +1,10 @@
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => document.querySelectorAll(selector);
const uiState = {
- page: 1, query: "", searchMode: "singer", busy: false,
+ page: 1, query: "", searchMode: "song", busy: false,
hotPage: 1, hotPageSize: 10, hotBusy: false,
- visible: true, connected: false, eqEnabled: false
+ visible: true, connected: false, eqEnabled: false,
+ equalizer: null, searchAbort: null, searchKey: ""
};
function escapeHtml(value) {
@@ -81,7 +82,9 @@ async function refresh() {
$("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空";
- renderEqualizer(data.equalizer);
+ // 均衡器卡片默认收起: 收起时跳过 DOM 更新(避免每秒轮询重绘), 展开时再渲染一次。
+ uiState.equalizer = data.equalizer;
+ if (!$("#eqBody")?.classList.contains("hidden")) renderEqualizer(data.equalizer);
} catch (_) {
setConnected(false);
}
@@ -107,7 +110,7 @@ function renderEqualizer(eq) {
const bands = Array.isArray(eq.bands) ? eq.bands : [];
const container = $("#eqBands");
if (container && container.childElementCount !== bands.length) {
- container.innerHTML = bands.map((band, index) => `
`).join("");
+ container.innerHTML = bands.map((band, index) => `
${escapeHtml(band.label)}Hz${Number(band.level).toFixed(1)}dB
`).join("");
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
if (!input) return;
@@ -160,7 +163,7 @@ function attachThumbOnlyDrag(input) {
const x = point.clientX;
const y = point.clientY;
// 粗略还原轨道几何:控件左右各留半个滑块宽度。
- const thumb = 22;
+ const thumb = 32;
const trackLeft = rect.left + thumb / 2;
const trackRight = rect.right - thumb / 2;
const span = trackRight - trackLeft;
@@ -200,31 +203,52 @@ async function updateAudio(values) {
}
async function search(reset = true) {
- if (uiState.busy) return;
if (reset) {
- uiState.page = 1;
- uiState.query = $("#query").value.trim();
- if (!uiState.query) {
+ const nextQuery = $("#query").value.trim();
+ if (!nextQuery) {
toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`);
$("#query").focus();
return;
}
+ // 同一关键词+同一搜索方式且结果已渲染 → 不重复请求设备(设备端也要查 1GB 曲库)。
+ const key = `${uiState.searchMode}|${nextQuery}`;
+ if (key === uiState.searchKey && $("#results").childElementCount) {
+ $("#searchResultBlock").scrollIntoView({ behavior: "smooth", block: "start" });
+ return;
+ }
+ uiState.page = 1;
+ uiState.query = nextQuery;
+ uiState.searchKey = key;
$("#results").innerHTML = "";
$("#searchResultBlock").classList.remove("hidden");
$("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果";
$("#resultSummary").textContent = `正在搜索“${uiState.query}”`;
+ $("#more").classList.add("hidden");
+ if (uiState.searchAbort) uiState.searchAbort.abort();
+ uiState.searchAbort = new AbortController();
}
+ const signal = uiState.searchAbort ? uiState.searchAbort.signal : undefined;
+ const requestedPage = uiState.page;
uiState.busy = true;
try {
- const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${uiState.page}&pageSize=20`);
- const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (uiState.page - 1) * 20 + index)).join("");
+ const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${requestedPage}&pageSize=20`, { signal });
+ const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (requestedPage - 1) * 20 + index)).join("");
if (rows) $("#results").insertAdjacentHTML("beforeend", rows);
else if (reset) $("#results").innerHTML = '
没有找到相关歌曲
请更换关键词后重试
';
$("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`;
$("#more").classList.toggle("hidden", !data.hasMore);
- uiState.page += 1;
- } catch (error) { toast(error.message); }
- finally { uiState.busy = false; }
+ uiState.page = requestedPage + 1;
+ // 预热下一页: 设备端会把它缓存下来, 用户点「更多」时基本瞬时。
+ if (requestedPage === 1 && data.hasMore) {
+ window.setTimeout(() => {
+ api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=2&pageSize=20`)
+ .catch(() => {});
+ }, 500);
+ }
+ } catch (error) {
+ // 被下一次搜索取消属于正常情况, 不弹提示。
+ if (error && error.name !== "AbortError") toast(error.message);
+ } finally { uiState.busy = false; }
}
async function loadHot(page) {
@@ -317,8 +341,11 @@ $("#eqCollapse")?.addEventListener("click", () => {
if (!body) return;
const collapsed = body.classList.toggle("hidden");
$("#eqCollapse").setAttribute("aria-expanded", collapsed ? "false" : "true");
+ // 展开时补一次渲染(收起期间轮询跳过了 EQ 的 DOM 更新)。
+ if (!collapsed) renderEqualizer(uiState.equalizer);
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
-window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
+// 搜索进行中暂停状态轮询: 避免与设备端 1GB 曲库查询抢 I/O。
+window.setInterval(() => { if (uiState.visible && !uiState.busy) refresh(); }, 1000);
refresh();
loadHot(1);
diff --git a/app/src/main/assets/mobile/index.html b/app/src/main/assets/mobile/index.html
index f7c165a..daca08d 100644
--- a/app/src/main/assets/mobile/index.html
+++ b/app/src/main/assets/mobile/index.html
@@ -36,8 +36,8 @@
-
-
+ ♪音乐音量85%
+ ●麦克风音量由功放/混音器硬件调节
@@ -62,10 +62,10 @@
-
-
+
+
-
+
歌手搜索结果
diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt
index 27a52e5..25f4ca6 100644
--- a/app/src/main/java/com/local/ktv/MainActivity.kt
+++ b/app/src/main/java/com/local/ktv/MainActivity.kt
@@ -150,6 +150,18 @@ class MainActivity : AppCompatActivity() {
private val remoteServer by lazy { LocalRemoteServer(this) }
@Volatile private var remoteStateVersion = 1L
private val remoteSongCache = Collections.synchronizedMap(LinkedHashMap
())
+
+ /**
+ * 远程搜索结果的短期缓存(LRU,最多 24 条 = 最近 4 个查询 × 6 页)。
+ *
+ * 曲库 DB 有 67 万首/约 1GB,搜索是 I/O 密集操作(实测同一查询冷态数秒、热态数十至数百毫秒)。
+ * 同一「类型+关键词+页码」重复请求直接复用上次的 JSON,翻页(「更多」)与重复搜索不再查库。
+ * TTL 60s + 曲库刷新时清空,保证隐藏/删除歌曲后结果及时跟上。
+ */
+ private val remoteSearchCache = object : LinkedHashMap>(8, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean =
+ size > 24
+ }
private var mobileQrOverlay: View? = null
private var fullScreenQrOverlay: View? = null
private var updateCheckStarted = false
@@ -170,25 +182,6 @@ class MainActivity : AppCompatActivity() {
}
}
- /** 全屏右上角扫码点歌二维码显隐控制: 手机连接点歌(页面可见时每秒轮询 state)
- * 期间自动隐藏, 断开(无请求超过约 4 秒)后恢复显示。 */
- private val qrAutoHideMonitor = object : Runnable {
- private var hiddenByClient = false
- override fun run() {
- val overlay = fullScreenQrOverlay
- if (overlay != null && isFullScreen) {
- val active = remoteServer.hasRecentActivity(4000)
- if (active && !hiddenByClient) {
- hiddenByClient = true
- overlay.visibility = View.GONE
- } else if (!active && hiddenByClient) {
- hiddenByClient = false
- overlay.visibility = View.VISIBLE
- }
- }
- main.postDelayed(this, 1000)
- }
- }
/** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
private val ktvHeartbeatRunnable = object : Runnable {
override fun run() {
@@ -505,7 +498,6 @@ class MainActivity : AppCompatActivity() {
startRemoteServer()
updateMobileQrOverlay()
main.postDelayed(remoteNetworkMonitor, 5000)
- main.postDelayed(qrAutoHideMonitor, 1000)
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
sendDeviceHeartbeat(boot = true)
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
@@ -690,7 +682,6 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(homeBannerRunnable)
main.removeCallbacks(autoFullscreenRunnable)
main.removeCallbacks(remoteNetworkMonitor)
- main.removeCallbacks(qrAutoHideMonitor)
stopRecording(false)
unregisterMicInputWatcher()
micActivator.stop()
@@ -2419,6 +2410,8 @@ class MainActivity : AppCompatActivity() {
fullScreenChromeVisible = visible
fullScreenControls?.visibility = if (visible) View.VISIBLE else View.GONE
fullScreenProgressRow?.visibility = if (visible) View.VISIBLE else View.GONE
+ // 右上角二维码跟随控制层一起出现/隐藏(不再按"手机是否在点歌"自动隐藏)。
+ fullScreenQrOverlay?.visibility = if (visible) View.VISIBLE else View.GONE
main.removeCallbacks(hideFullScreenChromeRunnable)
if (visible && isFullScreen) {
val controls = fullScreenControls
@@ -3156,10 +3149,6 @@ class MainActivity : AppCompatActivity() {
addressLine.gravity = Gravity.CENTER
box.addView(addressLine, LinearLayout.LayoutParams(-1, dp(32)))
}
- // 提示:扫码连上后播放界面右上角二维码会自动隐藏。
- val hint = label("扫码点歌后自动隐藏播放界面二维码", 14, Color.rgb(193, 193, 193))
- hint.gravity = Gravity.CENTER
- box.addView(hint, LinearLayout.LayoutParams(-1, dp(if (compact) 26 else 30)))
val scroll = android.widget.ScrollView(this).apply { addView(box) }
val dialog = AlertDialog.Builder(this)
.setTitle("扫码点歌")
@@ -3254,6 +3243,8 @@ class MainActivity : AppCompatActivity() {
}
container.addView(box, params)
box.bringToFront()
+ // 跟随控制层显隐:控制层收起时二维码也不显示(新建时按当前状态初始化)。
+ box.visibility = if (fullScreenChromeVisible) View.VISIBLE else View.GONE
// 同手机点歌二维码: 展示型表面不做焦点填充。
TvFocusStyler.suppressFill(box)
fullScreenQrOverlay = box
@@ -3741,12 +3732,22 @@ class MainActivity : AppCompatActivity() {
return remoteOk(equalizerJson().put("message", "均衡器已更新"))
}
+ /** 远程搜索结果缓存有效期。 */
+ private val SEARCH_CACHE_TTL_MS = 60_000L
+
private fun remoteSearchJson(query: Map): JSONObject {
val keyword = query["q"].orEmpty().trim()
val type = query["type"].orEmpty().lowercase(Locale.ROOT).let { if (it == "song") "song" else "singer" }
val page = (query["page"]?.toIntOrNull() ?: 1).coerceAtLeast(1)
val pageSize = (query["pageSize"]?.toIntOrNull() ?: 20).coerceIn(1, 50)
val offset = (page - 1) * pageSize
+ // 命中缓存: 不再查 1GB 曲库(翻页/重复搜索基本瞬时)。
+ val cacheKey = "$type|$keyword|$page|$pageSize"
+ remoteSearchCache[cacheKey]?.let { (json, at) ->
+ if (System.currentTimeMillis() - at < SEARCH_CACHE_TTL_MS) {
+ runCatching { JSONObject(json) }.getOrNull()?.let { return it }
+ }
+ }
val songs = if (library.muse.isAvailable() && keyword.isNotBlank()) {
if (type == "singer") library.muse.searchSongsBySingerKeyword(keyword, offset, pageSize + 1)
else library.muse.searchSongs(keyword, offset, pageSize + 1)
@@ -3767,8 +3768,10 @@ class MainActivity : AppCompatActivity() {
array.put(songJson(song))
}
while (remoteSongCache.size > 500) remoteSongCache.remove(remoteSongCache.keys.first())
- return JSONObject().put("songs", array).put("page", page).put("pageSize", pageSize)
+ val payload = JSONObject().put("songs", array).put("page", page).put("pageSize", pageSize)
.put("type", type).put("hasMore", hasMore)
+ remoteSearchCache[cacheKey] = payload.toString() to System.currentTimeMillis()
+ return payload
}
private fun remoteHotJson(query: Map): JSONObject {
@@ -6190,6 +6193,8 @@ class MainActivity : AppCompatActivity() {
}
private fun refreshLibrary(after: Runnable?) {
+ // 曲库变化后远程搜索结果缓存立即失效,避免还搜到已隐藏/已删除的歌。
+ remoteSearchCache.clear()
busy(true, "正在扫描曲库...")
io.execute(Runnable {
val locals = library.scanLocal().size
@@ -8956,7 +8961,6 @@ class MainActivity : AppCompatActivity() {
var pendingOrderedSongOriginal = orderedSongOriginal
var pendingOrderedVolumeMode = orderedVolumeMode
var pendingOrderedVocalMode = orderedVocalMode
- var pendingMusicVolume = musicVolume
fun title(text: String) = TextView(this).apply {
this.text = text; textSize = 16f; setTextColor(Color.WHITE); setPadding(0, dp(4), 0, 0)
}
@@ -8979,23 +8983,12 @@ class MainActivity : AppCompatActivity() {
changed(index)
}
}
- val musicVolumeTitle = title("歌曲最大音量 ${pendingMusicVolume}%")
+ // 说明:音量调节与均衡器入口已从这里移除 —— 它们统一收敛到「调音」弹窗,
+ // 避免同一功能出现两套入口(音量滑杆 + 均衡器开关/预设/频段都在「调音」里)。
val left = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(title("切到点播歌曲时音量:"))
addView(choices(arrayOf("跟随上首音量", "跟随上首点播歌曲音量"), pendingOrderedVolumeMode) { pendingOrderedVolumeMode = it })
- addView(musicVolumeTitle)
- addView(SeekBar(this@MainActivity).apply {
- max = 100; progress = pendingMusicVolume
- setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
- override fun onProgressChanged(bar: SeekBar?, value: Int, fromUser: Boolean) {
- pendingMusicVolume = value
- musicVolumeTitle.text = "歌曲最大音量 ${pendingMusicVolume}%"
- }
- override fun onStartTrackingTouch(bar: SeekBar?) = Unit
- override fun onStopTrackingTouch(bar: SeekBar?) = Unit
- })
- })
}
val right = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -9009,26 +9002,9 @@ class MainActivity : AppCompatActivity() {
addView(left, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(28) })
addView(right, LinearLayout.LayoutParams(0, -2, 1f))
}
- // 均衡器入口:与播放界面/主页「调音」里的入口等价,修改即时生效。
- val eqEntry = TextView(this).apply {
- text = "均衡器入口 ›"
- textSize = 17f
- setTextColor(Color.WHITE)
- gravity = Gravity.CENTER
- background = getDrawable(R.drawable.bg_btn_glass)
- isClickable = true
- isFocusable = true
- setPadding(dp(12), dp(10), dp(12), dp(10))
- setOnClickListener { showTuningDialog() }
- TvFocusStyler.installAction(this)
- }
val body = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(content, LinearLayout.LayoutParams(-1, -2))
- addView(eqEntry, LinearLayout.LayoutParams(-1, dp(48)).apply {
- marginStart = dp(28)
- marginEnd = dp(28)
- })
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
val dialog = AlertDialog.Builder(this).setTitle("声音设置")
@@ -9038,7 +9014,6 @@ class MainActivity : AppCompatActivity() {
orderedSongOriginal = pendingOrderedSongOriginal
orderedVolumeMode = pendingOrderedVolumeMode
orderedVocalMode = pendingOrderedVocalMode
- musicVolume = pendingMusicVolume
applyPlaybackMode()
saveState()
}.create()