up
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
if (!fadePause) {
|
||||
media.pause()
|
||||
media.volume = c.volume / 100
|
||||
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 не должно оживлять уже отменённый канал.
|
||||
|
||||
Reference in New Issue
Block a user