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.