mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user