From 9542330125c451bab6c6225e75ffd1973ebb9ab7 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 19:07:29 +0700 Subject: [PATCH] up --- electron/media-player.test.ts | 174 ++++++++++++++++++++++++++++++++++ electron/remote-state.ts | 8 +- src/utils/media-player.ts | 72 +++++++++++++- 3 files changed, 249 insertions(+), 5 deletions(-) diff --git a/electron/media-player.test.ts b/electron/media-player.test.ts index c47c341..28bd066 100644 --- a/electron/media-player.test.ts +++ b/electron/media-player.test.ts @@ -6,6 +6,7 @@ import { MediaPlayer } from '../src/utils/media-player.js' import { buildScreenPackage } from './screen-package.js' import { packageKey, type LocalPackage } from './package-store.js' import type { Intent, Report } from '../src/types/screen.js' +import { applyRemote, parseRemote } from './remote-state.js' function send(c: Controller, intent: Intent) { c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent }) @@ -291,3 +292,176 @@ test('DOM: следующий трек не предзагружается до assert.equal(spare.paused, false) assert.equal(playing.paused, true) }) + +test('DOM: пауза музыки затихает за секунду, snapshots не возвращают громкость и позицию', async (t) => { + const f = await fixture(t) + send(f.c, { action: 'volume', channel: 'music', volume: 80 }) + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const media = [f.a, f.b].find((m) => !m.paused)! + media.currentTime = 44 + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) + send(f.c, { action: 'pause', channel: 'music' }) + f.apply() + await setImmediate() + assert.equal(media.paused, false) + t.mock.timers.tick(500) + assert.ok(Math.abs(media.volume - 0.4) < 0.01) + assert.equal(f.c.state.channels.music.volume, 80) + f.c.state.revision++ + f.apply() + assert.ok(Math.abs(media.volume - 0.4) < 0.01) + // Повтор команды тоже не продлевает затухание. + send(f.c, { action: 'pause', channel: 'music' }) + f.apply() + await setImmediate() + media.currentTime = 45 + t.mock.timers.tick(500) + await setImmediate() + assert.equal(media.paused, true) + assert.equal(media.volume, 0.8) + assert.equal(media.currentTime, 45) + const report = f.reports.at(-1)! + assert.equal(report.status, 'paused') + assert.equal(report.position, 45) + f.c.report(report) + assert.equal(f.c.state.channels.music.position, 45) +}) + +test('DOM: продолжение отменяет затухание, изменение громкости не сбрасывает его', async (t) => { + const f = await fixture(t) + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const media = [f.a, f.b].find((m) => !m.paused)! + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) + send(f.c, { action: 'pause', channel: 'music' }) + f.apply() + await setImmediate() + t.mock.timers.tick(500) + send(f.c, { action: 'volume', channel: 'music', volume: 60 }) + f.apply() + assert.ok(Math.abs(media.volume - 0.3) < 0.01) + send(f.c, { action: 'resume', channel: 'music' }) + f.apply() + await setImmediate() + assert.equal(media.volume, 0.6) + assert.equal(media.paused, false) + t.mock.timers.tick(1500) + assert.equal(media.paused, false) + assert.equal(media.volume, 0.6) +}) + +for (const action of ['stop', 'next', 'error', 'session', 'destroy'] as const) { + test(`DOM: затухание отменяется при ${action}, поздняя пауза не затрагивает новый звук`, async (t) => { + const f = await fixture(t) + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const media = [f.a, f.b].find((m) => !m.paused)! + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) + send(f.c, { action: 'pause', channel: 'music' }) + f.apply() + await setImmediate() + t.mock.timers.tick(400) + if (action === 'destroy') f.player.destroy() + else if (action === 'error') { + media.error = { code: 3 } as MediaError + media.dispatchEvent(new Event('error')) + } else if (action === 'session') { + f.c.selectPackage(testPackage()) + f.apply() + } else { + send(f.c, { action, channel: 'music' }) + f.apply() + } + await setImmediate() + assert.equal(media.paused, true) + if (action === 'next' || action === 'stop') { + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const playing = [f.a, f.b].find((m) => !m.paused)! + assert.ok(playing) + t.mock.timers.tick(1500) + assert.equal(playing.paused, false) + assert.equal(playing.volume, f.c.state.channels.music.volume / 100) + } else { + const reports = f.reports.length + t.mock.timers.tick(1500) + assert.equal(f.reports.length, reports) + assert.equal(f.a.paused, true) + assert.equal(f.b.paused, true) + } + }) +} + +for (const silent of ['muted', 'zero', 'stopped'] as const) { + test(`DOM: беззвучная музыка ставится на паузу сразу: ${silent}`, async (t) => { + const f = await fixture(t) + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const media = [f.a, f.b].find((m) => !m.paused)! + if (silent === 'muted') send(f.c, { action: 'mute', enabled: true }) + if (silent === 'zero') send(f.c, { action: 'volume', channel: 'music', volume: 0 }) + if (silent === 'stopped') { + send(f.c, { action: 'stop', channel: 'music' }) + f.apply() + await setImmediate() + } + send(f.c, { action: 'pause', channel: 'music' }) + f.apply() + await setImmediate() + assert.equal(media.paused, true) + assert.equal(f.reports.at(-1)!.status, 'paused') + }) +} + +for (const mode of [2, 4]) { + test(`DOM: серверная автопауза при режиме ${mode} затихает и возобновляется без остановки основного канала`, async (t) => { + const f = await fixture(t) + const music = (version: number, playback: number, positionMs: number) => ({ + stateVersion: String(version), playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, + trackIndex: 0, playback, positionMs, volume: 80, loop: true, + }) + const playing = parseRemote({ mode: 1, stateVersion: '1', music: music(1, 2, 0) }) + applyRemote(f.c, parseRemote({}), playing) + f.apply() + await setImmediate() + const media = [f.a, f.b].find((m) => !m.paused)! + media.currentTime = 12 + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) + const paused = parseRemote({ mode, command: 1, stateVersion: '2', music: music(2, 3, 11500) }) + const seekOperation = f.c.state.channels.music.seekOperation + applyRemote(f.c, playing, paused) + assert.equal(f.c.state.channels.music.seekOperation, seekOperation) + f.apply() + await setImmediate() + assert.equal(media.paused, false) + t.mock.timers.tick(500) + assert.ok(Math.abs(media.volume - 0.4) < 0.01) + assert.equal((mode === 2 ? f.audio : f.video).paused, false) + media.currentTime = 13 + t.mock.timers.tick(500) + await setImmediate() + assert.equal(media.paused, true) + assert.equal(media.currentTime, 13) + const resumed = parseRemote({ mode: 1, stateVersion: '3', music: music(3, 2, 11500) }) + applyRemote(f.c, paused, resumed) + f.apply() + await setImmediate() + assert.equal(media.paused, false) + assert.equal(media.volume, 0.8) + assert.equal(media.currentTime, 11.5) + // A separate seek in the paused state still synchronizes position. + applyRemote(f.c, resumed, parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) })) + const previous = parseRemote({ mode: 1, stateVersion: '4', music: music(4, 3, 14000) }) + applyRemote(f.c, previous, parseRemote({ mode: 1, stateVersion: '5', music: music(5, 3, 15000) })) + f.apply() + await setImmediate() + assert.equal(media.currentTime, 15) + assert.equal(media.paused, true) + }) +} diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 5ac7a47..88578ee 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -295,7 +295,13 @@ export function applyRemote(controller: Controller, previous: RemoteState, next: action: m.playback === 2 ? 'resume' : m.playback === 3 ? 'pause' : 'stop', channel: 'music', }) - if (m.playback !== 1) + const pausingSameTrack = + m.playback === 3 && + old?.playback === 2 && + old.playlistId === m.playlistId && + old.trackId === m.trackId + // A pause keeps the audible position so a seek cannot cancel its fade-out. + if (m.playback !== 1 && !pausingSameTrack) send(controller, { action: 'seek', channel: 'music', position: m.position }) } } diff --git a/src/utils/media-player.ts b/src/utils/media-player.ts index 27521f9..8535dd1 100644 --- a/src/utils/media-player.ts +++ b/src/utils/media-player.ts @@ -41,6 +41,7 @@ interface Runtime { abort: AbortController | null media: HTMLMediaElement | null sourceId: string | null + fadeStartedAt: number | null } const runtime = (): Runtime => ({ operation: -1, @@ -48,6 +49,7 @@ const runtime = (): Runtime => ({ abort: null, media: null, sourceId: null, + fadeStartedAt: null, }) // DOM и асинхронные операции остаются здесь; решения о плейлисте принимает main. @@ -86,15 +88,71 @@ export class MediaPlayer { this.state = state for (const channel of ['main', 'music'] as const) { const r = this.channels[channel] + const c = state.channels[channel] if (r.media) { - r.media.volume = state.channels[channel].volume / 100 + const fadePause = this.canFadePause(channel, state) + if (!fadePause) r.fadeStartedAt = null + r.media.volume = (c.volume / 100) * this.fadeGain(r) r.media.muted = state.muted - if (state.channels[channel].desired !== 'playing') r.media.pause() + if (c.desired !== 'playing' && !fadePause) r.media.pause() } if (r.operation !== state.channels[channel].operation) void this.applyChannel(channel, state) } this.warmNext() } + private canFadePause(channel: Channel, snapshot: State) { + const r = this.channels[channel] + const c = snapshot.channels[channel] + return ( + channel === 'music' && + c.desired === 'paused' && + r.sourceId === (c.source ? `${c.source.id}:${c.source.url}` : null) && + r.seekOperation === c.seekOperation && + !!r.media && + !r.media.paused && + !r.media.ended && + !r.media.error && + !snapshot.muted && + c.volume > 0 + ) + } + private fadeGain(r: Runtime) { + return r.fadeStartedAt === null + ? 1 + : Math.min(1, Math.max(0, 1 - (Date.now() - r.fadeStartedAt) / 1000)) + } + private fadeMusic(media: HTMLMediaElement, r: Runtime, signal: AbortSignal) { + r.fadeStartedAt ??= Date.now() + return new Promise((resolve) => { + const cancel = () => { + clearInterval(timer) + signal.removeEventListener('abort', cancel) + resolve() + } + const tick = () => { + const volume = (this.state?.channels.music.volume ?? 0) / 100 + const gain = this.fadeGain(r) + media.volume = volume * gain + if ( + gain === 0 || + media.paused || + media.ended || + media.error || + media.muted || + volume === 0 + ) { + media.pause() + media.volume = volume + r.fadeStartedAt = null + cancel() + } + } + const timer = setInterval(tick, 20) + signal.addEventListener('abort', cancel, { once: true }) + if (signal.aborted) cancel() + else tick() + }) + } private source(media: HTMLMediaElement, url: string) { if (media.getAttribute('src') === url && !media.error) return media.pause() @@ -118,6 +176,7 @@ export class MediaPlayer { private async applyChannel(channel: Channel, snapshot: State) { const c = snapshot.channels[channel] const r = this.channels[channel] + const fadePause = this.canFadePause(channel, snapshot) r.abort?.abort() const abort = new AbortController() r.abort = abort @@ -145,8 +204,11 @@ export class MediaPlayer { if (r.media && r.media !== media) r.media.pause() } r.media = media - media.pause() - media.volume = c.volume / 100 + if (!fadePause) { + media.pause() + r.fadeStartedAt = null + } + media.volume = (c.volume / 100) * this.fadeGain(r) media.muted = snapshot.muted let failed = false const current = () => @@ -206,6 +268,8 @@ export class MediaPlayer { } if (!current() || failed) return r.seekOperation = c.seekOperation + if (fadePause) await this.fadeMusic(media, r, abort.signal) + if (!current() || failed) return if (c.desired === 'playing') { const playing = media.play() // Даже позднее завершение play не должно оживлять уже отменённый канал.