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:
neon
2026-10-07 21:39:38 +00:00
parent c35eb38485
commit bd141802bd
13 changed files with 399 additions and 121 deletions
+1 -1
View File
@@ -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()
})
+24 -29
View File
@@ -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)
}
}
+30 -67
View File
@@ -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,
+1
View File
@@ -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",