diff --git a/.github/workflows/player-validation.yml b/.github/workflows/player-validation.yml
new file mode 100644
index 00000000..98b12145
--- /dev/null
+++ b/.github/workflows/player-validation.yml
@@ -0,0 +1,32 @@
+name: Player validation
+
+on:
+ pull_request:
+ branches: [dev]
+ paths:
+ - 'public/music/**'
+ - 'test/**'
+ - 'src/**'
+ - 'package*.json'
+ - 'tsconfig.json'
+ - '.github/workflows/player-validation.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ player:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node: [22, 24]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ - run: npm test
+ - run: npm run build --ignore-scripts
diff --git a/changelog.md b/changelog.md
index 629c585f..ef215ac5 100644
--- a/changelog.md
+++ b/changelog.md
@@ -14,6 +14,11 @@
### 🐛 缺陷修复与体验优化
+- **搜索进度显示范围修复**:搜索状态条仅在网络搜索页显示,切换收藏分类或其他列表时隐藏,返回搜索后恢复当前状态,避免后台更新导致残留。
+- **暗色模式开关状态修复**:修复开启后的开关底色被暗色灰色样式覆盖的问题,统一使用主题色区分开启与关闭状态。
+- **AirPlay 原生播放恢复修复 (#511 @pokemonred06-creator)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改,恢复提示按指定时长显示。
+- **无需刷新恢复原生播放**:关闭音效或点击恢复按钮时,通过新音频元素交接歌曲、进度、暂停状态、音量和倍速,释放旧音频图及 iOS 保活桥;保留队列和音效配置,切歌及快速开关不会套用旧进度。切换可能短暂缓冲,浏览器阻止续播时提示手动播放。
+- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效,投播期间锁定本机音效控件。
- **删除用户时清理音源文件**:单个或批量删除用户并勾选删除数据时,同时清理该用户的自定义音源脚本、顺序及状态文件,保留公共音源和其他用户文件。
- **歌手与专辑详情切换及返回修复**:切换标签、收藏分类或歌单时清理详情头部与悬浮栏,避免残留到其他列表;修复打开专辑后歌手详情因容器被删除而一直加载的问题;记录详情入口,返回时恢复对应的收藏歌手、收藏专辑或原歌单,避免浏览器返回事件重复处理后跳到「我的喜爱」。
diff --git a/docs/chromecast/architecture.md b/docs/chromecast/architecture.md
index 35611518..95ad6c5f 100644
--- a/docs/chromecast/architecture.md
+++ b/docs/chromecast/architecture.md
@@ -1,6 +1,6 @@
# Cast architecture
-`public/music/js/player/playback.js` adapts the native audio element behind the player's existing media properties, methods and events. Local media events pass through. While casting, the adapter routes controls to the remote output and suppresses native events, preventing duplicate playback and false local recovery. Other audio modules retain the real DOM element.
+`public/music/js/player/playback.js` adapts the native audio element behind the player's existing media properties, methods and events. Local media events pass through. While casting, the adapter routes controls to the remote output and suppresses native events, preventing duplicate playback and false local recovery. The stable adapter can replace its native element without rebinding application listeners. AirPlay and visualization subscribe through the adapter; audio graph construction and the iOS bridge use the current real DOM element.
`chromecast.js` loads Google's official Web Sender SDK in supported secure browser contexts and uses the Default Media Receiver; no custom application registration or server dependency is required. RemotePlayer status drives transport events, position and volume. LOAD commands are serialized and versioned so stale preparation cannot replace a newer track. A seek made during LOAD is applied after it completes; local stall timers cannot recover across a route change. Pending play/pause requests both take effect after LOAD. Cast route changes synchronize lyric speed; local startup restoration yields to resumed remote sessions. Standard/local artwork is resolved through the shared image helper and retained in session metadata. Completion is delivered once. Session resumption adopts existing media without another LOAD. The shared controls remain available on mobile layouts. Equal side columns keep the transport centered, with repeat on the left and speed/wireless controls on the right. Local crossfade is suspended while casting, including an in-progress fade, to preserve receiver volume.
@@ -15,7 +15,7 @@ Capabilities live only in bounded process memory (two hours, sixteen per owner,
Native same-origin audio, artwork and browser downloads use the HttpOnly player session cookie (Secure on HTTPS); the player removes account query tokens from these URLs. Cookie fallback is confined to read-only media handlers and validates the account and session lifetime. Legacy API query-token handling remains for compatibility, but the player does not generate it. API mutations still require their existing header authentication. Local sound-effect changes are saved during wireless playback without building or updating a local graph.
-The current combined submission caches first-party modules under `lx-music-web-v51`. Google's SDK stays network-loaded. SDK failure or unsupported browsers leave local playback available.
+The native AirPlay recovery update caches first-party modules under `lx-music-web-v62`. Google's SDK stays network-loaded. SDK failure or unsupported browsers leave local playback available.
References: [Google Web Sender setup](https://developers.google.com/cast/docs/web_sender), [integration](https://developers.google.com/cast/docs/web_sender/integrate), [supported media](https://developers.google.com/cast/docs/media).
@@ -27,4 +27,12 @@ Cookie recovery first checks native media access without API headers, then resto
Token verification returns the session owner and upgrades guest or other-account cookies to the authenticated account before native media. Existing-Token fast paths also verify this binding. Cast quota ownership uses only a validated, unexpired stored session or a verified account; unknown cookies share the public quota. Playlist card metadata is escaped in both layouts, with JSON arguments escaped for inline click attributes.
-Mobile loads remain native until an explicit local effect edit opts into DSP. The panel reports native, local-processed or receiver output. After mobile capture the in-page AirPlay picker directs the user through a native reload, because disconnecting Web Audio does not release a captured media element. That reload preserves queue/position with a one-time session flag without changing the auto-resume preference. Existing wireless/Cast edits remain saved-only.
+Mobile loads remain native until the master switch explicitly enables local effects. The panel shows native playback with a visible mode tag and an off switch, retaining all saved values in a disabled preview. Explicit enable uses the retained configuration, including neutral settings. Wireless playback locks the local controls and master switch; loading settings never opts native playback into DSP.
+
+Master off or the native-restore action replaces a captured audio element without reloading the page. The replacement copies the source, position, paused state, volume, mute and playback rate. Playback is requested in the original click gesture; a browser rejection leaves the player paused and prompts a manual play. Buffering or a brief interruption is possible. AirPlay devices may need to be selected again after a handoff from an already captured element.
+
+The old element stops forwarding events before it is paused and unloaded, so its pause/error/ended events cannot trigger recovery or queue advancement. Metadata restoration is scoped to the element and source generation: changing tracks or seeking cancels the old position, while rapid master toggles preserve a pending position. Cast refuses local replacement during an active remote session and still returns to the current local element.
+
+Native restoration disposes the iOS bridge, the visualizer RAF loops and all graph nodes, clears shared graph references and closes the old context. Pending worklet and reverb callbacks cannot mutate a newer graph. Re-enabling effects builds a fresh graph for the new element. The vendored Wave renderer exposes dispose() to cancel its otherwise perpetual RAF loop.
+
+The per-tab `lx_native_audio_mode` preference prevents visualization and effects from recapturing on playback, resize or a later reload. Storage is optional for live handoff; the queue and UI stay in memory and no playback-state save is required. The legacy one-time `lx_native_audio_restore` flag is consumed for compatibility but is no longer written by restoration. Reverb presets and resets render slider values without synthetic input callbacks, and disabling reverb preserves native mode when other saved effects remain active. Recovery toasts honor their display duration.
diff --git a/docs/chromecast/progress.md b/docs/chromecast/progress.md
index 46609a75..3cb7ccea 100644
--- a/docs/chromecast/progress.md
+++ b/docs/chromecast/progress.md
@@ -1,5 +1,28 @@
# Chromecast progress
+## Live native audio handoff — 2026-11-07
+
+- Master off and native recovery now replace the captured audio element without a page reload, retaining source, pending position, pause state, volume, mute and playback rate. Saved effects and the queue remain available; storage failure does not block live recovery.
+- The playback proxy retains identity and listeners across replacements, suppresses old-element events and cancels stale seeks after a track change. AirPlay and visualization use the stable proxy; graph creation still uses the current DOM element. Cast sessions are not disconnected by local recovery.
+- Dispose the old iOS bridge, visualizer RAF loops and shared graph/context. Guard late worklet/reverb callbacks from mutating a new graph. Explicit master enable builds a new graph using retained settings. Player cache advances to v62.
+- Automated coverage includes paused/playing handoff, rapid toggles before metadata, track changes, old-element events, blocked storage, iOS bridge recreation, stale worklets and Cast return. No browser automation was run at the maintainer's request; rendered UI and physical Safari/AirPlay checks remain manual.
+- Validation: all 139 tests pass (95 CJS and 44 TypeScript); TypeScript checking, production compilation, changed JavaScript syntax and diff checks pass. Handoff-specific regressions also cover autoplay rejection, native load resetting playback rate and disposal of queued visualizer frames.
+
+## Native recovery follow-up review (superseded reload path) — 2026-11-07
+
+- Fixed reverb reset leaving native mode when saved EQ remains active. Removed synthetic slider input animations that could enable DSP after remote playback returns local or overwrite a later gain edit.
+- Playback saving now reports failure separately from an empty queue. Native reload is blocked on save failure; empty states do not set the one-time playback restore flag. Partial session writes are cleaned up, and recovery toasts honor their display duration.
+- Added nine recovery regressions plus six native-preview/master-switch regressions. Native mode displays a prominent tag, an off master switch and read-only saved controls; explicit enable applies the retained configuration. Wireless controls stay locked. Player cache advances to v61. Physical Safari/AirPlay receiver validation remains outstanding; the maintainer will check the rendered UI.
+
+## Native AirPlay capture recovery — 2026-11-07
+
+- [x] Detect any captured media source, including visualizer-only capture on desktop, and expose native restoration on all platforms. System/Control Center handoffs show recovery once per connection without automatically reloading.
+- [x] Keep native output across playback, resize and reload while retaining saved effects, queue/position and auto-resume preferences. Explicit local active-effect edits can resume DSP; remote edits, master-off edits and resets preserve native mode.
+- [x] Preserve current upstream VU artwork, mobile DSP opt-in, master bypass, preset/reset controls and existing playback restoration.
+- [x] Add 26 integrated capture, route, storage, playback and master-effect regressions plus Node 22/24 pull-request validation; refresh the worker cache to v59. The untouched upstream baseline failed 15 of the original 20 cases. The adapted branch passes all 117 tests (73 CJS and 44 TypeScript), type checking, production compilation and JavaScript/diff checks.
+- [x] AirPlay playback on the deployed repair was user-confirmed; the exact device/receiver profile was not recorded.
+- [ ] Maintainer review and merge. Broader physical device and receiver coverage remains unverified.
+
## Complete customization submission and version identity — 2026-10-20
### Expanded review before deployment
diff --git a/public/js/config.js b/public/js/config.js
index e0792ea5..0b95c9cd 100644
--- a/public/js/config.js
+++ b/public/js/config.js
@@ -2,6 +2,6 @@
// 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts)
// 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回
window.CONFIG = {
- buildHash: '573b4fd',
+ buildHash: '8d260f0',
version: 'v2.2.0',
};
diff --git a/public/music/app.js b/public/music/app.js
index cf9b3f16..1e4ee920 100644
--- a/public/music/app.js
+++ b/public/music/app.js
@@ -1853,6 +1853,7 @@ function switchTab(tabId) {
if (tabId !== 'search') window.SearchRequests?.cancel();
activeView.classList.remove('hidden');
+ window.refreshSearchStatusVisibility?.();
// small delay to allow display block to apply before opacity transition
setTimeout(() => {
activeView.classList.remove('opacity-0');
@@ -1903,6 +1904,7 @@ function switchTab(tabId) {
if (tabId === 'search') {
initGlobalListSearch(); // [New] 强制重置 ListSearch 为 'global' 模式
currentSearchScope = 'network';
+ window.refreshSearchStatusVisibility?.();
document.getElementById('search-source').classList.remove('hidden');
document.getElementById('search-type').classList.remove('hidden');
document.getElementById('page-title').innerText = "搜索音乐";
@@ -2401,6 +2403,7 @@ function performSearch(query, source = null) {
// 重置搜索范围到全网搜索
if (typeof currentSearchScope !== 'undefined') {
currentSearchScope = 'network';
+ window.refreshSearchStatusVisibility?.();
}
// 设置搜索框内容
@@ -4767,6 +4770,7 @@ function goBackToSearch(fromPopState = false) {
window.searchDetailOrigin = null;
if (origin) {
currentSearchScope = origin.scope;
+ window.refreshSearchStatusVisibility?.();
lastSearchResultList = origin.list;
lastSearchType = origin.type;
currentArtistId = null;
@@ -8243,7 +8247,7 @@ audio.addEventListener('pause', () => {
// ========================================
function savePlaybackState() {
- if (!currentPlayingSong && !currentPlaylist?.length) return;
+ if (!currentPlayingSong && !currentPlaylist?.length) return null;
try {
const state = {
song: currentPlayingSong,
@@ -8259,8 +8263,10 @@ function savePlaybackState() {
timestamp: Date.now()
};
localStorage.setItem('lx_playback_state', JSON.stringify(state));
+ return true;
} catch (e) {
console.error('[Resume] 无法保存播放状态:', e);
+ return false;
}
}
@@ -12067,6 +12073,7 @@ function handleArtistLibraryClick() {
document.getElementById('search-type').classList.add('hidden');
currentSearchScope = 'lib_artists';
+ window.refreshSearchStatusVisibility?.();
window.currentViewingListId = '__lib_artists__';
renderLibraryArtists(window.libraryData.artists);
}
@@ -12100,6 +12107,7 @@ function handleAlbumLibraryClick() {
document.getElementById('search-type').classList.add('hidden');
currentSearchScope = 'lib_albums';
+ window.refreshSearchStatusVisibility?.();
window.currentViewingListId = '__lib_albums__';
renderLibraryAlbums(window.libraryData.albums);
}
@@ -13494,6 +13502,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) {
// Set current viewing list ID for batch operations
window.currentViewingListId = listId;
currentSearchScope = 'local_list';
+ window.refreshSearchStatusVisibility?.();
let list = [];
let title = '';
@@ -13536,6 +13545,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) {
// Set Scope
currentSearchScope = 'local_list';
+ window.refreshSearchStatusVisibility?.();
document.getElementById('search-source').classList.add('hidden'); // Hide selector
document.getElementById('search-type').classList.add('hidden');
@@ -13768,6 +13778,7 @@ function handleDislikeArtistsClick() {
exitListSecondaryModes();
window.currentViewingListId = 'dislike_artists';
currentSearchScope = 'local_list';
+ window.refreshSearchStatusVisibility?.();
// Highlight Header
document.querySelectorAll('[id^="tab-"]').forEach(el => {
@@ -13808,6 +13819,7 @@ function handleDislikeAlbumsClick() {
exitListSecondaryModes();
window.currentViewingListId = 'dislike_albums';
currentSearchScope = 'local_list';
+ window.refreshSearchStatusVisibility?.();
// Highlight Header
document.querySelectorAll('[id^="tab-"]').forEach(el => {
@@ -17163,7 +17175,7 @@ function showToast(type, message, duration = 3000) {
// 封装旧 API
function showSuccess(message) { showToast('success', message, 2000); }
-function showInfo(message) { showToast('info', message, 2000); }
+function showInfo(message, duration = 2000) { showToast('info', message, duration); }
function showError(message) { showToast('error', message, 2000); }
/**
diff --git a/public/music/css/app.css b/public/music/css/app.css
index ba6a4c42..4ed9e79c 100644
--- a/public/music/css/app.css
+++ b/public/music/css/app.css
@@ -702,6 +702,11 @@ aside {
visibility: visible !important;
}
+/* Keep checked switch tracks above Tailwind's dark background utilities. */
+input.peer[type="checkbox"]:checked + .peer[class~="peer-checked:bg-emerald-500"] {
+ background-color: var(--c-500, #10b981);
+}
+
/* 菜单选中项样式 */
.active-option {
background-color: var(--c-500) !important;
@@ -2156,6 +2161,12 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli
/* Active Effect Tags in Status Bar */
.effects-tags-wrapper { min-height: 24px; }
+.effects-mode-tag { display: inline-flex; align-items: center; padding: 4px 9px; border: 1px solid #38bdf880; border-radius: 6px; background: #38bdf81a; color: var(--text-main); font-size: 12px; font-weight: 700; }
+.effects-mode-tag[hidden] { display: none; }
+.effects-controls { min-width: 0; margin: 0; padding: 0; border: 0; }
+.effects-controls:disabled { opacity: .7; }
+.effects-controls:disabled :is(input, button, label), .effects-tags-wrapper button:disabled { cursor: not-allowed; }
+.effects-tags-wrapper button:disabled { opacity: .35; pointer-events: none; }
.effects-tag {
display: inline-flex;
align-items: center;
@@ -2193,7 +2204,7 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli
margin-right: -2px;
border-radius: 4px;
background: transparent;
- color: currentColor;
+ color: currentcolor;
opacity: 0.65;
border: none;
cursor: pointer;
@@ -2254,18 +2265,19 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli
#sound-effects-content input[type="range"] { min-width: 0; height: 4px; accent-color: var(--c-500, #10b981); }
#sound-effects-content input[type="checkbox"] { accent-color: var(--c-500, #10b981); }
#sound-effects-content #reverb-list { gap: 4px; }
-#reverb-list > label { min-width: 0; padding: 3px 7px; font-size: 11px; border: 1px solid var(--border-main); border-radius: 6px; font-weight: 500; transition: all .15s; }
-#reverb-list > label { position: relative; }
+#reverb-list > label { position: relative; min-width: 0; padding: 3px 7px; font-size: 11px; border: 1px solid var(--border-main); border-radius: 6px; font-weight: 500; transition: all .15s; }
#reverb-list > label > div { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
#reverb-list > label:hover { border-color: var(--c-500, #10b981); }
-#reverb-list > label:has(input:checked) { color: var(--c-500, #10b981); border-color: currentColor; background: rgba(16, 185, 129, 0.1); }
+#reverb-list > label:has(input:checked) { color: var(--c-500, #10b981); border-color: currentcolor; background: rgba(16, 185, 129, 0.1); }
#eq-sliders-container { min-height: 0; margin-bottom: 8px; }
#eq-sliders-container > div { gap: 6px 14px; padding: 0; }
#eq-sliders-container > div > div { gap: 8px; min-width: 0; }
#eq-presets button { padding: 3px 8px; font-size: 11px; border-radius: 6px; font-weight: 500; }
+
@media (pointer: coarse), (max-width: 767px) {
.effects-tag-remove { opacity: 0.85; width: 18px; height: 18px; font-size: 11px; margin-left: 3px; }
}
+
@media (max-width: 767px) {
.effects-status-bar { top: -14px; margin: -14px -14px 14px; padding: 12px; }
#sound-effects-modal { padding: 8px; }
diff --git a/public/music/index.html b/public/music/index.html
index f7aa6dbe..4d900888 100644
--- a/public/music/index.html
+++ b/public/music/index.html
@@ -5541,6 +5541,7 @@
均衡器与音效调节
音效已开启
+
@@ -5559,9 +5560,9 @@
均衡器与音效调节
刷新并恢复原生播放 / AirPlay
+ onclick="window.soundEffects.restoreNativePlayback()">恢复原生播放 / AirPlay
-
+
@@ -5676,7 +5677,7 @@
快速预
-
+
@@ -6274,6 +6275,7 @@ 项目使用声明<
+
@@ -6285,7 +6287,6 @@ 项目使用声明<
-
diff --git a/public/music/js/library/search_requests.js b/public/music/js/library/search_requests.js
index 5ff5427b..1dd3f7ec 100644
--- a/public/music/js/library/search_requests.js
+++ b/public/music/js/library/search_requests.js
@@ -31,12 +31,17 @@
};
},
};
- window.setSearchStatus = function (message = '', loading = false) {
+ let searchStatus = { message: '', loading: false };
+ window.refreshSearchStatusVisibility = function () {
+ const { message, loading } = searchStatus;
+ const searchView = document.getElementById('view-search');
+ const visible = window.currentSearchScope === 'network' &&
+ !!searchView && !searchView.classList.contains('hidden');
const status = document.getElementById('search-status');
const wave = document.getElementById('search-progress-wave');
- if (wave) wave.classList.toggle('is-active', !!loading);
+ if (wave) wave.classList.toggle('is-active', visible && !!loading);
if (!status) return;
- status.classList.toggle('hidden', !message);
+ status.classList.toggle('hidden', !visible || !message);
const textEl = document.getElementById('search-status-text');
if (textEl) textEl.textContent = message;
const cancelBtn = document.getElementById('search-cancel');
@@ -46,6 +51,10 @@
const spinner = document.getElementById('search-status-spinner');
if (spinner) spinner.style.display = loading ? 'inline-flex' : 'none';
};
+ window.setSearchStatus = function (message = '', loading = false) {
+ searchStatus = { message, loading };
+ window.refreshSearchStatusVisibility();
+ };
window.cancelMusicSearch = function () {
cancel();
window.setSearchStatus('搜索已取消,可修改关键词后重试', false);
diff --git a/public/music/js/player/airplay.js b/public/music/js/player/airplay.js
index a7bb38bd..1ecf5f19 100644
--- a/public/music/js/player/airplay.js
+++ b/public/music/js/player/airplay.js
@@ -1,10 +1,11 @@
// Safari's picker must be opened synchronously from the user's click.
(() => {
- const audio = document.getElementById('audio-player');
+ const audio = window.LXPlayback || document.getElementById('audio-player');
const button = document.getElementById('btn-airplay');
if (!audio || !button || typeof audio.webkitShowPlaybackTargetPicker !== 'function') return;
let available = false;
+ let warnedCapturedRoute = false;
function update() {
const wireless = !!audio.webkitCurrentPlaybackTargetIsWireless;
button.hidden = !available && !wireless;
@@ -13,16 +14,24 @@
button.title = wireless ? 'AirPlay connected' : 'AirPlay';
button.classList.toggle('text-emerald-500', wireless);
window.iOSBackgroundAudio?.setWirelessPlayback(wireless);
+ if (!wireless) warnedCapturedRoute = false;
+ // Control Center can select AirPlay without going through our picker.
+ if (wireless && !warnedCapturedRoute && window.soundEffects?.needsNativeRestore()) {
+ warnedCapturedRoute = true;
+ window.soundEffects.open();
+ window.showInfo?.('请点击“恢复原生播放 / AirPlay”后,重新选择 AirPlay 设备。', 6500);
+ }
}
audio.addEventListener('webkitplaybacktargetavailabilitychanged', event => {
available = event.availability === 'available';
update();
});
audio.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', update);
+ window.addEventListener('lx-audio-replaced', () => { available = false; warnedCapturedRoute = false; update(); });
button.addEventListener('click', () => {
- if (window.soundEffects?.needsNativeReload()) {
+ if (window.soundEffects?.needsNativeRestore()) {
window.soundEffects.open();
- window.showInfo?.('请先点击“刷新并恢复原生播放 / AirPlay”,再选择 AirPlay 设备。', 5000);
+ window.showInfo?.('请先点击“恢复原生播放 / AirPlay”,再选择 AirPlay 设备。', 5000);
return;
}
try { audio.webkitShowPlaybackTargetPicker(); }
diff --git a/public/music/js/player/playback.js b/public/music/js/player/playback.js
index d9e45882..bc6ddcd5 100644
--- a/public/music/js/player/playback.js
+++ b/public/music/js/player/playback.js
@@ -1,27 +1,92 @@
// Keep the player's existing media controls/events independent of the output route.
(() => {
- const element = document.getElementById('audio-player');
+ let element = document.getElementById('audio-player');
if (!element) return;
const events = new EventTarget();
let remote = null;
let localVolumeState = null;
const emit = type => events.dispatchEvent(new Event(type));
- for (const type of ['play', 'playing', 'pause', 'ended', 'error', 'timeupdate', 'loadedmetadata',
- 'durationchange', 'canplay', 'seeked', 'seeking', 'waiting', 'stalled', 'ratechange', 'volumechange']) {
- element.addEventListener(type, () => { if (!remote) emit(type); });
+ const mediaEvents = ['play', 'playing', 'pause', 'ended', 'error', 'timeupdate', 'loadedmetadata',
+ 'durationchange', 'canplay', 'seeked', 'seeking', 'waiting', 'stalled', 'ratechange', 'volumechange',
+ 'webkitplaybacktargetavailabilitychanged', 'webkitcurrentplaybacktargetiswirelesschanged'];
+ function bindElement(media) {
+ for (const type of mediaEvents) media.addEventListener(type, event => {
+ if (remote || media !== element) return;
+ const forwarded = new Event(type);
+ if ('availability' in event) forwarded.availability = event.availability;
+ events.dispatchEvent(forwarded);
+ });
+ }
+ bindElement(element);
+ let seekVersion = 0;
+ let pendingSeek = null;
+ function replaceLocalElement(beforeResume) {
+ if (remote) return false;
+ const previous = element;
+ const source = previous.src || previous.currentSrc;
+ const position = pendingSeek?.element === previous ? pendingSeek.position : previous.currentTime;
+ const playbackRate = previous.playbackRate;
+ const resume = !previous.paused && !previous.ended;
+ const next = previous.cloneNode(false);
+ next.removeAttribute('src');
+ next.srcObject = null;
+ next.volume = previous.volume;
+ next.muted = previous.muted;
+ next.defaultPlaybackRate = previous.defaultPlaybackRate;
+ next.playbackRate = playbackRate;
+ next.loop = previous.loop;
+ for (const key of ['preservesPitch', 'webkitPreservesPitch', 'mozPreservesPitch']) {
+ if (key in previous) next[key] = previous[key];
+ }
+ next.autoplay = false;
+ previous.replaceWith(next);
+ element = next;
+ const version = ++seekVersion;
+ pendingSeek = source ? { element: next, position, duration: previous.duration } : null;
+ bindElement(next);
+ // Old pause/error events must never reach queue recovery or auto-advance.
+ previous.pause();
+ previous.removeAttribute('src');
+ previous.srcObject = null;
+ previous.load();
+ beforeResume?.();
+ window.dispatchEvent(new CustomEvent('lx-audio-replaced'));
+ if (source) {
+ next.addEventListener('loadedmetadata', () => {
+ if (next !== element || version !== seekVersion) return;
+ if (Number.isFinite(position) && position > 0) {
+ next.currentTime = Number.isFinite(next.duration) ? Math.min(position, next.duration) : position;
+ }
+ pendingSeek = null;
+ }, { once: true });
+ next.src = source;
+ next.load();
+ // load() resets playbackRate to defaultPlaybackRate in native media elements.
+ next.playbackRate = playbackRate;
+ // Call play in the original click gesture, without waiting for metadata.
+ if (resume) next.play().catch(error => {
+ if (next !== element || version !== seekVersion || error.name === 'AbortError') return;
+ emit('pause');
+ window.showInfo?.('已恢复原生播放,请点击播放继续。');
+ });
+ }
+ return true;
}
const properties = new Set(['src', 'currentSrc', 'currentTime', 'duration', 'paused', 'ended',
'volume', 'muted', 'playbackRate', 'error', 'readyState']);
window.LXPlayback = new Proxy(element, {
get(target, key) {
+ if (key === 'replaceLocalElement') return replaceLocalElement;
if (key === 'isRemote') return !!remote;
if (key === 'emit') return emit;
if (key === 'addEventListener' || key === 'removeEventListener') return events[key].bind(events);
if (key === 'connectRemote') return output => {
- const snapshot = { src: element.currentSrc || element.src, currentTime: element.currentTime,
+ const snapshot = { src: element.src || element.currentSrc, currentTime: pendingSeek?.position ?? element.currentTime,
paused: element.paused, volume: element.volume, muted: element.muted, playbackRate: element.playbackRate };
if (!remote) localVolumeState = { volume: element.volume, muted: element.muted };
remote = output;
+ seekVersion++;
+ pendingSeek = null;
element.pause(); // Suppressed locally; the receiver will supply playback events.
return snapshot;
};
@@ -48,12 +113,19 @@
const value = remote[key];
return typeof value === 'function' ? value.bind(remote) : value;
}
- const value = Reflect.get(target, key, target);
- return typeof value === 'function' ? value.bind(target) : value;
+ if (pendingSeek?.element === element) {
+ if (key === 'currentTime') return pendingSeek.position;
+ if (key === 'duration') return pendingSeek.duration;
+ }
+ const value = Reflect.get(element, key, element);
+ return typeof value === 'function' ? value.bind(element) : value;
},
set(target, key, value) {
if (remote && properties.has(key)) remote[key] = value;
- else Reflect.set(target, key, value, target);
+ else {
+ if (key === 'src' || key === 'currentTime') { seekVersion++; pendingSeek = null; }
+ Reflect.set(element, key, value, element);
+ }
return true;
}
});
diff --git a/public/music/js/player/sound-effects.js b/public/music/js/player/sound-effects.js
index d56164ad..83b14bf1 100644
--- a/public/music/js/player/sound-effects.js
+++ b/public/music/js/player/sound-effects.js
@@ -56,6 +56,16 @@ window.soundEffects = (function () {
let isGraphBuilt = false;
// Mobile starts native on every page load. Only a local effect edit opts into DSP.
let mobileEffectsEnabled = false;
+ // Native restoration must survive later playback, resize and reload events.
+ let preferNativePlayback = false;
+ try {
+ preferNativePlayback = sessionStorage.getItem('lx_native_audio_mode') === '1' ||
+ sessionStorage.getItem('lx_native_audio_restore') === '1';
+ } catch (_) { }
+
+ function needsNativeRestore() {
+ return !!(mediaSource || window._sharedAudioSourceNode);
+ }
function hasActiveEffects() {
if (settings.enabled === false) return false;
@@ -87,7 +97,7 @@ window.soundEffects = (function () {
function canProcessLocally() {
// Capturing a mobile media element cannot be undone by disconnecting Web Audio.
// Keep native playback until an explicit local effect edit opts into DSP.
- return (isGraphBuilt || mobileEffectsEnabled || !isMobileDevice()) &&
+ return !preferNativePlayback && (isGraphBuilt || mobileEffectsEnabled || !isMobileDevice()) &&
!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless &&
!window.LXPlayback?.isRemote;
}
@@ -207,10 +217,12 @@ window.soundEffects = (function () {
if (pitchShifterNode) return pitchShifterNode;
if (pitchShifterInitPromise) return pitchShifterInitPromise;
+ const context = audioContext;
pitchShifterInitPromise = (async () => {
try {
console.log('[SoundEffects] Loading Pitch Shifter Module from /music/js/player/pitch-shifter/phase-vocoder.js');
- await audioContext.audioWorklet.addModule('/music/js/player/pitch-shifter/phase-vocoder.js');
+ await context.audioWorklet.addModule('/music/js/player/pitch-shifter/phase-vocoder.js');
+ if (context !== audioContext) return null;
if (!canProcessLocally()) {
pitchShifterInitPromise = null;
return null;
@@ -237,6 +249,7 @@ window.soundEffects = (function () {
}
return pitchShifterNode;
} catch (e) {
+ if (context !== audioContext) return null;
console.error('[SoundEffects] Failed to initialize pitch shifter:', e);
pitchShifterInitPromise = null;
return null;
@@ -257,7 +270,7 @@ window.soundEffects = (function () {
pitchShifterNode.connect(pannerNode);
} else {
initPitchShifter().then(node => {
- if (canProcessLocally() && node && settings.pitch !== 1.0) {
+ if (canProcessLocally() && node && node === pitchShifterNode && settings.pitch !== 1.0) {
try { mixerNode.disconnect(pannerNode); } catch (e) { }
try { mixerNode.disconnect(node); } catch (e) { }
try { node.disconnect(pannerNode); } catch (e) { }
@@ -384,6 +397,7 @@ window.soundEffects = (function () {
const impulseBuffers = new Map();
function updateReverb() {
if (!canProcessLocally() || !convolverNode || !audioContext) return;
+ const context = audioContext;
const request = ++reverbRequest;
const rev = reverbOptions.find(r => r.id === settings.reverb.id);
@@ -399,8 +413,11 @@ window.soundEffects = (function () {
if (!r.ok) throw new Error(`Impulse response HTTP ${r.status}`);
return r.arrayBuffer();
})
- .then(data => audioContext.decodeAudioData(data))
- .catch(error => { impulseBuffers.delete(rev.source); throw error; });
+ .then(data => context.decodeAudioData(data))
+ .catch(error => {
+ if (impulseBuffers.get(rev.source) === pending) impulseBuffers.delete(rev.source);
+ throw error;
+ });
impulseBuffers.set(rev.source, pending);
}
impulseBuffers.get(rev.source)
@@ -409,7 +426,9 @@ window.soundEffects = (function () {
convolverNode.buffer = buffer;
if (dryGainNode) dryGainNode.gain.value = settings.reverb.mainGain;
if (wetGainNode) wetGainNode.gain.value = settings.reverb.sendGain;
- }).catch(error => console.warn('[SoundEffects] Reverb load failed:', error));
+ }).catch(error => {
+ if (context === audioContext) console.warn('[SoundEffects] Reverb load failed:', error);
+ });
}
}
@@ -465,29 +484,6 @@ window.soundEffects = (function () {
}
}
- function animateSlider(id, targetValue, duration = 300) {
- const slider = document.getElementById(id);
- if (!slider) return;
- const startValue = parseFloat(slider.value);
- const startTime = performance.now();
-
- function update(currentTime) {
- const elapsed = currentTime - startTime;
- const progress = Math.min(elapsed / duration, 1);
- const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic
- const currentValue = startValue + (targetValue - startValue) * ease;
- slider.value = currentValue;
-
- // Trigger the oninput handler to update labels and logic
- if (slider.oninput) slider.oninput({ target: slider });
-
- if (progress < 1) {
- requestAnimationFrame(update);
- }
- }
- requestAnimationFrame(update);
- }
-
function applyPitch() {
if (canProcessLocally() && pitchFactorParam) {
// Use setTargetAtTime for immediate and smooth Web Audio update
@@ -498,13 +494,25 @@ window.soundEffects = (function () {
}
function renderStatus() {
- const enabled = settings.enabled !== false;
+ const remote = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless || !!window.LXPlayback?.isRemote;
+ const native = preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled);
+ const enabled = settings.enabled !== false && !native && !remote;
const toggle = document.getElementById('sound-effects-enable');
- if (toggle) toggle.checked = enabled;
+ if (toggle) {
+ toggle.checked = enabled;
+ toggle.disabled = remote;
+ }
const title = document.getElementById('sound-effects-state');
- if (title) title.textContent = enabled ? '音效已开启' : '音效已暂停';
+ if (title) title.textContent = enabled ? '音效已开启' : '音效已关闭';
const switchLabel = document.getElementById('sound-effects-switch-label');
if (switchLabel) switchLabel.textContent = enabled ? '音效开' : '音效关';
+ const mode = document.getElementById('sound-effects-mode');
+ if (mode) {
+ mode.hidden = enabled;
+ mode.textContent = remote ? '无线投播 · 本机音效未启用' : native ? '原生播放 · 音效未接管' : '音效已旁路';
+ }
+ const controls = document.getElementById('sound-effects-controls');
+ if (controls) controls.disabled = !enabled;
const tagsContainer = document.getElementById('sound-effects-tags');
if (tagsContainer) {
@@ -587,24 +595,31 @@ window.soundEffects = (function () {
}
tagsContainer.innerHTML = html;
}
+ tagsContainer.querySelectorAll('button').forEach(button => { button.disabled = !enabled; });
}
}
function renderUI() {
renderStatus();
- const remote = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless || !!window.LXPlayback?.isRemote;
+ const wireless = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless;
+ const remote = wireless || !!window.LXPlayback?.isRemote;
+ const captured = needsNativeRestore();
const status = document.getElementById('sound-effects-status');
- if (status) status.textContent = remote
- ? 'AirPlay / 投放中:仅保存音效配置'
+ if (status) status.textContent = wireless && captured
+ ? '请恢复原生播放后,重新选择 AirPlay 设备'
+ : remote
+ ? '已保留音效配置;结束投播后可开启本机音效'
+ : preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled)
+ ? '已保留全部配置,仅供预览;打开音效开关后按当前配置接管本机音频'
: settings.enabled === false
- ? '正在播放原始音色,重新开启即可恢复以上设置'
- : isGraphBuilt
- ? (isMobileDevice() ? '本机音效已开启' : '')
+ ? '已保留全部配置,仅供预览;打开音效开关后恢复当前配置'
+ : captured
+ ? '本机音效或可视化已开启;使用 AirPlay 前请恢复原生播放'
: isMobileDevice()
? '选择音效后立即生效'
: '';
const nativeButton = document.getElementById('sound-effects-native');
- if (nativeButton) nativeButton.hidden = !isGraphBuilt || !isMobileDevice();
+ if (nativeButton) nativeButton.hidden = !captured;
// EQ Presets
const presetContainer = document.getElementById('eq-presets');
if (presetContainer) {
@@ -734,10 +749,19 @@ window.soundEffects = (function () {
}
}
- function ensureLocalGraph() {
+ function ensureLocalGraph(explicitEnable = false) {
const audio = document.getElementById('audio-player');
if (audio?.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) return false;
- if (isMobileDevice() && hasActiveEffects()) mobileEffectsEnabled = true;
+ if ((explicitEnable || hasActiveEffects()) && preferNativePlayback) {
+ // Only an explicit local edit opts back into processing. Remote edits
+ // and saved/server-loaded settings must leave native output alone.
+ try {
+ sessionStorage.removeItem('lx_native_audio_mode');
+ sessionStorage.removeItem('lx_native_audio_restore');
+ } catch (_) { }
+ preferNativePlayback = false;
+ }
+ if (isMobileDevice() && (explicitEnable || hasActiveEffects())) mobileEffectsEnabled = true;
if (!canProcessLocally()) return false;
if (!audioContext || !isGraphBuilt) init(true);
if (audioContext && ['suspended', 'interrupted'].includes(audioContext.state)) {
@@ -746,13 +770,41 @@ window.soundEffects = (function () {
return !!audioContext && isGraphBuilt;
}
+ function releaseAudioGraph() {
+ ++reverbRequest;
+ impulseBuffers.clear();
+ if (pannerInfo.interval) clearInterval(pannerInfo.interval);
+ pannerInfo.interval = null;
+ window.iOSBackgroundAudio?.dispose();
+ window.musicVisualizer?.releaseAudio();
+ for (const node of [mediaSource, analyser, ...eqFilters, convolverNode, dryGainNode,
+ wetGainNode, mixerNode, pannerNode, effectsGain, bypassGain, pitchShifterNode]) {
+ try { node?.disconnect(); } catch (_) { }
+ }
+ const context = audioContext || window._sharedAudioContext;
+ audioContext = mediaSource = analyser = null;
+ convolverNode = dryGainNode = wetGainNode = mixerNode = pannerNode = null;
+ effectsGain = bypassGain = pitchShifterNode = pitchFactorParam = null;
+ pitchShifterInitPromise = null;
+ eqFilters = [];
+ isGraphBuilt = mobileEffectsEnabled = false;
+ for (const key of ['_sharedAudioContext', '_sharedAudioSourceNode', '_sharedAudioAnalyser',
+ '_sharedSplitterNode', '_sharedAnalyserL', '_sharedAnalyserR', '_soundEffectsGains']) window[key] = null;
+ try { context?.close()?.catch(() => {}); } catch (_) { }
+ }
+
const manager = {
init,
setEnabled: function (enabled) {
if (!isGraphBuilt) loadSettings();
settings.enabled = !!enabled;
saveSettings();
- if (enabled) ensureLocalGraph();
+ if (!enabled && !window.LXPlayback?.isRemote) {
+ this.restoreNativePlayback();
+ renderUI();
+ return;
+ }
+ if (enabled) ensureLocalGraph(true);
if (canProcessLocally() && isGraphBuilt) {
applyMaster();
updatePanner();
@@ -834,11 +886,10 @@ window.soundEffects = (function () {
if (rev) {
settings.reverb.mainGain = rev.main;
settings.reverb.sendGain = rev.send;
- animateSlider('reverb-main-gain', rev.main * 100);
- animateSlider('reverb-send-gain', rev.send * 100);
}
saveSettings();
- if (ensureLocalGraph()) updateReverb();
+ // Disabling one effect must not activate other saved effects in native mode.
+ if (id === 'none' ? canProcessLocally() && isGraphBuilt : ensureLocalGraph()) updateReverb();
renderUI();
},
resetReverb: function () {
@@ -849,8 +900,6 @@ window.soundEffects = (function () {
settings.reverb.id = 'none';
settings.reverb.mainGain = 1.0;
settings.reverb.sendGain = 0;
- animateSlider('reverb-main-gain', 100);
- animateSlider('reverb-send-gain', 0);
settings.eq = Array(10).fill(0);
activePresetName = '';
settings.pitch = 1.0;
@@ -911,13 +960,26 @@ window.soundEffects = (function () {
renderUI();
},
isGraphBuilt: () => isGraphBuilt,
- needsNativeReload: () => isMobileDevice() && isGraphBuilt,
+ isNativePlaybackPreferred: () => preferNativePlayback,
+ needsNativeRestore,
restoreNativePlayback: function () {
- if (!this.needsNativeReload()) return;
- // Disconnecting nodes cannot release a captured media element.
- window.savePlaybackState?.();
- sessionStorage.setItem('lx_native_audio_restore', '1');
- window.location.reload();
+ if (window.LXPlayback?.isRemote) return false;
+ const captured = needsNativeRestore();
+ if (captured && !window.LXPlayback?.replaceLocalElement) {
+ window.showError?.('无法切换音频输出,请重新打开播放器后重试。');
+ return false;
+ }
+ preferNativePlayback = true;
+ // Persistence is optional: live handoff must work even if storage is blocked.
+ try {
+ sessionStorage.setItem('lx_native_audio_mode', '1');
+ sessionStorage.removeItem('lx_native_audio_restore');
+ } catch (_) { }
+ if (captured) {
+ window.LXPlayback.replaceLocalElement(releaseAudioGraph);
+ }
+ renderUI();
+ return true;
},
getContext: () => audioContext || window._sharedAudioContext || null,
getSourceNode: () => mediaSource || window._sharedAudioSourceNode || null,
@@ -1015,7 +1077,8 @@ window.soundEffects = (function () {
if (isGraphBuilt && canProcessLocally()) applySettings();
renderUI();
};
- document.getElementById('audio-player')?.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', restoreLocalEffects);
+ (window.LXPlayback || document.getElementById('audio-player'))?.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', restoreLocalEffects);
+ window.addEventListener('lx-audio-replaced', renderUI);
window.addEventListener('lx-cast-route', restoreLocalEffects);
// Reverb Gain Sliders
const mainGain = document.getElementById('reverb-main-gain');
diff --git a/public/music/js/player/visualizer.js b/public/music/js/player/visualizer.js
index 9cb21435..0059d47f 100644
--- a/public/music/js/player/visualizer.js
+++ b/public/music/js/player/visualizer.js
@@ -1075,7 +1075,7 @@ const musicVisualizer = (function () {
let standaloneFooterVU = null;
let standaloneDetailVU = null;
- const audio = document.getElementById('audio-player');
+ const audio = window.LXPlayback || document.getElementById('audio-player');
const footerCanvas = document.getElementById('footer-visualizer');
const detailCanvas = document.getElementById('detail-visualizer');
const playerFooter = document.getElementById('player-footer');
@@ -1278,13 +1278,18 @@ const musicVisualizer = (function () {
const isAnyVisualizerWanted = !isSuppressed && (isFooterOn || isDetailOn);
const hasEffectsAnalyser = window.soundEffects && window.soundEffects.getAnalyser && window.soundEffects.getAnalyser();
+ if (window.soundEffects?.isNativePlaybackPreferred?.()) {
+ triggerStandaloneLoop();
+ return;
+ }
+
if (!isAnyVisualizerWanted && !hasEffectsAnalyser) {
console.log('[Visualizer] Visualizer disabled or suppressed by iOS adaptive setting. Skipping AudioContext creation.');
return;
}
// AirPlay / 无线投送期间本地不创建音频流
- if (audio.webkitCurrentPlaybackTargetIsWireless) {
+ if (audio.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) {
return;
}
@@ -1300,7 +1305,7 @@ const musicVisualizer = (function () {
audioSource = window.soundEffects.getSourceNode ? window.soundEffects.getSourceNode() : window._sharedAudioSourceNode;
console.log('[Visualizer] Using analyser from SoundEffectsManager (exclusive output)');
} else {
- audioSource = window._sharedAudioSourceNode || (window._sharedAudioSourceNode = audioContext.createMediaElementSource(audio));
+ audioSource = window._sharedAudioSourceNode || (window._sharedAudioSourceNode = audioContext.createMediaElementSource(document.getElementById('audio-player')));
audioAnalyser = window._sharedAudioAnalyser || (window._sharedAudioAnalyser = audioContext.createAnalyser());
try { audioSource.disconnect(); } catch (_) { }
@@ -1699,7 +1704,21 @@ const musicVisualizer = (function () {
if (vContainer) resizeObserver.observe(vContainer);
}
- return { init, applySettings, syncSize, clear };
+ function releaseAudio() {
+ waveFooter?.dispose();
+ waveDetail?.dispose();
+ if (standaloneRafId) cancelAnimationFrame(standaloneRafId);
+ standaloneRafId = null;
+ for (const node of [audioSource, audioAnalyser, splitterNode, analyserL, analyserR]) {
+ try { node?.disconnect(); } catch (_) { }
+ }
+ audioContext = audioSource = audioAnalyser = null;
+ splitterNode = analyserL = analyserR = null;
+ waveFooter = waveDetail = null;
+ isInitialized = false;
+ }
+ window.addEventListener('lx-audio-replaced', () => { init(); applySettings(); });
+ return { init, applySettings, syncSize, clear, releaseAudio };
})();
window.musicVisualizer = musicVisualizer;
diff --git a/public/music/js/vendor/wave.js b/public/music/js/vendor/wave.js
index 15a8f1e5..b0937d2b 100644
--- a/public/music/js/vendor/wave.js
+++ b/public/music/js/vendor/wave.js
@@ -1 +1 @@
-!function(t,o){if("object"==typeof exports&&"object"==typeof module)module.exports=o();else if("function"==typeof define&&define.amd)define([],o);else{var i=o();for(var n in i)("object"==typeof exports?exports:t)[n]=i[n]}}(this,(()=>(()=>{"use strict";var t={935:function(t,o,i){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var o,i=1,n=arguments.length;i{Object.defineProperty(o,"__esModule",{value:!0}),o.AudioData=void 0;var i=function(){function t(t){this.data=t}return t.prototype.setFrequencyBand=function(t){var o=Math.floor(.0625*this.data.length),i=Math.floor(.0625*this.data.length),n=Math.floor(.375*this.data.length),s={base:this.data.slice(0,o),lows:this.data.slice(o+1,o+i),mids:this.data.slice(o+i+1,o+i+n),highs:this.data.slice(o+i+n+1)};this.data=s[t]},t.prototype.scaleData=function(t){t<255&&(this.data=this.data.map((function(o){var i=Math.round(o/255*100)/100;return t*i})))},t}();o.AudioData=i},426:function(t,o){var i=this&&this.__awaiter||function(t,o,i,n){return new(i||(i=Promise))((function(s,a){function e(t){try{h(n.next(t))}catch(t){a(t)}}function r(t){try{h(n.throw(t))}catch(t){a(t)}}function h(t){var o;t.done?s(t.value):(o=t.value,o instanceof i?o:new i((function(t){t(o)}))).then(e,r)}h((n=n.apply(t,o||[])).next())}))},n=this&&this.__generator||function(t,o){var i,n,s,a,e={label:0,sent:function(){if(1&s[0])throw s[1];return s[1]},trys:[],ops:[]};return a={next:r(0),throw:r(1),return:r(2)},"function"==typeof Symbol&&(a[Symbol.iterator]=function(){return this}),a;function r(a){return function(r){return function(a){if(i)throw new TypeError("Generator is already executing.");for(;e;)try{if(i=1,n&&(s=2&a[0]?n.return:a[0]?n.throw||((s=n.return)&&s.call(n),0):n.next)&&!(s=s.call(n,a[1])).done)return s;switch(n=0,s&&(a=[2&a[0],s.value]),a[0]){case 0:case 1:s=a;break;case 4:return e.label++,{value:a[1],done:!1};case 5:e.label++,n=a[1],a=[0];continue;case 7:a=e.ops.pop(),e.trys.pop();continue;default:if(!((s=(s=e.trys).length>0&&s[s.length-1])||6!==a[0]&&2!==a[0])){e=0;continue}if(3===a[0]&&(!s||a[1]>s[0]&&a[1]{var t=n;Object.defineProperty(t,"__esModule",{value:!0}),t.Wave=void 0;var o=i(935),s=i(519),a=i(938),e=i(540),r=i(522),h=i(658),u=i(817),c=i(123),l=i(270),p=i(857),d=function(){function t(t,i,n){void 0===n&&(n=!1);var d=this;this.animations={Arcs:o.Arcs,Circles:s.Circles,Cubes:a.Cubes,Flower:e.Flower,Glob:r.Glob,Lines:h.Lines,Shine:u.Shine,Square:c.Square,Turntable:l.Turntable,Wave:p.Wave},this._activeAnimations=[],this._canvasElement=i,this._canvasContext=this._canvasElement.getContext("2d"),this._muteAudio=n,this._interacted=!1,t instanceof HTMLAudioElement?(this._audioElement=t,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)?["touchstart","touchmove","touchend","mouseup","click"].forEach((function(t){document.body.addEventListener(t,(function(){return d.connectAnalyser()}),{once:!0})})):this._audioElement.addEventListener("play",(function(){return d.connectAnalyser()}),{once:!0})):t instanceof AnalyserNode?(this._audioAnalyser=t,this._audioContext=null,this._audioSource=null,this._play()):t&&(this._audioContext=t.context,this._audioSource=t.source,this._audioAnalyser=this._audioContext.createAnalyser(),this._play())}return t.prototype.connectAnalyser=function(){this._interacted||(this._interacted=!0,this._audioContext=new AudioContext,this._audioSource=this._audioContext.createMediaElementSource(this._audioElement),this._audioAnalyser=this._audioContext.createAnalyser(),this._play())},t.prototype._play=function(){var t=this;this._audioSource&&(this._audioSource.connect(this._audioAnalyser),this._muteAudio||this._audioSource.connect(this._audioContext.destination)),this._audioAnalyser.smoothingTimeConstant=.85,this._audioAnalyser.fftSize=1024;var o=new Uint8Array(this._audioAnalyser.frequencyBinCount),i=function(){t._audioAnalyser.getByteFrequencyData(o),t._canvasContext.clearRect(0,0,t._canvasContext.canvas.width,t._canvasContext.canvas.height),t._activeAnimations.forEach((function(i){i.draw(o,t._canvasContext)})),window.requestAnimationFrame(i)};i()},t.prototype.addAnimation=function(t){this._activeAnimations.push(t)},t.prototype.clearAnimations=function(){this._activeAnimations=[]},t}();t.Wave=d})(),n})()));
\ No newline at end of file
+!function(t,o){if("object"==typeof exports&&"object"==typeof module)module.exports=o();else if("function"==typeof define&&define.amd)define([],o);else{var i=o();for(var n in i)("object"==typeof exports?exports:t)[n]=i[n]}}(this,(()=>(()=>{"use strict";var t={935:function(t,o,i){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var o,i=1,n=arguments.length;i{Object.defineProperty(o,"__esModule",{value:!0}),o.AudioData=void 0;var i=function(){function t(t){this.data=t}return t.prototype.setFrequencyBand=function(t){var o=Math.floor(.0625*this.data.length),i=Math.floor(.0625*this.data.length),n=Math.floor(.375*this.data.length),s={base:this.data.slice(0,o),lows:this.data.slice(o+1,o+i),mids:this.data.slice(o+i+1,o+i+n),highs:this.data.slice(o+i+n+1)};this.data=s[t]},t.prototype.scaleData=function(t){t<255&&(this.data=this.data.map((function(o){var i=Math.round(o/255*100)/100;return t*i})))},t}();o.AudioData=i},426:function(t,o){var i=this&&this.__awaiter||function(t,o,i,n){return new(i||(i=Promise))((function(s,a){function e(t){try{h(n.next(t))}catch(t){a(t)}}function r(t){try{h(n.throw(t))}catch(t){a(t)}}function h(t){var o;t.done?s(t.value):(o=t.value,o instanceof i?o:new i((function(t){t(o)}))).then(e,r)}h((n=n.apply(t,o||[])).next())}))},n=this&&this.__generator||function(t,o){var i,n,s,a,e={label:0,sent:function(){if(1&s[0])throw s[1];return s[1]},trys:[],ops:[]};return a={next:r(0),throw:r(1),return:r(2)},"function"==typeof Symbol&&(a[Symbol.iterator]=function(){return this}),a;function r(a){return function(r){return function(a){if(i)throw new TypeError("Generator is already executing.");for(;e;)try{if(i=1,n&&(s=2&a[0]?n.return:a[0]?n.throw||((s=n.return)&&s.call(n),0):n.next)&&!(s=s.call(n,a[1])).done)return s;switch(n=0,s&&(a=[2&a[0],s.value]),a[0]){case 0:case 1:s=a;break;case 4:return e.label++,{value:a[1],done:!1};case 5:e.label++,n=a[1],a=[0];continue;case 7:a=e.ops.pop(),e.trys.pop();continue;default:if(!((s=(s=e.trys).length>0&&s[s.length-1])||6!==a[0]&&2!==a[0])){e=0;continue}if(3===a[0]&&(!s||a[1]>s[0]&&a[1]{var t=n;Object.defineProperty(t,"__esModule",{value:!0}),t.Wave=void 0;var o=i(935),s=i(519),a=i(938),e=i(540),r=i(522),h=i(658),u=i(817),c=i(123),l=i(270),p=i(857),d=function(){function t(t,i,n){void 0===n&&(n=!1);var d=this;this.animations={Arcs:o.Arcs,Circles:s.Circles,Cubes:a.Cubes,Flower:e.Flower,Glob:r.Glob,Lines:h.Lines,Shine:u.Shine,Square:c.Square,Turntable:l.Turntable,Wave:p.Wave},this._activeAnimations=[],this._canvasElement=i,this._canvasContext=this._canvasElement.getContext("2d"),this._muteAudio=n,this._interacted=!1,t instanceof HTMLAudioElement?(this._audioElement=t,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)?["touchstart","touchmove","touchend","mouseup","click"].forEach((function(t){document.body.addEventListener(t,(function(){return d.connectAnalyser()}),{once:!0})})):this._audioElement.addEventListener("play",(function(){return d.connectAnalyser()}),{once:!0})):t instanceof AnalyserNode?(this._audioAnalyser=t,this._audioContext=null,this._audioSource=null,this._play()):t&&(this._audioContext=t.context,this._audioSource=t.source,this._audioAnalyser=this._audioContext.createAnalyser(),this._play())}return t.prototype.connectAnalyser=function(){this._interacted||(this._interacted=!0,this._audioContext=new AudioContext,this._audioSource=this._audioContext.createMediaElementSource(this._audioElement),this._audioAnalyser=this._audioContext.createAnalyser(),this._play())},t.prototype._play=function(){var t=this;this._audioSource&&(this._audioSource.connect(this._audioAnalyser),this._muteAudio||this._audioSource.connect(this._audioContext.destination)),this._audioAnalyser.smoothingTimeConstant=.85,this._audioAnalyser.fftSize=1024;var o=new Uint8Array(this._audioAnalyser.frequencyBinCount),i=function(){if(t._disposed)return;t._audioAnalyser.getByteFrequencyData(o),t._canvasContext.clearRect(0,0,t._canvasContext.canvas.width,t._canvasContext.canvas.height),t._activeAnimations.forEach((function(i){i.draw(o,t._canvasContext)})),t._rafId=window.requestAnimationFrame(i)};i()},t.prototype.dispose=function(){this._disposed=!0,window.cancelAnimationFrame(this._rafId),this._activeAnimations=[]},t.prototype.addAnimation=function(t){this._activeAnimations.push(t)},t.prototype.clearAnimations=function(){this._activeAnimations=[]},t}();t.Wave=d})(),n})()));
\ No newline at end of file
diff --git a/public/music/sw.js b/public/music/sw.js
index f4c1bf06..79fd8bfb 100644
--- a/public/music/sw.js
+++ b/public/music/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'lx-music-web-v58';
+const CACHE_NAME = 'lx-music-web-v62';
// Refresh cached assets after organizing JavaScript modules by feature.
const ASSETS_TO_CACHE = [
'./',
diff --git a/test/airplay-native-handoff.test.cjs b/test/airplay-native-handoff.test.cjs
new file mode 100644
index 00000000..243ed7c5
--- /dev/null
+++ b/test/airplay-native-handoff.test.cjs
@@ -0,0 +1,738 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { readFileSync } = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+// Exercise the real routing modules together. Fake Web Audio records capture and
+// connections; these tests do not establish physical receiver/audio delivery.
+const playerRoot = path.resolve(__dirname, '../public/music/js/player');
+const sources = ['playback', 'visualizer', 'ios-background-audio', 'sound-effects', 'airplay']
+ .map(name => [name, readFileSync(path.join(playerRoot, `${name}.js`), 'utf8')]);
+
+class Events {
+ constructor() { this.listeners = new Map(); }
+ addEventListener(name, listener, options) {
+ if (options?.once) {
+ const original = listener;
+ listener = event => { this.removeEventListener(name, listener); original(event); };
+ }
+ if (!this.listeners.has(name)) this.listeners.set(name, new Set());
+ this.listeners.get(name).add(listener);
+ }
+ removeEventListener(name, listener) { this.listeners.get(name)?.delete(listener); }
+ dispatchEvent(event) { this.dispatch(event.type, event); return true; }
+ dispatch(name, event = {}) {
+ for (const listener of this.listeners.get(name) || []) listener(event);
+ }
+}
+
+function classes(initial = []) {
+ const values = new Set(initial);
+ return {
+ add: (...names) => names.forEach(name => values.add(name)),
+ remove: (...names) => names.forEach(name => values.delete(name)),
+ contains: name => values.has(name),
+ toggle(name, enabled) {
+ if (enabled === undefined) enabled = !values.has(name);
+ if (enabled) values.add(name); else values.delete(name);
+ }
+ };
+}
+
+function mediaElement() {
+ return Object.assign(new Events(), {
+ paused: true, style: {}, attributes: {}, src: '', currentSrc: '', currentTime: 0,
+ duration: 180, volume: 0.7, muted: false, playbackRate: 1, defaultPlaybackRate: 1,
+ readyState: 0,
+ setAttribute(name, value) { this.attributes[name] = value; },
+ play() { this.paused = false; this.dispatch('play'); return Promise.resolve(); },
+ removeAttribute(name) { delete this.attributes[name]; if (name === 'src') this.src = ''; },
+ cloneNode() { const next = mediaElement(); next.attributes = { ...this.attributes }; return next; },
+ load() { this.playbackRate = this.defaultPlaybackRate; },
+ pause() { this.paused = true; this.dispatch('pause'); }, remove() {}
+ });
+}
+
+function fixture({ device = 'iphone', adaptive = true, nativeMode = false,
+ wireless = false, cast = false, failSessionWrites = false,
+ local = new Map(), session = new Map(), sliders = false } = {}) {
+ let frames = [];
+ const observations = { captures: 0, picked: 0, reloads: 0, saves: 0,
+ errors: [], info: [], userErrors: [], waveAnalysers: [], closedContexts: 0, worklets: [] };
+ if (nativeMode) session.set('lx_native_audio_mode', '1');
+ const storage = map => ({
+ getItem: key => map.get(key) ?? null,
+ setItem(key, value) {
+ if (map === session && (failSessionWrites === true || failSessionWrites === key)) throw new Error('Storage unavailable');
+ map.set(key, String(value));
+ },
+ removeItem: key => map.delete(key)
+ });
+ const audio = Object.assign(mediaElement(), {
+ paused: false, webkitCurrentPlaybackTargetIsWireless: wireless,
+ webkitShowPlaybackTargetPicker() { observations.picked++; }
+ });
+ const button = Object.assign(mediaElement(), { hidden: true, disabled: true, classList: classes() });
+ const modal = { classList: classes(['hidden']) };
+ const nativeButton = { hidden: true };
+ const elements = {
+ 'audio-player': audio, 'btn-airplay': button,
+ 'sound-effects-modal': modal, 'sound-effects-native': nativeButton,
+ 'sound-effects-status': {}, 'sound-effects-state': {},
+ 'sound-effects-enable': {}, 'sound-effects-switch-label': {},
+ 'sound-effects-mode': {}, 'sound-effects-controls': {}
+ };
+ const install = media => {
+ media.replaceWith = next => { elements['audio-player'] = next; install(next); };
+ media.webkitShowPlaybackTargetPicker = () => observations.picked++;
+ };
+ install(audio);
+ if (sliders) {
+ for (const type of ['main', 'send']) {
+ elements[`reverb-${type}-gain`] = { value: type === 'main' ? 100 : 0, style: {} };
+ elements[`reverb-${type}-gain-val`] = {};
+ }
+ }
+ const document = Object.assign(new Events(), {
+ readyState: 'loading', visibilityState: 'visible',
+ getElementById: id => elements[id] || null,
+ createElement: () => mediaElement(),
+ body: { appendChild() {} }, documentElement: { classList: classes() },
+ querySelectorAll: () => [], querySelector: () => null
+ });
+ const parameter = () => ({
+ value: 0,
+ cancelScheduledValues() { return this; },
+ setValueAtTime(value) { this.value = value; return this; },
+ linearRampToValueAtTime(value) { this.value = value; return this; },
+ setTargetAtTime(value) { this.value = value; return this; }
+ });
+ const node = () => ({
+ connections: new Set(),
+ connect(target) { this.connections.add(target); },
+ disconnect(target) { if (target) this.connections.delete(target); else this.connections.clear(); },
+ gain: parameter(), frequency: parameter(), Q: parameter(),
+ positionX: parameter(), positionY: parameter(), positionZ: parameter()
+ });
+ class Context extends Events {
+ constructor() {
+ super(); this.state = 'running'; this.currentTime = 0; this.destination = node();
+ // Loading DSP code is outside the native-routing seam under test.
+ this.audioWorklet = { addModule: () => new Promise(resolve => observations.worklets.push(resolve)) };
+ }
+ close() { this.state = 'closed'; observations.closedContexts++; return Promise.resolve(); }
+ createMediaElementSource(media) {
+ assert.equal(media, elements['audio-player'], 'capture must use the actual player media element');
+ observations.captures++; return node();
+ }
+ createAnalyser() { return node(); }
+ createChannelSplitter() { return node(); }
+ createBiquadFilter() { return node(); }
+ createConvolver() { return node(); }
+ createGain() { return node(); }
+ createPanner() { return node(); }
+ createMediaStreamDestination() { return Object.assign(node(), { stream: {} }); }
+ resume() { this.state = 'running'; return Promise.resolve(); }
+ }
+ class Wave {
+ constructor(analyser) { observations.waveAnalysers.push(analyser); }
+ clearAnimations() {} addAnimation() {} dispose() {}
+ }
+ const mobile = device !== 'desktop';
+ const window = Object.assign(new Events(), {
+ innerWidth: mobile ? 390 : 1440, innerHeight: 900,
+ settings: { showFooterVisualizer: true, iosAdaptiveVisualizer: adaptive },
+ AudioContext: Context, Wave, LXPlayback: { isRemote: cast },
+ savePlaybackState: () => { observations.saves++; return true; },
+ showInfo: message => observations.info.push(message),
+ showError: message => observations.userErrors.push(message),
+ location: { reload: () => observations.reloads++ }
+ });
+ const userAgent = { iphone: 'iPhone Safari', android: 'Android Chrome', desktop: 'Macintosh Safari' }[device];
+ const quiet = { log() {}, info() {},
+ warn: (...args) => observations.errors.push(args.join(' ')),
+ error: (...args) => observations.errors.push(args.join(' ')) };
+ const context = vm.createContext({
+ window, document, navigator: { userAgent, platform: device === 'iphone' ? 'iPhone' : 'MacIntel', maxTouchPoints: mobile ? 5 : 0 },
+ console: quiet, Wave, Event, EventTarget,
+ CustomEvent: class extends Event {}, localStorage: storage(local), sessionStorage: storage(session),
+ fetch: () => new Promise(() => {}),
+ performance: { now: () => 0 },
+ requestAnimationFrame: callback => { frames.push(callback); return frames.length; }, cancelAnimationFrame() {},
+ getComputedStyle: () => ({ getPropertyValue: () => '' }),
+ setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {}
+ });
+ for (const [name, source] of sources) {
+ vm.runInContext(source, context, { filename: path.join(playerRoot, `${name}.js`) });
+ if (name === 'playback' && cast) window.LXPlayback.connectRemote({});
+ }
+ document.dispatch('DOMContentLoaded');
+ return {
+ get audio() { return elements['audio-player']; }, button, modal, nativeButton, window, elements, effects: window.soundEffects,
+ observations, local, session,
+ flushFrames() {
+ const pending = frames; frames = [];
+ for (const callback of pending) callback(400);
+ },
+ play() { const media = elements['audio-player']; media.paused = false; media.dispatch('play'); media.dispatch('playing'); media.dispatch('timeupdate'); },
+ clickAirPlay() {
+ elements['audio-player'].dispatch('webkitplaybacktargetavailabilitychanged', { availability: 'available' });
+ assert.equal(button.disabled, false, 'available picker must be reachable');
+ button.dispatch('click');
+ },
+ healthy() { assert.deepEqual(observations.errors, [], 'actual modules must initialize without fixture failures'); }
+ };
+}
+
+function savedEffects() {
+ return JSON.stringify({ settings: {
+ eq: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0], pitch: 1,
+ panner: { enable: false, speed: 25, distance: 5 },
+ reverb: { id: 'none', mainGain: 1, sendGain: 0 }
+ }, customPresets: [{ name: 'Retained preset', values: Array(10).fill(0) }] });
+}
+
+test('vendored Wave dispose cancels RAF and ignores already queued callbacks', () => {
+ const frames = new Map(); let nextId = 0, draws = 0;
+ class Analyser { constructor() { this.frequencyBinCount = 8; } getByteFrequencyData() {} }
+ const canvas = { getContext: () => ({ canvas: { width: 100, height: 50 }, clearRect() { draws++; } }) };
+ const context = {
+ HTMLAudioElement: class {}, AnalyserNode: Analyser,
+ window: { requestAnimationFrame(callback) { frames.set(++nextId, callback); return nextId; },
+ cancelAnimationFrame(id) { frames.delete(id); } }
+ };
+ vm.runInNewContext(readFileSync(path.join(playerRoot, '../vendor/wave.js'), 'utf8'), context);
+ const wave = new context.Wave(new Analyser(), canvas);
+ assert.equal(frames.size, 1);
+ const queued = [...frames.values()][0];
+ wave.dispose(); assert.equal(frames.size, 0);
+ queued(); assert.equal(draws, 1); assert.equal(frames.size, 0);
+});
+
+for (const paused of [true, false]) {
+ test(`live native handoff preserves state and event listeners (paused: ${paused})`, () => {
+ const f = fixture({ device: 'desktop', adaptive: false });
+ const playback = f.window.LXPlayback;
+ f.audio.src = '/music/current.mp3'; f.audio.currentTime = 37;
+ f.audio.volume = 0.4; f.audio.muted = true; f.audio.playbackRate = 1.25;
+ f.play(); f.audio.paused = paused;
+ const previous = f.audio;
+ let pauses = 0, ends = 0, errors = 0, ticks = 0;
+ playback.addEventListener('pause', () => pauses++);
+ playback.addEventListener('ended', () => ends++);
+ playback.addEventListener('error', () => errors++);
+ playback.addEventListener('timeupdate', () => ticks++);
+ f.effects.setEnabled(false);
+ assert.notEqual(f.audio, previous);
+ assert.equal(playback, f.window.LXPlayback, 'public proxy identity remains stable');
+ assert.equal(f.audio.src, '/music/current.mp3');
+ assert.equal(playback.currentTime, 37, 'progress is retained while metadata loads');
+ f.audio.dispatch('loadedmetadata');
+ assert.equal(f.audio.currentTime, 37);
+ assert.equal(f.audio.paused, paused);
+ assert.equal(f.audio.volume, 0.4); assert.equal(f.audio.muted, true);
+ assert.equal(f.audio.playbackRate, 1.25);
+ previous.dispatch('pause'); previous.dispatch('ended'); previous.dispatch('error');
+ assert.deepEqual([pauses, ends, errors], [0, 0, 0]);
+ f.audio.dispatch('timeupdate');
+ assert.equal(ticks, 1);
+ assert.equal(f.effects.needsNativeRestore(), false);
+ assert.equal(f.observations.closedContexts, 1);
+ assert.equal(f.observations.reloads, 0);
+ f.clickAirPlay(); assert.equal(f.observations.picked, 1);
+ f.healthy();
+ });
+}
+
+test('repeated rapid master toggles capture each new element once and retain pending position', () => {
+ const f = fixture({ device: 'desktop' });
+ f.audio.src = '/music/current.mp3'; f.audio.currentTime = 42;
+ for (let i = 0; i < 3; i++) {
+ f.effects.setEnabled(true);
+ assert.equal(f.effects.isGraphBuilt(), true);
+ f.effects.setEnabled(false);
+ f.window.dispatch('resize'); f.play();
+ assert.equal(f.effects.isGraphBuilt(), false);
+ assert.equal(f.window.LXPlayback.currentTime, 42);
+ }
+ f.audio.dispatch('loadedmetadata');
+ assert.equal(f.audio.currentTime, 42);
+ assert.equal(f.observations.captures, 3);
+ assert.equal(f.observations.closedContexts, 3);
+ f.healthy();
+});
+
+test('track change during handoff cancels old position restoration', () => {
+ const f = fixture({ device: 'desktop' });
+ f.audio.src = '/old.mp3'; f.audio.currentTime = 120; f.play();
+ f.effects.restoreNativePlayback();
+ f.window.LXPlayback.src = '/new.mp3';
+ f.audio.currentTime = 0; f.audio.dispatch('loadedmetadata');
+ assert.equal(f.audio.currentTime, 0);
+ assert.equal(f.window.LXPlayback.src, '/new.mp3');
+ f.healthy();
+});
+
+test('browser rejection of handoff playback prompts manual play without reporting a media error', async () => {
+ const f = fixture({ device: 'desktop' });
+ f.audio.src = '/current.mp3'; f.play();
+ f.audio.cloneNode = () => {
+ const next = mediaElement();
+ next.play = () => Promise.reject(Object.assign(new Error('Gesture required'), { name: 'NotAllowedError' }));
+ return next;
+ };
+ let errors = 0;
+ f.window.LXPlayback.addEventListener('error', () => errors++);
+ f.effects.restoreNativePlayback();
+ await Promise.resolve();
+ assert.equal(f.audio.paused, true);
+ assert.equal(errors, 0);
+ assert.match(f.observations.info[0], /点击播放/);
+ assert.equal(f.observations.reloads, 0);
+ f.healthy();
+});
+
+test('new src wins over stale currentSrc when native handoff starts during loading', () => {
+ const f = fixture({ device: 'desktop' }); f.play();
+ f.audio.src = '/new.mp3'; f.audio.currentSrc = '/old.mp3';
+ f.effects.restoreNativePlayback();
+ assert.equal(f.audio.src, '/new.mp3');
+ f.healthy();
+});
+
+test('native handoff disposes the iOS bridge and later master enable builds a fresh bridge', () => {
+ const f = fixture(); f.effects.setEQ(0, 5);
+ assert.equal(f.window.iOSBackgroundAudio.isActive(), true);
+ f.effects.setEnabled(false);
+ assert.equal(f.window.iOSBackgroundAudio.isActive(), false);
+ assert.equal(f.effects.getContext(), null);
+ f.effects.setEnabled(true);
+ assert.equal(f.window.iOSBackgroundAudio.isActive(), true);
+ assert.equal(f.observations.captures, 2);
+ f.healthy();
+});
+
+test('a pending worklet from the old graph cannot initialize in the new graph', async () => {
+ const f = fixture({ device: 'desktop' }); f.effects.setEnabled(true);
+ const oldWorklet = f.observations.worklets[0];
+ f.effects.setEnabled(false); f.effects.setEnabled(true);
+ oldWorklet(); await Promise.resolve(); await Promise.resolve();
+ assert.equal(f.observations.worklets.length, 2);
+ f.healthy();
+});
+
+test('Cast is not disconnected by a native handoff request and still restores to the new local element', () => {
+ const f = fixture({ device: 'desktop' }); f.play();
+ f.effects.restoreNativePlayback();
+ const current = f.audio;
+ const snapshot = f.window.LXPlayback.connectRemote({ src: '/cast.mp3', currentTime: 12, paused: true });
+ assert.equal(f.effects.restoreNativePlayback(), false);
+ assert.equal(f.audio, current);
+ assert.equal(f.window.LXPlayback.isRemote, true);
+ f.window.LXPlayback.restoreLocal(); f.audio.dispatch('loadedmetadata');
+ assert.equal(f.audio.src, '/cast.mp3'); assert.equal(f.audio.currentTime, 12);
+ assert.ok(snapshot);
+ f.healthy();
+});
+
+for (const device of ['iphone', 'desktop']) {
+ test(`${device} native mode previews saved effects until master explicitly enables them`, () => {
+ const f = fixture({ device, nativeMode: true, local: new Map([['lx_sound_effects', savedEffects()]]) });
+ f.effects.open();
+ assert.equal(f.elements['sound-effects-enable'].checked, false);
+ assert.equal(f.elements['sound-effects-controls'].disabled, true);
+ assert.equal(f.elements['sound-effects-mode'].hidden, false);
+ assert.match(f.elements['sound-effects-mode'].textContent, /原生播放/);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.eq[0], 5);
+ f.effects.setEnabled(true);
+ assert.equal(f.elements['sound-effects-enable'].checked, true);
+ assert.equal(f.elements['sound-effects-controls'].disabled, false);
+ assert.equal(f.elements['sound-effects-mode'].hidden, true);
+ assert.equal(f.observations.captures, 1);
+ f.effects.setEnabled(false);
+ assert.equal(f.elements['sound-effects-controls'].disabled, true);
+ assert.match(f.elements['sound-effects-mode'].textContent, /原生播放/);
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.eq[0], 5);
+ f.healthy();
+ });
+
+ test(`${device} explicit master enable exits native mode even with neutral settings`, () => {
+ const f = fixture({ device, nativeMode: true });
+ f.effects.open(); f.effects.setEnabled(true);
+ assert.equal(f.effects.isNativePlaybackPreferred(), false);
+ assert.equal(f.elements['sound-effects-enable'].checked, true);
+ assert.equal(f.elements['sound-effects-controls'].disabled, false);
+ assert.equal(f.observations.captures, 1);
+ f.healthy();
+ });
+}
+
+test('default mobile native playback presents a read-only preview with master off', () => {
+ const f = fixture(); f.effects.open();
+ assert.equal(f.elements['sound-effects-enable'].checked, false);
+ assert.equal(f.elements['sound-effects-controls'].disabled, true);
+ assert.match(f.elements['sound-effects-mode'].textContent, /原生播放/);
+ assert.equal(f.observations.captures, 0);
+ f.healthy();
+});
+
+test('wireless playback locks local effect controls and master switch', () => {
+ const f = fixture({ wireless: true }); f.effects.open();
+ assert.equal(f.elements['sound-effects-enable'].checked, false);
+ assert.equal(f.elements['sound-effects-enable'].disabled, true);
+ assert.equal(f.elements['sound-effects-controls'].disabled, true);
+ assert.match(f.elements['sound-effects-mode'].textContent, /无线投播/);
+ f.audio.webkitCurrentPlaybackTargetIsWireless = false;
+ f.audio.dispatch('webkitcurrentplaybacktargetiswirelesschanged');
+ assert.equal(f.elements['sound-effects-enable'].disabled, false);
+ assert.equal(f.elements['sound-effects-controls'].disabled, true);
+ f.healthy();
+});
+
+test('resetting reverb with saved EQ retains native output, including slider callbacks', () => {
+ const f = fixture({ device: 'desktop', nativeMode: true, sliders: true,
+ local: new Map([['lx_sound_effects', savedEffects()]]) });
+ f.effects.resetReverb();
+ f.flushFrames();
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.reverb.id, 'none');
+ f.healthy();
+});
+
+for (const route of ['AirPlay', 'Cast']) {
+ test(`${route} reverb animation cannot enable DSP after returning local`, () => {
+ const f = fixture({ device: 'desktop', nativeMode: true, sliders: true,
+ wireless: route === 'AirPlay', cast: route === 'Cast' });
+ f.effects.setReverb('hall');
+ f.audio.webkitCurrentPlaybackTargetIsWireless = false;
+ f.window.LXPlayback.restoreLocal();
+ f.flushFrames();
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.reverb.id, 'hall');
+ f.healthy();
+ });
+}
+
+test('native handoff works without saving playback or writing session storage', () => {
+ const f = fixture({ device: 'desktop', failSessionWrites: true }); f.play();
+ const previous = f.audio;
+ f.window.savePlaybackState = () => { throw new Error('Must not save'); };
+ f.effects.restoreNativePlayback();
+ assert.notEqual(f.audio, previous);
+ assert.equal(f.observations.reloads, 0);
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.effects.needsNativeRestore(), false);
+ assert.equal(f.observations.userErrors.length, 0);
+ f.healthy();
+});
+
+test('reverb presets and reset update sliders without overwriting later local edits', () => {
+ const f = fixture({ device: 'desktop', sliders: true });
+ f.effects.setReverb('hall');
+ assert.equal(f.effects.isGraphBuilt(), true);
+ assert.equal(f.observations.captures, 1);
+ assert.equal(f.elements['reverb-main-gain'].value, 80);
+ assert.equal(f.elements['reverb-send-gain'].value, 240);
+ f.effects.setReverbGain('send', 0.25);
+ f.flushFrames();
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.reverb.sendGain, 0.25);
+ f.effects.resetReverb(); f.flushFrames();
+ const reverb = JSON.parse(f.local.get('lx_sound_effects')).settings.reverb;
+ assert.deepEqual(reverb, { id: 'none', mainGain: 1, sendGain: 0 });
+ assert.equal(f.elements['reverb-main-gain'].value, 100);
+ assert.equal(f.elements['reverb-send-gain'].value, 0);
+ assert.equal(f.observations.captures, 1, 'existing local graph is reused');
+ f.healthy();
+});
+
+function appFunction(declaration) {
+ const source = readFileSync(path.join(playerRoot, '../../app.js'), 'utf8');
+ const start = source.indexOf(declaration);
+ const end = source.indexOf('\n}', start);
+ assert.ok(start >= 0 && end > start);
+ return source.slice(start, end + 2);
+}
+
+test('actual playback saver reports write failure, success and empty state separately', () => {
+ const context = {
+ currentPlayingSong: { id: 'current' }, currentPlaylist: [{ id: 'current' }],
+ currentIndex: 0, audio: { currentTime: 37 }, currentPlayingScope: 'network',
+ window: {}, playMode: 'list', currentQuality: '128k',
+ console: { error() {} }, localStorage: { setItem() { throw new Error('Quota exceeded'); } }
+ };
+ vm.runInNewContext(appFunction('function savePlaybackState()'), context);
+ assert.equal(context.savePlaybackState(), false);
+ let state;
+ context.localStorage.setItem = (_key, value) => { state = JSON.parse(value); };
+ assert.equal(context.savePlaybackState(), true);
+ assert.equal(state.time, 37);
+ assert.equal(state.song.id, 'current');
+ context.currentPlayingSong = null; context.currentPlaylist = [];
+ assert.equal(context.savePlaybackState(), null);
+});
+
+test('actual info toast honors AirPlay duration and keeps its default', () => {
+ const calls = [];
+ const source = readFileSync(path.join(playerRoot, '../../app.js'), 'utf8');
+ const definition = source.match(/^function showInfo\([^\n]+/m)[0];
+ const context = { showToast: (...args) => calls.push(args) };
+ vm.runInNewContext(definition, context);
+ context.showInfo('AirPlay', 6500); context.showInfo('normal');
+ assert.deepEqual(calls, [['info', 'AirPlay', 6500], ['info', 'normal', 2000]]);
+});
+
+test('default iPhone visualization preserves native AirPlay playback', () => {
+ const f = fixture(); f.play(); f.effects.init(true); f.clickAirPlay();
+ assert.equal(f.observations.captures, 0);
+ assert.equal(f.observations.picked, 1);
+ f.healthy();
+});
+
+for (const device of ['iphone', 'android', 'desktop']) {
+ test(`${device} visualizer-only capture gates AirPlay behind native restoration`, () => {
+ const f = fixture({ device, adaptive: false }); f.play();
+ assert.equal(f.observations.captures, 1);
+ assert.equal(f.effects.isGraphBuilt(), false);
+ assert.equal(f.effects.needsNativeRestore(), true);
+ f.clickAirPlay();
+ assert.equal(f.observations.picked, 0);
+ assert.equal(f.modal.classList.contains('hidden'), false);
+ assert.equal(f.nativeButton.hidden, false);
+ assert.equal(f.observations.reloads, 0, 'opening the picker must never reload automatically');
+ f.healthy();
+ });
+
+ test(`${device} live restoration preserves settings and native preference after a later reload`, () => {
+ const local = new Map([
+ ['lx_sound_effects', savedEffects()],
+ ['lx_playback_state', JSON.stringify({ song: { id: 'fixture-song' }, time: 37, playlist: [{ id: 'fixture-song' }] })]
+ ]);
+ const before = [...local.entries()];
+ const f = fixture({ device, adaptive: false, local }); f.play();
+ f.effects.restoreNativePlayback();
+ assert.equal(f.observations.saves, 0, 'live handoff retains the queue in memory');
+ assert.equal(f.observations.reloads, 0);
+ assert.equal(f.session.get('lx_native_audio_mode'), '1');
+ assert.equal(f.session.has('lx_native_audio_restore'), false);
+ assert.deepEqual([...local.entries()], before, 'retained effects and queue must not be cleared');
+ f.healthy();
+
+ const restored = fixture({ device, adaptive: false, local, session: f.session });
+ restored.effects.init(true); restored.play(); restored.window.dispatch('resize');
+ assert.equal(restored.effects.isNativePlaybackPreferred(), true);
+ assert.equal(restored.observations.captures, 0, 'saved EQ and visualization must not recapture native media');
+ assert.equal(restored.effects.isGraphBuilt(), false);
+ restored.clickAirPlay();
+ assert.equal(restored.observations.picked, 1);
+ assert.equal(restored.observations.reloads, 0);
+ restored.healthy();
+ });
+
+ test(`${device} explicit local effect edit opts out of native mode and captures once`, () => {
+ const f = fixture({ device, adaptive: false, nativeMode: true });
+ f.play(); f.effects.init(true);
+ assert.equal(f.observations.captures, 0);
+ f.effects.setEQ(0, 7);
+ assert.equal(f.effects.isNativePlaybackPreferred(), false);
+ assert.notEqual(f.session.get('lx_native_audio_mode'), '1');
+ assert.equal(f.effects.isGraphBuilt(), true);
+ assert.equal(f.observations.captures, 1);
+ assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.eq[0], 7);
+ f.effects.setEQ(0, 8); f.play();
+ assert.equal(f.observations.captures, 1, 'later edits reuse the media source');
+ f.healthy();
+ });
+}
+
+for (const device of ['android', 'desktop']) {
+ test(`${device} normal visualization continues to capture without native preference`, () => {
+ const f = fixture({ device }); f.play();
+ assert.equal(f.observations.captures, 1);
+ assert.equal(f.effects.isGraphBuilt(), false);
+ f.healthy();
+ });
+}
+
+for (const route of ['AirPlay', 'Cast']) {
+ test(`${route} effect edits remain saved-only and retain native preference`, () => {
+ const f = fixture({ device: 'desktop', nativeMode: true,
+ wireless: route === 'AirPlay', cast: route === 'Cast' });
+ f.play(); f.effects.init(true);
+ f.effects.setEQ(0, 9); f.effects.setPitch(1.2);
+ f.effects.setPanner('enable', true); f.effects.setReverbGain('send', 0.6);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(f.effects.isGraphBuilt(), false);
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.session.get('lx_native_audio_mode'), '1');
+ const settings = JSON.parse(f.local.get('lx_sound_effects')).settings;
+ assert.equal(settings.eq[0], 9); assert.equal(settings.pitch, 1.2);
+ assert.equal(settings.panner.enable, true); assert.equal(settings.reverb.sendGain, 0.6);
+ assert.equal(f.observations.reloads, 0);
+ f.healthy();
+ });
+}
+
+test('wireless route changes never trigger automatic reload or another media capture', () => {
+ const f = fixture({ adaptive: false }); f.play();
+ f.audio.webkitCurrentPlaybackTargetIsWireless = true;
+ f.audio.dispatch('webkitcurrentplaybacktargetiswirelesschanged');
+ f.audio.webkitCurrentPlaybackTargetIsWireless = false;
+ f.audio.dispatch('webkitcurrentplaybacktargetiswirelesschanged');
+ assert.equal(f.observations.captures, 1);
+ assert.equal(f.observations.reloads, 0);
+ f.healthy();
+});
+
+test('Control Center handoff after capture opens an actionable restore prompt only once', () => {
+ const f = fixture({ adaptive: false }); f.play();
+ let opened = 0;
+ const open = f.effects.open;
+ f.effects.open = function () { opened++; return open.call(this); };
+ f.audio.webkitCurrentPlaybackTargetIsWireless = true;
+ f.audio.dispatch('webkitcurrentplaybacktargetiswirelesschanged');
+ f.audio.dispatch('webkitcurrentplaybacktargetiswirelesschanged');
+ assert.equal(opened, 1, 'repeated route events must not reopen the prompt');
+ assert.equal(f.modal.classList.contains('hidden'), false);
+ assert.equal(f.nativeButton.hidden, false);
+ assert.equal(f.observations.info.length, 1);
+ assert.match(f.observations.info[0], /刷新|恢复原生/);
+ assert.match(f.observations.info[0], /AirPlay/);
+ assert.equal(f.observations.reloads, 0);
+ assert.equal(f.observations.picked, 0);
+ f.healthy();
+});
+
+test('explicit iPhone EQ supplies the adaptive visualizer with its existing analyser', () => {
+ const f = fixture({ nativeMode: true }); f.play();
+ assert.equal(f.observations.captures, 0);
+ f.effects.setEQ(0, 6);
+ assert.equal(f.effects.isNativePlaybackPreferred(), false);
+ assert.equal(f.effects.isGraphBuilt(), true);
+ assert.equal(f.observations.captures, 1);
+ assert.equal(f.observations.waveAnalysers.length, 2, 'both visualizers must initialize');
+ for (const analyser of f.observations.waveAnalysers) {
+ assert.equal(analyser, f.effects.getAnalyser(), 'visualizer must reuse the DSP analyser');
+ }
+ f.play(); f.window.musicVisualizer.init();
+ assert.equal(f.observations.captures, 1);
+ f.healthy();
+});
+
+test('live native preference persists without writing the legacy restore flag', () => {
+ const local = new Map([['lx_sound_effects', savedEffects()]]);
+ const f = fixture({ device: 'desktop', local }); f.play();
+ f.effects.restoreNativePlayback();
+ assert.equal(f.session.has('lx_native_audio_restore'), false);
+ // Live handoff no longer writes the legacy one-time queue/resume signal.
+ f.session.delete('lx_native_audio_restore');
+ assert.deepEqual([...f.session.entries()], [['lx_native_audio_mode', '1']]);
+ const restored = fixture({ device: 'desktop', local, session: f.session });
+ restored.effects.init(true); restored.play(); restored.window.dispatch('resize');
+ assert.equal(restored.effects.isNativePlaybackPreferred(), true);
+ assert.equal(restored.observations.captures, 0);
+ assert.equal(restored.effects.isGraphBuilt(), false);
+ restored.clickAirPlay();
+ assert.equal(restored.observations.picked, 1);
+ assert.equal(restored.observations.reloads, 0);
+ restored.healthy();
+});
+
+test('actual playback restoration honors one-time native return without enabling auto-resume', async () => {
+ const source = readFileSync(path.join(playerRoot, '../../app.js'), 'utf8');
+ const start = source.indexOf('async function restorePlaybackState()');
+ const end = source.indexOf('\n}', start);
+ assert.ok(start >= 0 && end > start, 'the actual playback restoration function must exist');
+ const session = new Map([['lx_native_audio_restore', '1'], ['lx_native_audio_mode', '1']]);
+ const lookups = [];
+ const settings = { autoResume: false };
+ const context = {
+ audio: { isRemote: false }, settings,
+ sessionStorage: {
+ getItem: key => session.get(key) ?? null,
+ removeItem: key => session.delete(key)
+ },
+ localStorage: { getItem: key => { lookups.push(key); return null; } }
+ };
+ vm.runInNewContext(source.slice(start, end + 2), context);
+ await context.restorePlaybackState();
+ assert.deepEqual(lookups, ['lx_playback_state'], 'native return must reach saved-state lookup');
+ assert.deepEqual([...session.entries()], [['lx_native_audio_mode', '1']]);
+ assert.equal(settings.autoResume, false, 'native return must preserve the user preference');
+
+ await context.restorePlaybackState();
+ assert.equal(lookups.length, 1, 'the consumed signal must not force another automatic restore');
+ settings.autoResume = true;
+ await context.restorePlaybackState();
+ assert.deepEqual(lookups, ['lx_playback_state', 'lx_playback_state'], 'ordinary auto-resume must still work');
+ assert.equal(session.get('lx_native_audio_mode'), '1');
+});
+
+for (const device of ['iphone', 'desktop']) {
+ test(`${device} master-off edits and reset retain native playback without capturing`, () => {
+ const saved = JSON.parse(savedEffects());
+ saved.settings.enabled = false;
+ const local = new Map([['lx_sound_effects', JSON.stringify(saved)]]);
+ const f = fixture({ device, nativeMode: true, local }); f.play();
+ f.effects.setEQ(0, 8); f.effects.setPitch(1.2);
+ f.effects.setPanner('enable', true); f.effects.setReverbGain('send', 0.6);
+ let settings = JSON.parse(local.get('lx_sound_effects')).settings;
+ assert.equal(settings.enabled, false);
+ assert.equal(settings.eq[0], 8); assert.equal(settings.pitch, 1.2);
+ assert.equal(settings.panner.enable, true); assert.equal(settings.reverb.sendGain, 0.6);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+
+ f.effects.resetAll();
+ settings = JSON.parse(local.get('lx_sound_effects')).settings;
+ assert.equal(settings.enabled, false);
+ assert.deepEqual(settings.eq, Array(10).fill(0));
+ assert.equal(settings.pitch, 1); assert.equal(settings.panner.enable, false);
+ assert.equal(settings.reverb.id, 'none');
+ assert.equal(f.observations.captures, 0);
+ assert.equal(f.effects.isGraphBuilt(), false);
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.session.get('lx_native_audio_mode'), '1');
+ f.healthy();
+ });
+
+ test(`${device} enabling saved active EQ explicitly leaves native mode and captures once`, () => {
+ const saved = JSON.parse(savedEffects());
+ saved.settings.enabled = false;
+ const local = new Map([['lx_sound_effects', JSON.stringify(saved)]]);
+ const f = fixture({ device, nativeMode: true, local }); f.play();
+ assert.equal(f.observations.captures, 0);
+ f.effects.setEnabled(true);
+ assert.equal(f.effects.isNativePlaybackPreferred(), false);
+ assert.notEqual(f.session.get('lx_native_audio_mode'), '1');
+ assert.equal(f.effects.isGraphBuilt(), true);
+ assert.equal(f.observations.captures, 1);
+ const settings = JSON.parse(local.get('lx_sound_effects')).settings;
+ assert.equal(settings.enabled, true); assert.equal(settings.eq[0], 5);
+ f.effects.setEnabled(true); f.play();
+ assert.equal(f.observations.captures, 1, 'repeated master enable must reuse the captured source');
+ f.healthy();
+ });
+}
+
+for (const route of ['AirPlay', 'Cast']) {
+ test(`${route} master enable saves active EQ without leaving native playback`, () => {
+ const saved = JSON.parse(savedEffects());
+ saved.settings.enabled = false;
+ const local = new Map([['lx_sound_effects', JSON.stringify(saved)]]);
+ const f = fixture({ device: 'desktop', nativeMode: true, local,
+ wireless: route === 'AirPlay', cast: route === 'Cast' }); f.play();
+ f.effects.setEnabled(true);
+ const settings = JSON.parse(local.get('lx_sound_effects')).settings;
+ assert.equal(settings.enabled, true); assert.equal(settings.eq[0], 5);
+ assert.equal(f.observations.captures, 0);
+ assert.equal(f.effects.isGraphBuilt(), false);
+ assert.equal(f.effects.isNativePlaybackPreferred(), true);
+ assert.equal(f.session.get('lx_native_audio_mode'), '1');
+ assert.equal(f.observations.reloads, 0);
+ f.healthy();
+ });
+}