diff --git a/.changeset/olive-streets-invent.md b/.changeset/olive-streets-invent.md new file mode 100644 index 000000000..16540ada4 --- /dev/null +++ b/.changeset/olive-streets-invent.md @@ -0,0 +1,5 @@ +--- +"@read-frog/extension": patch +--- + +fix(subtitles): stabilize YouTube subtitle navigation and popup mounting diff --git a/src/entrypoints/subtitles.content/init-youtube-subtitles.ts b/src/entrypoints/subtitles.content/init-youtube-subtitles.ts index 487a11e78..bf124fe46 100644 --- a/src/entrypoints/subtitles.content/init-youtube-subtitles.ts +++ b/src/entrypoints/subtitles.content/init-youtube-subtitles.ts @@ -1,26 +1,32 @@ -import { YOUTUBE_NAVIGATE_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles" +import { YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles" import { setupYoutubeSubtitles } from "./platforms/youtube" import { youtubeConfig } from "./platforms/youtube/config" import { mountSubtitlesUI } from "./renderer/mount-subtitles-ui" - -export function initYoutubeSubtitles() { - let initialized = false - + +export function initYoutubeSubtitles() { + let initialized = false + let mountedAdapter: ReturnType | null = null + const tryInit = async () => { - if (!window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)) { - return - } - if (initialized) { - return + if (!window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)) { + return } - initialized = true - const adapter = setupYoutubeSubtitles() - await mountSubtitlesUI({ adapter, config: youtubeConfig }) - void adapter.initialize() + if (!mountedAdapter) { + mountedAdapter = setupYoutubeSubtitles() + } + + await mountSubtitlesUI({ adapter: mountedAdapter, config: youtubeConfig }) + + if (initialized) { + return + } + + initialized = true + void mountedAdapter.initialize() } - - void tryInit() - - window.addEventListener(YOUTUBE_NAVIGATE_EVENT, () => void tryInit()) -} + + void tryInit() + + window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit()) +} diff --git a/src/entrypoints/subtitles.content/platforms/index.ts b/src/entrypoints/subtitles.content/platforms/index.ts index cb0baf902..2935f5a75 100644 --- a/src/entrypoints/subtitles.content/platforms/index.ts +++ b/src/entrypoints/subtitles.content/platforms/index.ts @@ -11,9 +11,10 @@ export interface PlatformConfig { nativeSubtitles: string } - events: { - navigate?: string - } + events: { + navigateStart?: string + navigateFinish?: string + } controls?: ControlsConfig diff --git a/src/entrypoints/subtitles.content/platforms/youtube/config.ts b/src/entrypoints/subtitles.content/platforms/youtube/config.ts index 86b85d3ff..e0b5ccc0b 100644 --- a/src/entrypoints/subtitles.content/platforms/youtube/config.ts +++ b/src/entrypoints/subtitles.content/platforms/youtube/config.ts @@ -1,18 +1,24 @@ -import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms" -import { DEFAULT_CONTROLS_HEIGHT, YOUTUBE_NATIVE_SUBTITLES_CLASS, YOUTUBE_NAVIGATE_EVENT } from "@/utils/constants/subtitles" -import { getYoutubeVideoId } from "@/utils/subtitles/video-id" +import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms" +import { + DEFAULT_CONTROLS_HEIGHT, + YOUTUBE_NATIVE_SUBTITLES_CLASS, + YOUTUBE_NAVIGATE_FINISH_EVENT, + YOUTUBE_NAVIGATE_START_EVENT, +} from "@/utils/constants/subtitles" +import { getYoutubeVideoId } from "@/utils/subtitles/video-id" -export const youtubeConfig: PlatformConfig = { - selectors: { - video: "video.html5-main-video", - playerContainer: ".html5-video-player", - controlsBar: ".ytp-right-controls", - nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS, - }, - - events: { - navigate: YOUTUBE_NAVIGATE_EVENT, - }, +export const youtubeConfig: PlatformConfig = { + selectors: { + video: "video.html5-main-video", + playerContainer: "#movie_player.html5-video-player", + controlsBar: "#movie_player .ytp-right-controls", + nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS, + }, + + events: { + navigateStart: YOUTUBE_NAVIGATE_START_EVENT, + navigateFinish: YOUTUBE_NAVIGATE_FINISH_EVENT, + }, controls: { measureHeight: (container) => { diff --git a/src/entrypoints/subtitles.content/renderer/mount-subtitles-ui.tsx b/src/entrypoints/subtitles.content/renderer/mount-subtitles-ui.tsx index 728485e68..06ceea042 100644 --- a/src/entrypoints/subtitles.content/renderer/mount-subtitles-ui.tsx +++ b/src/entrypoints/subtitles.content/renderer/mount-subtitles-ui.tsx @@ -13,6 +13,8 @@ import { subtitlesStore } from "../atoms" import { SubtitlesContainer } from "../ui/subtitles-container" import { SubtitlesUIContext } from "../ui/subtitles-ui-context" +const SUBTITLES_UI_HOST_ID = "read-frog-subtitles-ui-host" + type MountSubtitlesUIAdapter = Pick< UniversalVideoAdapter, "downloadSourceSubtitles" | "getControlsConfig" | "toggleSubtitlesManually" @@ -36,7 +38,18 @@ export async function mountSubtitlesUI( parentEl.style.position = "relative" } + const existingHost = document.getElementById(SUBTITLES_UI_HOST_ID) as HTMLDivElement | null + if (existingHost) { + if (existingHost.parentElement === parentEl) { + return + } + + ;(existingHost as any).__reactShadowContainerCleanup?.() + existingHost.remove() + } + const shadowHost = document.createElement("div") + shadowHost.id = SUBTITLES_UI_HOST_ID shadowHost.classList.add(REACT_SHADOW_HOST_CLASS) shadowHost.style.cssText = ` position: absolute; diff --git a/src/entrypoints/subtitles.content/universal-adapter.ts b/src/entrypoints/subtitles.content/universal-adapter.ts index 4e40499d2..26717d630 100644 --- a/src/entrypoints/subtitles.content/universal-adapter.ts +++ b/src/entrypoints/subtitles.content/universal-adapter.ts @@ -27,6 +27,8 @@ export class UniversalVideoAdapter { private config: PlatformConfig private subtitlesScheduler: SubtitlesScheduler | null = null private subtitlesFetcher: SubtitlesFetcher + private navigationReinitTimeoutId: ReturnType | null = null + private hasPendingNavigationReset = false private sourceSubtitles: SubtitlesFragment[] = [] private sourceProcessedSubtitles: SubtitlesFragment[] = [] @@ -64,7 +66,7 @@ export class UniversalVideoAdapter { await this.initializeScheduler() void this.getOrLoadSourceSubtitles().catch(() => {}) await this.tryAutoStartSubtitles() - this.setupNavigationListener() + this.setupNavigationListeners() } getControlsConfig(): ControlsConfig | undefined { @@ -94,6 +96,7 @@ export class UniversalVideoAdapter { } private resetForNavigation() { + this.clearNavigationReinitTimeout() this.destroyScheduler() this.clearRuntimeSession() this.clearSourceCache() @@ -175,35 +178,67 @@ export class UniversalVideoAdapter { this.subtitlesSummaryContextHash = null } - private setupNavigationListener() { + private clearVisibleStateForNavigation() { + this.clearNavigationReinitTimeout() + this.destroyScheduler() + this.translationCoordinator?.stop() + this.segmentationPipeline?.stop() + subtitlesStore.set(subtitlesSettingsPanelOpenAtom, false) + this.showNativeSubtitles() + } + + private clearNavigationReinitTimeout() { + if (this.navigationReinitTimeoutId !== null) { + clearTimeout(this.navigationReinitTimeoutId) + this.navigationReinitTimeoutId = null + } + } + + private setupNavigationListeners() { const { events } = this.config - if (events.navigate) { - const navigationListener = () => { - if (!this.videoIdChanged) { - return - } + if (events.navigateStart) { + window.addEventListener(events.navigateStart, this.handleNavigationStart) + } - this.subtitlesScheduler?.reset() + if (events.navigateFinish) { + window.addEventListener(events.navigateFinish, this.handleNavigationFinish) + } + } - setTimeout(() => { - void this.handleNavigation() - }, NAVIGATION_HANDLER_DELAY) - } + private handleNavigationStart = () => { + if (!this.videoIdChanged) { + return + } + + this.hasPendingNavigationReset = true + this.clearVisibleStateForNavigation() + } - window.addEventListener(events.navigate, navigationListener) + private handleNavigationFinish = () => { + if (!this.hasPendingNavigationReset) { + return } + + this.clearNavigationReinitTimeout() + this.navigationReinitTimeoutId = setTimeout(() => { + this.navigationReinitTimeoutId = null + void this.handleNavigation() + }, NAVIGATION_HANDLER_DELAY) } private async handleNavigation() { - if (this.videoIdChanged) { - this.resetForNavigation() - void this.renderTranslateButton() - - await this.initializeScheduler() - void this.getOrLoadSourceSubtitles().catch(() => {}) - await this.tryAutoStartSubtitles() + if (!this.hasPendingNavigationReset || !this.videoIdChanged) { + return } + + this.hasPendingNavigationReset = false + this.resetForNavigation() + void this.renderTranslateButton() + + await this.initializeScheduler() + void this.getOrLoadSourceSubtitles().catch(() => {}) + await this.tryAutoStartSubtitles() } private async renderTranslateButton() { diff --git a/src/utils/constants/subtitles.ts b/src/utils/constants/subtitles.ts index 8faec9373..ccf5c7bb8 100644 --- a/src/utils/constants/subtitles.ts +++ b/src/utils/constants/subtitles.ts @@ -26,7 +26,8 @@ export const TRANSLATE_BUTTON_CLASS = "read-frog-subtitles-translate-button" // YouTube specific export const YOUTUBE_WATCH_URL_PATTERN = "youtube.com/watch" -export const YOUTUBE_NAVIGATE_EVENT = "yt-navigate-finish" +export const YOUTUBE_NAVIGATE_START_EVENT = "yt-navigate-start" +export const YOUTUBE_NAVIGATE_FINISH_EVENT = "yt-navigate-finish" export const YOUTUBE_NATIVE_SUBTITLES_CLASS = ".ytp-caption-window-container" export const PLAYER_DATA_REQUEST_TYPE = "READ_FROG_GET_PLAYER_DATA" export const PLAYER_DATA_RESPONSE_TYPE = "READ_FROG_PLAYER_DATA"