diff --git a/drizzle_pg/0001_futuristic_harpoon.sql b/drizzle_pg/0001_futuristic_harpoon.sql new file mode 100644 index 0000000..722b14e --- /dev/null +++ b/drizzle_pg/0001_futuristic_harpoon.sql @@ -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; \ No newline at end of file diff --git a/drizzle_pg/meta/0001_snapshot.json b/drizzle_pg/meta/0001_snapshot.json new file mode 100644 index 0000000..e19c0cf --- /dev/null +++ b/drizzle_pg/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle_pg/meta/_journal.json b/drizzle_pg/meta/_journal.json index c9b3a38..5157a5b 100644 --- a/drizzle_pg/meta/_journal.json +++ b/drizzle_pg/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769393893675, "tag": "0000_military_nemesis", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1769404444945, + "tag": "0001_futuristic_harpoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 10bc47f..cc21bdc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -119,6 +119,50 @@ body { -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 { --header-height: 3.25rem; diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index b21081f..2f4e127 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -22,6 +22,7 @@ interface PDFOnLinkClickArgs { export function PDFViewer({ zoomLevel }: PDFViewerProps) { const containerRef = useRef(null); + const [isPageRendering, setIsPageRendering] = useState(false); const scaleRef = useRef(1); const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); @@ -75,6 +76,14 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { 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(''); + useEffect(() => { + if (layoutKey !== lastRenderedLayoutKeyRef.current) { + setIsPageRendering(true); + } + }, [layoutKey]); + const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); sentenceHighlightTimeoutsRef.current = []; @@ -282,7 +291,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { }, [calculateScale]); return ( -
+
} @@ -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" > -
+
{viewType === 'scroll' ? ( // Scroll mode: render all pages
@@ -316,6 +328,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { renderTextLayer={i + 1 === currDocPage} className="shadow-lg" scale={currentScale()} + onRenderSuccess={() => { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); @@ -334,6 +350,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { renderTextLayer={leftPage === currDocPage} className="shadow-lg" scale={currentScale()} + onRenderSuccess={() => { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); @@ -348,6 +368,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { renderTextLayer={rightPage === currDocPage} className="shadow-lg" scale={currentScale()} + onRenderSuccess={() => { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); setPageHeight(page.originalHeight); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 28be624..d3e0cb1 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -101,6 +101,8 @@ interface PDFContextType { const PDFContext = createContext(undefined); const CONTINUATION_PREVIEW_CHARS = 600; +const EMPTY_TEXT_RETRY_DELAY_MS = 120; +const EMPTY_TEXT_MAX_RETRIES = 6; /** * PDFProvider Component @@ -150,6 +152,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { // or when react-pdf tears down and recreates its internal worker. const pdfDocGenerationRef = useRef(0); const pdfDocumentRef = useRef(undefined); + const loadSeqRef = useRef(0); + const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); useEffect(() => { pdfDocumentRef.current = pdfDocument; @@ -183,6 +187,17 @@ export function PDFProvider({ children }: { children: ReactNode }) { const generation = pdfDocGenerationRef.current; const currentPdf = pdfDocumentRef.current; 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 = { header: headerMargin, @@ -228,6 +243,35 @@ export function PDFProvider({ children }: { children: ReactNode }) { if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { 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 === '') { setCurrDocText(text); @@ -277,6 +321,11 @@ export function PDFProvider({ children }: { children: ReactNode }) { // `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects // or fast refresh. pdfDocGenerationRef.current += 1; + loadSeqRef.current += 1; + if (emptyRetryRef.current?.timer) { + clearTimeout(emptyRetryRef.current.timer); + } + emptyRetryRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); @@ -304,6 +353,11 @@ export function PDFProvider({ children }: { children: ReactNode }) { const clearCurrDoc = useCallback(() => { pdfDocGenerationRef.current += 1; pdfDocumentRef.current = undefined; + loadSeqRef.current += 1; + if (emptyRetryRef.current?.timer) { + clearTimeout(emptyRetryRef.current.timer); + } + emptyRetryRef.current = null; setCurrDocId(undefined); setCurrDocName(undefined); setCurrDocData(undefined); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 04fb6fb..ac8b33a 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -299,7 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const audioContext = useAudioContext(); const audioCache = useAudioCache(25); 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 const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -820,6 +820,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement 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 { 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) if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { - toast.error('Daily TTS limit reached.', { - id: 'tts-limit-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, - duration: 5000, - }); + // Avoid noisy toasts from background preloading; keep the user-facing error for active playback. + if (!preload) { + toast.error('Daily TTS limit reached.', { + id: 'tts-limit-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 5000, + }); + } triggerRateLimit(); refreshRateLimit().catch(console.error); // Do NOT re-throw, just return undefined to stop playback gracefully return undefined; } - toast.error('Failed to generate audio. Server not responding.', { - id: 'tts-api-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, - duration: 7000, - }); + // Avoid noisy toasts from background preloading. + if (!preload) { + toast.error('TTS failed. Skipped sentence and paused.', { + id: 'tts-api-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 7000, + }); + } throw error; } }, [ @@ -947,7 +962,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement triggerRateLimit, refreshRateLimit, onTTSComplete, - onTTSStart + onTTSStart, + isAtLimit ]); /** @@ -1000,9 +1016,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (preload) { preloadRequests.current.set(sentence, processPromise); // Clean up the map entry once the promise resolves or rejects - processPromise.finally(() => { - preloadRequests.current.delete(sentence); - }); + void processPromise + .finally(() => { + preloadRequests.current.delete(sentence); + }) + .catch(() => { + // Prevent unhandled rejections from the cleanup-only chained promise. + }); } return processPromise; @@ -1024,31 +1044,33 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const INITIAL_RETRY_DELAY = 1000; // 1 second const createHowl = async (retryCount = 0): Promise => { - try { - // Get the processed audio data URI directly from processSentence - const audioDataUri = await processSentence(sentence); - if (!audioDataUri) { - // Graceful exit for rate limit or skipped sentence - console.log('Skipping playback for sentence (no audio generated)'); - return null; - } + let playErrorAttempts = 0; + // Get the processed audio data URI directly from processSentence + const audioDataUri = await processSentence(sentence); + if (!audioDataUri) { + // Graceful exit for rate limit / abort / intentionally skipped sentence + console.log('Skipping playback for sentence (no audio generated)'); + return null; + } - // Force unload any previous Howl instance to free up resources - if (activeHowl) { - activeHowl.unload(); - } + // Force unload any previous Howl instance to free up resources + if (activeHowl) { + activeHowl.unload(); + } - return new Howl({ - src: [audioDataUri], - format: ['mp3', 'mpeg'], - html5: true, - preload: true, - pool: 5, - rate: audioSpeed, - onload: function (this: Howl) { - const estimate = pageTurnEstimateRef.current; - if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; - if (!visualPageChangeHandlerRef.current) return; + return new Howl({ + src: [audioDataUri], + format: ['mp3', 'mpeg'], + html5: true, + preload: true, + // We never need overlapping playback for a single sentence. Keeping this low avoids + // Safari/HTML5 Audio pool exhaustion when retries happen. + pool: 1, + rate: audioSpeed, + onload: function (this: Howl) { + const estimate = pageTurnEstimateRef.current; + if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; + if (!visualPageChangeHandlerRef.current) return; const duration = this.duration(); if (!duration || !Number.isFinite(duration)) return; @@ -1066,46 +1088,69 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return; visualPageChangeHandlerRef.current?.(currentEstimate.location); }, 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); - if ('mediaSession' in navigator) { - navigator.mediaSession.playbackState = 'playing'; - } - }, - onplayerror: function (this: Howl, error) { - console.warn('Howl playback error:', error); - // Try to recover by forcing HTML5 audio mode - if (this.state() === 'loaded') { + setActiveHowl(null); + this.unload(); + setIsPlaying(false); + advance(); + return; + } + + // 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.once('load', () => { - this.play(); - }); - this.load(); + } catch { + // ignore unload errors } - }, - 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...`); + // Wait for the delay + await new Promise(resolve => setTimeout(resolve, delay)); - // Wait for the delay - await new Promise(resolve => setTimeout(resolve, delay)); - - // Try to create a new Howl instance + // Try to create a new Howl instance + try { const retryHowl = await createHowl(retryCount + 1); if (retryHowl) { setActiveHowl(retryHowl); retryHowl.play(); + } else { + // No audio generated (quota/abort). Stop cleanly without spamming errors. + setIsProcessing(false); + setActiveHowl(null); + setIsPlaying(false); } - } else { - console.error('Max retries reached, moving to next sentence'); + } catch (err) { + console.error('Error creating Howl instance:', err); setIsProcessing(false); setActiveHowl(null); - this.unload(); setIsPlaying(false); toast.error('Audio loading failed after retries. Moving to next sentence...', { @@ -1117,6 +1162,24 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement 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(); } }, @@ -1131,43 +1194,38 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement advance(); } }, - onstop: function (this: Howl) { - setIsProcessing(false); - this.unload(); - } - }); - } catch (error) { - console.error('Error creating Howl instance:', error); - return null; - } + onstop: function (this: Howl) { + setIsProcessing(false); + this.unload(); + } + }); }; try { const howl = await createHowl(); - if (howl) { - setActiveHowl(howl); - return howl; + if (!howl) { + // No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without + // 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) { console.error('Error playing TTS:', error); setActiveHowl(null); setIsProcessing(false); - toast.error('Failed to process audio. Skipping problematic sentence.', { - id: 'tts-processing-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, - duration: 3000, - }); - + // Skip the sentence but pause playback (user can resume manually). + abortAudio(true); + setIsPlaying(false); advance(); return null; } - }, [isPlaying, advance, activeHowl, processSentence, audioSpeed]); + }, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]); const playAudio = useCallback(async () => { const sentence = sentences[currentIndex]; @@ -1245,6 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * Preloads the next sentence's audio */ const preloadNextAudio = useCallback(async () => { + if (isAtLimit) return; try { const nextSentence = sentences[currentIndex + 1]; if (nextSentence) { @@ -1283,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } catch (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 diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index ad2debf..276cef4 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -54,14 +54,14 @@ export const session = pgTable("session", { updatedAt: timestamp('updated_at').notNull(), ipAddress: text('ip_address'), 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", { id: text("id").primaryKey(), accountId: text('account_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'), refreshToken: text('refresh_token'), idToken: text('id_token'), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 1887ede..1d1a3dc 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -55,14 +55,14 @@ export const session = sqliteTable("session", { updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), ipAddress: text('ip_address'), 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", { id: text("id").primaryKey(), accountId: text('account_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'), refreshToken: text('refresh_token'), idToken: text('id_token'), diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index 7e96eb2..ff738ca 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -88,6 +88,14 @@ class RateLimitExceeded extends Error { name = 'RateLimitExceeded' as const; } +function getRowsAffected(result: unknown): number { + if (typeof result !== 'object' || result === null) return 0; + const rec = result as Record; + if (typeof rec.rowCount === 'number') return rec.rowCount; + if (typeof rec.changes === 'number') return rec.changes; + return 0; +} + export class RateLimiter { constructor() { } @@ -144,7 +152,6 @@ export class RateLimiter { // Attempt to increment each bucket for (const bucket of buckets) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const updateResult = await tx.update(userTtsChars) .set({ charCount: sql`${userTtsChars.charCount} + ${charCount}`, @@ -156,13 +163,10 @@ export class RateLimiter { lt(userTtsChars.charCount, bucket.limit) )); - // Check if update actually happened - const row = await tx.select({ count: userTtsChars.charCount }).from(userTtsChars) - .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today))) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .then((res: any) => res[0]); - - if (!row || Number(row.count) > bucket.limit) { + // If any bucket is already at/over its limit, reject the request (and roll back all increments). + // Note: we intentionally allow a request to push a bucket over its limit; we only block when the + // bucket was already exhausted before this request started. + if (getRowsAffected(updateResult) <= 0) { throw new RateLimitExceeded(); } } @@ -180,10 +184,6 @@ export class RateLimiter { const effective = pickEffectiveResult(bucketResults); - if (effective.currentCount > effective.limit) { - throw new RateLimitExceeded(); - } - return { allowed: true, currentCount: effective.currentCount, @@ -330,4 +330,4 @@ export class RateLimiter { } // Export singleton instance -export const rateLimiter = new RateLimiter(); \ No newline at end of file +export const rateLimiter = new RateLimiter();