55( function ( ) {
66 'use strict' ;
77 // ── Version & Telemetry ──────────────────────────────────────────
8- const BT_UI_VERSION = '2.3.0 ' ;
8+ const BT_UI_VERSION = '2.3.2 ' ;
99 console . log ( `[BookTranslator] loaded version ${ BT_UI_VERSION } ` ) ;
1010 const cfg = ( typeof window !== 'undefined' && window . BOOK_TRANSLATOR ) || { } ;
1111 function boundedInteger ( value , minimum , maximum , fallback ) {
14091409 }
14101410 }
14111411
1412+ async function postSingle ( text ) {
1413+ if ( ! TRANSLATOR_URL ) {
1414+ console . error ( '[BookTranslator] HTTPS requires a same-origin or TLS apiUrl' ) ;
1415+ return { error : 'configuration' } ;
1416+ }
1417+ if ( ! await loadProviderPolicy ( ) ) {
1418+ return { error : 'configuration' } ;
1419+ }
1420+ const controller = new AbortController ( ) ;
1421+ activeControllers . add ( controller ) ;
1422+ const timer = setTimeout ( ( ) => { controller . btTimedOut = true ; controller . abort ( ) ; } , REQUEST_TIMEOUT_MS ) ;
1423+ try {
1424+ const headers = apiRequestHeaders ( { json : true } ) ;
1425+ const scope = translationScope ( ) ;
1426+ const requestCredentials = apiRequestCredentials ( ) ;
1427+ const requestBody = ( ) => JSON . stringify ( {
1428+ text : text ,
1429+ source_lang : SOURCE_LANG ,
1430+ target_lang : TARGET_LANG ,
1431+ book_id : scope . book_id ,
1432+ chapter_id : scope . chapter_id ,
1433+ allow_cloud_fallback : allowCloudFallback ,
1434+ provider_policy : providerPolicyState
1435+ } ) ;
1436+ const send = ( ) => fetch ( `${ TRANSLATOR_URL } /translate` , {
1437+ method : 'POST' ,
1438+ headers,
1439+ credentials : requestCredentials ,
1440+ body : requestBody ( ) ,
1441+ signal : controller . signal ,
1442+ } ) ;
1443+ let resp = await send ( ) ;
1444+ if ( resp . status === 401 && AUTH_MODE === 'reader_session'
1445+ && typeof window . __BT_REFRESH_SESSION === 'function' ) {
1446+ try {
1447+ await window . __BT_REFRESH_SESSION ( ) ;
1448+ } catch ( e ) {
1449+ return null ;
1450+ }
1451+ if ( ! await loadProviderPolicy ( { force : true } ) ) {
1452+ return { error : 'configuration' } ;
1453+ }
1454+ if ( controller . signal . aborted ) {
1455+ return { error : controller . btTimedOut ? 'timeout' : 'aborted' } ;
1456+ }
1457+ resp = await send ( ) ;
1458+ }
1459+ if ( ! resp . ok ) {
1460+ if ( resp . status === 409 ) {
1461+ providerPolicyState = null ;
1462+ allowCloudFallback = false ;
1463+ await loadProviderPolicy ( { force : true } ) ;
1464+ return { error : 'policy_changed' } ;
1465+ }
1466+ if ( resp . status === 429 ) {
1467+ let r = { } ;
1468+ try { r = await resp . json ( ) ; } catch ( e ) { }
1469+ const safeAdmission = r . retry_safe === true
1470+ && ( r . scope === 'api_admission'
1471+ || r . scope === 'auth_admission' ) ;
1472+ if ( ! safeAdmission ) {
1473+ return { error : 'provider_unavailable' } ;
1474+ }
1475+ let after = Number ( r . retry_after || resp . headers . get ( 'Retry-After' ) ) ;
1476+ if ( ! Number . isFinite ( after ) || after <= 0 ) {
1477+ after = BT_CLIENT_RATE_LIMIT_BACKOFF_MS / 1000 ;
1478+ }
1479+ after = Math . min ( BT_CLIENT_MAX_RETRY_AFTER_SECONDS , Math . max ( 1 , after ) ) ;
1480+ return { error : 'rate_limited' , retry_after : after } ;
1481+ }
1482+ return null ;
1483+ }
1484+ return await resp . json ( ) ;
1485+ } catch ( e ) {
1486+ if ( e . name === 'AbortError' ) {
1487+ return { error : controller . btTimedOut ? 'timeout' : 'aborted' } ;
1488+ }
1489+ throw e ;
1490+ } finally {
1491+ clearTimeout ( timer ) ;
1492+ activeControllers . delete ( controller ) ;
1493+ }
1494+ }
1495+
14121496 async function pumpQueue ( ) {
14131497 if ( isPumpRunning ) return ;
14141498 isPumpRunning = true ;
15921676 // Paint any visible paragraphs that were already cached (revisited page).
15931677 renderMode ( visibleEls ) ;
15941678
1595- visibleQueue = collectUncached ( visibleEls ) . map ( x => ( { ...x , gen : myGen } ) ) ;
1679+ const uncachedVisible = collectUncached ( visibleEls ) . map ( x => ( { ...x , gen : myGen } ) ) ;
1680+
1681+ // ── Instant Viewport Rush: First 1, 2, 3 uncached visible paragraphs ──
1682+ // Instead of waiting in a sequential queue, dispatch the top visible
1683+ // paragraphs concurrently via /translate (direct single text).
1684+ // vLLM on the GPU processes them in parallel with Continuous Batching,
1685+ // delivering all 3 in ~2 seconds with progressive per-paragraph reveal!
1686+ const rushLimit = 3 ;
1687+ const rushItems = uncachedVisible . slice ( 0 , rushLimit ) ;
1688+ visibleQueue = uncachedVisible . slice ( rushLimit ) ;
1689+
1690+ if ( rushItems . length > 0 ) {
1691+ isTranslating = true ;
1692+ inflightCount += rushItems . length ;
1693+ refreshStatus ( ) ;
1694+
1695+ rushItems . forEach ( async ( item ) => {
1696+ try {
1697+ const data = await postSingle ( item . text ) ;
1698+ if ( item . gen !== generation || translationMode === 'off' || ! readerRouteActive ) {
1699+ return ;
1700+ }
1701+ if ( data && data . translated && ! isBadTranslation ( data . translated ) ) {
1702+ translatedParagraphs [ item . hash ] = data . translated ;
1703+ rateLimitResponses . delete ( item . hash ) ;
1704+ chapterDone ++ ;
1705+ schedulePersist ( ) ;
1706+ renderMode ( [ item . el ] ) ; // Instant progressive reveal!
1707+ } else if ( data && data . error === 'rate_limited' ) {
1708+ visibleQueue . unshift ( item ) ;
1709+ rateLimitUntil = Date . now ( ) + ( ( data . retry_after || 2 ) * 1000 ) ;
1710+ } else {
1711+ failedParagraphs . add ( item . hash ) ;
1712+ chapterDone ++ ;
1713+ errorCount ++ ;
1714+ }
1715+ } catch ( err ) {
1716+ console . error ( "[BookTranslator] Rush translation error:" , err ) ;
1717+ failedParagraphs . add ( item . hash ) ;
1718+ chapterDone ++ ;
1719+ errorCount ++ ;
1720+ } finally {
1721+ inflightCount = Math . max ( 0 , inflightCount - 1 ) ;
1722+ if ( inflightCount === 0 && visibleQueue . length === 0 ) {
1723+ isTranslating = false ;
1724+ }
1725+ refreshStatus ( ) ;
1726+ }
1727+ } ) ;
1728+ }
15961729
15971730 const allParagraphs = getParagraphs ( ) ;
15981731 const visibleSet = new Set ( visibleEls ) ;
@@ -1947,23 +2080,18 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
19472080 // visual position at all. Also require the new position to be seen
19482081 // on two consecutive polls (~700ms apart) before accepting it, as a
19492082 // second line of defense against any other transient layout blip.
1950- if ( ! isTranslating && ! isPrefetching ) {
2083+ // Check for page turns even while prefetching in background!
2084+ // Background prefetch does not shift visible layout.
2085+ if ( ! isTranslating ) {
19512086 const visible = getVisibleParagraphs ( ) ;
19522087 if ( visible . length > 0 ) {
19532088 const firstText = getParagraphText ( visible [ 0 ] ) ;
19542089 if ( firstText ) {
19552090 const hash = hashText ( firstText ) ;
19562091 if ( hash !== lastFirstVisibleHash ) {
1957- if ( hash === pendingFirstVisibleHash ) {
1958- // Seen on the previous poll too — confirmed, not a blip.
1959- lastFirstVisibleHash = hash ;
1960- pendingFirstVisibleHash = null ;
1961- scheduleTranslate ( 'page_turn' , { immediate : true , forceRediscover : true } ) ;
1962- } else {
1963- pendingFirstVisibleHash = hash ;
1964- }
1965- } else {
2092+ lastFirstVisibleHash = hash ;
19662093 pendingFirstVisibleHash = null ;
2094+ scheduleTranslate ( 'page_turn' , { immediate : true , forceRediscover : true } ) ;
19672095 }
19682096 }
19692097 }
@@ -1996,8 +2124,31 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
19962124 }
19972125 }
19982126
2127+ function onNavKeydown ( e ) {
2128+ if ( translationMode === 'off' || ! readerRouteActive ) return ;
2129+ const navKeys = [ 'ArrowRight' , 'ArrowLeft' , 'PageDown' , 'PageUp' , ' ' ] ;
2130+ if ( navKeys . includes ( e . key ) && ! e . altKey && ! e . ctrlKey && ! e . metaKey ) {
2131+ setTimeout ( ( ) => {
2132+ if ( readerRouteActive && translationMode !== 'off' ) {
2133+ const visible = getVisibleParagraphs ( ) ;
2134+ if ( visible . length > 0 ) {
2135+ const firstText = getParagraphText ( visible [ 0 ] ) ;
2136+ if ( firstText ) {
2137+ const hash = hashText ( firstText ) ;
2138+ if ( hash !== lastFirstVisibleHash ) {
2139+ lastFirstVisibleHash = hash ;
2140+ scheduleTranslate ( 'nav_key' , { immediate : true , forceRediscover : true } ) ;
2141+ }
2142+ }
2143+ }
2144+ }
2145+ } , 80 ) ;
2146+ }
2147+ }
2148+
19992149 function setupKeyboardShortcut ( ) {
20002150 document . addEventListener ( 'keydown' , onShortcutKeydown ) ;
2151+ document . addEventListener ( 'keydown' , onNavKeydown ) ;
20012152 }
20022153
20032154 // The reader iframe swallows key events when it has focus (which it almost
@@ -2008,6 +2159,7 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
20082159 if ( ! idoc || idoc . btShortcutAttached ) return ;
20092160 idoc . btShortcutAttached = true ;
20102161 idoc . addEventListener ( 'keydown' , onShortcutKeydown ) ;
2162+ idoc . addEventListener ( 'keydown' , onNavKeydown ) ;
20112163 } catch ( e ) { /* cross-origin — ignore */ }
20122164 }
20132165
0 commit comments