Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/player-validation.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

### 🐛 缺陷修复与体验优化

- **搜索进度显示范围修复**:搜索状态条仅在网络搜索页显示,切换收藏分类或其他列表时隐藏,返回搜索后恢复当前状态,避免后台更新导致残留。
- **暗色模式开关状态修复**:修复开启后的开关底色被暗色灰色样式覆盖的问题,统一使用主题色区分开启与关闭状态。
- **AirPlay 原生播放恢复修复 (#511 @pokemonred06-creator)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改,恢复提示按指定时长显示。
- **无需刷新恢复原生播放**:关闭音效或点击恢复按钮时,通过新音频元素交接歌曲、进度、暂停状态、音量和倍速,释放旧音频图及 iOS 保活桥;保留队列和音效配置,切歌及快速开关不会套用旧进度。切换可能短暂缓冲,浏览器阻止续播时提示手动播放。
- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效,投播期间锁定本机音效控件。
- **删除用户时清理音源文件**:单个或批量删除用户并勾选删除数据时,同时清理该用户的自定义音源脚本、顺序及状态文件,保留公共音源和其他用户文件。

- **歌手与专辑详情切换及返回修复**:切换标签、收藏分类或歌单时清理详情头部与悬浮栏,避免残留到其他列表;修复打开专辑后歌手详情因容器被删除而一直加载的问题;记录详情入口,返回时恢复对应的收藏歌手、收藏专辑或原歌单,避免浏览器返回事件重复处理后跳到「我的喜爱」。
Expand Down
14 changes: 11 additions & 3 deletions docs/chromecast/architecture.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -15,7 +15,7 @@ Capabilities live only in bounded process memory (two hours, sixteen per owner,

Native same-origin audio, artwork and browser downloads use the HttpOnly player session cookie (Secure on HTTPS); the player removes account query tokens from these URLs. Cookie fallback is confined to read-only media handlers and validates the account and session lifetime. Legacy API query-token handling remains for compatibility, but the player does not generate it. API mutations still require their existing header authentication. Local sound-effect changes are saved during wireless playback without building or updating a local graph.

The current combined submission caches first-party modules under `lx-music-web-v51`. Google's SDK stays network-loaded. SDK failure or unsupported browsers leave local playback available.
The native AirPlay recovery update caches first-party modules under `lx-music-web-v62`. Google's SDK stays network-loaded. SDK failure or unsupported browsers leave local playback available.

References: [Google Web Sender setup](https://developers.google.com/cast/docs/web_sender), [integration](https://developers.google.com/cast/docs/web_sender/integrate), [supported media](https://developers.google.com/cast/docs/media).

Expand All @@ -27,4 +27,12 @@ Cookie recovery first checks native media access without API headers, then resto

Token verification returns the session owner and upgrades guest or other-account cookies to the authenticated account before native media. Existing-Token fast paths also verify this binding. Cast quota ownership uses only a validated, unexpired stored session or a verified account; unknown cookies share the public quota. Playlist card metadata is escaped in both layouts, with JSON arguments escaped for inline click attributes.

Mobile loads remain native until an explicit local effect edit opts into DSP. The panel reports native, local-processed or receiver output. After mobile capture the in-page AirPlay picker directs the user through a native reload, because disconnecting Web Audio does not release a captured media element. That reload preserves queue/position with a one-time session flag without changing the auto-resume preference. Existing wireless/Cast edits remain saved-only.
Mobile loads remain native until the master switch explicitly enables local effects. The panel shows native playback with a visible mode tag and an off switch, retaining all saved values in a disabled preview. Explicit enable uses the retained configuration, including neutral settings. Wireless playback locks the local controls and master switch; loading settings never opts native playback into DSP.

Master off or the native-restore action replaces a captured audio element without reloading the page. The replacement copies the source, position, paused state, volume, mute and playback rate. Playback is requested in the original click gesture; a browser rejection leaves the player paused and prompts a manual play. Buffering or a brief interruption is possible. AirPlay devices may need to be selected again after a handoff from an already captured element.

The old element stops forwarding events before it is paused and unloaded, so its pause/error/ended events cannot trigger recovery or queue advancement. Metadata restoration is scoped to the element and source generation: changing tracks or seeking cancels the old position, while rapid master toggles preserve a pending position. Cast refuses local replacement during an active remote session and still returns to the current local element.

Native restoration disposes the iOS bridge, the visualizer RAF loops and all graph nodes, clears shared graph references and closes the old context. Pending worklet and reverb callbacks cannot mutate a newer graph. Re-enabling effects builds a fresh graph for the new element. The vendored Wave renderer exposes dispose() to cancel its otherwise perpetual RAF loop.

The per-tab `lx_native_audio_mode` preference prevents visualization and effects from recapturing on playback, resize or a later reload. Storage is optional for live handoff; the queue and UI stay in memory and no playback-state save is required. The legacy one-time `lx_native_audio_restore` flag is consumed for compatibility but is no longer written by restoration. Reverb presets and resets render slider values without synthetic input callbacks, and disabling reverb preserves native mode when other saved effects remain active. Recovery toasts honor their display duration.
23 changes: 23 additions & 0 deletions docs/chromecast/progress.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Chromecast progress

## Live native audio handoff — 2026-11-07

- Master off and native recovery now replace the captured audio element without a page reload, retaining source, pending position, pause state, volume, mute and playback rate. Saved effects and the queue remain available; storage failure does not block live recovery.
- The playback proxy retains identity and listeners across replacements, suppresses old-element events and cancels stale seeks after a track change. AirPlay and visualization use the stable proxy; graph creation still uses the current DOM element. Cast sessions are not disconnected by local recovery.
- Dispose the old iOS bridge, visualizer RAF loops and shared graph/context. Guard late worklet/reverb callbacks from mutating a new graph. Explicit master enable builds a new graph using retained settings. Player cache advances to v62.
- Automated coverage includes paused/playing handoff, rapid toggles before metadata, track changes, old-element events, blocked storage, iOS bridge recreation, stale worklets and Cast return. No browser automation was run at the maintainer's request; rendered UI and physical Safari/AirPlay checks remain manual.
- Validation: all 139 tests pass (95 CJS and 44 TypeScript); TypeScript checking, production compilation, changed JavaScript syntax and diff checks pass. Handoff-specific regressions also cover autoplay rejection, native load resetting playback rate and disposal of queued visualizer frames.

## Native recovery follow-up review (superseded reload path) — 2026-11-07

- Fixed reverb reset leaving native mode when saved EQ remains active. Removed synthetic slider input animations that could enable DSP after remote playback returns local or overwrite a later gain edit.
- Playback saving now reports failure separately from an empty queue. Native reload is blocked on save failure; empty states do not set the one-time playback restore flag. Partial session writes are cleaned up, and recovery toasts honor their display duration.
- Added nine recovery regressions plus six native-preview/master-switch regressions. Native mode displays a prominent tag, an off master switch and read-only saved controls; explicit enable applies the retained configuration. Wireless controls stay locked. Player cache advances to v61. Physical Safari/AirPlay receiver validation remains outstanding; the maintainer will check the rendered UI.

## Native AirPlay capture recovery — 2026-11-07

- [x] Detect any captured media source, including visualizer-only capture on desktop, and expose native restoration on all platforms. System/Control Center handoffs show recovery once per connection without automatically reloading.
- [x] Keep native output across playback, resize and reload while retaining saved effects, queue/position and auto-resume preferences. Explicit local active-effect edits can resume DSP; remote edits, master-off edits and resets preserve native mode.
- [x] Preserve current upstream VU artwork, mobile DSP opt-in, master bypass, preset/reset controls and existing playback restoration.
- [x] Add 26 integrated capture, route, storage, playback and master-effect regressions plus Node 22/24 pull-request validation; refresh the worker cache to v59. The untouched upstream baseline failed 15 of the original 20 cases. The adapted branch passes all 117 tests (73 CJS and 44 TypeScript), type checking, production compilation and JavaScript/diff checks.
- [x] AirPlay playback on the deployed repair was user-confirmed; the exact device/receiver profile was not recorded.
- [ ] Maintainer review and merge. Broader physical device and receiver coverage remains unverified.

## Complete customization submission and version identity — 2026-10-20

### Expanded review before deployment
Expand Down
2 changes: 1 addition & 1 deletion public/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
// 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts)
// 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回
window.CONFIG = {
buildHash: '573b4fd',
buildHash: '8d260f0',
version: 'v2.2.0',
};
16 changes: 14 additions & 2 deletions public/music/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1853,6 +1853,7 @@ function switchTab(tabId) {
if (tabId !== 'search') window.SearchRequests?.cancel();

activeView.classList.remove('hidden');
window.refreshSearchStatusVisibility?.();
// small delay to allow display block to apply before opacity transition
setTimeout(() => {
activeView.classList.remove('opacity-0');
Expand Down Expand Up @@ -1903,6 +1904,7 @@ function switchTab(tabId) {
if (tabId === 'search') {
initGlobalListSearch(); // [New] 强制重置 ListSearch 为 'global' 模式
currentSearchScope = 'network';
window.refreshSearchStatusVisibility?.();
document.getElementById('search-source').classList.remove('hidden');
document.getElementById('search-type').classList.remove('hidden');
document.getElementById('page-title').innerText = "搜索音乐";
Expand Down Expand Up @@ -2401,6 +2403,7 @@ function performSearch(query, source = null) {
// 重置搜索范围到全网搜索
if (typeof currentSearchScope !== 'undefined') {
currentSearchScope = 'network';
window.refreshSearchStatusVisibility?.();
}

// 设置搜索框内容
Expand Down Expand Up @@ -4767,6 +4770,7 @@ function goBackToSearch(fromPopState = false) {
window.searchDetailOrigin = null;
if (origin) {
currentSearchScope = origin.scope;
window.refreshSearchStatusVisibility?.();
lastSearchResultList = origin.list;
lastSearchType = origin.type;
currentArtistId = null;
Expand Down Expand Up @@ -8243,7 +8247,7 @@ audio.addEventListener('pause', () => {
// ========================================

function savePlaybackState() {
if (!currentPlayingSong && !currentPlaylist?.length) return;
if (!currentPlayingSong && !currentPlaylist?.length) return null;
try {
const state = {
song: currentPlayingSong,
Expand All @@ -8259,8 +8263,10 @@ function savePlaybackState() {
timestamp: Date.now()
};
localStorage.setItem('lx_playback_state', JSON.stringify(state));
return true;
} catch (e) {
console.error('[Resume] 无法保存播放状态:', e);
return false;
}
}

Expand Down Expand Up @@ -12067,6 +12073,7 @@ function handleArtistLibraryClick() {
document.getElementById('search-type').classList.add('hidden');

currentSearchScope = 'lib_artists';
window.refreshSearchStatusVisibility?.();
window.currentViewingListId = '__lib_artists__';
renderLibraryArtists(window.libraryData.artists);
}
Expand Down Expand Up @@ -12100,6 +12107,7 @@ function handleAlbumLibraryClick() {
document.getElementById('search-type').classList.add('hidden');

currentSearchScope = 'lib_albums';
window.refreshSearchStatusVisibility?.();
window.currentViewingListId = '__lib_albums__';
renderLibraryAlbums(window.libraryData.albums);
}
Expand Down Expand Up @@ -13494,6 +13502,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) {
// Set current viewing list ID for batch operations
window.currentViewingListId = listId;
currentSearchScope = 'local_list';
window.refreshSearchStatusVisibility?.();

let list = [];
let title = '';
Expand Down Expand Up @@ -13536,6 +13545,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) {

// Set Scope
currentSearchScope = 'local_list';
window.refreshSearchStatusVisibility?.();
document.getElementById('search-source').classList.add('hidden'); // Hide selector
document.getElementById('search-type').classList.add('hidden');

Expand Down Expand Up @@ -13768,6 +13778,7 @@ function handleDislikeArtistsClick() {
exitListSecondaryModes();
window.currentViewingListId = 'dislike_artists';
currentSearchScope = 'local_list';
window.refreshSearchStatusVisibility?.();

// Highlight Header
document.querySelectorAll('[id^="tab-"]').forEach(el => {
Expand Down Expand Up @@ -13808,6 +13819,7 @@ function handleDislikeAlbumsClick() {
exitListSecondaryModes();
window.currentViewingListId = 'dislike_albums';
currentSearchScope = 'local_list';
window.refreshSearchStatusVisibility?.();

// Highlight Header
document.querySelectorAll('[id^="tab-"]').forEach(el => {
Expand Down Expand Up @@ -17163,7 +17175,7 @@ function showToast(type, message, duration = 3000) {

// 封装旧 API
function showSuccess(message) { showToast('success', message, 2000); }
function showInfo(message) { showToast('info', message, 2000); }
function showInfo(message, duration = 2000) { showToast('info', message, duration); }
function showError(message) { showToast('error', message, 2000); }

/**
Expand Down
Loading
Loading