fix(reader): prevent duplicate media overlay playback

Wait for the requested SMIL fragment to resolve before falling back, and invalidate stale audio loads so overlapping starts cannot orphan playback.

Fixes #1477
This commit is contained in:
neon
2026-09-22 11:38:28 +00:00
parent 12cf724c39
commit 6fa4d68728
8 changed files with 281 additions and 26 deletions
+31 -13
View File
@@ -410,14 +410,16 @@ class MediaOverlay extends EventTarget {
#volume = 1
#rate = 1
#state
#playbackId = 0
constructor(book, loadXML) {
super()
this.book = book
this.loadXML = loadXML
}
async #loadSMIL(item) {
if (this.#lastMediaOverlayItem === item) return
async #loadSMIL(item, playbackId) {
if (this.#lastMediaOverlayItem === item) return playbackId === this.#playbackId
const doc = await this.loadXML(item.href)
if (playbackId !== this.#playbackId) return false
const resolve = (href) => (href ? resolveURL(href, item.href) : null)
const { $, $$$ } = childGetter(doc, NS.SMIL)
this.#audioIndex = -1
@@ -435,6 +437,7 @@ class MediaOverlay extends EventTarget {
return arr
}, [])
this.#lastMediaOverlayItem = item
return true
}
get #activeAudio() {
return this.#entries[this.#audioIndex]
@@ -452,20 +455,23 @@ class MediaOverlay extends EventTarget {
#unhighlight() {
this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem }))
}
async #play(audioIndex, itemIndex) {
async #play(audioIndex, itemIndex, playbackId = ++this.#playbackId) {
this.#stop()
this.#audioIndex = audioIndex
this.#itemIndex = itemIndex
const src = this.#activeAudio?.src
if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1)
if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1, undefined, playbackId)
const url = URL.createObjectURL(await this.book.loadBlob(src))
const blob = await this.book.loadBlob(src)
if (playbackId !== this.#playbackId) return
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
this.#audio = audio
const isCurrent = () => playbackId === this.#playbackId && this.#audio === audio
audio.volume = this.#volume
audio.playbackRate = this.#rate
audio.addEventListener('timeupdate', () => {
if (audio.paused) return
if (!isCurrent() || audio.paused) return
const t = audio.currentTime
const { items } = this.#activeAudio
if (t > this.#activeItem?.end) {
@@ -479,9 +485,14 @@ class MediaOverlay extends EventTarget {
while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++
if (this.#itemIndex !== oldIndex) this.#highlight()
})
audio.addEventListener('error', () => this.#error(new Error(`Failed to load ${src}`)))
audio.addEventListener('playing', () => this.#highlight())
audio.addEventListener('error', () => {
if (isCurrent()) this.#error(new Error(`Failed to load ${src}`))
})
audio.addEventListener('playing', () => {
if (isCurrent()) this.#highlight()
})
audio.addEventListener('ended', () => {
if (!isCurrent()) return
this.#unhighlight()
URL.revokeObjectURL(url)
this.#audio = null
@@ -494,6 +505,7 @@ class MediaOverlay extends EventTarget {
audio.addEventListener(
'canplaythrough',
() => {
if (!isCurrent()) return
// for some reason need to seek in `canplaythrough`
// or it won't play when skipping in WebKit
audio.currentTime = this.#activeItem.begin ?? 0
@@ -503,23 +515,28 @@ class MediaOverlay extends EventTarget {
{ once: true },
)
}
async start(sectionIndex, filter = () => true) {
async start(sectionIndex, filter = () => true, playbackId = ++this.#playbackId) {
if (playbackId !== this.#playbackId) return false
this.#audio?.pause()
const section = this.book.sections[sectionIndex]
const href = section?.id
if (!href) return
if (!href) return false
const { mediaOverlay } = section
if (!mediaOverlay) return this.start(sectionIndex + 1)
if (!mediaOverlay) return this.start(sectionIndex + 1, undefined, playbackId)
this.#sectionIndex = sectionIndex
await this.#loadSMIL(mediaOverlay)
if (!(await this.#loadSMIL(mediaOverlay, playbackId))) return false
for (let i = 0; i < this.#entries.length; i++) {
const { items } = this.#entries[i]
for (let j = 0; j < items.length; j++) {
if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) return this.#play(i, j).catch((e) => this.#error(e))
if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) {
await this.#play(i, j, playbackId).catch((e) => this.#error(e))
return true
}
}
}
return false
}
pause() {
this.#state = 'paused'
@@ -538,6 +555,7 @@ class MediaOverlay extends EventTarget {
}
}
stop() {
this.#playbackId++
this.#state = 'stopped'
this.#stop()
}
+6 -9
View File
@@ -32,6 +32,7 @@ import { getProviders, getVoices } from '@/features/tts/api/tts.api'
import { useMediaOverlay } from './media-overlay/composables/useMediaOverlay'
import { resolveMediaOverlayResume, type MediaOverlayResumePosition } from './media-overlay/lib/media-overlay-resume'
import { injectMediaOverlayHighlightCss, MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS } from './media-overlay/lib/media-overlay-highlight'
import { startMediaOverlayWithFallback } from './media-overlay/lib/media-overlay-start'
import TtsResumePrompt from '@/features/tts/components/TtsResumePrompt.vue'
import ReaderHeader from './epub/components/ReaderHeader.vue'
import ReaderFooter from './epub/components/ReaderFooter.vue'
@@ -258,6 +259,7 @@ const { setExpanded: setMiniPlayerExpanded, setReaderFooterVisible } = useTtsMin
const { loadBookPreferences, loadUserPreferences, defaultProviderId, defaultVoiceId, defaultSpeed } = useTtsPreferences()
const ttsPosition = useTtsPosition()
const mediaOverlay = useMediaOverlay()
let mediaOverlayStartId = 0
const isMediaOverlayAvailable = computed(() => isTtsAvailable && hasMediaOverlay.value)
const isTtsActive = computed(() => isActive.value && currentBook.value?.bookFileId === fileId)
@@ -605,6 +607,7 @@ watch(
// element id when byId=true); if no matching <par> plays, it falls back to the
// start of the section instead of staying silent.
async function beginNarration(sectionIdx: number, target: string | null, byId: boolean) {
const startId = ++mediaOverlayStartId
const mo = getMediaOverlay()
if (!mo) {
toast.error('This book has no embedded narration')
@@ -621,16 +624,9 @@ async function beginNarration(sectionIdx: number, target: string | null, byId: b
: (item: { text: string }) => item.text === target
: null
mediaOverlay.start(
await mediaOverlay.start(
mo,
() => {
mo.start(sectionIdx, matches ?? undefined)
if (matches) {
window.setTimeout(() => {
if (mediaOverlay.isActive.value && !mediaOverlay.currentFragment.value) mo.start(sectionIdx)
}, 700)
}
},
() => startMediaOverlayWithFallback(mo, sectionIdx, matches, () => startId === mediaOverlayStartId && mediaOverlay.isActive.value),
book,
)
}
@@ -1243,6 +1239,7 @@ watch(
)
onUnmounted(() => {
mediaOverlayStartId++
if (pendingManualNavigationClearTimer) clearTimeout(pendingManualNavigationClearTimer)
setReaderFooterVisible(false)
mediaOverlay.stop()
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// @ts-expect-error public Foliate asset has no TypeScript declarations.
import { EPUB } from '../../../../../public/assets/foliate/epub.js'
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
class FakeAudio extends EventTarget {
static instances: FakeAudio[] = []
readonly src: string
paused = true
currentTime = 0
volume = 1
playbackRate = 1
playCalls = 0
constructor(src: string) {
super()
this.src = src
FakeAudio.instances.push(this)
}
play(): Promise<void> {
this.playCalls++
this.paused = false
this.dispatchEvent(new Event('playing'))
return Promise.resolve()
}
pause(): void {
this.paused = true
}
emitCanPlayThrough(): void {
this.dispatchEvent(new Event('canplaythrough'))
}
}
const smil = `<?xml version="1.0"?>
<smil xmlns="http://www.w3.org/ns/SMIL">
<body><seq>
<par><text src="chapter.xhtml#top"/><audio src="audio.mp3" clipBegin="0" clipEnd="1"/></par>
<par><text src="chapter.xhtml#selected"/><audio src="audio.mp3" clipBegin="1" clipEnd="2"/></par>
</seq></body>
</smil>`
function makeOverlay(loadBlob: (src: string) => Promise<Blob>) {
const book = new EPUB({
loadText: async (src: string) => (src === 'OPS/chapter.smil' ? smil : null),
loadBlob,
getSize: () => 0,
})
book.sections = [
{
id: 'OPS/chapter.xhtml',
mediaOverlay: { href: 'OPS/chapter.smil' },
},
]
return book.getMediaOverlay()
}
describe('Foliate MediaOverlay concurrency', () => {
const originalAudio = globalThis.Audio
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
beforeEach(() => {
FakeAudio.instances = []
globalThis.Audio = FakeAudio as unknown as typeof Audio
URL.createObjectURL = vi.fn<(object: Blob | MediaSource) => string>(() => 'blob:media-overlay')
URL.revokeObjectURL = vi.fn<(url: string) => void>()
})
afterEach(() => {
globalThis.Audio = originalAudio
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
vi.restoreAllMocks()
})
it('reports whether the requested SMIL entry exists', async () => {
const loadBlob = vi.fn<(src: string) => Promise<Blob>>().mockResolvedValue(new Blob(['audio']))
const overlay = makeOverlay(loadBlob)
await expect(overlay.start(0, (item: { text: string }) => item.text.endsWith('#selected'))).resolves.toBe(true)
await expect(overlay.start(0, (item: { text: string }) => item.text.endsWith('#missing'))).resolves.toBe(false)
expect(loadBlob).toHaveBeenCalledOnce()
overlay.stop()
})
it('discards an in-flight audio load when a newer start supersedes it', async () => {
const loads: Deferred<Blob>[] = []
const loadBlob = vi.fn<(src: string) => Promise<Blob>>(() => {
const load = deferred<Blob>()
loads.push(load)
return load.promise
})
const overlay = makeOverlay(loadBlob)
const highlights: string[] = []
overlay.addEventListener('highlight', (event: Event) => highlights.push((event as CustomEvent<{ text: string }>).detail.text))
const selectedStart = overlay.start(0, (item: { text: string }) => item.text.endsWith('#selected'))
await vi.waitFor(() => expect(loads).toHaveLength(1))
const fallbackStart = overlay.start(0)
await vi.waitFor(() => expect(loads).toHaveLength(2))
loads[0]!.resolve(new Blob(['selected audio']))
await selectedStart
expect(FakeAudio.instances).toHaveLength(0)
loads[1]!.resolve(new Blob(['fallback audio']))
await fallbackStart
expect(FakeAudio.instances).toHaveLength(1)
FakeAudio.instances[0]!.emitCanPlayThrough()
expect(highlights).toEqual(['OPS/chapter.xhtml#top'])
overlay.stop()
expect(FakeAudio.instances[0]!.paused).toBe(true)
})
it('does not create audio when playback stops during a blob load', async () => {
const load = deferred<Blob>()
const loadBlob = vi.fn<(src: string) => Promise<Blob>>(() => load.promise)
const overlay = makeOverlay(loadBlob)
const start = overlay.start(0)
await vi.waitFor(() => expect(loadBlob).toHaveBeenCalledOnce())
overlay.stop()
load.resolve(new Blob(['audio']))
await start
expect(FakeAudio.instances).toHaveLength(0)
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('ignores a late canplaythrough event after playback stops', async () => {
const overlay = makeOverlay(async () => new Blob(['audio']))
await overlay.start(0)
const audio = FakeAudio.instances[0]!
overlay.stop()
audio.emitCanPlayThrough()
expect(audio.playCalls).toBe(0)
expect(audio.paused).toBe(true)
})
})
@@ -74,7 +74,7 @@ function makeFoliateFetchFile(): FoliateFetchFile {
}
export interface FoliateMediaOverlay extends EventTarget {
start: (index?: number, filter?: (item: { text: string }, i: number, items: { text: string }[]) => boolean) => unknown
start: (index?: number, filter?: (item: { text: string }, i: number, items: { text: string }[]) => boolean) => Promise<boolean>
pause: () => void
resume: () => void
stop: () => void
@@ -14,7 +14,7 @@ function makeFakeMediaOverlay() {
setRate: ReturnType<typeof vi.fn>
setVolume: ReturnType<typeof vi.fn>
}
mo.start = vi.fn<() => void>()
mo.start = vi.fn<FoliateMediaOverlay['start']>().mockResolvedValue(true)
mo.pause = vi.fn<() => void>()
mo.resume = vi.fn<() => void>()
mo.stop = vi.fn<() => void>()
@@ -54,6 +54,15 @@ describe('useMediaOverlay', () => {
expect(startFn).toHaveBeenCalledOnce()
})
it('returns the start function result', async () => {
const mo = makeFakeMediaOverlay()
const player = useMediaOverlay()
const result = Promise.resolve('started')
expect(player.start(mo, () => result, book)).toBe(result)
await result
})
it('toggle pauses then resumes the underlying instance', () => {
const mo = makeFakeMediaOverlay()
const player = useMediaOverlay()
@@ -83,7 +83,7 @@ export function useMediaOverlay() {
// `mo` is the foliate MediaOverlay instance (view.mediaOverlay) and `startFn`
// is view.startMediaOverlay - both obtained from useFoliate in the reader.
function start(mo: FoliateMediaOverlay, startFn: () => void, book: TtsCurrentBook) {
function start<T>(mo: FoliateMediaOverlay, startFn: () => T, book: TtsCurrentBook): T {
stop()
requestAudioFocus('media-overlay')
instance = mo
@@ -117,7 +117,7 @@ export function useMediaOverlay() {
stop,
})
startFn()
return startFn()
}
function toggle() {
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import type { FoliateMediaOverlay } from '@/features/reader/epub/composables/useFoliate'
import { startMediaOverlayWithFallback } from './media-overlay-start'
function makeMediaOverlay(start: FoliateMediaOverlay['start']): FoliateMediaOverlay {
const mediaOverlay = new EventTarget() as FoliateMediaOverlay
mediaOverlay.start = start
mediaOverlay.pause = vi.fn<() => void>()
mediaOverlay.resume = vi.fn<() => void>()
mediaOverlay.stop = vi.fn<() => void>()
mediaOverlay.next = vi.fn<() => void>()
mediaOverlay.prev = vi.fn<() => void>()
mediaOverlay.setRate = vi.fn<(rate: number) => void>()
mediaOverlay.setVolume = vi.fn<(volume: number) => void>()
return mediaOverlay
}
describe('startMediaOverlayWithFallback', () => {
it('waits for a matching start without launching a time-based fallback', async () => {
let resolveStart!: (found: boolean) => void
const firstStart = new Promise<boolean>((resolve) => {
resolveStart = resolve
})
const start = vi.fn<FoliateMediaOverlay['start']>(() => firstStart)
const mediaOverlay = makeMediaOverlay(start)
const filter = (item: { text: string }) => item.text.endsWith('#selected')
const result = startMediaOverlayWithFallback(mediaOverlay, 3, filter, () => true)
expect(start).toHaveBeenCalledOnce()
resolveStart(true)
await result
expect(start).toHaveBeenCalledOnce()
})
it('falls back to the section start only when no matching SMIL entry exists', async () => {
const start = vi.fn<FoliateMediaOverlay['start']>().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const mediaOverlay = makeMediaOverlay(start)
const filter = (item: { text: string }) => item.text.endsWith('#missing')
await startMediaOverlayWithFallback(mediaOverlay, 3, filter, () => true)
expect(start).toHaveBeenNthCalledWith(1, 3, filter)
expect(start).toHaveBeenNthCalledWith(2, 3)
})
it('does not fall back after the request is no longer current', async () => {
const start = vi.fn<FoliateMediaOverlay['start']>().mockResolvedValue(false)
const mediaOverlay = makeMediaOverlay(start)
const filter = (item: { text: string }) => item.text.endsWith('#missing')
await startMediaOverlayWithFallback(mediaOverlay, 3, filter, () => false)
expect(start).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,14 @@
import type { FoliateMediaOverlay } from '@/features/reader/epub/composables/useFoliate'
type MediaOverlayItem = { text: string }
type MediaOverlayFilter = (item: MediaOverlayItem, index: number, items: MediaOverlayItem[]) => boolean
export async function startMediaOverlayWithFallback(
mediaOverlay: FoliateMediaOverlay,
sectionIndex: number,
filter: MediaOverlayFilter | null,
shouldFallback: () => boolean,
): Promise<void> {
const found = await mediaOverlay.start(sectionIndex, filter ?? undefined)
if (filter && !found && shouldFallback()) await mediaOverlay.start(sectionIndex)
}