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/streaming-hbo-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---

Add HBO Max / Max as a second streaming subtitle adapter on the shared framework. It captures HBO's official WebVTT subtitle requests (and DASH manifests listing segmented WebVTT tracks), then reuses the same source/target selection and bilingual alignment as the Netflix adapter — no new plumbing, just one more adapter in the registry.
2 changes: 1 addition & 1 deletion src/entrypoints/interceptor.content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { injectPlayerApi } from "./inject-player-api"
import { injectStreamingSubtitlesInterceptor } from "./streaming-subtitles-interceptor"

export default defineContentScript({
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*", "*://*.netflix.com/*"],
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*", "*://*.netflix.com/*", "*://*.max.com/*", "*://*.hbomax.com/*"],
allFrames: true,
world: "MAIN",
runAt: "document_start",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { STREAMING_ENSURE_NATIVE_SUBTITLES_TYPE, STREAMING_SUBTITLE_TRACKS_TYPE } from "@/utils/constants/subtitles"

// MAIN-world interceptor: watches Netflix's own network traffic for the subtitle
// tracks it lists in manifest JSON (under `timedtexttracks`) and forwards their
// download URLs to the content script. A new platform adds its own recogniser to
// `inspectJson`; the shared StreamingSubtitlesFetcher does selection + alignment.
import {
STREAMING_ENSURE_NATIVE_SUBTITLES_TYPE,
STREAMING_SUBTITLE_CAPTURED_TYPE,
STREAMING_SUBTITLE_TRACKS_TYPE,
} from "@/utils/constants/subtitles"
import { parseDashMpdTextTracks } from "@/utils/subtitles/fetchers/streaming/dash-mpd"

// MAIN-world interceptor: watches a streaming site's own network traffic for its
// official subtitle tracks and forwards them to the content script (track-store).
// Two capture strategies, one per site family:
// - Netflix: subtitle download URLs live in manifest JSON under `timedtexttracks`.
// - HBO Max / Max: subtitles are fetched as .vtt files or listed in a DASH .mpd.
// Adding a site = teach `inspect()` how to recognise its subtitle responses.

interface InterceptedTrack {
url: string
id?: string
url?: string
urls?: string[]
segmentStartMs?: number[]
language?: string
label?: string
kind?: string
Expand All @@ -20,14 +30,24 @@ declare global {
}

const NETFLIX_HOST = /(?:^|\.)netflix\.com$/i
const WEBVTT_HOST = /(?:^|\.)(?:max|hbomax)\.com$/i
const VTT_URL = /\.vtt(?:[?#]|$)/i
const MPD_URL = /\.mpd(?:[?#]|$)/i
const JSON_URL = /manifest|pathevaluator|metadata|timedtext|\.json/i

const nativeParse = JSON.parse.bind(JSON)
const tracksByUrl = new Map<string, InterceptedTrack>()
const replayedUrls = new Set<string>()

function pagePath(): string {
return window.location.pathname
}
function isNetflix(): boolean {
return NETFLIX_HOST.test(window.location.hostname)
}
function isWebVttSite(): boolean {
return WEBVTT_HOST.test(window.location.hostname)
}

function normalizeUrl(value: unknown): string | null {
if (typeof value !== "string" || !value)
Expand All @@ -44,9 +64,9 @@ function str(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined
}

// Netflix lists subtitle tracks under `timedtexttracks`; each track's download URLs
// live in `ttDownloadables.<profile>.downloadUrls.*`. Audio/video URLs sit
// elsewhere, so reading only ttDownloadables ignores non-subtitle media.
// Netflix manifests list subtitle tracks under `timedtexttracks`; each track's
// download URLs live in `ttDownloadables.<profile>.downloadUrls.*`. Media (audio /
// video) URLs sit elsewhere, so reading only ttDownloadables ignores them.
export function collectStreamingSubtitleTracks(json: unknown): InterceptedTrack[] {
const tracks: InterceptedTrack[] = []
const seen = new WeakSet<object>()
Expand Down Expand Up @@ -75,25 +95,29 @@ function ttDownloadableUrls(track: Record<string, unknown>): string[] {
for (const profile of Object.values(downloadables)) {
const downloadUrls = (profile as Record<string, unknown>)?.downloadUrls
if (downloadUrls && typeof downloadUrls === "object") {
for (const value of Object.values(downloadUrls)) {
const url = normalizeUrl(value)
if (url)
urls.push(url)
for (const url of Object.values(downloadUrls)) {
const normalized = normalizeUrl(url)
if (normalized)
urls.push(normalized)
}
}
}
return urls
}

function postTracks(tracks: InterceptedTrack[]) {
const unique = [...new Map(tracks.map(track => [track.url, track])).values()]
const unique = [...new Map(tracks.map(track => [track.id ?? track.url ?? track.urls?.join("\n") ?? "", track])).values()]
if (unique.length)
window.postMessage({ type: STREAMING_SUBTITLE_TRACKS_TYPE, tracks: unique }, window.location.origin)
}

function publishTracks(tracks: InterceptedTrack[]) {
const scoped = tracks.map(track => ({ ...track, pagePath: pagePath() }))
scoped.forEach(track => tracksByUrl.set(track.url, track))
for (const track of scoped) {
if (track.url)
tracksByUrl.set(track.url, track)
track.urls?.forEach(url => tracksByUrl.set(url, track))
}
postTracks(scoped)
}

Expand All @@ -108,8 +132,25 @@ function publishFromJson(text: string | null) {
}
}

// The manifest may have been fetched before this script injected (extension reload
// / late inject). Replay recent JSON resources so their tracks reappear.
function guessLanguage(url: string): string | undefined {
try {
const parsed = new URL(url)
return parsed.searchParams.get("lang") ?? parsed.pathname.match(/[/_-]([a-z]{2}(?:-[a-z]{2,4})?)\.vtt/i)?.[1] ?? undefined
}
catch {
return undefined
}
}

function captureText(url: string, text: string | null) {
const track = tracksByUrl.get(url)
if (!text || !track || track.pagePath !== pagePath())
return
window.postMessage({ type: STREAMING_SUBTITLE_CAPTURED_TYPE, url, text, pagePath: track.pagePath }, window.location.origin)
}

// Netflix's manifest may have been fetched before this script injected (extension
// reload / late inject). Replay recent JSON resources so their tracks reappear.
function replayKnownResources() {
const entries = typeof performance?.getEntriesByType === "function" ? performance.getEntriesByType("resource") : []
entries
Expand All @@ -122,14 +163,24 @@ function replayKnownResources() {
})
}

function inspectJson(rawUrl: string | null, text: string | null, contentType: string | null) {
async function inspect(rawUrl: string | null, text: string | null, contentType: string | null) {
const url = normalizeUrl(rawUrl)
if (url && (/\bjson\b/i.test(contentType ?? "") || JSON_URL.test(url)))
if (!url)
return
if (isWebVttSite() && MPD_URL.test(url)) {
publishTracks([{ id: `${url}#manifest`, url, kind: "dash-manifest" }])
if (text)
publishTracks(parseDashMpdTextTracks(text, url))
}
if (isWebVttSite() && VTT_URL.test(url))
publishTracks([{ url, language: guessLanguage(url), kind: "subtitles" }])
captureText(url, text)
if (/\bjson\b/i.test(contentType ?? "") || JSON_URL.test(url))
publishFromJson(text)
}

export function injectStreamingSubtitlesInterceptor() {
if (window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ || !NETFLIX_HOST.test(window.location.hostname))
if (window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ || !(isNetflix() || isWebVttSite()))
return
window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ = true

Expand All @@ -142,7 +193,7 @@ export function injectStreamingSubtitlesInterceptor() {
replayKnownResources()
})

// Netflix parses manifests with JSON.parse and fetches them over fetch/XHR.
// Netflix parses manifests through JSON.parse; HBO fetches .vtt/.mpd over fetch/XHR.
const originalParse = JSON.parse
JSON.parse = function (text, reviver) {
const result = originalParse.call(this, text, reviver)
Expand All @@ -155,7 +206,7 @@ export function injectStreamingSubtitlesInterceptor() {
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
const response = await originalFetch.call(this, input, init)
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
void response.clone().text().then(text => inspectJson(url, text, response.headers.get("content-type"))).catch(() => {})
void response.clone().text().then(text => inspect(url, text, response.headers.get("content-type"))).catch(() => {})
return response
}

Expand All @@ -169,7 +220,7 @@ export function injectStreamingSubtitlesInterceptor() {
this.addEventListener("load", function () {
const url = this.responseURL || (this as any).__readFrogUrl
const text = this.responseType === "" || this.responseType === "text" ? this.responseText : null
inspectJson(url, text, this.getResponseHeader("content-type"))
void inspect(url, text, this.getResponseHeader("content-type"))
})
return originalSend.apply(this, args as any)
}
Expand Down
50 changes: 41 additions & 9 deletions src/entrypoints/subtitles.content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@ declare global {

const NETFLIX_WATCH_PATH_PATTERN = /^\/watch\//
const URL_CHANGE_EVENT = "extension:URLChange"
// Streaming SPAs that route through the unified adapter registry (see platforms/streaming.ts).
const STREAMING_HOST_PATTERN = /(?:^|\.)(?:netflix\.com|max\.com|hbomax\.com)$/i
// ponytail: bounded poll for a late-mounting <video> on non-Netflix players; raise if some site mounts slower.
const VIDEO_WAIT_MAX_ATTEMPTS = 20
const VIDEO_WAIT_INTERVAL_MS = 1000

function isNetflixPage(): boolean {
return /(?:^|\.)netflix\.com$/i.test(window.location.hostname)
}

// Netflix browse pages have no player; only bootstrap once the SPA reaches /watch/.
function isStreamingPage(): boolean {
return STREAMING_HOST_PATTERN.test(window.location.hostname)
}

// Netflix exposes a reliable /watch/ path; other streaming SPAs vary, so gate on
// the presence of a <video> element instead.
function isPlaybackReady(): boolean {
return NETFLIX_WATCH_PATH_PATTERN.test(window.location.pathname)
if (isNetflixPage())
return NETFLIX_WATCH_PATH_PATTERN.test(window.location.pathname)
return !!document.querySelector("video")
}

function watchStreamingUrlChanges(): () => void {
Expand All @@ -39,6 +51,8 @@ export default defineContentScript({
"*://*.youtube.com/*",
"*://*.youtube-nocookie.com/*",
"*://*.netflix.com/*",
"*://*.max.com/*",
"*://*.hbomax.com/*",
],
allFrames: true,
cssInjectionMode: "manifest",
Expand Down Expand Up @@ -66,20 +80,38 @@ export default defineContentScript({
await bootstrapSubtitlesRuntime()
}

if (isNetflixPage()) {
if (isStreamingPage()) {
cleanupHandlers.push(watchStreamingUrlChanges())
if (!isPlaybackReady()) {
// Not on a /watch/ page yet. Netflix navigates SPA-only, so keep listening
// until the user reaches a title — no timeout, since they may browse for a
// while first — then bootstrap once.
const onNavigate = () => {
if (!isPlaybackReady())
return
// Wait for the player. The navigation listener is never removed on a timeout,
// so a slow title pick still bootstraps; only the optional <video> poll (for
// non-Netflix sites whose player can mount without a URL change) is bounded.
let pollId: ReturnType<typeof setInterval> | undefined
const onReady = () => {
window.removeEventListener(URL_CHANGE_EVENT, onNavigate)
if (pollId)
clearInterval(pollId)
void bootstrapRuntime()
}
function onNavigate() {
if (isPlaybackReady())
onReady()
}
window.addEventListener(URL_CHANGE_EVENT, onNavigate)
cleanupHandlers.push(() => window.removeEventListener(URL_CHANGE_EVENT, onNavigate))

if (!isNetflixPage()) {
let attempts = 0
pollId = setInterval(() => {
if (isPlaybackReady())
onReady()
else if (++attempts >= VIDEO_WAIT_MAX_ATTEMPTS && pollId)
clearInterval(pollId)
}, VIDEO_WAIT_INTERVAL_MS)
const poll = pollId
cleanupHandlers.push(() => clearInterval(poll))
}

window.__READ_FROG_SUBTITLES_INJECTED__ = false
return
}
Expand Down
38 changes: 33 additions & 5 deletions src/entrypoints/subtitles.content/platforms/streaming.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
import type { UniversalVideoAdapter } from "../universal-adapter"
import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms"
import { hboMaxSiteAdapter } from "@/utils/subtitles/fetchers/streaming/hbo-max"
import { StreamingSubtitlesFetcher } from "@/utils/subtitles/fetchers/streaming/streaming-fetcher"
import { UniversalVideoAdapter } from "../universal-adapter"
import { createNetflixSubtitlesAdapter, getNetflixConfig } from "./netflix"

// Registry of streaming sites that render official bilingual subtitles through the
// shared StreamingSubtitlesFetcher. Each supported platform is one StreamingSite
// entry; a new site (HBO Max, …) plugs in by adding its adapter here — the capture,
// source/target selection and alignment plumbing is shared, not re-implemented.
const URL_CHANGE_EVENT = "extension:URLChange"

export interface StreamingSite {
id: string
matches: (url: URL) => boolean
create: () => { config: PlatformConfig, adapter: UniversalVideoAdapter }
}

// HBO Max plays inside an SPA like Netflix, so it reuses the same URLChange
// navigation events and floating-button UI. Native-caption selectors are
// best-effort and may need adjustment against the real site.
function streamingConfig(nativeSubtitles: string): PlatformConfig {
return {
selectors: {
video: "video",
playerContainer: "body",
nativeSubtitles,
},
events: {
navigateStart: URL_CHANGE_EVENT,
navigateFinish: URL_CHANGE_EVENT,
},
// Only player pages (those with a <video>) carry a video id; browse pages return
// null so they don't trigger a navigation reset that drops the next title.
getVideoId: () => document.querySelector("video") ? window.location.pathname : null,
}
}

const STREAMING_SITES: StreamingSite[] = [
{
id: "netflix",
Expand All @@ -21,6 +41,14 @@ const STREAMING_SITES: StreamingSite[] = [
return { config, adapter: createNetflixSubtitlesAdapter(config) }
},
},
{
id: hboMaxSiteAdapter.id,
matches: hboMaxSiteAdapter.matches,
create: () => {
const config = streamingConfig("[data-testid='subtitle-text'], .subtitle, .subtitles, [class*='CaptionWindow-Fuse-Web-Play'], [class*='TextCue-Fuse-Web-Play']")
return { config, adapter: new UniversalVideoAdapter({ config, subtitlesFetcher: new StreamingSubtitlesFetcher(hboMaxSiteAdapter) }) }
},
},
]

export function findStreamingSite(url: URL): StreamingSite | null {
Expand Down
Loading