mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): preserve reading progress across narration resume
Keep text and narration bookmarks independent when opening and navigating EPUBs. Use the narrated section for sentence bookmarks and attribute delayed page moves to narration. Recover stalled WebKit blob frames with a bounded fallback that preserves XHTML location structure. Cover restoration, narration attribution, and iframe fallback behavior with regression tests. Closes #1708
This commit is contained in:
@@ -450,7 +450,7 @@ class MediaOverlay extends EventTarget {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: e }))
|
||||
}
|
||||
#highlight() {
|
||||
this.dispatchEvent(new CustomEvent('highlight', { detail: this.#activeItem }))
|
||||
this.dispatchEvent(new CustomEvent('highlight', { detail: { ...this.#activeItem, sectionIndex: this.#sectionIndex } }))
|
||||
}
|
||||
#unhighlight() {
|
||||
this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem }))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const inlineDocument = (text, type, src) => {
|
||||
const parsed = new DOMParser().parseFromString(text, type === 'application/xhtml+xml' ? type : 'text/html')
|
||||
if (parsed.querySelector('parsererror')) throw new DOMException('', 'SyntaxError')
|
||||
const html = document.implementation.createHTMLDocument('')
|
||||
html.replaceChild(html.importNode(parsed.documentElement, true), html.documentElement)
|
||||
const base = html.createElement('base')
|
||||
base.href = src
|
||||
html.head.prepend(base)
|
||||
// HTML serialization keeps explicit end tags for non-void XHTML elements. Passing the XML
|
||||
// text straight to srcdoc would reinterpret self-closing divs and change persisted CFIs.
|
||||
return { markup: '<!DOCTYPE html>' + html.documentElement.outerHTML, body: html.body }
|
||||
}
|
||||
|
||||
export const loadIframeDocument = (iframe, src, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let inline = false
|
||||
let inlineBody
|
||||
const controller = new AbortController()
|
||||
const finish = (error, doc) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(fallbackTimer)
|
||||
clearTimeout(timeoutTimer)
|
||||
iframe.removeEventListener('load', onLoad)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
controller.abort()
|
||||
if (error) reject(error)
|
||||
else resolve(doc)
|
||||
}
|
||||
const onAbort = () => finish(new DOMException('', 'AbortError'))
|
||||
const onLoad = () => {
|
||||
const doc = iframe.contentDocument
|
||||
if (doc?.body && doc.URL === (inline ? 'about:srcdoc' : src)) {
|
||||
// HTML parsing can insert elements such as tbody. Restore the XHTML body tree so CFIs
|
||||
// saved here still address the same nodes in other browsers and the native reader.
|
||||
if (inlineBody) doc.body.replaceWith(doc.importNode(inlineBody, true))
|
||||
finish(null, doc)
|
||||
}
|
||||
}
|
||||
// Some embedded WebKit browsers leave blob frame navigations at about:blank. Only those
|
||||
// failed navigations use srcdoc; the sandbox and normal XHTML loading path stay intact.
|
||||
const fallbackTimer = setTimeout(async () => {
|
||||
if (settled || iframe.contentDocument?.URL !== 'about:blank' || !src.startsWith('blob:')) return
|
||||
try {
|
||||
const response = await fetch(src, { signal: controller.signal })
|
||||
const text = await response.text()
|
||||
if (settled) return
|
||||
const document = inlineDocument(text, response.headers.get('content-type')?.split(';')[0], src)
|
||||
inlineBody = document.body
|
||||
inline = true
|
||||
iframe.srcdoc = document.markup
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
}, 1000)
|
||||
const timeoutTimer = setTimeout(() => finish(new DOMException('', 'TimeoutError')), 30_000)
|
||||
iframe.addEventListener('load', onLoad)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
iframe.removeAttribute('srcdoc')
|
||||
iframe.src = src
|
||||
if (signal?.aborted) onAbort()
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { loadIframeDocument } from './iframe-loader.js'
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const debounce = (f, wait, immediate) => {
|
||||
@@ -248,6 +250,7 @@ class View {
|
||||
#column = true
|
||||
#size
|
||||
#layout = {}
|
||||
#loadController
|
||||
constructor({ container, onExpand }) {
|
||||
this.container = container
|
||||
this.onExpand = onExpand
|
||||
@@ -284,39 +287,30 @@ class View {
|
||||
}
|
||||
async load(src, afterLoad, beforeRender) {
|
||||
if (typeof src !== 'string') throw new Error(`${src} is not string`)
|
||||
return new Promise((resolve) => {
|
||||
this.#iframe.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const doc = this.document
|
||||
afterLoad?.(doc)
|
||||
this.#loadController?.abort()
|
||||
this.#loadController = new AbortController()
|
||||
const doc = await loadIframeDocument(this.#iframe, src, this.#loadController.signal)
|
||||
afterLoad?.(doc)
|
||||
|
||||
// it needs to be visible for Firefox to get computed style
|
||||
this.#iframe.style.display = 'block'
|
||||
const { vertical, rtl } = getDirection(doc)
|
||||
const background = getBackground(doc)
|
||||
this.#iframe.style.display = 'none'
|
||||
// it needs to be visible for Firefox to get computed style
|
||||
this.#iframe.style.display = 'block'
|
||||
const { vertical, rtl } = getDirection(doc)
|
||||
const background = getBackground(doc)
|
||||
this.#iframe.style.display = 'none'
|
||||
|
||||
this.#vertical = vertical
|
||||
this.#rtl = rtl
|
||||
this.#vertical = vertical
|
||||
this.#rtl = rtl
|
||||
|
||||
this.#contentRange.selectNodeContents(doc.body)
|
||||
const layout = beforeRender?.({ vertical, rtl, background })
|
||||
this.#iframe.style.display = 'block'
|
||||
this.render(layout)
|
||||
this.#observer.observe(doc.body)
|
||||
this.#contentRange.selectNodeContents(doc.body)
|
||||
const layout = beforeRender?.({ vertical, rtl, background })
|
||||
this.#iframe.style.display = 'block'
|
||||
this.render(layout)
|
||||
this.#observer.observe(doc.body)
|
||||
|
||||
// the resize observer above doesn't work in Firefox
|
||||
// (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939)
|
||||
// until the bug is fixed we can at least account for font load
|
||||
doc.fonts.ready.then(() => this.expand())
|
||||
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
this.#iframe.src = src
|
||||
})
|
||||
// the resize observer above doesn't work in Firefox
|
||||
// (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939)
|
||||
// until the bug is fixed we can at least account for font load
|
||||
doc.fonts.ready.then(() => this.expand())
|
||||
}
|
||||
render(layout) {
|
||||
if (!layout) return
|
||||
@@ -450,6 +444,7 @@ class View {
|
||||
return this.#overlayer
|
||||
}
|
||||
destroy() {
|
||||
this.#loadController?.abort()
|
||||
if (this.document) this.#observer.unobserve(this.document.body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,28 +281,15 @@ const isOverlayFooterVisible = computed(() => footerVisible.value && !showTapZon
|
||||
const showTtsResumePrompt = ref(false)
|
||||
const clearingSavedTtsPosition = ref(false)
|
||||
const pendingTtsChapterNavigation = ref<number | null>(null)
|
||||
const isTtsRelocating = ref(false)
|
||||
const lastNavigationBlockedToastAt = ref(0)
|
||||
let initialOpenCompleted = false
|
||||
let pendingManualNavigationClearsMediaOverlay = false
|
||||
let pendingManualNavigationClearTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let mediaOverlayPlaylistPromise: Promise<EpubMediaOverlayPlaylist | null> | null = null
|
||||
// A paused narration can still finish loading its page. Keep its relocations separate from
|
||||
// reading progress until the reader explicitly navigates, rather than relying on playback state.
|
||||
let relocationSource: 'text' | 'narration' = 'text'
|
||||
|
||||
function withTtsRelocation(fn: () => void | Promise<void>) {
|
||||
isTtsRelocating.value = true
|
||||
const res = fn()
|
||||
if (res instanceof Promise) {
|
||||
void res.finally(() => {
|
||||
setTimeout(() => {
|
||||
isTtsRelocating.value = false
|
||||
}, 150)
|
||||
})
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
isTtsRelocating.value = false
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
watch(mediaOverlay.isPlaying, (playing) => {
|
||||
if (playing) relocationSource = 'narration'
|
||||
})
|
||||
|
||||
function ensureTtsAvailable(): boolean {
|
||||
if (isTtsAvailable) return true
|
||||
@@ -385,9 +372,7 @@ watch([isTtsActive, foliateReady, sectionIndex], () => {
|
||||
pendingTtsChapterNavigation.value = null
|
||||
return
|
||||
}
|
||||
withTtsRelocation(() => {
|
||||
syncFoliateHighlightToCurrentBlock()
|
||||
})
|
||||
syncFoliateHighlightToCurrentBlock()
|
||||
})
|
||||
|
||||
watch([foliateReady, isTtsActive, sectionIndex, () => ttsPosition.savedPosition.value?.cfi], ([ready, active, , savedCfi], previous) => {
|
||||
@@ -400,24 +385,22 @@ watch([foliateReady, isTtsActive, sectionIndex, () => ttsPosition.savedPosition.
|
||||
})
|
||||
|
||||
// Keep the Foliate overlay in lockstep with TTS block changes.
|
||||
watch([currentChapterIndex, currentBlockIndex], ([chapterIdx]) => {
|
||||
watch([currentChapterIndex, currentBlockIndex], async ([chapterIdx]) => {
|
||||
if (!isTtsActive.value || !foliateReady.value) return
|
||||
|
||||
withTtsRelocation(async () => {
|
||||
// Keep the visual reader chapter aligned with the TTS chapter before
|
||||
// highlighting; otherwise chapter rollover can jump backwards. The highlight
|
||||
// for the new chapter is applied by the foliateReady/sectionIndex watcher
|
||||
// once navigation settles and the new document's block ranges are ready.
|
||||
if (sectionIndex.value !== chapterIdx) {
|
||||
if (pendingTtsChapterNavigation.value !== chapterIdx) {
|
||||
pendingTtsChapterNavigation.value = chapterIdx
|
||||
await goToSection(chapterIdx)
|
||||
}
|
||||
return
|
||||
// Keep the visual reader chapter aligned with the TTS chapter before
|
||||
// highlighting; otherwise chapter rollover can jump backwards. The highlight
|
||||
// for the new chapter is applied by the foliateReady/sectionIndex watcher
|
||||
// once navigation settles and the new document's block ranges are ready.
|
||||
if (sectionIndex.value !== chapterIdx) {
|
||||
if (pendingTtsChapterNavigation.value !== chapterIdx) {
|
||||
pendingTtsChapterNavigation.value = chapterIdx
|
||||
await goToSection(chapterIdx)
|
||||
}
|
||||
pendingTtsChapterNavigation.value = null
|
||||
syncFoliateHighlightToCurrentBlock()
|
||||
})
|
||||
return
|
||||
}
|
||||
pendingTtsChapterNavigation.value = null
|
||||
syncFoliateHighlightToCurrentBlock()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -579,14 +562,12 @@ async function resolveSavedNarrationPos(): Promise<MediaOverlayResumePosition |
|
||||
}
|
||||
|
||||
// Persist the exact narrated sentence so playback can resume there after reload.
|
||||
watch(
|
||||
() => mediaOverlay.currentFragment.value,
|
||||
(fragment) => {
|
||||
if (!mediaOverlay.isActive.value || !fragment) return
|
||||
saveNarrationPos(sectionIndex.value, fragment)
|
||||
progress.setMediaOverlayProgress(fragment, sectionIndex.value)
|
||||
},
|
||||
)
|
||||
watch([mediaOverlay.currentFragment, mediaOverlay.currentSectionIndex], ([fragment, narratedSection]) => {
|
||||
if (!mediaOverlay.isActive.value || !fragment || narratedSection === null) return
|
||||
relocationSource = 'narration'
|
||||
saveNarrationPos(narratedSection, fragment)
|
||||
progress.setMediaOverlayProgress(fragment, narratedSection)
|
||||
})
|
||||
|
||||
// When narration is stopped (mini player closed), foliate clears its highlight.
|
||||
// Re-mark the last sentence so the resume point stays visible.
|
||||
@@ -612,6 +593,8 @@ async function beginNarration(sectionIdx: number, matches: ((item: { text: strin
|
||||
|
||||
clearResumeNarrationHighlight()
|
||||
|
||||
relocationSource = 'narration'
|
||||
|
||||
await mediaOverlay.start(
|
||||
mo,
|
||||
() => startMediaOverlayWithFallback(mo, sectionIdx, matches, () => startId === mediaOverlayStartId && mediaOverlay.isActive.value),
|
||||
@@ -746,15 +729,7 @@ async function handlePlayFromCurrentPage() {
|
||||
}
|
||||
|
||||
function onRelocateHandler(detail: RelocateDetail) {
|
||||
progress.onRelocate(detail)
|
||||
if (initialOpenCompleted && !mediaOverlay.isActive.value && !isTtsRelocating.value && pendingManualNavigationClearsMediaOverlay) {
|
||||
progress.clearMediaOverlayProgress()
|
||||
pendingManualNavigationClearsMediaOverlay = false
|
||||
if (pendingManualNavigationClearTimer) {
|
||||
clearTimeout(pendingManualNavigationClearTimer)
|
||||
pendingManualNavigationClearTimer = null
|
||||
}
|
||||
}
|
||||
progress.onRelocate(detail, relocationSource)
|
||||
onActivity()
|
||||
bookmarks.setCfi(detail?.cfi ?? null)
|
||||
toc.setActiveHref(detail?.tocItem?.href ?? '')
|
||||
@@ -776,7 +751,7 @@ function onMiddleTapHandler() {
|
||||
|
||||
function canRunManualNavigation(): boolean {
|
||||
if (!isNavigationLocked.value) {
|
||||
markManualNavigation()
|
||||
relocationSource = 'text'
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -789,15 +764,6 @@ function canRunManualNavigation(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function markManualNavigation() {
|
||||
pendingManualNavigationClearsMediaOverlay = true
|
||||
if (pendingManualNavigationClearTimer) clearTimeout(pendingManualNavigationClearTimer)
|
||||
pendingManualNavigationClearTimer = setTimeout(() => {
|
||||
pendingManualNavigationClearsMediaOverlay = false
|
||||
pendingManualNavigationClearTimer = null
|
||||
}, 2_500)
|
||||
}
|
||||
|
||||
function handleBlockedPageInteraction() {
|
||||
canRunManualNavigation()
|
||||
}
|
||||
@@ -899,9 +865,7 @@ onMounted(async () => {
|
||||
fixedLayoutSpread: state.value.fixedLayoutSpread,
|
||||
mediaOverlayFragment: savedNarration?.fragment,
|
||||
mediaOverlaySectionIndex: savedNarration?.section,
|
||||
preferMediaOverlay: savedNarration != null,
|
||||
})
|
||||
initialOpenCompleted = true
|
||||
setChapters(getChapters())
|
||||
sectionFractions.value = getSectionFractions()
|
||||
await bookmarks.load(bookId)
|
||||
@@ -1227,7 +1191,6 @@ watch(
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaOverlayStartId++
|
||||
if (pendingManualNavigationClearTimer) clearTimeout(pendingManualNavigationClearTimer)
|
||||
setReaderFooterVisible(false)
|
||||
mediaOverlay.stop()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// @ts-expect-error public Foliate asset has no TypeScript declarations.
|
||||
import { loadIframeDocument } from '../../../../../public/assets/foliate/iframe-loader.js'
|
||||
|
||||
const chapter =
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body><div id="empty"/><p id="sentence">Saved sentence</p><svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg></body></html>'
|
||||
|
||||
describe('chapter iframe loading', () => {
|
||||
let iframe: HTMLIFrameElement
|
||||
let currentDocument: Document
|
||||
const src = 'blob:http://localhost/chapter'
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
|
||||
function complete(url: string) {
|
||||
currentDocument = new DOMParser().parseFromString(chapter, 'application/xhtml+xml')
|
||||
Object.defineProperty(currentDocument, 'URL', { value: url })
|
||||
iframe.dispatchEvent(new Event('load'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
iframe = document.createElement('iframe')
|
||||
currentDocument = document.implementation.createHTMLDocument('')
|
||||
Object.defineProperty(currentDocument, 'URL', { value: 'about:blank', configurable: true })
|
||||
Object.defineProperty(iframe, 'contentDocument', { get: () => currentDocument })
|
||||
fetchMock.mockResolvedValue(new Response(chapter, { headers: { 'content-type': 'application/xhtml+xml' } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps normal blob navigation and never fetches an inline fallback after loading', async () => {
|
||||
iframe.srcdoc = '<p>Previous chapter</p>'
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
expect(iframe.hasAttribute('srcdoc')).toBe(false)
|
||||
complete(src)
|
||||
await expect(loaded).resolves.toBe(currentDocument)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(iframe.srcdoc).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores the initial blank load event, then falls back without changing XHTML structure', async () => {
|
||||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
iframe.dispatchEvent(new Event('load'))
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(src, { signal: expect.any(AbortSignal) })
|
||||
const inline = new DOMParser().parseFromString(iframe.srcdoc, 'text/html')
|
||||
expect(inline.querySelector('#empty')?.children).toHaveLength(0)
|
||||
expect(inline.querySelector('#sentence')?.parentElement).toBe(inline.body)
|
||||
expect(inline.querySelector('svg')?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
||||
expect(inline.querySelector('base')?.getAttribute('href')).toBe(src)
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-same-origin allow-scripts')
|
||||
complete(src)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
complete('about:srcdoc')
|
||||
await expect(loaded).resolves.toBe(currentDocument)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not replace a chapter that has already navigated away from about:blank', async () => {
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
Object.defineProperty(currentDocument, 'URL', { value: src, configurable: true })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
complete(src)
|
||||
await expect(loaded).resolves.toBe(currentDocument)
|
||||
})
|
||||
|
||||
it('preserves XHTML table nodes instead of saving CFIs against an inserted HTML tbody', async () => {
|
||||
const tableChapter = chapter.replace('<div id="empty"/>', '<table id="table"><tr><td>Text</td></tr></table>')
|
||||
fetchMock.mockResolvedValueOnce(new Response(tableChapter, { headers: { 'content-type': 'application/xhtml+xml' } }))
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
currentDocument = new DOMParser().parseFromString(iframe.srcdoc, 'text/html')
|
||||
expect(currentDocument.querySelector('tbody')).not.toBeNull()
|
||||
Object.defineProperty(currentDocument, 'URL', { value: 'about:srcdoc' })
|
||||
iframe.dispatchEvent(new Event('load'))
|
||||
const doc = await loaded
|
||||
expect(doc.querySelector('tbody')).toBeNull()
|
||||
expect(doc.querySelector('tr')?.parentElement).toBe(doc.querySelector('#table'))
|
||||
expect(doc.querySelector('#sentence')?.parentElement).toBe(doc.body)
|
||||
})
|
||||
|
||||
it('cancels fallback work when the reader is destroyed', async () => {
|
||||
const controller = new AbortController()
|
||||
const loaded = loadIframeDocument(iframe, src, controller.signal)
|
||||
const rejected = loaded.catch((error: unknown) => error)
|
||||
controller.abort()
|
||||
expect(await rejected).toMatchObject({ name: 'AbortError' })
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects failed fallback fetches and removes its pending timers', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
const rejected = loaded.catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(await rejected).toBeInstanceOf(TypeError)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds waiting when even the inline document cannot load', async () => {
|
||||
const loaded = loadIframeDocument(iframe, src)
|
||||
const rejected = loaded.catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(await rejected).toMatchObject({ name: 'TimeoutError' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -109,8 +109,11 @@ describe('Foliate MediaOverlay concurrency', () => {
|
||||
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 highlights: { text: string; sectionIndex: number }[] = []
|
||||
overlay.addEventListener('highlight', (event: Event) => {
|
||||
const { text, sectionIndex } = (event as CustomEvent<{ text: string; sectionIndex: number }>).detail
|
||||
highlights.push({ text, sectionIndex })
|
||||
})
|
||||
|
||||
const selectedStart = overlay.start(0, (item: { text: string }) => item.text.endsWith('#selected'))
|
||||
await vi.waitFor(() => expect(loads).toHaveLength(1))
|
||||
@@ -126,7 +129,7 @@ describe('Foliate MediaOverlay concurrency', () => {
|
||||
expect(FakeAudio.instances).toHaveLength(1)
|
||||
|
||||
FakeAudio.instances[0]!.emitCanPlayThrough()
|
||||
expect(highlights).toEqual(['OPS/chapter.xhtml#top'])
|
||||
expect(highlights).toEqual([{ text: 'OPS/chapter.xhtml#top', sectionIndex: 0 }])
|
||||
|
||||
overlay.stop()
|
||||
expect(FakeAudio.instances[0]!.paused).toBe(true)
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('useFoliate.open', () => {
|
||||
expect(inputMock.suppressNextTapNavigation.mock.invocationCallOrder[0]!).toBeLessThan(onAnnotationClick.mock.invocationCallOrder[0]!)
|
||||
})
|
||||
|
||||
it('prefers media-overlay fragment when requested', async () => {
|
||||
it('restores the reading CFI before an older narration fragment', async () => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
@@ -314,11 +314,93 @@ describe('useFoliate.open', () => {
|
||||
fallbackFraction: 0.75,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
preferMediaOverlay: true,
|
||||
})
|
||||
|
||||
expect(mockGoTo).toHaveBeenCalledWith('OPS/ch1.xhtml#s12')
|
||||
expect(mockGoTo).not.toHaveBeenCalledWith('epubcfi(/6/4!)')
|
||||
expect(mockGoTo).toHaveBeenCalledExactlyOnceWith('epubcfi(/6/4!)')
|
||||
expect(mockGoToFraction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the reading fraction when its CFI cannot be resolved, retaining narration as a fallback', async () => {
|
||||
mockGoTo.mockResolvedValueOnce(undefined)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
cfi: 'epubcfi(/bad)',
|
||||
fallbackFraction: 0.75,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
expect(mockGoTo).toHaveBeenCalledExactlyOnceWith('epubcfi(/bad)')
|
||||
expect(mockGoToFraction).toHaveBeenCalledExactlyOnceWith(0.75)
|
||||
})
|
||||
|
||||
it.each([0, 0.75])('opens an explicit reading checkpoint at fraction %s rather than narration', async (fraction) => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
fallbackFraction: fraction,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
expect(mockGoTo).not.toHaveBeenCalledWith('OPS/ch1.xhtml#s12')
|
||||
expect(mockGoTo.mock.calls).toEqual(fraction === 0 ? [[0]] : [])
|
||||
expect(mockGoToFraction.mock.calls).toEqual(fraction === 0 ? [] : [[fraction]])
|
||||
})
|
||||
|
||||
it('restores fixed-layout reading progress before a narration fragment', async () => {
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce({
|
||||
rendition: { layout: 'pre-paginated' },
|
||||
})
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
cfi: 'epubcfi(/6/4!)',
|
||||
fallbackFraction: 0.75,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
})
|
||||
|
||||
expect(mockGoTo).not.toHaveBeenCalled()
|
||||
expect(mockGoToFraction).toHaveBeenCalledExactlyOnceWith(0.75)
|
||||
})
|
||||
|
||||
it('falls back to narration when there is no saved reading position', async () => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
expect(mockGoTo).toHaveBeenCalledExactlyOnceWith('OPS/ch1.xhtml#s12')
|
||||
})
|
||||
|
||||
it('falls back to narration when both reading targets fail', async () => {
|
||||
mockGoTo.mockRejectedValueOnce(new Error('invalid CFI'))
|
||||
mockGoToFraction.mockRejectedValueOnce(new Error('invalid fraction'))
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
cfi: 'epubcfi(/bad)',
|
||||
fallbackFraction: 0.75,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
expect(mockGoTo.mock.calls).toEqual([['epubcfi(/bad)'], ['OPS/ch1.xhtml#s12']])
|
||||
expect(mockGoToFraction).toHaveBeenCalledWith(0.75)
|
||||
})
|
||||
|
||||
it('falls back to the narration section when its fragment fails', async () => {
|
||||
mockGoTo.mockResolvedValueOnce(undefined)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', {
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#missing',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
expect(mockGoTo.mock.calls).toEqual([['OPS/ch1.xhtml#missing'], [2]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { api, getAccessToken } from '@/lib/api'
|
||||
import { i18n } from '@/i18n'
|
||||
import { useFoliateAnnotations } from './useFoliateAnnotations'
|
||||
import { useFoliateSelection } from './useFoliateSelection'
|
||||
import { useFoliateInput } from './useFoliateInput'
|
||||
@@ -89,7 +90,6 @@ export interface FoliateOpenOptions extends EpubOpenOptions {
|
||||
fallbackFraction?: number
|
||||
mediaOverlayFragment?: string | null
|
||||
mediaOverlaySectionIndex?: number | null
|
||||
preferMediaOverlay?: boolean
|
||||
}
|
||||
|
||||
export function useFoliate(
|
||||
@@ -277,7 +277,7 @@ export function useFoliate(
|
||||
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (loading.value) {
|
||||
error.value = 'Could not open the book. Your browser may not fully support the reader. Try refreshing or using a different browser.'
|
||||
error.value = i18n.global.t('reader.bookLoadTimeout')
|
||||
loading.value = false
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -330,9 +330,6 @@ export function useFoliate(
|
||||
}
|
||||
|
||||
let didNavigate = false
|
||||
if (options.preferMediaOverlay && options.mediaOverlayFragment) {
|
||||
didNavigate = await navigateTo(options.mediaOverlayFragment)
|
||||
}
|
||||
if (!didNavigate && options.cfi && !shouldRestoreByFraction) {
|
||||
didNavigate = await navigateTo(options.cfi)
|
||||
}
|
||||
@@ -346,6 +343,9 @@ export function useFoliate(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!didNavigate && options.fallbackFraction === 0) {
|
||||
didNavigate = await navigateTo(0)
|
||||
}
|
||||
if (!didNavigate && options.mediaOverlayFragment) {
|
||||
didNavigate = await navigateTo(options.mediaOverlayFragment)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ export function useFoliate(
|
||||
} catch (e) {
|
||||
console.error('[useFoliate]', e)
|
||||
clearTimeout(loadTimeoutId)
|
||||
error.value = e instanceof Error ? e.message : 'Failed to open book'
|
||||
error.value = e instanceof Error && e.message ? e.message : i18n.global.t('reader.failedToLoadBook')
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,19 +123,21 @@ describe('useMediaOverlay', () => {
|
||||
player.pause()
|
||||
expect(player.isPlaying.value).toBe(false)
|
||||
|
||||
mo.dispatchEvent(new CustomEvent('highlight', { detail: { text: 'ch.xhtml#s1' } }))
|
||||
mo.dispatchEvent(new CustomEvent('highlight', { detail: { text: 'ch.xhtml#s1', sectionIndex: 3 } }))
|
||||
expect(player.isPlaying.value).toBe(true)
|
||||
expect(player.currentFragment.value).toBe('ch.xhtml#s1')
|
||||
expect(player.currentSectionIndex.value).toBe(3)
|
||||
})
|
||||
|
||||
it('stop clears the tracked fragment', () => {
|
||||
const mo = makeFakeMediaOverlay()
|
||||
const player = useMediaOverlay()
|
||||
player.start(mo, vi.fn<() => void>(), book)
|
||||
mo.dispatchEvent(new CustomEvent('highlight', { detail: { text: 'ch.xhtml#s9' } }))
|
||||
mo.dispatchEvent(new CustomEvent('highlight', { detail: { text: 'ch.xhtml#s9', sectionIndex: 3 } }))
|
||||
expect(player.currentFragment.value).toBe('ch.xhtml#s9')
|
||||
|
||||
player.stop()
|
||||
expect(player.currentFragment.value).toBeNull()
|
||||
expect(player.currentSectionIndex.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ const error = ref<string | null>(null)
|
||||
// The SMIL text fragment of the sentence currently being narrated, e.g.
|
||||
// ".../chap02.xhtml#sentence14". Used to persist + resume the exact sentence.
|
||||
const currentFragment = ref<string | null>(null)
|
||||
const currentSectionIndex = ref<number | null>(null)
|
||||
|
||||
let instance: FoliateMediaOverlay | null = null
|
||||
let highlightListener: ((e: Event) => void) | null = null
|
||||
@@ -60,6 +61,7 @@ function stop() {
|
||||
isPlaying.value = false
|
||||
currentBook.value = null
|
||||
currentFragment.value = null
|
||||
currentSectionIndex.value = null
|
||||
error.value = null
|
||||
mediaSession.clearHandlers()
|
||||
mediaSession.setPlaybackState('none')
|
||||
@@ -98,6 +100,8 @@ export function useMediaOverlay() {
|
||||
highlightListener = (e: Event) => {
|
||||
isPlaying.value = true
|
||||
const text = (e as CustomEvent).detail?.text
|
||||
const section = (e as CustomEvent).detail?.sectionIndex
|
||||
currentSectionIndex.value = Number.isInteger(section) && section >= 0 ? section : null
|
||||
if (typeof text === 'string') currentFragment.value = text
|
||||
mediaSession.setPlaybackState('playing')
|
||||
}
|
||||
@@ -153,6 +157,7 @@ export function useMediaOverlay() {
|
||||
volume,
|
||||
currentBook,
|
||||
currentFragment,
|
||||
currentSectionIndex,
|
||||
error,
|
||||
sleepTimer,
|
||||
start,
|
||||
|
||||
@@ -615,6 +615,59 @@ describe('useReaderProgress', () => {
|
||||
await save()
|
||||
expect(postedProgressBody(1).source).toBe('text')
|
||||
})
|
||||
|
||||
it('saves and reloads later reading progress without losing the paused narration position', async () => {
|
||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||
const progress = useReaderProgress(1, 42, ref(0))
|
||||
progress.onRelocate({ fraction: 0.1, cfi: 'epubcfi(/6/4)' } as never)
|
||||
progress.setMediaOverlayProgress('OPS/ch1.xhtml#s12', 2, 44.3)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(postedProgressBody()).toMatchObject({ source: 'narration', percentage: 10 })
|
||||
|
||||
progress.onRelocate({ fraction: 0.32, cfi: 'epubcfi(/6/40)' } as never)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
const saved = postedProgressBody(1)
|
||||
expect(saved).toMatchObject({
|
||||
source: 'text',
|
||||
percentage: 32,
|
||||
cfi: 'epubcfi(/6/40)',
|
||||
positionSeconds: 44.3,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
mediaOverlaySectionIndex: 2,
|
||||
})
|
||||
|
||||
mockApi.mockResolvedValueOnce(makeOkResponse(saved))
|
||||
const reopened = useReaderProgress(1, 42, ref(0))
|
||||
await reopened.load()
|
||||
expect(reopened.cfi.value).toBe('epubcfi(/6/40)')
|
||||
expect(reopened.percentage.value).toBe(32)
|
||||
expect(reopened.positionSeconds.value).toBe(44.3)
|
||||
expect(reopened.mediaOverlayFragment.value).toBe('OPS/ch1.xhtml#s12')
|
||||
expect(reopened.mediaOverlaySectionIndex.value).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps delayed narration page relocations attributed to narration, then saves manual reading as text', async () => {
|
||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||
const progress = useReaderProgress(1, 42, ref(0))
|
||||
progress.onRelocate({ fraction: 0.32, cfi: 'epubcfi(/6/40)' })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
progress.setMediaOverlayProgress('OPS/ch1.xhtml#s12', 2)
|
||||
progress.onRelocate({ fraction: 0.1, cfi: 'epubcfi(/6/4)' }, 'narration')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(postedProgressBody(1)).toMatchObject({ source: 'narration', percentage: 10 })
|
||||
|
||||
progress.onRelocate({ fraction: 0.11, cfi: 'epubcfi(/6/4)' }, 'narration')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(postedProgressBody(2).source).toBe('narration')
|
||||
|
||||
progress.onRelocate({ fraction: 0.33, cfi: 'epubcfi(/6/42)' })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(postedProgressBody(3)).toMatchObject({
|
||||
source: 'text',
|
||||
percentage: 33,
|
||||
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cycleFooterMode()', () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export function useReaderProgress(
|
||||
saveTimer = setTimeout(() => save(), 2000)
|
||||
}
|
||||
|
||||
function onRelocate(detail: RelocateDetail) {
|
||||
function onRelocate(detail: RelocateDetail, source: 'text' | 'narration' = 'text') {
|
||||
cfi.value = normalizeString(detail?.cfi)
|
||||
const relocatedFraction = normalizeFraction(detail?.fraction)
|
||||
if (relocatedFraction !== null) {
|
||||
@@ -156,7 +156,7 @@ export function useReaderProgress(
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
return
|
||||
}
|
||||
pendingSource.value = 'text'
|
||||
pendingSource.value = source
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
@@ -168,12 +168,6 @@ export function useReaderProgress(
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function clearMediaOverlayProgress() {
|
||||
positionSeconds.value = null
|
||||
mediaOverlayFragment.value = null
|
||||
mediaOverlaySectionIndex.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
const safePercentage = updatePercentage(percentage.value)
|
||||
@@ -339,7 +333,6 @@ export function useReaderProgress(
|
||||
load,
|
||||
onRelocate,
|
||||
setMediaOverlayProgress,
|
||||
clearMediaOverlayProgress,
|
||||
save,
|
||||
cycleFooterMode,
|
||||
updateHeadsFeet,
|
||||
|
||||
@@ -9880,6 +9880,7 @@
|
||||
},
|
||||
"loadingBook": "Loading book…",
|
||||
"failedToLoadBook": "Failed to load book",
|
||||
"bookLoadTimeout": "The book took too long to open. Try refreshing the page.",
|
||||
"chapterNumber": "Chapter {number}",
|
||||
"peek": {
|
||||
"badge": "Peeking",
|
||||
|
||||
Reference in New Issue
Block a user