Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-streets-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---

fix(subtitles): stabilize YouTube subtitle navigation and popup mounting
44 changes: 25 additions & 19 deletions src/entrypoints/subtitles.content/init-youtube-subtitles.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setupYoutubeSubtitles> | 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())
}
7 changes: 4 additions & 3 deletions src/entrypoints/subtitles.content/platforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ export interface PlatformConfig {
nativeSubtitles: string
}

events: {
navigate?: string
}
events: {
navigateStart?: string
navigateFinish?: string
}

controls?: ControlsConfig

Expand Down
34 changes: 20 additions & 14 deletions src/entrypoints/subtitles.content/platforms/youtube/config.ts
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Target the video selector to #movie_player

This change narrows playerContainer to #movie_player... but leaves video as the global video.html5-main-video, and initializeScheduler() relies on waitForElement() validation that only checks the first document.querySelector match. On pages where another YouTube player video appears earlier in DOM order (e.g., preview/miniplayer instances), validation against #movie_player keeps failing until timeout, so scheduler initialization never happens and subtitles cannot start. Please scope the video selector itself to the active player (or make element lookup iterate all matches) so the active watch player is always discovered.

Useful? React with πŸ‘Β / πŸ‘Ž.

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) => {
Expand Down
13 changes: 13 additions & 0 deletions src/entrypoints/subtitles.content/renderer/mount-subtitles-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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;
Expand Down
75 changes: 55 additions & 20 deletions src/entrypoints/subtitles.content/universal-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export class UniversalVideoAdapter {
private config: PlatformConfig
private subtitlesScheduler: SubtitlesScheduler | null = null
private subtitlesFetcher: SubtitlesFetcher
private navigationReinitTimeoutId: ReturnType<typeof setTimeout> | null = null
private hasPendingNavigationReset = false

private sourceSubtitles: SubtitlesFragment[] = []
private sourceProcessedSubtitles: SubtitlesFragment[] = []
Expand Down Expand Up @@ -64,7 +66,7 @@ export class UniversalVideoAdapter {
await this.initializeScheduler()
void this.getOrLoadSourceSubtitles().catch(() => {})
await this.tryAutoStartSubtitles()
this.setupNavigationListener()
this.setupNavigationListeners()
}

getControlsConfig(): ControlsConfig | undefined {
Expand Down Expand Up @@ -94,6 +96,7 @@ export class UniversalVideoAdapter {
}

private resetForNavigation() {
this.clearNavigationReinitTimeout()
this.destroyScheduler()
this.clearRuntimeSession()
this.clearSourceCache()
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion src/utils/constants/subtitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down