fix: reduce pdf flicker and harden tts

- Smooth react-pdf page turns with stable canvas staging and fade-in
- Retry transient empty page text extraction during fast page turns
- Avoid noisy TTS preload toasts, guard requests when quota is exhausted,
  and improve Howler retry/unload behavior to prevent pool exhaustion
- Enforce auth FK cascade deletes and tighten rate-limiter bucket updates
  using affected-row checks
This commit is contained in:
Richard R 2026-01-26 17:01:51 -07:00
parent c57b913cb5
commit 8189087ad9
10 changed files with 902 additions and 116 deletions

View file

@ -0,0 +1,6 @@
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,592 @@
{
"id": "62e82369-d624-4cc1-a482-7ea5fbc7275b",
"prevId": "5ac6552c-824b-4b79-8cb6-f0ec3d121031",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobook_chapters": {
"name": "audiobook_chapters",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"chapter_index": {
"name": "chapter_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"audiobook_chapters_book_id_audiobooks_id_fk": {
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobooks": {
"name": "audiobooks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_path": {
"name": "cover_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"last_modified": {
"name": "last_modified",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"documents_id_user_id_pk": {
"name": "documents_id_user_id_pk",
"columns": [
"id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_anonymous": {
"name": "is_anonymous",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_tts_chars": {
"name": "user_tts_chars",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"char_count": {
"name": "char_count",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_user_tts_chars_date": {
"name": "idx_user_tts_chars_date",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_tts_chars_user_id_date_pk": {
"name": "user_tts_chars_user_id_date_pk",
"columns": [
"user_id",
"date"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1769393893675, "when": 1769393893675,
"tag": "0000_military_nemesis", "tag": "0000_military_nemesis",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1769404444945,
"tag": "0001_futuristic_harpoon",
"breakpoints": true
} }
] ]
} }

View file

@ -119,6 +119,50 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/*
* PDF page turn smoothing
* - Keep a stable paint surface (prevents background flash)
* - Fade-in newly rendered canvas
*/
.pdf-page-stage {
/* Ensure we always paint something (matches app theme) while react-pdf swaps canvases */
background: var(--background);
}
.pdf-viewer .react-pdf__Page {
/* Make page paints more stable and avoid seeing the app background between renders */
background: var(--background);
isolation: isolate;
}
.pdf-viewer .react-pdf__Page canvas {
display: block;
background: var(--background);
/* GPU hint; helps reduce flicker on some browsers */
transform: translateZ(0);
backface-visibility: hidden;
}
/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */
.pdf-viewer .react-pdf__Page canvas {
opacity: 0;
animation: pdf-canvas-fade-in 140ms ease-out forwards;
}
@keyframes pdf-canvas-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */
.pdf-viewer .react-pdf__message {
display: none !important;
}
.pdf-page-stage {
position: relative;
}
/* App shell utility (fullscreen, vertical layout) */ /* App shell utility (fullscreen, vertical layout) */
.app-shell { .app-shell {
--header-height: 3.25rem; --header-height: 3.25rem;

View file

@ -22,6 +22,7 @@ interface PDFOnLinkClickArgs {
export function PDFViewer({ zoomLevel }: PDFViewerProps) { export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isPageRendering, setIsPageRendering] = useState(false);
const scaleRef = useRef<number>(1); const scaleRef = useRef<number>(1);
const { containerWidth, containerHeight } = usePDFResize(containerRef); const { containerWidth, containerHeight } = usePDFResize(containerRef);
const sentenceHighlightSeqRef = useRef(0); const sentenceHighlightSeqRef = useRef(0);
@ -75,6 +76,14 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
// Track page turns so we can keep the previous canvas visible until the new one paints.
const lastRenderedLayoutKeyRef = useRef<string>('');
useEffect(() => {
if (layoutKey !== lastRenderedLayoutKeyRef.current) {
setIsPageRendering(true);
}
}, [layoutKey]);
const clearSentenceHighlightTimeouts = useCallback(() => { const clearSentenceHighlightTimeouts = useCallback(() => {
for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t);
sentenceHighlightTimeoutsRef.current = []; sentenceHighlightTimeoutsRef.current = [];
@ -282,7 +291,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
}, [calculateScale]); }, [calculateScale]);
return ( return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full"> <div
ref={containerRef}
className="flex flex-col items-center overflow-auto w-full px-6 h-full pdf-viewer"
>
<Document <Document
key={currDocId || 'pdf'} key={currDocId || 'pdf'}
loading={<DocumentSkeleton />} loading={<DocumentSkeleton />}
@ -302,9 +314,9 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
} }
} }
}} }}
className="flex flex-col items-center m-0 z-0" className="flex flex-col items-center m-0 z-0"
> >
<div> <div className="pdf-page-stage" data-rendering={isPageRendering ? 'true' : 'false'}>
{viewType === 'scroll' ? ( {viewType === 'scroll' ? (
// Scroll mode: render all pages // Scroll mode: render all pages
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@ -316,6 +328,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={i + 1 === currDocPage} renderTextLayer={i + 1 === currDocPage}
className="shadow-lg" className="shadow-lg"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => { onLoadSuccess={(page) => {
setPageWidth(page.originalWidth); setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight); setPageHeight(page.originalHeight);
@ -334,6 +350,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={leftPage === currDocPage} renderTextLayer={leftPage === currDocPage}
className="shadow-lg" className="shadow-lg"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => { onLoadSuccess={(page) => {
setPageWidth(page.originalWidth); setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight); setPageHeight(page.originalHeight);
@ -348,6 +368,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={rightPage === currDocPage} renderTextLayer={rightPage === currDocPage}
className="shadow-lg" className="shadow-lg"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => { onLoadSuccess={(page) => {
setPageWidth(page.originalWidth); setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight); setPageHeight(page.originalHeight);

View file

@ -101,6 +101,8 @@ interface PDFContextType {
const PDFContext = createContext<PDFContextType | undefined>(undefined); const PDFContext = createContext<PDFContextType | undefined>(undefined);
const CONTINUATION_PREVIEW_CHARS = 600; const CONTINUATION_PREVIEW_CHARS = 600;
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
const EMPTY_TEXT_MAX_RETRIES = 6;
/** /**
* PDFProvider Component * PDFProvider Component
@ -150,6 +152,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// or when react-pdf tears down and recreates its internal worker. // or when react-pdf tears down and recreates its internal worker.
const pdfDocGenerationRef = useRef(0); const pdfDocGenerationRef = useRef(0);
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined); const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
const loadSeqRef = useRef(0);
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
useEffect(() => { useEffect(() => {
pdfDocumentRef.current = pdfDocument; pdfDocumentRef.current = pdfDocument;
@ -183,6 +187,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const generation = pdfDocGenerationRef.current; const generation = pdfDocGenerationRef.current;
const currentPdf = pdfDocumentRef.current; const currentPdf = pdfDocumentRef.current;
if (!currentPdf) return; if (!currentPdf) return;
const seq = ++loadSeqRef.current;
const pageNumber = currDocPageNumber;
const existingRetry = emptyRetryRef.current;
if (existingRetry?.timer) {
clearTimeout(existingRetry.timer);
}
emptyRetryRef.current =
existingRetry && existingRetry.page === pageNumber
? { ...existingRetry, timer: null }
: null;
const margins = { const margins = {
header: headerMargin, header: headerMargin,
@ -228,6 +243,35 @@ export function PDFProvider({ children }: { children: ReactNode }) {
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return; return;
} }
if (seq !== loadSeqRef.current || pageNumber !== currDocPageNumber) {
return;
}
const trimmed = text.trim();
if (!trimmed) {
const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0;
const attempt = prevAttempt + 1;
// Avoid pushing empty text into TTS immediately; transient empty extractions can happen
// during page turns or react-pdf worker churn. Retry a few times before treating it as
// a truly blank page.
if (attempt <= EMPTY_TEXT_MAX_RETRIES) {
const timer = setTimeout(() => {
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
}
if (pageNumber !== currDocPageNumber) {
return;
}
void loadCurrDocText();
}, EMPTY_TEXT_RETRY_DELAY_MS);
emptyRetryRef.current = { page: pageNumber, attempt, timer };
return;
}
} else {
emptyRetryRef.current = null;
}
if (text !== currDocText || text === '') { if (text !== currDocText || text === '') {
setCurrDocText(text); setCurrDocText(text);
@ -277,6 +321,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects // `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
// or fast refresh. // or fast refresh.
pdfDocGenerationRef.current += 1; pdfDocGenerationRef.current += 1;
loadSeqRef.current += 1;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}
emptyRetryRef.current = null;
pageTextCacheRef.current.clear(); pageTextCacheRef.current.clear();
setPdfDocument(undefined); setPdfDocument(undefined);
setCurrDocPages(undefined); setCurrDocPages(undefined);
@ -304,6 +353,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const clearCurrDoc = useCallback(() => { const clearCurrDoc = useCallback(() => {
pdfDocGenerationRef.current += 1; pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = undefined; pdfDocumentRef.current = undefined;
loadSeqRef.current += 1;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}
emptyRetryRef.current = null;
setCurrDocId(undefined); setCurrDocId(undefined);
setCurrDocName(undefined); setCurrDocName(undefined);
setCurrDocData(undefined); setCurrDocData(undefined);

View file

@ -299,7 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const audioContext = useAudioContext(); const audioContext = useAudioContext();
const audioCache = useAudioCache(25); const audioCache = useAudioCache(25);
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit(); const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAutoRateLimit();
// Add ref for location change handler // Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
@ -820,6 +820,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return cachedAudio; return cachedAudio;
} }
// If the user is already out of quota, avoid spamming requests.
// Cached audio above is still allowed to play.
if (isAtLimit) {
if (!preload) {
setIsPlaying(false);
}
return undefined;
}
try { try {
console.log('Requesting audio for sentence:', sentence); console.log('Requesting audio for sentence:', sentence);
@ -906,28 +915,34 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Handle daily quota exceeded (429 + Problem Details code) // Handle daily quota exceeded (429 + Problem Details code)
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
toast.error('Daily TTS limit reached.', { // Avoid noisy toasts from background preloading; keep the user-facing error for active playback.
id: 'tts-limit-error', if (!preload) {
style: { toast.error('Daily TTS limit reached.', {
background: 'var(--background)', id: 'tts-limit-error',
color: 'var(--accent)', style: {
}, background: 'var(--background)',
duration: 5000, color: 'var(--accent)',
}); },
duration: 5000,
});
}
triggerRateLimit(); triggerRateLimit();
refreshRateLimit().catch(console.error); refreshRateLimit().catch(console.error);
// Do NOT re-throw, just return undefined to stop playback gracefully // Do NOT re-throw, just return undefined to stop playback gracefully
return undefined; return undefined;
} }
toast.error('Failed to generate audio. Server not responding.', { // Avoid noisy toasts from background preloading.
id: 'tts-api-error', if (!preload) {
style: { toast.error('TTS failed. Skipped sentence and paused.', {
background: 'var(--background)', id: 'tts-api-error',
color: 'var(--accent)', style: {
}, background: 'var(--background)',
duration: 7000, color: 'var(--accent)',
}); },
duration: 7000,
});
}
throw error; throw error;
} }
}, [ }, [
@ -947,7 +962,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
triggerRateLimit, triggerRateLimit,
refreshRateLimit, refreshRateLimit,
onTTSComplete, onTTSComplete,
onTTSStart onTTSStart,
isAtLimit
]); ]);
/** /**
@ -1000,9 +1016,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (preload) { if (preload) {
preloadRequests.current.set(sentence, processPromise); preloadRequests.current.set(sentence, processPromise);
// Clean up the map entry once the promise resolves or rejects // Clean up the map entry once the promise resolves or rejects
processPromise.finally(() => { void processPromise
preloadRequests.current.delete(sentence); .finally(() => {
}); preloadRequests.current.delete(sentence);
})
.catch(() => {
// Prevent unhandled rejections from the cleanup-only chained promise.
});
} }
return processPromise; return processPromise;
@ -1024,31 +1044,33 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const INITIAL_RETRY_DELAY = 1000; // 1 second const INITIAL_RETRY_DELAY = 1000; // 1 second
const createHowl = async (retryCount = 0): Promise<Howl | null> => { const createHowl = async (retryCount = 0): Promise<Howl | null> => {
try { let playErrorAttempts = 0;
// Get the processed audio data URI directly from processSentence // Get the processed audio data URI directly from processSentence
const audioDataUri = await processSentence(sentence); const audioDataUri = await processSentence(sentence);
if (!audioDataUri) { if (!audioDataUri) {
// Graceful exit for rate limit or skipped sentence // Graceful exit for rate limit / abort / intentionally skipped sentence
console.log('Skipping playback for sentence (no audio generated)'); console.log('Skipping playback for sentence (no audio generated)');
return null; return null;
} }
// Force unload any previous Howl instance to free up resources // Force unload any previous Howl instance to free up resources
if (activeHowl) { if (activeHowl) {
activeHowl.unload(); activeHowl.unload();
} }
return new Howl({ return new Howl({
src: [audioDataUri], src: [audioDataUri],
format: ['mp3', 'mpeg'], format: ['mp3', 'mpeg'],
html5: true, html5: true,
preload: true, preload: true,
pool: 5, // We never need overlapping playback for a single sentence. Keeping this low avoids
rate: audioSpeed, // Safari/HTML5 Audio pool exhaustion when retries happen.
onload: function (this: Howl) { pool: 1,
const estimate = pageTurnEstimateRef.current; rate: audioSpeed,
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; onload: function (this: Howl) {
if (!visualPageChangeHandlerRef.current) return; const estimate = pageTurnEstimateRef.current;
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
if (!visualPageChangeHandlerRef.current) return;
const duration = this.duration(); const duration = this.duration();
if (!duration || !Number.isFinite(duration)) return; if (!duration || !Number.isFinite(duration)) return;
@ -1066,46 +1088,69 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return; if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
visualPageChangeHandlerRef.current?.(currentEstimate.location); visualPageChangeHandlerRef.current?.(currentEstimate.location);
}, delayMs); }, delayMs);
}, },
onplay: () => { onplay: () => {
setIsProcessing(false);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
},
onplayerror: function (this: Howl, error) {
console.warn('Howl playback error:', error);
playErrorAttempts += 1;
// Avoid looping for many seconds on Safari: if playback still fails after a single
// recovery attempt, skip the sentence and pause.
if (playErrorAttempts > 1) {
setIsProcessing(false); setIsProcessing(false);
if ('mediaSession' in navigator) { setActiveHowl(null);
navigator.mediaSession.playbackState = 'playing'; this.unload();
} setIsPlaying(false);
}, advance();
onplayerror: function (this: Howl, error) { return;
console.warn('Howl playback error:', error); }
// Try to recover by forcing HTML5 audio mode
if (this.state() === 'loaded') { // Try to recover by reloading once.
if (this.state() === 'loaded') {
this.unload();
this.once('load', () => this.play());
this.load();
}
},
onloaderror: async function (this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
if (retryCount < MAX_RETRIES) {
// Calculate exponential backoff delay
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay}ms...`);
// Free the current Howl/audio objects before retrying to avoid pool exhaustion.
try {
this.unload(); this.unload();
this.once('load', () => { } catch {
this.play(); // ignore unload errors
});
this.load();
} }
},
onloaderror: async function (this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
if (retryCount < MAX_RETRIES) { // Wait for the delay
// Calculate exponential backoff delay await new Promise(resolve => setTimeout(resolve, delay));
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay}ms...`);
// Wait for the delay // Try to create a new Howl instance
await new Promise(resolve => setTimeout(resolve, delay)); try {
// Try to create a new Howl instance
const retryHowl = await createHowl(retryCount + 1); const retryHowl = await createHowl(retryCount + 1);
if (retryHowl) { if (retryHowl) {
setActiveHowl(retryHowl); setActiveHowl(retryHowl);
retryHowl.play(); retryHowl.play();
} else {
// No audio generated (quota/abort). Stop cleanly without spamming errors.
setIsProcessing(false);
setActiveHowl(null);
setIsPlaying(false);
} }
} else { } catch (err) {
console.error('Max retries reached, moving to next sentence'); console.error('Error creating Howl instance:', err);
setIsProcessing(false); setIsProcessing(false);
setActiveHowl(null); setActiveHowl(null);
this.unload();
setIsPlaying(false); setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', { toast.error('Audio loading failed after retries. Moving to next sentence...', {
@ -1117,6 +1162,24 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
duration: 2000, duration: 2000,
}); });
advance();
}
} else {
console.error('Max retries reached, moving to next sentence');
setIsProcessing(false);
setActiveHowl(null);
this.unload();
setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', {
id: 'audio-load-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 2000,
});
advance(); advance();
} }
}, },
@ -1131,43 +1194,38 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
advance(); advance();
} }
}, },
onstop: function (this: Howl) { onstop: function (this: Howl) {
setIsProcessing(false); setIsProcessing(false);
this.unload(); this.unload();
} }
}); });
} catch (error) {
console.error('Error creating Howl instance:', error);
return null;
}
}; };
try { try {
const howl = await createHowl(); const howl = await createHowl();
if (howl) { if (!howl) {
setActiveHowl(howl); // No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
return howl; // advancing or spamming errors.
setActiveHowl(null);
setIsProcessing(false);
setIsPlaying(false);
return null;
} }
throw new Error('Failed to create Howl instance'); setActiveHowl(howl);
return howl;
} catch (error) { } catch (error) {
console.error('Error playing TTS:', error); console.error('Error playing TTS:', error);
setActiveHowl(null); setActiveHowl(null);
setIsProcessing(false); setIsProcessing(false);
toast.error('Failed to process audio. Skipping problematic sentence.', { // Skip the sentence but pause playback (user can resume manually).
id: 'tts-processing-error', abortAudio(true);
style: { setIsPlaying(false);
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 3000,
});
advance(); advance();
return null; return null;
} }
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]); }, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]);
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
const sentence = sentences[currentIndex]; const sentence = sentences[currentIndex];
@ -1245,6 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* Preloads the next sentence's audio * Preloads the next sentence's audio
*/ */
const preloadNextAudio = useCallback(async () => { const preloadNextAudio = useCallback(async () => {
if (isAtLimit) return;
try { try {
const nextSentence = sentences[currentIndex + 1]; const nextSentence = sentences[currentIndex + 1];
if (nextSentence) { if (nextSentence) {
@ -1283,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} catch (error) { } catch (error) {
console.error('Error initiating preload:', error); console.error('Error initiating preload:', error);
} }
}, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]); }, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
/** /**
* Main Playback Driver * Main Playback Driver

View file

@ -54,14 +54,14 @@ export const session = pgTable("session", {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'), ipAddress: text('ip_address'),
userAgent: text('user_agent'), userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id) userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
}); });
export const account = pgTable("account", { export const account = pgTable("account", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text('account_id').notNull(), accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(), providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'), accessToken: text('access_token'),
refreshToken: text('refresh_token'), refreshToken: text('refresh_token'),
idToken: text('id_token'), idToken: text('id_token'),

View file

@ -55,14 +55,14 @@ export const session = sqliteTable("session", {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'), ipAddress: text('ip_address'),
userAgent: text('user_agent'), userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id) userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
}); });
export const account = sqliteTable("account", { export const account = sqliteTable("account", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text('account_id').notNull(), accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(), providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'), accessToken: text('access_token'),
refreshToken: text('refresh_token'), refreshToken: text('refresh_token'),
idToken: text('id_token'), idToken: text('id_token'),

View file

@ -88,6 +88,14 @@ class RateLimitExceeded extends Error {
name = 'RateLimitExceeded' as const; name = 'RateLimitExceeded' as const;
} }
function getRowsAffected(result: unknown): number {
if (typeof result !== 'object' || result === null) return 0;
const rec = result as Record<string, unknown>;
if (typeof rec.rowCount === 'number') return rec.rowCount;
if (typeof rec.changes === 'number') return rec.changes;
return 0;
}
export class RateLimiter { export class RateLimiter {
constructor() { } constructor() { }
@ -144,7 +152,6 @@ export class RateLimiter {
// Attempt to increment each bucket // Attempt to increment each bucket
for (const bucket of buckets) { for (const bucket of buckets) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const updateResult = await tx.update(userTtsChars) const updateResult = await tx.update(userTtsChars)
.set({ .set({
charCount: sql`${userTtsChars.charCount} + ${charCount}`, charCount: sql`${userTtsChars.charCount} + ${charCount}`,
@ -156,13 +163,10 @@ export class RateLimiter {
lt(userTtsChars.charCount, bucket.limit) lt(userTtsChars.charCount, bucket.limit)
)); ));
// Check if update actually happened // If any bucket is already at/over its limit, reject the request (and roll back all increments).
const row = await tx.select({ count: userTtsChars.charCount }).from(userTtsChars) // Note: we intentionally allow a request to push a bucket over its limit; we only block when the
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today))) // bucket was already exhausted before this request started.
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (getRowsAffected(updateResult) <= 0) {
.then((res: any) => res[0]);
if (!row || Number(row.count) > bucket.limit) {
throw new RateLimitExceeded(); throw new RateLimitExceeded();
} }
} }
@ -180,10 +184,6 @@ export class RateLimiter {
const effective = pickEffectiveResult(bucketResults); const effective = pickEffectiveResult(bucketResults);
if (effective.currentCount > effective.limit) {
throw new RateLimitExceeded();
}
return { return {
allowed: true, allowed: true,
currentCount: effective.currentCount, currentCount: effective.currentCount,
@ -330,4 +330,4 @@ export class RateLimiter {
} }
// Export singleton instance // Export singleton instance
export const rateLimiter = new RateLimiter(); export const rateLimiter = new RateLimiter();