From 3468ddb3cef5bf34b1be36173432fe0463bc403a Mon Sep 17 00:00:00 2001 From: pokemonred06-creator Date: Sat, 7 Nov 2026 01:47:22 +0800 Subject: [PATCH 1/3] fix(player): preserve native AirPlay after Web Audio capture Detect shared media capture on every platform, retain native restoration across reloads, and expose recovery for Control Center handoffs. Preserve current VU artwork, mobile DSP opt-in and advanced effects controls. Add 26 focused regressions and Node 22/24 validation. Verified all 117 tests, type checking, production compilation, JavaScript syntax and diff checks. --- .github/workflows/player-validation.yml | 32 ++ docs/chromecast/architecture.md | 6 +- docs/chromecast/progress.md | 9 + public/music/js/player/airplay.js | 8 + public/music/js/player/sound-effects.js | 49 ++- public/music/js/player/visualizer.js | 7 +- public/music/sw.js | 2 +- test/airplay-native-handoff.test.cjs | 424 ++++++++++++++++++++++++ 8 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/player-validation.yml create mode 100644 test/airplay-native-handoff.test.cjs 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/docs/chromecast/architecture.md b/docs/chromecast/architecture.md index 35611518..f64cdb19 100644 --- a/docs/chromecast/architecture.md +++ b/docs/chromecast/architecture.md @@ -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-v59`. 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,6 @@ 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 an explicit local effect edit opts into DSP. The panel reports native, local-processed or receiver output. Any shared media source, including visualizer-only capture on desktop, requires a native reload before AirPlay because disconnecting Web Audio cannot release a captured media element. The native-restore control is available on all platforms; Control Center handoffs expose the same recovery without automatically reloading. + +The existing one-time `lx_native_audio_restore` flag preserves queue/position without changing auto-resume. A separate per-tab `lx_native_audio_mode` preference prevents both visualization and effects from recapturing audio on playback, resize or another reload. Only an explicit local active-effect edit opts back into DSP; saved/server-loaded settings, master-off edits, resets and wireless/Cast edits retain native output. Storage failure reports an error and prevents an ineffective reload. The current VU renderer and master-enable/bypass controls remain intact. diff --git a/docs/chromecast/progress.md b/docs/chromecast/progress.md index 46609a75..7157ab8a 100644 --- a/docs/chromecast/progress.md +++ b/docs/chromecast/progress.md @@ -1,5 +1,14 @@ # Chromecast progress +## 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/music/js/player/airplay.js b/public/music/js/player/airplay.js index a7bb38bd..d9645291 100644 --- a/public/music/js/player/airplay.js +++ b/public/music/js/player/airplay.js @@ -5,6 +5,7 @@ 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,6 +14,13 @@ 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?.needsNativeReload()) { + warnedCapturedRoute = true; + window.soundEffects.open(); + window.showInfo?.('请点击“刷新并恢复原生播放 / AirPlay”后,重新选择 AirPlay 设备。', 6500); + } } audio.addEventListener('webkitplaybacktargetavailabilitychanged', event => { available = event.availability === 'available'; diff --git a/public/music/js/player/sound-effects.js b/public/music/js/player/sound-effects.js index d56164ad..aed58069 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 needsNativeReload() { + 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; } @@ -592,19 +602,25 @@ window.soundEffects = (function () { 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 = needsNativeReload(); const status = document.getElementById('sound-effects-status'); - if (status) status.textContent = remote + if (status) status.textContent = wireless && captured + ? '请刷新并恢复原生播放后,重新选择 AirPlay 设备' + : remote ? 'AirPlay / 投放中:仅保存音效配置' + : preferNativePlayback + ? '正在使用原生播放,可直接选择 AirPlay;已保留音效设置' : 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) { @@ -737,6 +753,15 @@ window.soundEffects = (function () { function ensureLocalGraph() { const audio = document.getElementById('audio-player'); if (audio?.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) return false; + if (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() && hasActiveEffects()) mobileEffectsEnabled = true; if (!canProcessLocally()) return false; if (!audioContext || !isGraphBuilt) init(true); @@ -911,12 +936,20 @@ window.soundEffects = (function () { renderUI(); }, isGraphBuilt: () => isGraphBuilt, - needsNativeReload: () => isMobileDevice() && isGraphBuilt, + isNativePlaybackPreferred: () => preferNativePlayback, + needsNativeReload, restoreNativePlayback: function () { if (!this.needsNativeReload()) return; // Disconnecting nodes cannot release a captured media element. window.savePlaybackState?.(); - sessionStorage.setItem('lx_native_audio_restore', '1'); + try { + sessionStorage.setItem('lx_native_audio_mode', '1'); + sessionStorage.setItem('lx_native_audio_restore', '1'); + } catch (_) { + window.showError?.('无法保存原生播放状态,请允许网站存储后重试。'); + return; + } + preferNativePlayback = true; window.location.reload(); }, getContext: () => audioContext || window._sharedAudioContext || null, diff --git a/public/music/js/player/visualizer.js b/public/music/js/player/visualizer.js index 9cb21435..f4d53b39 100644 --- a/public/music/js/player/visualizer.js +++ b/public/music/js/player/visualizer.js @@ -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; } diff --git a/public/music/sw.js b/public/music/sw.js index f4c1bf06..db57b445 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-v59'; // 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..87538a88 --- /dev/null +++ b/test/airplay-native-handoff.test.cjs @@ -0,0 +1,424 @@ +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 = ['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) { + 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); } + 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: {}, + setAttribute(name, value) { this.attributes[name] = value; }, + play() { this.paused = false; return Promise.resolve(); }, + pause() { this.paused = true; }, remove() {} + }); +} + +function fixture({ device = 'iphone', adaptive = true, nativeMode = false, + wireless = false, cast = false, failSessionWrites = false, + local = new Map(), session = new Map() } = {}) { + const observations = { captures: 0, picked: 0, reloads: 0, saves: 0, + errors: [], info: [], userErrors: [], waveAnalysers: [] }; + 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) 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': {} + }; + 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(() => {}) }; + } + createMediaElementSource(media) { + assert.equal(media, audio, '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() {} + } + 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++, + 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, localStorage: storage(local), sessionStorage: storage(session), + requestAnimationFrame: () => 1, 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`) }); + } + document.dispatch('DOMContentLoaded'); + return { + audio, button, modal, nativeButton, window, effects: window.soundEffects, + observations, local, session, + play() { audio.dispatch('play'); audio.dispatch('playing'); audio.dispatch('timeupdate'); }, + clickAirPlay() { + audio.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('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.needsNativeReload(), 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} native restoration preserves settings and prevents recapture after 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, 1, 'save the current queue before reloading'); + assert.equal(f.observations.reloads, 1); + assert.equal(f.session.get('lx_native_audio_mode'), '1'); + assert.equal(f.session.get('lx_native_audio_restore'), '1'); + 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('native restore reports unavailable storage and does not reload', () => { + const f = fixture({ device: 'desktop', failSessionWrites: true }); f.play(); + f.effects.restoreNativePlayback(); + assert.equal(f.observations.reloads, 0); + assert.equal(f.observations.userErrors.length, 1); + assert.match(f.observations.userErrors[0], /存储|storage/i); + assert.equal(f.effects.isNativePlaybackPreferred(), false); + assert.equal(f.session.has('lx_native_audio_mode'), false); + 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('native preference survives consumption of the one-time playback 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.get('lx_native_audio_restore'), '1'); + // restorePlaybackState consumes this 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(); + }); +} From 02232c6b7ea9d5abf084ee87ba46686be7f1ea89 Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Sat, 7 Nov 2026 20:01:48 +0800 Subject: [PATCH 2/3] fix(player): harden native AirPlay recovery and effect preview --- changelog.md | 2 + docs/chromecast/architecture.md | 6 +- docs/chromecast/progress.md | 6 + public/music/app.js | 6 +- public/music/css/app.css | 6 + public/music/index.html | 5 +- public/music/js/player/sound-effects.js | 78 +++++----- public/music/sw.js | 2 +- test/airplay-native-handoff.test.cjs | 192 +++++++++++++++++++++++- 9 files changed, 250 insertions(+), 53 deletions(-) diff --git a/changelog.md b/changelog.md index 517b3a3c..b585b903 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,8 @@ ### 🐛 缺陷修复与体验优化 +- **AirPlay 原生播放恢复修复 (#511)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改;播放状态或会话标记保存失败时阻止刷新并清理不完整标记,恢复提示按指定时长显示。 +- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效。关闭后的旁路状态与原生播放分别提示,投播期间锁定本机音效控件。 - **歌手与专辑详情切换及返回修复**:切换标签、收藏分类或歌单时清理详情头部与悬浮栏,避免残留到其他列表;修复打开专辑后歌手详情因容器被删除而一直加载的问题;记录详情入口,返回时恢复对应的收藏歌手、收藏专辑或原歌单,避免浏览器返回事件重复处理后跳到「我的喜爱」。 - **批量选择行高亮修复**:修复收藏、搜索等歌曲列表点击「全选」后仅更新选中数量、未同步行高亮的问题;正确识别当前显示的列表,统一排行榜批量操作的刷新入口,确保全选、清空及退出批量模式时选中状态与高亮同步。 - **播放队列去重与歌曲定位修复**:统一搜索结果与默认列表的歌曲 ID 匹配规则,修复播放时自动加入默认列表的歌曲仍可重复入队的问题;「加入播放队列」遇到已有歌曲时提示并展开侧边面板,滚动定位、高亮对应歌曲;「下一首播放」将已有条目移至下一首,当前正在播放的歌曲不再重复添加。 diff --git a/docs/chromecast/architecture.md b/docs/chromecast/architecture.md index f64cdb19..e745a4f0 100644 --- a/docs/chromecast/architecture.md +++ b/docs/chromecast/architecture.md @@ -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 native AirPlay recovery update caches first-party modules under `lx-music-web-v59`. 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-v61`. 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). @@ -30,3 +30,7 @@ Token verification returns the session owner and upgrades guest or other-account Mobile loads remain native until an explicit local effect edit opts into DSP. The panel reports native, local-processed or receiver output. Any shared media source, including visualizer-only capture on desktop, requires a native reload before AirPlay because disconnecting Web Audio cannot release a captured media element. The native-restore control is available on all platforms; Control Center handoffs expose the same recovery without automatically reloading. The existing one-time `lx_native_audio_restore` flag preserves queue/position without changing auto-resume. A separate per-tab `lx_native_audio_mode` preference prevents both visualization and effects from recapturing audio on playback, resize or another reload. Only an explicit local active-effect edit opts back into DSP; saved/server-loaded settings, master-off edits, resets and wireless/Cast edits retain native output. Storage failure reports an error and prevents an ineffective reload. The current VU renderer and master-enable/bypass controls remain intact. + +Playback saving reports success, failure or an empty state before native reload; failures block reload, empty states do not force playback restoration, and partial session writes are rolled back. Reverb presets and resets update sliders through the regular UI render without synthesizing input events, preventing delayed callbacks from enabling DSP after a route change or overwriting later edits. Disabling reverb preserves native mode even when other saved effects are active. Recovery toasts honor their requested display duration. + +The panel shows native playback with a visible mode tag and an off master switch while retaining all saved values in a disabled preview. Explicit master enable restores the saved configuration and opts into local DSP, including a neutral configuration. Master off bypasses an existing graph and locks editing; it does not claim to release Web Audio capture. Wireless playback also locks the local controls and master switch until playback returns local. Settings loaded from storage or the server do not activate DSP in native mode. diff --git a/docs/chromecast/progress.md b/docs/chromecast/progress.md index 7157ab8a..ada95df5 100644 --- a/docs/chromecast/progress.md +++ b/docs/chromecast/progress.md @@ -1,5 +1,11 @@ # Chromecast progress +## Native recovery follow-up review — 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. diff --git a/public/music/app.js b/public/music/app.js index 7b4de8ab..b5f19d04 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -8235,7 +8235,7 @@ audio.addEventListener('pause', () => { // ======================================== function savePlaybackState() { - if (!currentPlayingSong && !currentPlaylist?.length) return; + if (!currentPlayingSong && !currentPlaylist?.length) return null; try { const state = { song: currentPlayingSong, @@ -8251,8 +8251,10 @@ function savePlaybackState() { timestamp: Date.now() }; localStorage.setItem('lx_playback_state', JSON.stringify(state)); + return true; } catch (e) { console.error('[Resume] 无法保存播放状态:', e); + return false; } } @@ -17136,7 +17138,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..d4a813b9 100644 --- a/public/music/css/app.css +++ b/public/music/css/app.css @@ -2156,6 +2156,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; diff --git a/public/music/index.html b/public/music/index.html index 3f353758..843aa4d8 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -5537,6 +5537,7 @@

均衡器与音效调节

音效已开启 +
@@ -5557,7 +5558,7 @@

均衡器与音效调节

class="mt-2 px-3 py-1.5 text-xs rounded-lg t-bg-track t-text-main hover:t-bg-item-hover transition-colors" onclick="window.soundEffects.restoreNativePlayback()">刷新并恢复原生播放 / AirPlay
-
+
@@ -5672,7 +5673,7 @@

快速预

-
+ diff --git a/public/music/js/player/sound-effects.js b/public/music/js/player/sound-effects.js index aed58069..50b617b8 100644 --- a/public/music/js/player/sound-effects.js +++ b/public/music/js/player/sound-effects.js @@ -475,29 +475,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 @@ -508,13 +485,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) { @@ -597,6 +586,7 @@ window.soundEffects = (function () { } tagsContainer.innerHTML = html; } + tagsContainer.querySelectorAll('button').forEach(button => { button.disabled = !enabled; }); } } @@ -609,11 +599,11 @@ window.soundEffects = (function () { if (status) status.textContent = wireless && captured ? '请刷新并恢复原生播放后,重新选择 AirPlay 设备' : remote - ? 'AirPlay / 投放中:仅保存音效配置' - : preferNativePlayback - ? '正在使用原生播放,可直接选择 AirPlay;已保留音效设置' + ? '已保留音效配置;结束投播后可开启本机音效' + : preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled) + ? '已保留全部配置,仅供预览;打开音效开关后按当前配置接管本机音频' : settings.enabled === false - ? '正在播放原始音色,重新开启即可恢复以上设置' + ? '已保留全部配置,仅供预览;打开音效开关后恢复当前配置' : captured ? '本机音效或可视化已开启;使用 AirPlay 前请恢复原生播放' : isMobileDevice() @@ -750,10 +740,10 @@ window.soundEffects = (function () { } } - function ensureLocalGraph() { + function ensureLocalGraph(explicitEnable = false) { const audio = document.getElementById('audio-player'); if (audio?.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) return false; - if (hasActiveEffects() && preferNativePlayback) { + 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 { @@ -762,7 +752,7 @@ window.soundEffects = (function () { } catch (_) { } preferNativePlayback = false; } - if (isMobileDevice() && hasActiveEffects()) mobileEffectsEnabled = true; + if (isMobileDevice() && (explicitEnable || hasActiveEffects())) mobileEffectsEnabled = true; if (!canProcessLocally()) return false; if (!audioContext || !isGraphBuilt) init(true); if (audioContext && ['suspended', 'interrupted'].includes(audioContext.state)) { @@ -777,7 +767,7 @@ window.soundEffects = (function () { if (!isGraphBuilt) loadSettings(); settings.enabled = !!enabled; saveSettings(); - if (enabled) ensureLocalGraph(); + if (enabled) ensureLocalGraph(true); if (canProcessLocally() && isGraphBuilt) { applyMaster(); updatePanner(); @@ -859,11 +849,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 () { @@ -874,8 +863,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; @@ -941,11 +928,20 @@ window.soundEffects = (function () { restoreNativePlayback: function () { if (!this.needsNativeReload()) return; // Disconnecting nodes cannot release a captured media element. - window.savePlaybackState?.(); try { + const saved = window.savePlaybackState?.(); + if (saved !== true && saved !== null) { + window.showError?.('无法保存当前播放状态,请检查网站存储后重试。'); + return; + } sessionStorage.setItem('lx_native_audio_mode', '1'); - sessionStorage.setItem('lx_native_audio_restore', '1'); + if (saved) sessionStorage.setItem('lx_native_audio_restore', '1'); + else sessionStorage.removeItem('lx_native_audio_restore'); } catch (_) { + try { + sessionStorage.removeItem('lx_native_audio_mode'); + sessionStorage.removeItem('lx_native_audio_restore'); + } catch (_) { } window.showError?.('无法保存原生播放状态,请允许网站存储后重试。'); return; } diff --git a/public/music/sw.js b/public/music/sw.js index db57b445..2b9e3170 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'lx-music-web-v59'; +const CACHE_NAME = 'lx-music-web-v61'; // 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 index 87538a88..b11da707 100644 --- a/test/airplay-native-handoff.test.cjs +++ b/test/airplay-native-handoff.test.cjs @@ -46,14 +46,15 @@ function mediaElement() { function fixture({ device = 'iphone', adaptive = true, nativeMode = false, wireless = false, cast = false, failSessionWrites = false, - local = new Map(), session = new Map() } = {}) { + local = new Map(), session = new Map(), sliders = false } = {}) { + let frames = []; const observations = { captures: 0, picked: 0, reloads: 0, saves: 0, errors: [], info: [], userErrors: [], waveAnalysers: [] }; 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) throw new Error('Storage unavailable'); + if (map === session && (failSessionWrites === true || failSessionWrites === key)) throw new Error('Storage unavailable'); map.set(key, String(value)); }, removeItem: key => map.delete(key) @@ -68,8 +69,16 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, const elements = { 'audio-player': audio, 'btn-airplay': button, 'sound-effects-modal': modal, 'sound-effects-native': nativeButton, - 'sound-effects-status': {} + 'sound-effects-status': {}, 'sound-effects-state': {}, + 'sound-effects-enable': {}, 'sound-effects-switch-label': {}, + 'sound-effects-mode': {}, 'sound-effects-controls': {} }; + 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, @@ -119,7 +128,7 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, innerWidth: mobile ? 390 : 1440, innerHeight: 900, settings: { showFooterVisualizer: true, iosAdaptiveVisualizer: adaptive }, AudioContext: Context, Wave, LXPlayback: { isRemote: cast }, - savePlaybackState: () => observations.saves++, + savePlaybackState: () => { observations.saves++; return true; }, showInfo: message => observations.info.push(message), showError: message => observations.userErrors.push(message), location: { reload: () => observations.reloads++ } @@ -131,7 +140,9 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, const context = vm.createContext({ window, document, navigator: { userAgent, platform: device === 'iphone' ? 'iPhone' : 'MacIntel', maxTouchPoints: mobile ? 5 : 0 }, console: quiet, Wave, localStorage: storage(local), sessionStorage: storage(session), - requestAnimationFrame: () => 1, cancelAnimationFrame() {}, + fetch: () => new Promise(() => {}), + performance: { now: () => 0 }, + requestAnimationFrame: callback => { frames.push(callback); return frames.length; }, cancelAnimationFrame() {}, getComputedStyle: () => ({ getPropertyValue: () => '' }), setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} }); @@ -140,8 +151,12 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, } document.dispatch('DOMContentLoaded'); return { - audio, button, modal, nativeButton, window, effects: window.soundEffects, + audio, button, modal, nativeButton, window, elements, effects: window.soundEffects, observations, local, session, + flushFrames() { + const pending = frames; frames = []; + for (const callback of pending) callback(400); + }, play() { audio.dispatch('play'); audio.dispatch('playing'); audio.dispatch('timeupdate'); }, clickAirPlay() { audio.dispatch('webkitplaybacktargetavailabilitychanged', { availability: 'available' }); @@ -160,6 +175,171 @@ function savedEffects() { }, customPresets: [{ name: 'Retained preset', values: Array(10).fill(0) }] }); } +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.isRemote = false; + 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('failed playback save prevents native reload and session flags', () => { + const f = fixture({ device: 'desktop' }); f.play(); + f.window.savePlaybackState = () => false; + f.effects.restoreNativePlayback(); + assert.equal(f.observations.reloads, 0); + assert.equal(f.session.size, 0); + assert.equal(f.effects.isNativePlaybackPreferred(), false); + assert.equal(f.observations.userErrors.length, 1); +}); + +test('empty playback state allows native reload without forcing old playback restoration', () => { + const f = fixture({ device: 'desktop' }); f.play(); + f.window.savePlaybackState = () => null; + f.effects.restoreNativePlayback(); + assert.equal(f.observations.reloads, 1); + assert.equal(f.session.get('lx_native_audio_mode'), '1'); + assert.equal(f.session.has('lx_native_audio_restore'), false); +}); + +test('partial session write is rolled back without reloading or enabling native mode', () => { + const f = fixture({ device: 'desktop', failSessionWrites: 'lx_native_audio_restore' }); f.play(); + f.effects.restoreNativePlayback(); + assert.equal(f.observations.reloads, 0); + assert.equal(f.session.size, 0); + assert.equal(f.effects.isNativePlaybackPreferred(), false); + assert.equal(f.observations.userErrors.length, 1); + 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); From a04dad37e200048408c0460f80efe2841e1ea67e Mon Sep 17 00:00:00 2001 From: XCQ0607 Date: Sat, 7 Nov 2026 20:59:08 +0800 Subject: [PATCH 3/3] fix(player): restore native audio without reload and polish UI states --- changelog.md | 7 +- docs/chromecast/architecture.md | 14 +- docs/chromecast/progress.md | 10 +- public/js/config.js | 2 +- public/music/app.js | 10 + public/music/css/app.css | 14 +- public/music/index.html | 4 +- public/music/js/library/search_requests.js | 15 +- public/music/js/player/airplay.js | 11 +- public/music/js/player/playback.js | 88 +++++++- public/music/js/player/sound-effects.js | 90 +++++--- public/music/js/player/visualizer.js | 20 +- public/music/js/vendor/wave.js | 2 +- public/music/sw.js | 2 +- test/airplay-native-handoff.test.cjs | 250 ++++++++++++++++----- 15 files changed, 416 insertions(+), 123 deletions(-) diff --git a/changelog.md b/changelog.md index b585b903..d42c9bfc 100644 --- a/changelog.md +++ b/changelog.md @@ -11,8 +11,11 @@ ### 🐛 缺陷修复与体验优化 -- **AirPlay 原生播放恢复修复 (#511)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改;播放状态或会话标记保存失败时阻止刷新并清理不完整标记,恢复提示按指定时长显示。 -- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效。关闭后的旁路状态与原生播放分别提示,投播期间锁定本机音效控件。 +- **搜索进度显示范围修复**:搜索状态条仅在网络搜索页显示,切换收藏分类或其他列表时隐藏,返回搜索后恢复当前状态,避免后台更新导致残留。 +- **暗色模式开关状态修复**:修复开启后的开关底色被暗色灰色样式覆盖的问题,统一使用主题色区分开启与关闭状态。 +- **AirPlay 原生播放恢复修复 (#511 @pokemonred06-creator)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改,恢复提示按指定时长显示。 +- **无需刷新恢复原生播放**:关闭音效或点击恢复按钮时,通过新音频元素交接歌曲、进度、暂停状态、音量和倍速,释放旧音频图及 iOS 保活桥;保留队列和音效配置,切歌及快速开关不会套用旧进度。切换可能短暂缓冲,浏览器阻止续播时提示手动播放。 +- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效,投播期间锁定本机音效控件。 - **歌手与专辑详情切换及返回修复**:切换标签、收藏分类或歌单时清理详情头部与悬浮栏,避免残留到其他列表;修复打开专辑后歌手详情因容器被删除而一直加载的问题;记录详情入口,返回时恢复对应的收藏歌手、收藏专辑或原歌单,避免浏览器返回事件重复处理后跳到「我的喜爱」。 - **批量选择行高亮修复**:修复收藏、搜索等歌曲列表点击「全选」后仅更新选中数量、未同步行高亮的问题;正确识别当前显示的列表,统一排行榜批量操作的刷新入口,确保全选、清空及退出批量模式时选中状态与高亮同步。 - **播放队列去重与歌曲定位修复**:统一搜索结果与默认列表的歌曲 ID 匹配规则,修复播放时自动加入默认列表的歌曲仍可重复入队的问题;「加入播放队列」遇到已有歌曲时提示并展开侧边面板,滚动定位、高亮对应歌曲;「下一首播放」将已有条目移至下一首,当前正在播放的歌曲不再重复添加。 diff --git a/docs/chromecast/architecture.md b/docs/chromecast/architecture.md index e745a4f0..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 native AirPlay recovery update caches first-party modules under `lx-music-web-v61`. 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,10 +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. Any shared media source, including visualizer-only capture on desktop, requires a native reload before AirPlay because disconnecting Web Audio cannot release a captured media element. The native-restore control is available on all platforms; Control Center handoffs expose the same recovery without automatically reloading. +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. -The existing one-time `lx_native_audio_restore` flag preserves queue/position without changing auto-resume. A separate per-tab `lx_native_audio_mode` preference prevents both visualization and effects from recapturing audio on playback, resize or another reload. Only an explicit local active-effect edit opts back into DSP; saved/server-loaded settings, master-off edits, resets and wireless/Cast edits retain native output. Storage failure reports an error and prevents an ineffective reload. The current VU renderer and master-enable/bypass controls remain intact. +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. -Playback saving reports success, failure or an empty state before native reload; failures block reload, empty states do not force playback restoration, and partial session writes are rolled back. Reverb presets and resets update sliders through the regular UI render without synthesizing input events, preventing delayed callbacks from enabling DSP after a route change or overwriting later edits. Disabling reverb preserves native mode even when other saved effects are active. Recovery toasts honor their requested display duration. +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. -The panel shows native playback with a visible mode tag and an off master switch while retaining all saved values in a disabled preview. Explicit master enable restores the saved configuration and opts into local DSP, including a neutral configuration. Master off bypasses an existing graph and locks editing; it does not claim to release Web Audio capture. Wireless playback also locks the local controls and master switch until playback returns local. Settings loaded from storage or the server do not activate DSP in native mode. +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 ada95df5..3cb7ccea 100644 --- a/docs/chromecast/progress.md +++ b/docs/chromecast/progress.md @@ -1,6 +1,14 @@ # Chromecast progress -## Native recovery follow-up review — 2026-11-07 +## 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. diff --git a/public/js/config.js b/public/js/config.js index f685fabb..08eaf101 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: '475edaf', + buildHash: '4128a0a', version: 'v2.1.4', }; diff --git a/public/music/app.js b/public/music/app.js index b5f19d04..63fcb62f 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1852,6 +1852,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'); @@ -1902,6 +1903,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 = "搜索音乐"; @@ -2400,6 +2402,7 @@ function performSearch(query, source = null) { // 重置搜索范围到全网搜索 if (typeof currentSearchScope !== 'undefined') { currentSearchScope = 'network'; + window.refreshSearchStatusVisibility?.(); } // 设置搜索框内容 @@ -4766,6 +4769,7 @@ function goBackToSearch(fromPopState = false) { window.searchDetailOrigin = null; if (origin) { currentSearchScope = origin.scope; + window.refreshSearchStatusVisibility?.(); lastSearchResultList = origin.list; lastSearchType = origin.type; currentArtistId = null; @@ -12043,6 +12047,7 @@ function handleArtistLibraryClick() { document.getElementById('search-type').classList.add('hidden'); currentSearchScope = 'lib_artists'; + window.refreshSearchStatusVisibility?.(); window.currentViewingListId = '__lib_artists__'; renderLibraryArtists(window.libraryData.artists); } @@ -12075,6 +12080,7 @@ function handleAlbumLibraryClick() { document.getElementById('search-type').classList.add('hidden'); currentSearchScope = 'lib_albums'; + window.refreshSearchStatusVisibility?.(); window.currentViewingListId = '__lib_albums__'; renderLibraryAlbums(window.libraryData.albums); } @@ -13469,6 +13475,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 = ''; @@ -13511,6 +13518,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'); @@ -13743,6 +13751,7 @@ function handleDislikeArtistsClick() { exitListSecondaryModes(); window.currentViewingListId = 'dislike_artists'; currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); // Highlight Header document.querySelectorAll('[id^="tab-"]').forEach(el => { @@ -13783,6 +13792,7 @@ function handleDislikeAlbumsClick() { exitListSecondaryModes(); window.currentViewingListId = 'dislike_albums'; currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); // Highlight Header document.querySelectorAll('[id^="tab-"]').forEach(el => { diff --git a/public/music/css/app.css b/public/music/css/app.css index d4a813b9..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; @@ -2199,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; @@ -2260,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 843aa4d8..275e6969 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -5556,7 +5556,7 @@

均衡器与音效调节

+ onclick="window.soundEffects.restoreNativePlayback()">恢复原生播放 / AirPlay
@@ -6271,6 +6271,7 @@

项目使用声明< + @@ -6282,7 +6283,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 d9645291..1ecf5f19 100644 --- a/public/music/js/player/airplay.js +++ b/public/music/js/player/airplay.js @@ -1,6 +1,6 @@ // 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; @@ -16,10 +16,10 @@ window.iOSBackgroundAudio?.setWirelessPlayback(wireless); if (!wireless) warnedCapturedRoute = false; // Control Center can select AirPlay without going through our picker. - if (wireless && !warnedCapturedRoute && window.soundEffects?.needsNativeReload()) { + if (wireless && !warnedCapturedRoute && window.soundEffects?.needsNativeRestore()) { warnedCapturedRoute = true; window.soundEffects.open(); - window.showInfo?.('请点击“刷新并恢复原生播放 / AirPlay”后,重新选择 AirPlay 设备。', 6500); + window.showInfo?.('请点击“恢复原生播放 / AirPlay”后,重新选择 AirPlay 设备。', 6500); } } audio.addEventListener('webkitplaybacktargetavailabilitychanged', event => { @@ -27,10 +27,11 @@ 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 50b617b8..83b14bf1 100644 --- a/public/music/js/player/sound-effects.js +++ b/public/music/js/player/sound-effects.js @@ -63,7 +63,7 @@ window.soundEffects = (function () { sessionStorage.getItem('lx_native_audio_restore') === '1'; } catch (_) { } - function needsNativeReload() { + function needsNativeRestore() { return !!(mediaSource || window._sharedAudioSourceNode); } @@ -217,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; @@ -247,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; @@ -267,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) { } @@ -394,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); @@ -409,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) @@ -419,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); + }); } } @@ -594,10 +603,10 @@ window.soundEffects = (function () { renderStatus(); const wireless = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless; const remote = wireless || !!window.LXPlayback?.isRemote; - const captured = needsNativeReload(); + const captured = needsNativeRestore(); const status = document.getElementById('sound-effects-status'); if (status) status.textContent = wireless && captured - ? '请刷新并恢复原生播放后,重新选择 AirPlay 设备' + ? '请恢复原生播放后,重新选择 AirPlay 设备' : remote ? '已保留音效配置;结束投播后可开启本机音效' : preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled) @@ -761,12 +770,40 @@ 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 && !window.LXPlayback?.isRemote) { + this.restoreNativePlayback(); + renderUI(); + return; + } if (enabled) ensureLocalGraph(true); if (canProcessLocally() && isGraphBuilt) { applyMaster(); @@ -924,29 +961,25 @@ window.soundEffects = (function () { }, isGraphBuilt: () => isGraphBuilt, isNativePlaybackPreferred: () => preferNativePlayback, - needsNativeReload, + needsNativeRestore, restoreNativePlayback: function () { - if (!this.needsNativeReload()) return; - // Disconnecting nodes cannot release a captured media element. + 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 { - const saved = window.savePlaybackState?.(); - if (saved !== true && saved !== null) { - window.showError?.('无法保存当前播放状态,请检查网站存储后重试。'); - return; - } sessionStorage.setItem('lx_native_audio_mode', '1'); - if (saved) sessionStorage.setItem('lx_native_audio_restore', '1'); - else sessionStorage.removeItem('lx_native_audio_restore'); - } catch (_) { - try { - sessionStorage.removeItem('lx_native_audio_mode'); - sessionStorage.removeItem('lx_native_audio_restore'); - } catch (_) { } - window.showError?.('无法保存原生播放状态,请允许网站存储后重试。'); - return; + sessionStorage.removeItem('lx_native_audio_restore'); + } catch (_) { } + if (captured) { + window.LXPlayback.replaceLocalElement(releaseAudioGraph); } - preferNativePlayback = true; - window.location.reload(); + renderUI(); + return true; }, getContext: () => audioContext || window._sharedAudioContext || null, getSourceNode: () => mediaSource || window._sharedAudioSourceNode || null, @@ -1044,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 f4d53b39..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'); @@ -1305,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 (_) { } @@ -1704,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 2b9e3170..79fd8bfb 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'lx-music-web-v61'; +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 index b11da707..243ed7c5 100644 --- a/test/airplay-native-handoff.test.cjs +++ b/test/airplay-native-handoff.test.cjs @@ -7,16 +7,21 @@ 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 = ['visualizer', 'ios-background-audio', 'sound-effects', 'airplay'] +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) { + 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); } @@ -37,10 +42,15 @@ function classes(initial = []) { function mediaElement() { return Object.assign(new Events(), { - paused: true, style: {}, attributes: {}, + 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; return Promise.resolve(); }, - pause() { this.paused = true; }, remove() {} + 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() {} }); } @@ -49,7 +59,7 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = 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: [] }; + 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, @@ -73,6 +83,11 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, '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: {} }; @@ -104,10 +119,11 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, 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(() => {}) }; + this.audioWorklet = { addModule: () => new Promise(resolve => observations.worklets.push(resolve)) }; } + close() { this.state = 'closed'; observations.closedContexts++; return Promise.resolve(); } createMediaElementSource(media) { - assert.equal(media, audio, 'capture must use the actual player media element'); + assert.equal(media, elements['audio-player'], 'capture must use the actual player media element'); observations.captures++; return node(); } createAnalyser() { return node(); } @@ -121,7 +137,7 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, } class Wave { constructor(analyser) { observations.waveAnalysers.push(analyser); } - clearAnimations() {} addAnimation() {} + clearAnimations() {} addAnimation() {} dispose() {} } const mobile = device !== 'desktop'; const window = Object.assign(new Events(), { @@ -139,7 +155,8 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, 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, localStorage: storage(local), sessionStorage: storage(session), + 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() {}, @@ -148,18 +165,19 @@ function fixture({ device = 'iphone', adaptive = true, nativeMode = false, }); 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 { - audio, button, modal, nativeButton, window, elements, effects: window.soundEffects, + 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() { audio.dispatch('play'); audio.dispatch('playing'); audio.dispatch('timeupdate'); }, + play() { const media = elements['audio-player']; media.paused = false; media.dispatch('play'); media.dispatch('playing'); media.dispatch('timeupdate'); }, clickAirPlay() { - audio.dispatch('webkitplaybacktargetavailabilitychanged', { availability: 'available' }); + elements['audio-player'].dispatch('webkitplaybacktargetavailabilitychanged', { availability: 'available' }); assert.equal(button.disabled, false, 'available picker must be reachable'); button.dispatch('click'); }, @@ -175,6 +193,149 @@ function savedEffects() { }, 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()]]) }); @@ -192,7 +353,7 @@ for (const device of ['iphone', 'desktop']) { 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.match(f.elements['sound-effects-mode'].textContent, /原生播放/); assert.equal(JSON.parse(f.local.get('lx_sound_effects')).settings.eq[0], 5); f.healthy(); }); @@ -247,7 +408,7 @@ for (const route of ['AirPlay', 'Cast']) { wireless: route === 'AirPlay', cast: route === 'Cast' }); f.effects.setReverb('hall'); f.audio.webkitCurrentPlaybackTargetIsWireless = false; - f.window.LXPlayback.isRemote = false; + f.window.LXPlayback.restoreLocal(); f.flushFrames(); assert.equal(f.effects.isNativePlaybackPreferred(), true); assert.equal(f.observations.captures, 0); @@ -256,32 +417,16 @@ for (const route of ['AirPlay', 'Cast']) { }); } -test('failed playback save prevents native reload and session flags', () => { - const f = fixture({ device: 'desktop' }); f.play(); - f.window.savePlaybackState = () => false; - f.effects.restoreNativePlayback(); - assert.equal(f.observations.reloads, 0); - assert.equal(f.session.size, 0); - assert.equal(f.effects.isNativePlaybackPreferred(), false); - assert.equal(f.observations.userErrors.length, 1); -}); - -test('empty playback state allows native reload without forcing old playback restoration', () => { - const f = fixture({ device: 'desktop' }); f.play(); - f.window.savePlaybackState = () => null; - f.effects.restoreNativePlayback(); - assert.equal(f.observations.reloads, 1); - assert.equal(f.session.get('lx_native_audio_mode'), '1'); - assert.equal(f.session.has('lx_native_audio_restore'), false); -}); - -test('partial session write is rolled back without reloading or enabling native mode', () => { - const f = fixture({ device: 'desktop', failSessionWrites: 'lx_native_audio_restore' }); f.play(); +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.session.size, 0); - assert.equal(f.effects.isNativePlaybackPreferred(), false); - assert.equal(f.observations.userErrors.length, 1); + assert.equal(f.effects.isNativePlaybackPreferred(), true); + assert.equal(f.effects.needsNativeRestore(), false); + assert.equal(f.observations.userErrors.length, 0); f.healthy(); }); @@ -352,7 +497,7 @@ for (const device of ['iphone', 'android', 'desktop']) { const f = fixture({ device, adaptive: false }); f.play(); assert.equal(f.observations.captures, 1); assert.equal(f.effects.isGraphBuilt(), false); - assert.equal(f.effects.needsNativeReload(), true); + assert.equal(f.effects.needsNativeRestore(), true); f.clickAirPlay(); assert.equal(f.observations.picked, 0); assert.equal(f.modal.classList.contains('hidden'), false); @@ -361,7 +506,7 @@ for (const device of ['iphone', 'android', 'desktop']) { f.healthy(); }); - test(`${device} native restoration preserves settings and prevents recapture after reload`, () => { + 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' }] })] @@ -369,10 +514,10 @@ for (const device of ['iphone', 'android', 'desktop']) { const before = [...local.entries()]; const f = fixture({ device, adaptive: false, local }); f.play(); f.effects.restoreNativePlayback(); - assert.equal(f.observations.saves, 1, 'save the current queue before reloading'); - assert.equal(f.observations.reloads, 1); + 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.get('lx_native_audio_restore'), '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(); @@ -461,17 +606,6 @@ test('Control Center handoff after capture opens an actionable restore prompt on f.healthy(); }); -test('native restore reports unavailable storage and does not reload', () => { - const f = fixture({ device: 'desktop', failSessionWrites: true }); f.play(); - f.effects.restoreNativePlayback(); - assert.equal(f.observations.reloads, 0); - assert.equal(f.observations.userErrors.length, 1); - assert.match(f.observations.userErrors[0], /存储|storage/i); - assert.equal(f.effects.isNativePlaybackPreferred(), false); - assert.equal(f.session.has('lx_native_audio_mode'), false); - 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); @@ -488,12 +622,12 @@ test('explicit iPhone EQ supplies the adaptive visualizer with its existing anal f.healthy(); }); -test('native preference survives consumption of the one-time playback restore flag', () => { +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.get('lx_native_audio_restore'), '1'); - // restorePlaybackState consumes this one-time queue/resume signal. + 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 });