// Run: node --experimental-vm-modules scripts/test_player_ui.mjs import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; const fields = new Map(); function element(id) { const classes = new Set(), attributes = new Map(), events = new Map(); const el = { id, dataset: {}, style: {}, children: [], queries: {}, events, isConnected: true, classList: { add: name => classes.add(name), remove: name => classes.delete(name), contains: name => classes.has(name), toggle(name, value) { value ? classes.add(name) : classes.delete(name); } }, appendChild(child) { child.parentElement?.children.splice(child.parentElement.children.indexOf(child), 1); child.parentElement = el; el.children.push(child); }, insertBefore(child, target) { if (child === target) return; child.parentElement?.children.splice(child.parentElement.children.indexOf(child), 1); child.parentElement = el; el.children.splice(target ? el.children.indexOf(target) : el.children.length, 0, child); }, removeAttribute: name => attributes.delete(name), setAttribute: (name, value) => attributes.set(name, value), getAttribute: name => attributes.get(name), addEventListener: (name, callback) => events.set(name, callback), querySelector: selector => el.queries[selector] || null, querySelectorAll: selector => el.queries[selector] || [], contains(child) { return Boolean(child && (child === el || el.children.some(node => node.contains(child)))); }, closest: () => null, focus() { document.activeElement = el; }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 100 }), scrollTo() {} }; Object.defineProperty(el, 'firstChild', { get: () => el.children[0] || null }); fields.set(id, el); return el; } const body = element('body'), full = element('fullscreen-player'), stage = element('fs-stage'), actions = element('actions'), songActions = element('fs-track-actions'); const favorite = element('fs-btn-favorite'), add = element('fs-btn-add'), lyrics = element('lyrics-panel'), queue = element('queue-panel'), lyricsBody = element('lyrics-body'); const viewButtons = ['cover', 'lyrics', 'queue'].map(value => { const button = element(`view-${value}`); button.dataset.fsView = value; return button; }); element('queue-list'); element('fs-view-label'); const minimize = element('minimize'); element('player-cover'); body.appendChild(full); full.appendChild(stage); full.appendChild(actions); full.appendChild(songActions); body.appendChild(lyrics); body.appendChild(queue); songActions.appendChild(favorite); songActions.appendChild(add); viewButtons.forEach(button => actions.appendChild(button)); full.queries['.fs-actions'] = actions; actions.queries['.lyrics-toggle-btn'] = viewButtons[1]; full.queries['[data-fs-view]'] = viewButtons; full.queries['.fs-minimize'] = minimize; full.queries['[data-fs-view="cover"]'] = viewButtons[0]; const volumeBars = [element('volume-bar'), element('fs-volume-bar')], fills = [element('fill1'), element('fill2')], knobs = [element('knob1'), element('knob2')]; const muteButtons = [element('btn-volume-icon'), element('fs-volume-icon')]; const volumePopover = element('volume-popover'); full.queries['.fs-volume'] = volumePopover; const detailStage = element('fs-details'), info = element('fs-info'), cover = element('fs-cover'), volumeSlot = element('fs-volume-slot'), volumePanel = element('fs-volume-panel'); const more = element('fs-more'), menu = element('fs-more-menu'), controls = element('fs-controls'); fields.set('fs-actions', actions); fields.set('fs-btn-cover', viewButtons[0]); const shuffle = element('fs-btn-shuffle'), repeat = element('fs-btn-repeat'), sleep = element('fs-btn-sleep'), middle = element('fs-audio-action'); element('fs-btn-previous'); element('fs-btn-next'); element('fs-time-remaining'); full.appendChild(detailStage); full.appendChild(info); full.appendChild(volumeSlot); full.appendChild(more); full.queries['.fs-more'] = more; more.appendChild(menu); volumePopover.appendChild(volumePanel); stage.appendChild(cover); controls.appendChild(shuffle); controls.appendChild(repeat); actions.appendChild(middle); actions.appendChild(sleep); const progress = element('fs-progress-bar'); progress.queries['.progress-knob, .volume-knob, .fs-progress-knob'] = knobs[0]; const document = { body, activeElement: body, documentElement: { dataset: { theme: 'classic' } }, addEventListener() {}, dispatchEvent() {}, getElementById: id => fields.get(id), querySelector: selector => selector === '#lyrics-panel .lyrics-body' ? lyricsBody : null, querySelectorAll: selector => selector === '.volume-bar-fill' ? fills : selector === '.volume-knob' ? knobs : selector === '#volume-bar, #fs-volume-bar' ? volumeBars : selector === '#btn-volume-icon, #fs-volume-icon' ? muteButtons : selector === '.lyrics-toggle-btn' ? [viewButtons[1]] : selector === '#progress-bar, #fs-progress-bar' ? [progress] : [] }; const window = { matchMedia: () => ({ matches: false }) }; const values = { isFullscreenPlayerOpen: false, isQueueOpen: false, currentTrack: { db_id: 1, title: 'First', artist: 'Artist', is_local: true }, userFavorites: new Set([1]), repeatMode: 'off', isPlaying: false, shuffleMode: false, contextQueue: [], contextIndex: -1, userQueue: [], audio: { duration: 120, currentTime: 0 }, ytReady: false, ytPlayer: null, API: '/api' }; let stateModule, volume = 0.7, respond = async () => ({ ok: true, json: async () => ({ plain: 'New lyrics' }) }); for (const [setter, key] of [['setIsFullscreenPlayerOpen', 'isFullscreenPlayerOpen'], ['setIsQueueOpen', 'isQueueOpen']]) values[setter] = value => { values[key] = value; stateModule.setExport(key, value); }; const context = vm.createContext({ window, document, console, AbortController, URLSearchParams, CustomEvent: class { constructor(type, data) { this.type = type; Object.assign(this, data); } }, setTimeout, clearTimeout, fetch: (...args) => respond(...args) }); function synthetic(values) { return new vm.SyntheticModule(Object.keys(values), function () { for (const [key, value] of Object.entries(values)) this.setExport(key, value); }, { context }); } stateModule = synthetic(values); const engine = synthetic({ getVolume: () => volume, setVolume: value => { volume = Math.max(0, Math.min(1, value)); } }); const imports = { './state.js': stateModule, './audio_engine.js': engine, './player.js': synthetic({ persistPlaybackSession() {} }) }; async function load(name) { const module = new vm.SourceTextModule(await readFile(new URL(`../Aoede/assets/js/modules/${name}.js`, import.meta.url), 'utf8') + (name === 'lyrics' ? '\nexport { parseLRC, lineAt, playbackTime };' : ''), { context }); await module.link(key => imports[key]); await module.evaluate(); imports[`./${name}.js`] = module; return module.namespace; } const ui = await load('ui'), queueApi = await load('queue'), lyricsApi = await load('lyrics'); // Transport gestures: one horizontal swipe, with taps, scrolling and controls isolated. const swipeSurface = element('swipe-surface'), swipeActions = []; ui.setupTrackSwipe(swipeSurface, action => swipeActions.push(action)); const touch = (x, y = 0, identifier = 1) => ({ clientX: x, clientY: y, identifier }); function touchEvent(type, points, changedTouches = [], target = swipeSurface) { const event = { touches: points, changedTouches, target, cancelable: true, prevented: false, preventDefault() { this.prevented = true; } }; swipeSurface.events.get(type)(event); return event; } const swipe = (start, end, target = swipeSurface) => { touchEvent('touchstart', [start], [], target); const move = touchEvent('touchmove', [end], [], target); touchEvent('touchend', [], [end], target); return move; }; const normalMatchMedia = window.matchMedia; const gestureClock = vm.runInContext('Date', context), realNow = gestureClock.now; let gestureTime = 1000; gestureClock.now = () => gestureTime; window.matchMedia = () => ({ matches: true }); assert.equal(swipe(touch(180), touch(80)).prevented, true); swipe(touch(80), touch(180)); assert.deepEqual(swipeActions, ['next', 'previous']); const click = detail => ({ detail, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopImmediatePropagation() { this.stopped = true; } }); let ghost = click(1); swipeSurface.events.get('click')(ghost); assert.equal(ghost.prevented && ghost.stopped, true); const keyboardClick = click(0); swipeSurface.events.get('click')(keyboardClick); assert.equal(keyboardClick.prevented, false); swipe(touch(120), touch(120)); ghost = click(1); swipeSurface.events.get('click')(ghost); assert.equal(ghost.prevented, false); assert.equal(swipe(touch(120), touch(116, 100)).prevented, false); swipe(touch(120), touch(98, 2)); // Short horizontal drag. swipe(touch(120), touch(20, 90)); // Diagonal scroll. swipe(touch(120), touch(20), { closest: () => ({}) }); // A button/slider/interactive child. touchEvent('touchstart', [touch(120)]); touchEvent('touchmove', [touch(20), touch(80, 0, 2)]); touchEvent('touchend', [], [touch(20)]); touchEvent('touchstart', [touch(120), touch(80, 0, 2)]); touchEvent('touchend', [], [touch(20)]); touchEvent('touchstart', [touch(120)]); touchEvent('touchcancel', []); touchEvent('touchend', [], [touch(20)]); touchEvent('touchstart', [touch(120)]); touchEvent('touchmove', [touch(20, 0, 2)]); // A different touch cannot finish an abandoned gesture. touchEvent('touchend', [], [touch(20, 0, 2)]); touchEvent('touchstart', [touch(120)]); touchEvent('touchmove', [touch(122, 30)]); touchEvent('touchend', [], [touch(20, 35)]); // Do not reclassify a vertical gesture at release. touchEvent('touchstart', [touch(120)]); gestureTime += 900; touchEvent('touchend', [], [touch(20)]); // A long press is not a swipe. stateModule.setExport('currentTrack', null); swipe(touch(180), touch(80)); stateModule.setExport('currentTrack', values.currentTrack); window.matchMedia = normalMatchMedia; gestureClock.now = realNow; swipe(touch(180), touch(80)); // Mouse/desktop layouts keep their existing behavior. assert.deepEqual(swipeActions, ['next', 'previous']); console.log('PASS: mobile track gestures preserve taps, keyboard clicks, controls, vertical scrolling and cancelled/multi-touch input.'); // Native full-player gestures move artwork, preserve controls and dismiss without audio commands. const gesturePanel = element('gesture-panel'), gestureStage = element('gesture-stage'), gestureCover = element('gesture-cover'); gestureStage.classList.add('fs-stage'); gesturePanel.appendChild(gestureStage); gestureStage.appendChild(gestureCover); const frames = [], nativeActions = []; function animateFrames(keyframes) { frames.push(keyframes); return { finished: Promise.resolve(), cancel() {} }; } gesturePanel.animate = gestureCover.animate = animateFrames; gestureCover.cloneNode = () => ({ parentElement: null, removeAttribute() {}, setAttribute() {}, animate: animateFrames, remove() { if (this.parentElement) this.parentElement.children.splice(this.parentElement.children.indexOf(this), 1); this.parentElement = null; } }); gesturePanel.querySelectorAll = () => gestureStage.children.filter(child => child.className === 'fs-cover-ghost'); window.AOEDE_ANDROID = true; window.matchMedia = query => ({ matches: query.startsWith('(max-width:') }); stateModule.setExport('isFullscreenPlayerOpen', true); const originalAudio = values.audio; let dismisses = 0; ui.setupTrackSwipe(gesturePanel, direction => { nativeActions.push(direction); stateModule.setExport('currentTrack', { db_id: nativeActions.length + 10 }); }, { artwork: gestureCover, dismiss: () => { dismisses++; stateModule.setExport('isFullscreenPlayerOpen', false); } }); function nativeTouch(type, points, changed = [], target = gestureCover) { const event = { touches: points, changedTouches: changed, target, cancelable: true, preventDefault() { this.prevented = true; } }; gesturePanel.events.get(type)(event); return event; } nativeTouch('touchstart', [touch(200, 120)]); nativeTouch('touchmove', [touch(100, 125)]); assert.match(gestureCover.style.transform, /translateX\(-100px\)/); nativeTouch('touchend', [], [touch(100, 125)]); await new Promise(setImmediate); assert.deepEqual(nativeActions, ['next']); assert.ok(frames.length >= 2); assert.equal(gesturePanel.querySelectorAll().length, 0); nativeTouch('touchstart', [touch(150, 100)]); nativeTouch('touchmove', [touch(151, 220)]); assert.match(gesturePanel.style.transform, /translateY\(120px\)/); nativeTouch('touchend', [], [touch(151, 220)]); assert.equal(dismisses, 1); assert.equal(gesturePanel.style.transform, ''); assert.equal(values.audio, originalAudio); stateModule.setExport('isFullscreenPlayerOpen', true); for (const target of [ { closest: selector => selector.includes('button') ? {} : null }, { closest: selector => selector.includes('#lyrics-panel') ? {} : null } ]) { nativeTouch('touchstart', [touch(150, 100)], [], target); nativeTouch('touchmove', [touch(150, 250)], [], target); nativeTouch('touchend', [], [touch(150, 250)], target); } assert.equal(dismisses, 1); nativeTouch('touchstart', [touch(150, 100)]); nativeTouch('touchmove', [touch(150, 0)]); nativeTouch('touchend', [], [touch(150, 0)]); assert.equal(dismisses, 1); // Upward scrolling does not minimize. nativeTouch('touchstart', [touch(150, 100)]); nativeTouch('touchmove', [touch(80, 100)]); nativeTouch('touchcancel', []); nativeTouch('touchend', [], [touch(80, 100)]); assert.deepEqual(nativeActions, ['next']); assert.equal(gestureCover.style.transform, ''); const beforeReduced = frames.length; document.documentElement.dataset.nativeEffects = 'reduced'; nativeTouch('touchstart', [touch(80, 100)]); nativeTouch('touchmove', [touch(180, 100)]); nativeTouch('touchend', [], [touch(180, 100)]); await new Promise(setImmediate); assert.deepEqual(nativeActions, ['next', 'previous']); assert.equal(frames.length, beforeReduced); delete window.AOEDE_ANDROID; delete document.documentElement.dataset.nativeEffects; window.matchMedia = normalMatchMedia; stateModule.setExport('isFullscreenPlayerOpen', false); stateModule.setExport('currentTrack', values.currentTrack); console.log('PASS: native carousel, downward dismissal, gesture cancellation, reduced effects and protected controls/lyrics.'); // Dismiss controls remain named and never submit a surrounding form, even without Lucide. const closeButton = element('close-test'), namedClose = element('named-close-test'); namedClose.setAttribute('aria-label', 'Close collaborators'); ui.refreshIcons({ querySelectorAll: selector => selector.includes('button.modal-close') ? [closeButton, namedClose] : [] }); assert.equal(closeButton.getAttribute('aria-label'), 'Close'); assert.equal(closeButton.getAttribute('type'), 'button'); assert.equal(namedClose.getAttribute('aria-label'), 'Close collaborators'); imports['./api.js'] = synthetic({}); imports['./offline_store.js'] = synthetic({}); const favoritesApi = await load('favorites'); window.toggleQueue = queueApi.toggleQueue; window.closeLyricsPanel = lyricsApi.closeLyricsPanel; ui.initFullscreenPlayer(); volumePopover.open = true; full.events.get('click')({ composedPath: () => [muteButtons[1], volumePopover, full] }); assert.equal(volumePopover.open, true); // A replaced SVG target still belongs to the popup's event path. full.events.get('click')({ composedPath: () => [stage, full] }); assert.equal(volumePopover.open, false); assert.equal(favorite.parentElement, songActions); ui.toggleFullscreenPlayer(); assert.equal(lyrics.parentElement, stage); assert.equal(queue.parentElement, stage); assert.equal(full.getAttribute('aria-hidden'), 'false'); assert.equal(document.activeElement, minimize); // A response whose JSON decoding finishes after a new track must not replace it. let finishOld; respond = async () => ({ ok: true, json: () => new Promise(resolve => { finishOld = resolve; }) }); lyricsApi.toggleLyricsPanel(); await new Promise(setImmediate); assert.equal(full.dataset.view, 'lyrics'); values.currentTrack = { db_id: 2, title: 'Second', artist: 'Other artist' }; stateModule.setExport('currentTrack', values.currentTrack); respond = async () => ({ ok: true, json: async () => ({ plain: 'Current song lyrics' }) }); lyricsApi.onTrackChange(values.currentTrack); await new Promise(setImmediate); finishOld({ plain: 'Stale lyrics' }); await new Promise(setImmediate); assert.match(lyricsBody.innerHTML, /Current song lyrics/); assert.doesNotMatch(lyricsBody.innerHTML, /Stale lyrics/); queueApi.toggleQueue(); assert.equal(full.dataset.view, 'queue'); assert.equal(lyricsApi.isLyricsPanelOpen(), false); lyricsApi.toggleLyricsPanel(); assert.equal(full.dataset.view, 'lyrics'); assert.equal(values.isQueueOpen, false); lyricsApi.closeLyricsPanel(); values.currentTrack = { db_id: 3, title: 'Retry', artist: 'Artist' }; stateModule.setExport('currentTrack', values.currentTrack); let lyricRequests = 0; respond = async () => { lyricRequests++; return { ok: false }; }; lyricsApi.toggleLyricsPanel(); await new Promise(setImmediate); lyricsApi.closeLyricsPanel(); respond = async () => { lyricRequests++; return { ok: true, json: async () => ({ plain: 'Retried lyrics' }) }; }; lyricsApi.toggleLyricsPanel(); await new Promise(setImmediate); assert.equal(lyricRequests, 2); assert.match(lyricsBody.innerHTML, /Retried lyrics/); ui.showFullscreenCover(); assert.equal(full.dataset.view, 'cover'); ui.toggleFullscreenPlayer(); assert.equal(lyrics.parentElement, body); assert.equal(queue.parentElement, body); assert.equal(full.getAttribute('aria-hidden'), 'true'); // All themes embed the same panels and retain the selected view on a theme change. for (const theme of ['classic', 'glass', 'vitrine']) { document.documentElement.dataset.theme = theme; for (const playing of [false, true, false]) { stateModule.setExport('isPlaying', playing); ui.updateFullscreenPlayButton(); assert.equal(full.dataset.playing, String(playing)); } ui.toggleFullscreenPlayer(); queueApi.toggleQueue(); assert.equal(full.dataset.view, 'queue'); assert.equal(queue.parentElement, theme === 'vitrine' ? detailStage : stage); if (theme === 'vitrine') { assert.equal(cover.parentElement, info); assert.equal(volumePanel.parentElement, volumeSlot); assert.equal(shuffle.parentElement, menu); } lyricsApi.toggleLyricsPanel(); await new Promise(setImmediate); assert.equal(full.dataset.view, 'lyrics'); assert.equal(values.isQueueOpen, false); document.documentElement.dataset.theme = theme === 'glass' ? 'classic' : 'glass'; ui.syncFullscreenPlayerLayout(); assert.equal(full.dataset.view, 'lyrics'); assert.equal(lyrics.parentElement, stage); assert.equal(favorite.parentElement, songActions); assert.equal(add.parentElement, songActions); assert.equal(cover.parentElement, stage); assert.equal(volumePanel.parentElement, volumePopover); assert.equal(shuffle.parentElement, controls); assert.deepEqual(actions.children.map(node => node.id), ['view-cover', 'view-lyrics', 'view-queue', 'fs-audio-action', 'fs-btn-sleep']); queueApi.toggleQueue(); assert.equal(full.dataset.view, 'queue'); assert.equal(lyricsApi.isLyricsPanelOpen(), false); ui.toggleFullscreenPlayer(); assert.equal(lyrics.parentElement, body); assert.equal(queue.parentElement, body); assert.equal(values.isQueueOpen, false); assert.equal(lyricsApi.isLyricsPanelOpen(), false); } respond = async () => ({ ok: true, json: async () => ({ liked: false }) }); await favoritesApi.toggleFavorite(1, favorite); assert.equal(favorite.classList.contains('active'), false); assert.equal(favorite.getAttribute('aria-pressed'), 'false'); values.currentTrack = { id: 'fed_3_4', fed_instance_id: 3, fed_remote_id: 4, is_local: false }; stateModule.setExport('currentTrack', values.currentTrack); ui.updateFullscreenPlayButton(); assert.equal(favorite.disabled, true); assert.equal(add.disabled, false); values.currentTrack = { id: 'youtube-preview', is_local: false }; stateModule.setExport('currentTrack', values.currentTrack); ui.updateFullscreenPlayButton(); assert.equal(add.disabled, true); values.currentTrack = { db_id: 1 }; stateModule.setExport('currentTrack', values.currentTrack); ui.updateFullscreenPlayButton(); assert.equal(favorite.disabled, false); // Older saved local tracks may omit is_local. ui.updateVolumeUI(); assert.deepEqual(fills.map(fill => fill.style.width), ['70%', '70%']); ui.toggleMute(); assert.equal(volume, 0); assert.equal(muteButtons[1].getAttribute('aria-pressed'), 'true'); ui.toggleMute(); assert.equal(volume, 0.7); ui.nudgeVolume(1); assert.deepEqual(fills.map(fill => fill.style.width), ['100%', '100%']); assert.ok(knobs.every(knob => knob.style.left === 'clamp(6px, 100%, calc(100% - 6px))' && knob.style.right === 'auto')); const seeks = []; ui.setupDraggable(progress, (pct, dragging) => seeks.push([pct, dragging])); ui.updateUIProgress(30, 120); assert.equal(fields.get('fs-time-remaining').innerText, '−1:30'); assert.equal(progress.getAttribute('aria-valuenow'), '25'); let stopped = false, prevented = false; const key = value => progress.events.get('keydown')({ key: value, preventDefault() { prevented = true; }, stopPropagation() { stopped = true; } }); key('ArrowRight'); assert.deepEqual(seeks.at(-1), [0.3, false]); assert.ok(stopped && prevented); key('End'); assert.deepEqual(seeks.at(-1), [1, false]); key('Home'); assert.deepEqual(seeks.at(-1), [0, false]); ui.updateUIProgress(10, Infinity); assert.equal(progress.getAttribute('aria-disabled'), 'true'); const count = seeks.length; key('ArrowRight'); assert.equal(seeks.length, count); progress.events.get('mousedown')({ clientX: 50 }); assert.equal(seeks.length, count); // Exercise the seek dispatcher without exporting an internal helper in production. const playerModule = new vm.SourceTextModule((await readFile(new URL('../Aoede/assets/js/modules/player.js', import.meta.url), 'utf8')) + '\nexport { seekToPercentage };', { context }); await playerModule.link(key => imports[key]); await playerModule.evaluate(); const routedSeeks = []; values.ytPlayer = { seekTo: (...args) => routedSeeks.push(args) }; stateModule.setExport('ytPlayer', values.ytPlayer); stateModule.setExport('currentTrack', { source: 'youtube', is_local: false }); playerModule.namespace.seekToPercentage(0.5); assert.equal(routedSeeks.length, 0); stateModule.setExport('ytReady', true); playerModule.namespace.seekToPercentage(0.5); assert.deepEqual(routedSeeks.at(-1), [15, true]); let partySeek; playerModule.namespace.setPartyController({ isActive: () => true, seekTo: value => { partySeek = value; } }); playerModule.namespace.seekToPercentage(0.25); assert.equal(partySeek, 30); assert.equal(routedSeeks.length, 1); playerModule.namespace.setPartyController(null);stateModule.setExport('currentTrack', { db_id: 1, is_local: true }); values.audio.duration = 120; playerModule.namespace.seekToPercentage(0.25); assert.equal(values.audio.currentTime, 30); values.audio.duration = Infinity; playerModule.namespace.seekToPercentage(0.5); assert.equal(values.audio.currentTime, 30); console.log('PASS: shared Classic/Glass/Vitrine player layout, theme changes with open panels, stale lyrics, synchronized volume and keyboard seeking.'); // The global player shortcut must not consume Space on a menu disclosure. const mainSource = await readFile(new URL('../Aoede/assets/js/main.js', import.meta.url), 'utf8'); let shortcut, playClicks = 0; document.addEventListener = (_name, callback) => { shortcut = callback; }; element('play-pause-btn').click = () => { playClicks++; }; context.state = values; vm.runInContext(mainSource.match(/function isTypingTarget\(el\) \{[\s\S]*?\n\}/)[0] + '\n' + mainSource.match(/function initKeyboardShortcuts\(\) \{[\s\S]*?\n\}/)[0] + '\ninitKeyboardShortcuts();', context); for (const tagName of ['BUTTON', 'SUMMARY', 'BODY']) { let prevented = false; shortcut({ target: { tagName }, code: 'Space', key: ' ', preventDefault() { prevented = true; } }); assert.equal(prevented, tagName === 'BODY'); } assert.equal(playClicks, 1); console.log('PASS: Space opens native menu disclosures without triggering the player.'); // LRC offsets, fractions, multiple stamps, invalid seconds and backward seeks. assert.equal(JSON.stringify(lyricsApi.parseLRC('[offset:+500]\n[00:02.00][00:04:250] Chorus\n[00:65]invalid')), JSON.stringify([{time:1.5,text:'Chorus'},{time:3.75,text:'Chorus'}])); const lines = [{time:2}, {time:4}, {time:4}, {time:10}]; assert.equal(lyricsApi.lineAt(lines, 0), -1); assert.equal(lyricsApi.lineAt(lines, 4), 2); assert.equal(lyricsApi.lineAt(lines, 20), 3); assert.equal(lyricsApi.lineAt(lines, 3), 0); respond = async () => ({ ok: true, json: async () => ({ synced: 'malformed timestamps', plain: 'Plain fallback' }) }); await lyricsApi.loadLyricsFor({ db_id: 7, fed_instance_id: 1, title: 'Same ID' }); assert.match(lyricsBody.innerHTML, /Plain fallback/); let requestCount = 0; respond = async () => { requestCount++; return { ok: true, json: async () => ({ plain: 'Another node' }) }; }; await lyricsApi.loadLyricsFor({ db_id: 7, fed_instance_id: 2, title: 'Same ID' }); assert.equal(requestCount, 1); let finishRemoved; respond = async () => ({ ok: true, json: () => new Promise(resolve => { finishRemoved = resolve; }) }); const loading = lyricsApi.loadLyricsFor({ db_id: 42, title: 'Removed' }); await new Promise(setImmediate); await lyricsApi.loadLyricsFor(null); finishRemoved({plain:'stale after stop'}); await loading; assert.doesNotMatch(lyricsBody.innerHTML, /stale after stop/); stateModule.setExport('ytPlayer', { getCurrentTime: () => 12.5 }); stateModule.setExport('currentTrack', { source: 'youtube', is_local: false }); assert.equal(lyricsApi.playbackTime(), 12.5); stateModule.setExport('currentTrack', { source: 'youtube', fed_instance_id: 2 }); assert.equal(lyricsApi.playbackTime(), values.audio.currentTime); console.log('PASS: lyric offsets, intro, backward seeks, malformed LRC fallback, federated identity, cleared-track races and preview clocks.'); // Exercise highlighting against rendered line nodes, including reopening a cached song. const lyricNodes = [element('lyric-0'), element('lyric-1'), element('lyric-2')]; let scrollCalls = 0; lyricsBody.querySelectorAll = () => lyricNodes.filter(node => node.classList.contains('active')); lyricsBody.querySelector = selector => lyricNodes[Number(selector.match(/data-idx="(-?\d+)"/)?.[1])] || null; lyricsBody.scrollTo = () => { scrollCalls++; }; values.audio.currentTime = 0; values.audio.duration = 999; stateModule.setExport('currentTrack', { db_id: 1000, title: 'Timing', duration: 120 }); respond = async (url) => { assert.equal(new URL(url, 'https://aoede.test').searchParams.get('duration'), '120'); return { ok: true, json: async () => ({ synced: '[00:02]First\n[00:04]Second\n[00:10]Last' }) }; }; lyricsApi.toggleLyricsPanel(); await new Promise(setImmediate); assert.equal(lyricNodes.some(node => node.classList.contains('active')), false); lyricsApi.tick(4.5); assert.equal(lyricNodes[1].getAttribute('aria-current'), 'true'); lyricsApi.tick(2.5); assert.equal(lyricNodes[0].getAttribute('aria-current'), 'true'); assert.equal(lyricNodes[1].getAttribute('aria-current'), undefined); lyricsApi.closeLyricsPanel(); values.audio.currentTime = 10.5; const previousScrolls = scrollCalls; lyricsApi.toggleLyricsPanel(); assert.equal(lyricNodes[2].getAttribute('aria-current'), 'true'); assert.ok(scrollCalls > previousScrolls); lyricsApi.closeLyricsPanel(); respond = async () => ({ ok: true, json: async () => ({ synced: '', plain: '' }) }); await lyricsApi.loadLyricsFor({db_id:1001}); assert.match(lyricsBody.innerHTML, /No lyrics found/); console.log('PASS: rendered highlighting, backward seek, cached reopen, recording duration and empty lyric state.');