diff --git a/.gitignore b/.gitignore index 3926f78..eedb62e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ .next/ test-results/ dist/ +packages/web/out/ *.log # OS diff --git a/package-lock.json b/package-lock.json index 042718e..c2849b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2933,6 +2933,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3551,6 +3568,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -4496,51 +4526,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/effector": { - "version": "23.4.4", - "resolved": "https://registry.npmjs.org/effector/-/effector-23.4.4.tgz", - "integrity": "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg==", - "funding": [ - { - "type": "patreon", - "url": "https://www.patreon.com/zero_bias" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/effector" - } - ], - "license": "MIT", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/effector-react": { - "version": "23.3.0", - "resolved": "https://registry.npmjs.org/effector-react/-/effector-react-23.3.0.tgz", - "integrity": "sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==", - "funding": [ - { - "type": "patreon", - "url": "https://www.patreon.com/zero_bias" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/effector" - } - ], - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.0.0" - }, - "engines": { - "node": ">=11.0.0" - }, - "peerDependencies": { - "effector": "^23.0.0", - "react": ">=16.8.0 <20.0.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -5202,6 +5187,38 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8783,15 +8800,6 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10236,6 +10244,16 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/when": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", @@ -10324,6 +10342,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -10660,12 +10700,10 @@ }, "packages/extension": { "name": "@inkah/extension", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "classnames": "^2.5.1", "dexie": "^4.0.0", - "effector": "^23.4.4", - "effector-react": "^23.3.0", "file-saver": "^2.0.5", "lucide-react": "^1.7.0", "react": "^19.2.0", @@ -10679,6 +10717,7 @@ "@wxt-dev/module-react": "^1.1.0", "autoprefixer": "^10.4.17", "fake-indexeddb": "^6.0.0", + "happy-dom": "^20.11.1", "postcss": "^8.5.0", "sass": "^1.88.0", "tailwindcss": "^3.4.1", diff --git a/packages/extension/package.json b/packages/extension/package.json index a52e7ed..5a8bc21 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@inkah/extension", - "version": "1.1.0", + "version": "2.0.0", "description": "Chinese & Korean pop-up dictionary browser extension", "private": true, "type": "module", @@ -18,8 +18,6 @@ "dependencies": { "classnames": "^2.5.1", "dexie": "^4.0.0", - "effector": "^23.4.4", - "effector-react": "^23.3.0", "file-saver": "^2.0.5", "lucide-react": "^1.7.0", "react": "^19.2.0", @@ -33,6 +31,7 @@ "@wxt-dev/module-react": "^1.1.0", "autoprefixer": "^10.4.17", "fake-indexeddb": "^6.0.0", + "happy-dom": "^20.11.1", "postcss": "^8.5.0", "sass": "^1.88.0", "tailwindcss": "^3.4.1", diff --git a/packages/extension/src/data/dict-cache.ts b/packages/extension/src/data/dict-cache.ts index cc4d15f..d13770d 100644 --- a/packages/extension/src/data/dict-cache.ts +++ b/packages/extension/src/data/dict-cache.ts @@ -11,6 +11,19 @@ const kedictCache = new Map(); const viconCache = new Map(); const lemmaCache = new Map(); +/** + * Drop all read-through caches. Must be called after a dictionary import + * completes: lookups served DURING an in-progress import cache misses as + * `null`, and those negative entries would otherwise shadow the freshly + * imported data until the service worker restarts. + */ +export function clearDictionaryCaches(): void { + cedictCache.clear(); + kedictCache.clear(); + viconCache.clear(); + lemmaCache.clear(); +} + export async function getCedict(key: string): Promise { if (cedictCache.has(key)) return cedictCache.get(key) ?? undefined; const entry = await db.cedict.get(key); diff --git a/packages/extension/src/data/import-dictionaries.ts b/packages/extension/src/data/import-dictionaries.ts index 7ed60ed..eb07e93 100644 --- a/packages/extension/src/data/import-dictionaries.ts +++ b/packages/extension/src/data/import-dictionaries.ts @@ -1,4 +1,5 @@ import { db } from './schema'; +import { clearDictionaryCaches } from './dict-cache'; import { csvToJson, csvToTags } from '../lib/csv-utils'; import AvailableLanguages from '../lib/available-languages'; @@ -160,14 +161,28 @@ async function importKoreanDicts(progress: ImportProgress): Promise { } } -export async function importDictionariesIfNeeded(): Promise { +// Guard against concurrent runs (onInstalled + service worker startup +// can both trigger an import attempt) +let importInFlight: Promise | null = null; + +export function importDictionariesIfNeeded(): Promise { + if (!importInFlight) { + importInFlight = doImport().finally(() => { + importInFlight = null; + }); + } + return importInFlight; +} + +async function doImport(): Promise { const progress = await getImportProgress(); if (progress.version >= IMPORT_VERSION) { - console.log('[inkah] Dictionaries already imported'); return; } - // Import preferred language first, then the other + // Import preferred language first, then the other. + // MV3 service workers can be killed mid-import — per-table progress + // flags let the next startup resume where this run left off. const langResult = await chrome.storage.local.get(['targetLanguage']); const preferred: SupportedLanguages = langResult.targetLanguage ?? 'zh'; @@ -182,5 +197,11 @@ export async function importDictionariesIfNeeded(): Promise { } await updateImportProgress({ version: IMPORT_VERSION }); + + // Lookups served while the import was running cached their misses as + // negative entries — drop them so the imported data is visible now + // rather than after the next service worker restart. + clearDictionaryCaches(); + console.log('[inkah] Dictionary import complete!'); } diff --git a/packages/extension/src/entrypoints/background.ts b/packages/extension/src/entrypoints/background.ts index 9075387..6cdc89d 100644 --- a/packages/extension/src/entrypoints/background.ts +++ b/packages/extension/src/entrypoints/background.ts @@ -27,11 +27,18 @@ export default defineBackground(() => { } }); - // Warm the IndexedDB cache on every service worker startup - // (service workers are ephemeral in MV3 — this runs each time one spins up) - warmDictionaryCache().catch((err) => { - console.warn('[inkah] Cache warm failed (dicts may not be imported yet):', err); - }); + // On every service worker startup: resume any incomplete dictionary + // import (MV3 workers can be killed mid-import — onInstalled alone + // never retries), then warm the in-memory cache. + importDictionariesIfNeeded() + .catch((err) => { + console.error('[inkah] Dictionary import failed:', err); + }) + .finally(() => { + warmDictionaryCache().catch((err) => { + console.warn('[inkah] Cache warm failed (dicts may not be imported yet):', err); + }); + }); // Message handler for content scripts and popup chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { diff --git a/packages/extension/src/entrypoints/content.ts b/packages/extension/src/entrypoints/content.ts index 525f832..f4543c5 100644 --- a/packages/extension/src/entrypoints/content.ts +++ b/packages/extension/src/entrypoints/content.ts @@ -573,6 +573,11 @@ export default defineContentScript({ topPos = anchorBottom + gap; } + // Final clamp — never let the popup extend past the viewport edges + const actualWidth = popup.offsetWidth; + leftPos = Math.max(8, Math.min(leftPos, viewWidth - actualWidth - 8)); + topPos = Math.max(8, Math.min(topPos, viewHeight - popupHeight - 8)); + popup.style.left = `${leftPos}px`; popup.style.top = `${topPos}px`; } @@ -763,7 +768,9 @@ export default defineContentScript({ lockedRangeNode = result.rangeNode; lockedRangeOffset = result.rangeOffset; lastPopup = createPopupElement(definitions, result.rect); - document.body.appendChild(lastPopup); + // Append inside the fullscreen element when active — nodes outside + // it don't render while fullscreen + ((document.fullscreenElement as HTMLElement | null) ?? document.body).appendChild(lastPopup); positionPopup(lastPopup); // Highlight the matched word in the text (old code's setHoverSelection) @@ -822,7 +829,9 @@ export default defineContentScript({ showPopup: (definitions: WordDefinitions[], rect: DOMRect) => { removePopup(); lastPopup = createPopupElement(definitions, rect); - document.body.appendChild(lastPopup); + // Append inside the fullscreen element when active — nodes outside + // it don't render while fullscreen + ((document.fullscreenElement as HTMLElement | null) ?? document.body).appendChild(lastPopup); positionPopup(lastPopup); }, removePopup, @@ -875,7 +884,9 @@ export default defineContentScript({ const rect = range.getBoundingClientRect(); removePopup(); lastPopup = createPopupElement(definitions, rect); - document.body.appendChild(lastPopup); + // Append inside the fullscreen element when active — nodes outside + // it don't render while fullscreen + ((document.fullscreenElement as HTMLElement | null) ?? document.body).appendChild(lastPopup); positionPopup(lastPopup); } } catch {} diff --git a/packages/extension/src/entrypoints/video-injector.content.ts b/packages/extension/src/entrypoints/video-injector.content.ts index d33a799..4138d62 100644 --- a/packages/extension/src/entrypoints/video-injector.content.ts +++ b/packages/extension/src/entrypoints/video-injector.content.ts @@ -26,6 +26,51 @@ function initNetflixInterception() { const parseMock = JSON.parse; const stringifyMock = JSON.stringify; + // Known Netflix manifest-request profile strings — used to recognize the + // profiles array in requests regardless of property naming (Netflix + // renames manifest fields periodically; ported from subadub). + const NETFLIX_PROFILES = [ + 'heaac-2-dash', + 'heaac-2hq-dash', + 'playready-h264mpl30-dash', + 'playready-h264mpl31-dash', + 'playready-h264hpl30-dash', + 'playready-h264hpl31-dash', + 'vp9-profile0-L30-dash-cenc', + 'vp9-profile0-L31-dash-cenc', + 'dfxp-ls-sdh', + 'simplesdh', + 'nflx-cmisc', + 'BIF240', + 'BIF320', + ]; + + function isSubtitlesProperty(key: string, value: unknown[]): boolean { + return ( + key === 'profiles' || + value.some((item) => NETFLIX_PROFILES.includes(item as string)) + ); + } + + /** Recursively locate the profiles array in a manifest request object. */ + function findSubtitlesProperty(obj: any): unknown[] | null { + for (const key in obj) { + const value = obj[key]; + if (Array.isArray(value)) { + if (isSubtitlesProperty(key, value)) { + return value; + } + } + if (value && typeof value === 'object') { + const prop = findSubtitlesProperty(value); + if (prop) { + return prop; + } + } + } + return null; + } + const inkah: any = { hasLoadedOnce: false, isLoaded: false, @@ -35,14 +80,22 @@ function initNetflixInterception() { }; (window as any).inkah = inkah; - // Hook JSON.parse to capture subtitle track data + // Hook JSON.parse to capture subtitle track data. + // Netflix has used two manifest shapes over time: + // old: result.timedtexttracks[].ttDownloadables[fmt].urls (object map) + // new: result.textTracks[].downloadables[fmt].urls ([{url}] array) + // Detect both here; NetflixService.processSubData normalizes them. JSON.parse = function () { const data = parseMock.apply(this, arguments as any); - if (data?.result?.timedtexttracks) { - lastSubtitleData = data.result; + const result = data?.result; + if ( + result && + (result.timedtexttracks || (result.movieId && result.textTracks)) + ) { + lastSubtitleData = result; // Serialize as JSON string to survive structured cloning across worlds try { - const serialized = stringifyMock(data.result); + const serialized = stringifyMock(result); window.dispatchEvent( new CustomEvent('inkahsubs_data', { detail: serialized }), ); @@ -51,28 +104,19 @@ function initNetflixInterception() { return data; }; - // Hook JSON.stringify to force Netflix to include all subtitle tracks - JSON.stringify = function (response: any) { - if (!response) return stringifyMock.apply(this, arguments as any); + // Hook JSON.stringify to force WebVTT into manifest requests. + // Don't hardcode property paths — Netflix renames them often; find the + // profiles array by content and mutate in place (ported from subadub). + JSON.stringify = function (value: any) { try { - const data = parseMock(stringifyMock.apply(this, arguments as any)); - - let modified = false; - if (data?.params?.showAllSubDubTracks != null) { - data.params.showAllSubDubTracks = true; - modified = true; - } - if (data?.params?.profiles) { - data.params.profiles.push('webvtt-lssdh-ios8'); - modified = true; + if (value && typeof value === 'object') { + const prop = findSubtitlesProperty(value); + if (prop && !prop.includes('webvtt-lssdh-ios8')) { + prop.unshift('webvtt-lssdh-ios8'); + } } - - return modified - ? stringifyMock(data) - : stringifyMock.apply(this, arguments as any); - } catch { - return stringifyMock.apply(this, arguments as any); - } + } catch {} + return stringifyMock.apply(this, arguments as any); }; function getPlayer(): any { @@ -219,6 +263,21 @@ function initNetflixInterception() { } function initYouTubeInterception() { + // Handle seek requests from the content script (right panel carets, + // progress bar cues). detail is milliseconds — same contract as Netflix. + window.addEventListener('inkahsubsSeek', ((event: CustomEvent) => { + try { + const player = document.getElementById('movie_player') as any; + if (player?.seekTo) { + player.seekTo(event.detail / 1000, true); + player.playVideo?.(); + } else { + const video = document.querySelector('video'); + if (video) video.currentTime = event.detail / 1000; + } + } catch {} + }) as EventListener); + // Detect URL changes (SPA navigation) const origPush = history.pushState; history.pushState = function () { @@ -252,9 +311,12 @@ function initYouTubeInterception() { (window as any).inkahYtLoaded = true; window.dispatchEvent(new CustomEvent('inkahsubsVideoReady')); - if (subsToggle?.getAttribute('aria-pressed') === 'true') { - player.toggleSubtitles?.(); - } else { + // Native captions stay enabled — overlay CSS hides them while + // Inkah renders. (Previously we called player.toggleSubtitles() + // to turn them off, but the poll below then saw captions "off" + // and dispatched an empty language change that wiped the + // freshly-loaded cues — killing the right panel and overlay.) + if (subsToggle?.getAttribute('aria-pressed') !== 'true') { window.dispatchEvent( new CustomEvent('inkahsubsSubtitlesChanged', { detail: '' }), ); diff --git a/packages/extension/src/lib/video/netflix-service.ts b/packages/extension/src/lib/video/netflix-service.ts index 5cc533f..8d78c3d 100644 --- a/packages/extension/src/lib/video/netflix-service.ts +++ b/packages/extension/src/lib/video/netflix-service.ts @@ -117,46 +117,57 @@ export class NetflixService implements VideoService { } } - // Only process EPISODE and MOVIE types - if (!['EPISODE', 'MOVIE'].includes(detail.viewableType)) { + // Only filter on viewableType when present (newer manifests omit it) + if ( + detail.viewableType && + !['EPISODE', 'MOVIE'].includes(detail.viewableType) + ) { return; } - console.log('[inkah] processSubData: movieId=', detail.movieId, 'type=', detail.viewableType); + // Netflix manifest shapes: old = timedtexttracks/ttDownloadables, + // new = textTracks/downloadables. Handle both. + const tracks = detail.timedtexttracks ?? detail.textTracks; + if (!detail.movieId || !tracks) return; - this.subCache[detail.movieId] = {}; - const tracks = detail.timedtexttracks; + console.log('[inkah] processSubData: movieId=', detail.movieId, 'type=', detail.viewableType ?? '(none)'); - if (!tracks) return; + this.subCache[detail.movieId] = {}; for (const track of tracks) { if (track.isNoneTrack) { continue; } - let type = SUB_TYPES[track.rawTrackType]; - if (typeof type === 'undefined') type = `[${track.rawTrackType}]`; + const rawType = (track.rawTrackType ?? '').toLowerCase(); + let type = SUB_TYPES[rawType]; + if (typeof type === 'undefined') type = `[${rawType}]`; // isForcedNarrative = incomplete preview subtitles (only 15-20 lines) const lang = track.language + type + (track.isForcedNarrative ? '-forced' : ''); - // ttDownloadables (with 's') is the Netflix field name - if (!track.ttDownloadables || !track.ttDownloadables[WEBVTT]) { + const downloadables = track.ttDownloadables ?? track.downloadables; + if (!downloadables || !downloadables[WEBVTT]) { continue; } - // Support both old format (downloadUrls) and new format (urls) + // urls has been an object map (old), and an array of {url} (new); + // downloadUrls is the oldest map variant const urls = - track.ttDownloadables[WEBVTT].urls || - track.ttDownloadables[WEBVTT].downloadUrls; - + downloadables[WEBVTT].urls || downloadables[WEBVTT].downloadUrls; if (!urls) { continue; } - // Pick a random CDN URL from the available options - this.subCache[detail.movieId][lang] = this.randomProperty(urls); + const url = Array.isArray(urls) + ? (urls[0]?.url ?? urls[0]) + : this.randomProperty(urls); + if (!url) { + continue; + } + + this.subCache[detail.movieId][lang] = url; } const cached = Object.keys(this.subCache[detail.movieId]); diff --git a/packages/extension/src/lib/video/video-controller.ts b/packages/extension/src/lib/video/video-controller.ts index e3b33a3..486ff0d 100644 --- a/packages/extension/src/lib/video/video-controller.ts +++ b/packages/extension/src/lib/video/video-controller.ts @@ -51,10 +51,11 @@ export class VideoController { private showBackground = true; private showNativeDoubled = false; private nativeLanguage = navigator.language.split('-')[0]; - private subFontSize = 125; + private subFontSize = 110; private autoPause = true; private wasAutoPaused = false; private enabled = true; + private showProgressBar = true; private showTransliteration = false; private isHoveringSubWord = false; // Prevent re-render while hovering private currentSubIndex = -1; @@ -134,6 +135,7 @@ export class VideoController { stop() { cancelAnimationFrame(this.animFrame); document.documentElement.classList.remove('inkahsubs-enable'); + this.setActive(false); this.unmountAll(); this.removeStyles(); window.removeEventListener( @@ -148,11 +150,19 @@ export class VideoController { // === Event handlers === + /** Mark whether Inkah is actively rendering subtitles. Native subtitle + * hiding CSS requires this class — if our pipeline breaks, native subs + * stay visible instead of leaving the user with nothing. */ + private setActive(active: boolean) { + document.documentElement.classList.toggle('inkahsubs-active', active); + } + private handleVideoReady = () => { this.cues = []; this.nativeCues = []; this.currentText = ''; this.currentSubIndex = -1; + this.setActive(false); this.unmountAll(); }; @@ -164,6 +174,7 @@ export class VideoController { this.cues = []; this.nativeCues = []; this.currentText = ''; + this.setActive(false); this.unmountAll(); return; } @@ -183,11 +194,19 @@ export class VideoController { } if (this.cues.length > 0 && this.enabled) { + this.setActive(true); this.mountAll(); this.startTimeSync(); this.renderRightPanel(); + } else { + this.setActive(false); + console.warn( + '[inkah] No subtitle cues available for', language, + '— leaving native subtitles visible', + ); } } catch (err) { + this.setActive(false); console.warn('[inkah] Failed to fetch subtitles:', err); } }; @@ -202,10 +221,16 @@ export class VideoController { private mountAll() { const video = this.service.findVideo(); - if (!video) return; + if (!video) { + console.warn('[inkah] mountAll: no