This commit is contained in:
2026-10-18 19:07:29 +07:00
parent a35c09d0dc
commit 9542330125
3 changed files with 249 additions and 5 deletions
+174
View File
@@ -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)
})
}
+7 -1
View File
@@ -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 })
}
}
+68 -4
View File
@@ -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<void>((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 не должно оживлять уже отменённый канал.