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
+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 не должно оживлять уже отменённый канал.