diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 067a3534..a822a2b9 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -171,6 +171,46 @@ function postToRN(type, data) { if (RN) RN.postMessage(JSON.stringify({ type, ...data })); } + const SCROLL_RELOCATE_POST_INTERVAL_MS = 500; + let pendingScrollRelocatePayload = null; + let scrollRelocatePostTimer = null; + let lastScrollRelocatePostAt = 0; + + function flushPendingScrollRelocate() { + if (scrollRelocatePostTimer) { + clearTimeout(scrollRelocatePostTimer); + scrollRelocatePostTimer = null; + } + if (!pendingScrollRelocatePayload) return; + lastScrollRelocatePostAt = Date.now(); + postToRN('relocate', pendingScrollRelocatePayload); + pendingScrollRelocatePayload = null; + } + + function postRelocateToRN(payload) { + const isContinuousScroll = + currentViewMode === 'scroll' && + view && + view.renderer && + view.renderer.scrolled && + !view.isFixedLayout; + if (!isContinuousScroll) { + flushPendingScrollRelocate(); + postToRN('relocate', payload); + return; + } + + pendingScrollRelocatePayload = payload; + const now = Date.now(); + const remaining = SCROLL_RELOCATE_POST_INTERVAL_MS - (now - lastScrollRelocatePostAt); + if (remaining <= 0) { + flushPendingScrollRelocate(); + return; + } + if (!scrollRelocatePostTimer) { + scrollRelocatePostTimer = setTimeout(flushPendingScrollRelocate, remaining); + } + } window.__READANY_READER_BUILD_ID = 'android-local-server-cors'; postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID }); Promise.withResolvers ??= function () { @@ -313,6 +353,7 @@ if (currentViewMode === 'scroll') { renderer.removeAttribute('no-continuous-scroll'); renderer.setAttribute('flow', 'scrolled'); + renderer.setAttribute('max-block-size', '100%'); renderer.setAttribute('max-inline-size', '9999px'); return; } @@ -1189,9 +1230,6 @@ if (!hit) return; armNoteTapGuard(doc, 900); - // Prevent subsequent click / show-annotation - e.preventDefault && e.preventDefault(); - const rect = hit.range.getBoundingClientRect(); const iframe = doc.defaultView && doc.defaultView.frameElement; const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; @@ -1206,7 +1244,7 @@ }, }); }, LONG_PRESS_MS); - }, { passive: false }); + }, { passive: true }); doc.addEventListener('touchmove', (e) => { if (!timer) return; @@ -1551,7 +1589,7 @@ total: Math.max(1, rendererPages - 2), } : null; - postToRN('relocate', { + postRelocateToRN({ fraction: detail.fraction, section: { current: currentSectionIndex, total: relocatedSectionTotal }, location: detail.location, @@ -4693,8 +4731,8 @@ diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 7a981ea1..e140f264 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -171,6 +171,46 @@ function postToRN(type, data) { if (RN) RN.postMessage(JSON.stringify({ type, ...data })); } + const SCROLL_RELOCATE_POST_INTERVAL_MS = 500; + let pendingScrollRelocatePayload = null; + let scrollRelocatePostTimer = null; + let lastScrollRelocatePostAt = 0; + + function flushPendingScrollRelocate() { + if (scrollRelocatePostTimer) { + clearTimeout(scrollRelocatePostTimer); + scrollRelocatePostTimer = null; + } + if (!pendingScrollRelocatePayload) return; + lastScrollRelocatePostAt = Date.now(); + postToRN('relocate', pendingScrollRelocatePayload); + pendingScrollRelocatePayload = null; + } + + function postRelocateToRN(payload) { + const isContinuousScroll = + currentViewMode === 'scroll' && + view && + view.renderer && + view.renderer.scrolled && + !view.isFixedLayout; + if (!isContinuousScroll) { + flushPendingScrollRelocate(); + postToRN('relocate', payload); + return; + } + + pendingScrollRelocatePayload = payload; + const now = Date.now(); + const remaining = SCROLL_RELOCATE_POST_INTERVAL_MS - (now - lastScrollRelocatePostAt); + if (remaining <= 0) { + flushPendingScrollRelocate(); + return; + } + if (!scrollRelocatePostTimer) { + scrollRelocatePostTimer = setTimeout(flushPendingScrollRelocate, remaining); + } + } window.__READANY_READER_BUILD_ID = 'android-local-server-cors'; postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID }); Promise.withResolvers ??= function () { @@ -313,6 +353,7 @@ if (currentViewMode === 'scroll') { renderer.removeAttribute('no-continuous-scroll'); renderer.setAttribute('flow', 'scrolled'); + renderer.setAttribute('max-block-size', '100%'); renderer.setAttribute('max-inline-size', '9999px'); return; } @@ -1189,9 +1230,6 @@ if (!hit) return; armNoteTapGuard(doc, 900); - // Prevent subsequent click / show-annotation - e.preventDefault && e.preventDefault(); - const rect = hit.range.getBoundingClientRect(); const iframe = doc.defaultView && doc.defaultView.frameElement; const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; @@ -1206,7 +1244,7 @@ }, }); }, LONG_PRESS_MS); - }, { passive: false }); + }, { passive: true }); doc.addEventListener('touchmove', (e) => { if (!timer) return; @@ -1551,7 +1589,7 @@ total: Math.max(1, rendererPages - 2), } : null; - postToRN('relocate', { + postRelocateToRN({ fraction: detail.fraction, section: { current: currentSectionIndex, total: relocatedSectionTotal }, location: detail.location, diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 97f05c13..78d4ce0a 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -82,6 +82,8 @@ const MAX_TRACKED_PAGE_DELTA = 20; const MAX_TRACKED_FRACTION_DELTA = 0.08; const INITIAL_PROGRESS_RESTORE_GUARD_MS = 1800; const PROGRAMMATIC_NAV_GUARD_MS = 1200; +const RELOCATE_UI_UPDATE_MS = 250; +const RELOCATE_CONTEXT_UPDATE_MS = 1000; const BOOK_MIME_TYPES = [ "application/epub+zip", "application/pdf", @@ -149,11 +151,7 @@ const NOTE_TOOLTIP_TOP_THRESHOLD = 180; import { useRubyStore } from "@readany/core/stores/ruby-store"; import { ReaderSettingsPanel } from "./reader/ReaderSettingsPanel"; import { ReaderTOCPanel } from "./reader/ReaderTOCPanel"; -import { - CONTROLS_TIMEOUT, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from "./reader/reader-constants"; +import { CONTROLS_TIMEOUT, SCREEN_HEIGHT, SCREEN_WIDTH } from "./reader/reader-constants"; import { BatteryIcon, ListIcon, SettingsIcon } from "./reader/reader-icons"; import { makeStyles, noteTooltipMdStyles } from "./reader/reader-styles"; import { useReaderBookmark } from "./reader/useReaderBookmark"; @@ -167,6 +165,22 @@ const LOCAL_FONT_SERVER_DIR = "readany-fonts"; type Props = NativeStackScreenProps; type TTSSegment = VisibleTTSSegment; +type PendingRelocateState = { + bookId: string; + fraction?: number; + cfi?: string; + pageCurrent: number; + pageTotal: number; + chapter?: string; +}; +type PendingReadingContextState = { + bookId: string; + cfi?: string; + chapterIndex: number; + chapterTitle: string; + chapterHref: string; + percentage: number; +}; // ──────────────────────────── helpers ──────────────────────────── @@ -203,6 +217,24 @@ function buildCustomFontFaceCSS( .join("\n"); } +function syncReadingContextFromRelocate(pending: PendingReadingContextState | null): void { + if (!pending) return; + const currentBook = useLibraryStore.getState().books.find((item) => item.id === pending.bookId); + readingContextService.updateContext({ + bookId: pending.bookId, + bookTitle: currentBook?.meta?.title || "", + currentChapter: { + index: pending.chapterIndex, + title: pending.chapterTitle, + href: pending.chapterHref, + }, + currentPosition: { + cfi: pending.cfi || "", + percentage: pending.percentage, + }, + }); +} + // ──────────────────────────── ReaderScreen ──────────────────────────── export function ReaderScreen({ route, navigation }: Props) { const colors = useColors(); @@ -349,6 +381,9 @@ export function ReaderScreen({ route, navigation }: Props) { const locationHistoryRef = useRef([]); const lastNavigatedCfiRef = useRef(undefined); const fileServerRef = useRef(null); + const pendingRelocateStateRef = useRef(null); + const pendingReadingContextRef = useRef(null); + const isMountedRef = useRef(true); const sessionProgressRef = useRef<{ mode: "location" | "page" | "characters"; current: number; @@ -374,6 +409,31 @@ export function ReaderScreen({ route, navigation }: Props) { }); }, 5000), ).current; + const flushPendingRelocateState = useRef( + throttle(() => { + if (!isMountedRef.current) return; + const pending = pendingRelocateStateRef.current; + if (!pending) return; + pendingRelocateStateRef.current = null; + + if (pending.fraction != null) { + progressRef.current = pending.fraction; + setProgress(pending.fraction); + } + setCurrentPage(pending.pageCurrent); + setTotalPages(pending.pageTotal); + if (pending.chapter) setCurrentChapter(pending.chapter); + if (pending.cfi) setCurrentCfi(pending.cfi); + }, RELOCATE_UI_UPDATE_MS), + ).current; + const flushPendingReadingContext = useRef( + throttle(() => { + if (!isMountedRef.current) return; + const pending = pendingReadingContextRef.current; + pendingReadingContextRef.current = null; + syncReadingContextFromRelocate(pending); + }, RELOCATE_CONTEXT_UPDATE_MS), + ).current; const { addHighlight, updateHighlight, @@ -624,13 +684,6 @@ export function ReaderScreen({ route, navigation }: Props) { totalBookCharactersRef.current = totalCharacters > 0 ? totalCharacters : null; }, onRelocate: (detail: RelocateEvent) => { - console.log("[ReaderScreen] onRelocate", { - section: detail.section, - fraction: detail.fraction, - cfi: detail.cfi, - routeCfi: cfi, - lastNavigated: lastNavigatedCfiRef.current, - }); if (loading) { setLoading(false); } @@ -641,20 +694,17 @@ export function ReaderScreen({ route, navigation }: Props) { setTranslationReady(false); chapterTranslation.reset(); } + const previousProgress = progressRef.current; - if (detail.fraction != null) setProgress(detail.fraction); - + let nextPageCurrent = 0; + let nextPageTotal = 0; if (detail.page) { - setCurrentPage(Math.max(1, detail.page.current)); - setTotalPages(Math.max(1, detail.page.total)); + nextPageCurrent = Math.max(1, detail.page.current); + nextPageTotal = Math.max(1, detail.page.total); } else if (detail.section?.total && !detail.location?.total) { // Fixed-layout documents can still expose stable section pages. - setCurrentPage(Math.max(1, detail.section.current + 1)); - setTotalPages(Math.max(1, detail.section.total)); - } else { - // Reflowable books without renderer-backed pagination should fall back to percent. - setCurrentPage(0); - setTotalPages(0); + nextPageCurrent = Math.max(1, detail.section.current + 1); + nextPageTotal = Math.max(1, detail.section.total); } const trackingSuppressed = Date.now() < progressTrackingGuardUntilRef.current; @@ -731,10 +781,9 @@ export function ReaderScreen({ route, navigation }: Props) { } sessionProgressRef.current = { mode: "page", current: detail.section.current }; } - if (detail.tocItem?.label) setCurrentChapter(detail.tocItem.label); if (detail.cfi) { if (lastCfiRef.current && detail.cfi !== lastCfiRef.current) { - const fractionDiff = Math.abs((detail.fraction ?? 0) - progress); + const fractionDiff = Math.abs((detail.fraction ?? 0) - previousProgress); if (fractionDiff > 0.02 || locationHistoryRef.current.length === 0) { locationHistoryRef.current.push(lastCfiRef.current); if (locationHistoryRef.current.length > 50) { @@ -743,18 +792,35 @@ export function ReaderScreen({ route, navigation }: Props) { } } lastCfiRef.current = detail.cfi; - setCurrentCfi(detail.cfi); // Use throttled save instead of immediate update throttledSaveProgress(bookId, detail.fraction ?? 0, detail.cfi); } + pendingRelocateStateRef.current = { + bookId, + fraction: detail.fraction, + cfi: detail.cfi, + pageCurrent: nextPageCurrent, + pageTotal: nextPageTotal, + chapter: detail.tocItem?.label, + }; + flushPendingRelocateState(); + pendingReadingContextRef.current = { + bookId, + cfi: detail.cfi, + chapterIndex: detail.section?.current ?? 0, + chapterTitle: detail.tocItem?.label || "", + chapterHref: detail.tocItem?.href || "", + percentage: (detail.fraction ?? 0) * 100, + }; + flushPendingReadingContext(); + // Mark translation ready after first successful relocate (CFI navigation done) if (!translationReady) setTranslationReady(true); // If TTS is waiting for a page turn to complete, fire the continuation callback now // that the renderer has fully updated its position (renderer.start reflects new page). if (ttsPendingContinueRef.current?.pendingTTSContinueCallbackRef.current) { - console.log("[ReaderScreen][TTS] onRelocate triggered pending TTS continuation"); const cb = ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current; ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current = null; // Cancel the safety timer since onRelocate fired successfully @@ -766,20 +832,7 @@ export function ReaderScreen({ route, navigation }: Props) { void cb(); } - // Sync reading context for AI tools - readingContextService.updateContext({ - bookId, - bookTitle: book?.meta?.title || "", - currentChapter: { - index: detail.section?.current ?? 0, - title: detail.tocItem?.label || "", - href: detail.tocItem?.href || "", - }, - currentPosition: { - cfi: detail.cfi || "", - percentage: (detail.fraction ?? 0) * 100, - }, - }); + // Reading context is synchronized by the throttled relocate flush above. }, onTocReady: (items: TOCItem[]) => { setToc(items); @@ -906,10 +959,25 @@ export function ReaderScreen({ route, navigation }: Props) { appActive, // 维护约定:任何新增遮盖正文/输入态/导航跳转,必须在此追加判定。 [ - readSettings.volumeButtonsPageTurn, webViewReady, loading, error, isReimporting, - showSearch, showTOC, showSettings, showNotebook, showTTS, - showTranslation, showChapterTranslation, chapterTranslation.state.status, - selection, noteViewHighlight, noteTooltip, ttsPlayState, isFocused, appActive, + readSettings.volumeButtonsPageTurn, + webViewReady, + loading, + error, + isReimporting, + showSearch, + showTOC, + showSettings, + showNotebook, + showTTS, + showTranslation, + showChapterTranslation, + chapterTranslation.state.status, + selection, + noteViewHighlight, + noteTooltip, + ttsPlayState, + isFocused, + appActive, ], ); @@ -1079,6 +1147,17 @@ export function ReaderScreen({ route, navigation }: Props) { // Save progress immediately on unmount useEffect(() => { return () => { + const pendingRelocate = pendingRelocateStateRef.current; + if (pendingRelocate?.fraction != null) { + progressRef.current = pendingRelocate.fraction; + } + if (pendingRelocate?.cfi) { + lastCfiRef.current = pendingRelocate.cfi; + } + syncReadingContextFromRelocate(pendingReadingContextRef.current); + pendingRelocateStateRef.current = null; + pendingReadingContextRef.current = null; + isMountedRef.current = false; if (fileServerRef.current) { stopFileServer(); fileServerRef.current = null; diff --git a/packages/app-expo/src/stores/tts-store.ts b/packages/app-expo/src/stores/tts-store.ts index bf1e3e80..d447461c 100644 --- a/packages/app-expo/src/stores/tts-store.ts +++ b/packages/app-expo/src/stores/tts-store.ts @@ -14,9 +14,9 @@ import TrackPlayer from "react-native-track-player"; import { create } from "zustand"; import { ExpoSpeechTTSPlayer } from "../lib/platform/expo-speech-player"; import { canUseSystemTtsSynthesis } from "../lib/platform/system-tts-synthesis"; +import { TrackPlayerCloudTTSPlayer } from "../lib/platform/track-player-cloud-tts-player"; import { TrackPlayerDashScopeTTSPlayer } from "../lib/platform/track-player-dashscope-player"; import { TrackPlayerEdgeTTSPlayer } from "../lib/platform/track-player-edge-player"; -import { TrackPlayerCloudTTSPlayer } from "../lib/platform/track-player-cloud-tts-player"; import { TrackPlayerSystemTTSPlayer } from "../lib/platform/track-player-system-player"; import { withPersist } from "./persist"; @@ -126,7 +126,18 @@ function detachAndStopPlayer(player: ITTSPlayer | null): void { } function detachAndStopAllPlayers(): void { + const activeTTS = _activeTTS; _activeTTS = null; + if ( + activeTTS && + activeTTS !== _systemTTS && + activeTTS !== _edgeTTS && + activeTTS !== _dashscopeTTS && + activeTTS !== _xiaomiTTS && + activeTTS !== _openAICompatibleTTS + ) { + detachAndStopPlayer(activeTTS); + } detachAndStopPlayer(_systemTTS); detachAndStopPlayer(_edgeTTS); detachAndStopPlayer(_dashscopeTTS); @@ -143,13 +154,6 @@ function normalizeSegments(text: string | string[]): string[] { .filter(Boolean); } -function previewSessionSegments(segments: string[], limit = 8) { - return segments.slice(0, limit).map((text, index) => ({ - index, - text: text.replace(/\s+/g, " ").trim(), - })); -} - function syncProfileUpdatesFromLegacyFields( previousConfig: TTSConfig, updates: Partial, @@ -175,7 +179,8 @@ function syncProfileUpdatesFromLegacyFields( } else if (targetProvider === "openai-compatible") { if (updates.openaiTtsBaseUrl !== undefined) profileUpdates.baseUrl = updates.openaiTtsBaseUrl; if (updates.openaiTtsApiKey !== undefined) profileUpdates.apiKey = updates.openaiTtsApiKey; - if (updates.openaiTtsEndpoint !== undefined) profileUpdates.endpoint = updates.openaiTtsEndpoint; + if (updates.openaiTtsEndpoint !== undefined) + profileUpdates.endpoint = updates.openaiTtsEndpoint; if (updates.openaiTtsModel !== undefined) profileUpdates.model = updates.openaiTtsModel; if (updates.openaiTtsVoice !== undefined) profileUpdates.voice = updates.openaiTtsVoice; if (updates.openaiTtsFormat !== undefined) profileUpdates.format = updates.openaiTtsFormat; @@ -219,19 +224,7 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { return getSystemTTS(); } -function startPlayback( - segments: string[], - config: TTSConfig, - startIndex: number, - set: (partial: Partial) => void, - get: () => TTSState, -): void { - const player = getPlayerForConfig(config); - const gen = _sessionGeneration; - let isStarting = true; - _activeTTS = player; - - // Set artwork getter for RNTP players +function applyPlayerMetadataGetters(player: ITTSPlayer, get: () => TTSState): void { if ( "setArtworkGetter" in player && typeof (player as { setArtworkGetter?: unknown }).setArtworkGetter === "function" @@ -241,8 +234,6 @@ function startPlayback( ); } - // Set title getter for RNTP players — chapter name shown on lock screen - // / control center / notification, with fallback to book title. if ( "setTitleGetter" in player && typeof (player as { setTitleGetter?: unknown }).setTitleGetter === "function" @@ -254,17 +245,25 @@ function startPlayback( }, ); } +} + +function startPlayback( + segments: string[], + config: TTSConfig, + startIndex: number, + set: (partial: Partial) => void, + get: () => TTSState, +): void { + const player = getPlayerForConfig(config); + const gen = _sessionGeneration; + let isStarting = true; + _activeTTS = player; + + applyPlayerMetadataGetters(player, get); player.onStateChange = (playState) => { if (gen !== _sessionGeneration) return; if (isStarting && playState === "stopped") return; - console.log("[TTSStore][player] state-change", { - playState, - gen, - currentIndex: _sessionCurrentIndex, - total: _sessionSegments.length, - currentText: _sessionSegments[_sessionCurrentIndex] || "", - }); if (playState === "stopped") { _activeTTS = null; } @@ -275,14 +274,6 @@ function startPlayback( if (gen !== _sessionGeneration) return; const absoluteIndex = startIndex + chunkIndex; _sessionCurrentIndex = absoluteIndex; - console.log("[TTSStore][player] chunk-change", { - chunkIndex, - absoluteIndex, - startIndex, - total: _sessionSegments.length, - currentText: _sessionSegments[absoluteIndex] || "", - nextText: _sessionSegments[absoluteIndex + 1] || "", - }); set({ currentChunkIndex: absoluteIndex, totalChunks: _sessionSegments.length, @@ -295,14 +286,6 @@ function startPlayback( _activeTTS = null; const lastIndex = Math.max(0, _sessionSegments.length - 1); _sessionCurrentIndex = lastIndex; - console.log("[TTSStore][player] end", { - gen, - lastIndex, - total: _sessionSegments.length, - lastText: _sessionSegments[lastIndex] || "", - queuePreview: previewSessionSegments(_sessionSegments), - hasOnEnd: !!get().onEnd, - }); set({ playState: "stopped", currentChunkIndex: lastIndex, @@ -332,6 +315,112 @@ function startPlayback( }); } +function startSystemPlaybackWithFallback( + segments: string[], + config: TTSConfig, + startIndex: number, + set: (partial: Partial) => void, + get: () => TTSState, +): void { + const gen = _sessionGeneration; + const nativePlayer = getSystemTTS(); + const fallbackPlayer = new ExpoSpeechTTSPlayer(); + let nativeSettled = false; + let fallbackStarted = false; + + applyPlayerMetadataGetters(nativePlayer, get); + + const attachPlayer = (player: ITTSPlayer) => { + _activeTTS = player; + player.onStateChange = (playState) => { + if (gen !== _sessionGeneration) return; + if (player === nativePlayer && playState === "stopped" && !nativeSettled) { + startFallback(new Error("Native system TTS stopped before playback started")); + return; + } + if (playState === "stopped") _activeTTS = null; + set({ playState }); + }; + player.onChunkChange = (chunkIndex) => { + if (gen !== _sessionGeneration) return; + const absoluteIndex = startIndex + chunkIndex; + _sessionCurrentIndex = absoluteIndex; + set({ + currentChunkIndex: absoluteIndex, + totalChunks: _sessionSegments.length, + currentSegmentText: _sessionSegments[absoluteIndex] || "", + }); + }; + player.onEnd = () => { + if (gen !== _sessionGeneration) return; + _activeTTS = null; + const lastIndex = Math.max(0, _sessionSegments.length - 1); + _sessionCurrentIndex = lastIndex; + set({ + playState: "stopped", + currentChunkIndex: lastIndex, + totalChunks: _sessionSegments.length, + currentSegmentText: _sessionSegments[lastIndex] || "", + }); + get().onEnd?.(); + }; + }; + + const startFallback = (error: unknown) => { + if (gen !== _sessionGeneration || fallbackStarted) return; + fallbackStarted = true; + console.warn("[TTSStore] Native system TTS failed; falling back to ExpoSpeech:", error); + try { + nativePlayer.onStateChange = undefined; + nativePlayer.onChunkChange = undefined; + nativePlayer.onEnd = undefined; + nativePlayer.stop(); + } catch {} + attachPlayer(fallbackPlayer); + void Promise.resolve(fallbackPlayer.speak(segments, config)).catch((fallbackError) => { + if (gen !== _sessionGeneration) return; + console.error("[TTSStore] ExpoSpeech fallback failed:", fallbackError); + _activeTTS = null; + set({ playState: "stopped" }); + }); + }; + + attachPlayer(nativePlayer); + try { + const playback = nativePlayer.speak(segments, config); + void Promise.resolve(playback) + .then(() => { + if (gen === _sessionGeneration) nativeSettled = true; + }) + .catch((error) => { + if (gen !== _sessionGeneration) return; + if (!nativeSettled) { + startFallback(error); + return; + } + console.error("[TTSStore] play failed:", error); + _activeTTS = null; + set({ playState: "stopped" }); + }); + } catch (error) { + startFallback(error); + } +} + +function startPlaybackForConfig( + segments: string[], + config: TTSConfig, + startIndex: number, + set: (partial: Partial) => void, + get: () => TTSState, +): void { + if (config.engine === "system") { + startSystemPlaybackWithFallback(segments, config, startIndex, set, get); + return; + } + startPlayback(segments, config, startIndex, set, get); +} + export interface TTSState { playState: TTSPlayState; currentText: string; @@ -389,7 +478,6 @@ export const useTTSStore = create()( const segments = normalizeSegments(text); const joinedText = segments.join(" ").trim(); if (!joinedText) { - console.log("[TTSStore] No text to speak"); return; } @@ -399,16 +487,6 @@ export const useTTSStore = create()( _sessionSegments = segments; _sessionCurrentIndex = 0; - console.log("[TTSStore] play called", { - engine: config.engine, - segments: segments.length, - edgeVoice: config.edgeVoice, - voiceName: config.voiceName, - firstText: segments[0] || "", - secondText: segments[1] || "", - queuePreview: previewSessionSegments(segments), - }); - set({ playState: "loading", currentText: joinedText, @@ -417,7 +495,7 @@ export const useTTSStore = create()( totalChunks: segments.length, }); - startPlayback(segments, config, 0, set, get); + startPlaybackForConfig(segments, config, 0, set, get); }, append: (text: string | string[]) => { @@ -431,15 +509,6 @@ export const useTTSStore = create()( try { _activeTTS.append(segments); _sessionSegments = [..._sessionSegments, ...segments]; - console.log("[TTSStore] append called", { - appended: segments.length, - previousTotal: previousSegments.length, - nextTotal: _sessionSegments.length, - appendFirstText: segments[0] || "", - appendSecondText: segments[1] || "", - appendedPreview: previewSessionSegments(segments), - fullQueueTailPreview: previewSessionSegments(_sessionSegments.slice(-8)), - }); set((state) => ({ currentText: [state.currentText, joinedText].filter(Boolean).join(" ").trim(), totalChunks: _sessionSegments.length, @@ -455,7 +524,6 @@ export const useTTSStore = create()( }, pause: () => { - console.log("[TTSStore] pause called"); clearRespeakTimer(); const { playState } = get(); if (playState !== "playing" && playState !== "loading") return; @@ -464,7 +532,6 @@ export const useTTSStore = create()( }, resume: () => { - console.log("[TTSStore] resume called"); if (get().playState === "paused" && _activeTTS) { _activeTTS.resume(); set({ playState: "playing" }); @@ -495,11 +562,10 @@ export const useTTSStore = create()( totalChunks: _sessionSegments.length, }); - startPlayback(remainingSegments, config, nextIndex, set, get); + startPlaybackForConfig(remainingSegments, config, nextIndex, set, get); }, stop: () => { - console.log("[TTSStore] stop called"); clearSleepTimerHandle(); clearRespeakTimer(); _sessionGeneration += 1; @@ -523,7 +589,6 @@ export const useTTSStore = create()( }, toggle: (text?: string) => { - console.log("[TTSStore] toggle called, playState:", get().playState); const { playState, currentText, play } = get(); if (playState === "playing" || playState === "loading") { get().pause(); @@ -557,7 +622,6 @@ export const useTTSStore = create()( setPlayState: (playState) => set({ playState }), setOnEnd: (cb) => { - console.log("[TTSStore] setOnEnd", { hasCallback: !!cb }); set({ onEnd: cb }); }, @@ -603,12 +667,6 @@ export const useTTSStore = create()( return; } - console.log("[TTSStore] jumpToChunk", { - index, - engine: config.engine, - segments: _sessionSegments.length, - }); - detachAndStopAllPlayers(); _sessionGeneration += 1; _sessionCurrentIndex = index; @@ -620,7 +678,7 @@ export const useTTSStore = create()( totalChunks: _sessionSegments.length, }); - startPlayback(remainingSegments, config, index, set, get); + startPlaybackForConfig(remainingSegments, config, index, set, get); }, setSleepTimer: (minutes: number) => { diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index b2e507be..21250183 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1067,6 +1067,9 @@ export class Paginator extends HTMLElement { grid-row: 1 / -1; overflow: auto; overflow-anchor: auto; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; flex-direction: column; background: var(--_scrollbar-track-bg, transparent); scrollbar-width: none; @@ -1107,6 +1110,7 @@ export class Paginator extends HTMLElement { } :host([flow="scrolled"]) #container.vertical { flex-direction: row; + touch-action: pan-x; } #header { grid-column: 3 / 4; @@ -2062,6 +2066,11 @@ export class Paginator extends HTMLElement { } #onTouchStart(e) { if (this.#navigationLocked) return + if (this.scrolled) { + this.#touchState = null + this.#touchScrolled = false + return + } const contents = this.getContents?.() ?? [] for (const { doc } of contents) { const selection = doc?.getSelection?.() @@ -2084,50 +2093,10 @@ export class Paginator extends HTMLElement { pv.element.style.willChange = 'transform' } } - #onScrolledTouchMove(e, state) { - if (e.touches.length > 1) return - const touch = e.changedTouches[0] - if (!touch) return - - const x = touch.screenX - const y = touch.screenY - const totalDx = Math.abs(x - (state.startX ?? x)) - const totalDy = Math.abs(y - (state.startY ?? y)) - const primaryTotal = this.#vertical ? totalDx : totalDy - const crossTotal = this.#vertical ? totalDy : totalDx - - if (primaryTotal < 2 || primaryTotal < crossTotal) { - state.x = x - state.y = y - state.t = e.timeStamp - return - } - - const dx = state.x - x - const dy = state.y - y - state.x = x - state.y = y - state.t = e.timeStamp - - const delta = this.#vertical ? -dx : dy - if (Math.abs(delta) < 0.5) return - - e.preventDefault() - this.#touchScrolled = true - - const previous = this.containerPosition - this.containerPosition = previous + delta - const moved = Math.abs(this.containerPosition - previous) >= 0.5 - - if (!moved && Math.abs(delta) > 2) { - const forward = this.#vertical ? delta < 0 : delta > 0 - if (forward && !this.atEnd) void this.next() - else if (!forward && !this.atStart) void this.prev() - } - } #onTouchMove(e) { const state = this.#touchState if (this.#navigationLocked || !state) return + if (this.scrolled) return const contents = this.getContents?.() ?? [] for (const { doc } of contents) { const selection = doc?.getSelection?.() @@ -2136,10 +2105,6 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (state.pinched) return - if (this.scrolled) { - this.#onScrolledTouchMove(e, state) - return - } // When the host opts out of swipe-to-paginate, let touch events reach // native behavior (text selection, etc.) without us tracking or // pre-empting them.