feat(subtitles): take AI subtitles out of beta behind a Pro/Ultra minute quota (#2072)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MengXi
2026-08-14 17:23:07 -07:00
committed by GitHub
parent e80a0e525c
commit 1590dfd17e
29 changed files with 1123 additions and 199 deletions
+11
View File
@@ -0,0 +1,11 @@
---
"@read-frog/extension": minor
---
feat(subtitles): AI subtitles leave beta — Pro/Ultra minute quota with per-pool usage
AI subtitle transcription is out of beta: the "(Beta)" label, the beta pre-flight, and the Tally application form are gone. Requesting subtitles now needs a Pro or Ultra subscription — a free account gets an upgrade prompt instead of an application link, a lapsed payment is pointed at billing, and an unsupported video length gets its own message. The create request now reports the player's video duration so the server can check the quota before spending transcription time.
Every one of those walls now answers in place with a button you choose to press — "Log in", "Upgrade", "Update payment" — instead of a browser tab opening on its own mid-video. The plan and quota are checked before the subtitles flow starts, so a click that gets turned away leaves the subtitles you were already watching untouched, and running out of minutes now says when they come back. The "Loading AI subtitles" pill no longer stays pinned to the player after a refusal, and a refusal that arrives while the transcript is being fetched is translated instead of showing the server's raw English.
The options page quota section shows one usage bar per quota pool: the monthly subscription quota with its reset date, and — for launch-window subscribers — the one-time launch gift with its expiry date. Against an older server the section falls back to the single-bar totals it showed before.
+1 -1
View File
@@ -73,7 +73,7 @@
"@orpc/client": "^1.14.14",
"@orpc/tanstack-query": "^1.14.14",
"@radix-ui/react-slot": "^1.3.3",
"@read-frog/api-contract": "0.12.6",
"@read-frog/api-contract": "0.14.0",
"@read-frog/definitions": "0.4.4",
"@remixicon/react": "^4.9.0",
"@t3-oss/env-core": "^0.13.11",
+5 -5
View File
@@ -135,8 +135,8 @@ importers:
specifier: ^1.3.3
version: 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@read-frog/api-contract':
specifier: 0.12.6
version: 0.12.6(@opentelemetry/api@1.9.1)
specifier: 0.14.0
version: 0.14.0(@opentelemetry/api@1.9.1)
'@read-frog/definitions':
specifier: 0.4.4
version: 0.4.4
@@ -2256,8 +2256,8 @@ packages:
'@types/react':
optional: true
'@read-frog/api-contract@0.12.6':
resolution: {integrity: sha512-nR7D2oRHqQynew20jvw/23ykJB+p9rMQYd7T9K+Zb7EdWqR4hQ2Rtq7SFxZnXh1al9ubfpSEKX8pEAaxyxQeMw==}
'@read-frog/api-contract@0.14.0':
resolution: {integrity: sha512-LDfjzQcjZYmjJK+xyCtDIvx+kmBSZpp3jQH/4cSJnwWctJpFLrYGDJXNvztm96npX+ihXeBDdAEjtZ6A/UEsGA==}
'@read-frog/definitions@0.4.4':
resolution: {integrity: sha512-HnBrzjhfwvzD0CLcAtpzUOvhjfy1ratFWfh58Bw2p4ogcu5mzMCmGMNQw0LH1VK+g8g/+9k8GKHuNcYsDhvJAA==}
@@ -8920,7 +8920,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
'@read-frog/api-contract@0.12.6(@opentelemetry/api@1.9.1)':
'@read-frog/api-contract@0.14.0(@opentelemetry/api@1.9.1)':
dependencies:
'@orpc/contract': 1.14.6(@opentelemetry/api@1.9.1)
'@read-frog/definitions': 0.4.4
@@ -1,32 +1,35 @@
import type { VideoTranscriptUsage, VideoTranscriptUsagePool } from "@read-frog/api-contract"
import type { ReactNode } from "react"
import { ORPCError } from "@orpc/client"
import { useQuery } from "@tanstack/react-query"
import { Button } from "@/components/ui/base-ui/button"
import { Progress } from "@/components/ui/base-ui/progress"
import { Progress, ProgressLabel } from "@/components/ui/base-ui/progress"
import { Skeleton } from "@/components/ui/base-ui/skeleton"
import { openLogIn } from "@/components/user-account-menu/shared"
import { authClient } from "@/utils/auth/auth-client"
import { VIDEO_TRANSCRIPTION_APPLY_URL } from "@/utils/constants/subtitles"
import { i18n } from "@/utils/i18n"
import { orpc } from "@/utils/orpc/client"
import { cn } from "@/utils/styles/utils"
import {
formatQuotaDate,
launchBonusCutoffLabel,
pricingUrl,
} from "@/utils/subtitles/ai/entitlement"
import { ConfigItem } from "../../../components/config-item"
import { ConfigSection } from "../../../components/config-section"
const NEAR_LIMIT_RATIO = 0.9
interface QuotaUsageData {
usedMinutes: number
limitMinutes: number
remainingMinutes: number
}
function errorStatus(error: unknown): number | null {
return error instanceof ORPCError ? error.status : null
}
/**
* How much of the month's AI transcription the account has spent. Nothing here is set — the row
* stacks instead of splitting, so the bar can run the full width the reading of it needs.
* How much AI transcription the account has spent. One progress bar per quota
* pool: the monthly subscription pool (labeled with its reset date) and, for
* launch-window subscribers, the one-time gift (labeled with its expiry).
* Usage is fetched only when this section mounts — never on page load in a
* content script — mirroring how the Built-in AI usage panel reads its status.
*/
export function AiQuotaSection() {
const { data: session, isPending: isSessionPending } = authClient.useSession()
@@ -36,6 +39,7 @@ export function AiQuotaSection() {
orpc.videoTranscript.getUsage.queryOptions({
enabled: isSignedIn,
retry: false,
staleTime: 60_000,
meta: {
suppressToast: true,
},
@@ -54,17 +58,20 @@ export function AiQuotaSection() {
return <QuotaLoginGuide />
}
// Signed in but without beta access (403) -> prompt to apply, not to log in.
// A pre-launch server still gates getUsage behind the beta 403; the new
// server answers free accounts with plan "free" instead. Both mean the
// same thing now: this account needs a subscription. Drop this branch once
// the server retires VIDEO_TRANSCRIPTION_BETA_RESTRICTED for good.
if (status === 403) {
return <QuotaBetaGuide />
return <QuotaUpgradeGuide />
}
if (usageQuery.isError || !usageQuery.data) {
return (
<p className="text-sm text-muted-foreground">
{i18n.t("options.videoSubtitles.aiQuota.loadError")}
</p>
)
return <QuotaNotice>{i18n.t("options.videoSubtitles.aiQuota.loadError")}</QuotaNotice>
}
if (usageQuery.data.plan === "free") {
return <QuotaUpgradeGuide />
}
return <QuotaUsage usage={usageQuery.data} />
@@ -91,55 +98,133 @@ function QuotaSkeleton() {
)
}
/**
* Secondary copy in this slot sits directly under the ConfigItem description
* and must not out-size it, so it shares that 13px scale rather than the
* text-sm the Built-in AI panel can afford — that panel hangs straight off its
* ConfigSection, with no description above it to be measured against.
*/
function QuotaNotice({ children }: { children: ReactNode }) {
return <p className="text-[13px] leading-[18px] text-muted-foreground">{children}</p>
}
/**
* The launch offer, sitting under the wall it is trying to overturn. Renders
* nothing once the window closes: the server stops issuing the grant at the
* cutoff, so an ungated banner would age into a promise we no longer keep.
*/
function LaunchBonusPromo() {
const cutoff = launchBonusCutoffLabel()
if (!cutoff) {
return null
}
return (
<p className="text-[13px] leading-[18px] text-blue-600 dark:text-blue-400">
{i18n.t("options.videoSubtitles.aiQuota.launchBonusPromo", [cutoff])}
</p>
)
}
function QuotaLoginGuide() {
return (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{i18n.t("options.videoSubtitles.aiQuota.loginRequired")}
</p>
<div className="flex flex-col items-start gap-2.5">
<QuotaNotice>{i18n.t("options.videoSubtitles.aiQuota.loginRequired")}</QuotaNotice>
<Button variant="outline" size="sm" onClick={openLogIn}>
{i18n.t("options.videoSubtitles.aiQuota.logIn")}
{i18n.t("account.login")}
</Button>
<LaunchBonusPromo />
</div>
)
}
function QuotaBetaGuide() {
function QuotaUpgradeGuide() {
return (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{i18n.t("options.videoSubtitles.aiQuota.betaRequired")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(VIDEO_TRANSCRIPTION_APPLY_URL, "_blank")}
>
{i18n.t("options.videoSubtitles.aiQuota.betaApply")}
<div className="flex flex-col items-start gap-2.5">
<QuotaNotice>{i18n.t("options.videoSubtitles.aiQuota.upgradeRequired")}</QuotaNotice>
{/* Unlike the player, this surface never navigates on its own — opening a
tab just for landing on the settings page would be hostile. */}
<Button variant="outline" size="sm" onClick={() => window.open(pricingUrl(), "_blank")}>
{i18n.t("action.upgrade")}
</Button>
<LaunchBonusPromo />
</div>
)
}
function QuotaUsage({ usage }: { usage: QuotaUsageData }) {
const { usedMinutes, limitMinutes, remainingMinutes } = usage
const ratio = limitMinutes > 0 ? usedMinutes / limitMinutes : 0
const percent = Math.min(100, Math.max(0, ratio * 100))
const isNearLimit = ratio >= NEAR_LIMIT_RATIO
function QuotaUsage({ usage }: { usage: VideoTranscriptUsage }) {
// A server that predates pools reports totals only; render them as one bar.
const pools: VideoTranscriptUsagePool[] = usage.pools?.length
? usage.pools
: [
{
id: "subscription",
usedMinutes: usage.usedMinutes,
limitMinutes: usage.limitMinutes,
remainingMinutes: usage.remainingMinutes,
resetAt: null,
expiresAt: null,
},
]
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{pools.map((pool) => (
<QuotaPoolUsage key={pool.id} pool={pool} />
))}
</div>
)
}
/**
* Typography and rhythm mirror the Built-in AI usage panel: the pool name and
* the remaining count share the Progress header row, the track fill is what is
* LEFT (a fresh pool reads as a full bar), and the metadata line below carries
* the spent minutes and the pool's one meaningful date at text-xs.
*/
function QuotaPoolUsage({ pool }: { pool: VideoTranscriptUsagePool }) {
const { usedMinutes, limitMinutes, remainingMinutes } = pool
const remainingRatio = limitMinutes > 0 ? remainingMinutes / limitMinutes : 0
const remainingPercent = Math.min(100, Math.max(0, remainingRatio * 100))
const isNearLimit = remainingRatio <= 1 - NEAR_LIMIT_RATIO
const label =
pool.id === "launchBonus"
? i18n.t("options.videoSubtitles.aiQuota.pools.launchBonus")
: i18n.t("options.videoSubtitles.aiQuota.pools.subscription")
const remainingText = i18n.t("options.videoSubtitles.aiQuota.remainingOf", [
remainingMinutes,
limitMinutes,
])
// The monthly pool resets; the one-time gift only expires. Show whichever
// date the pool actually has.
const resetAt = formatQuotaDate(pool.resetAt)
const expiresAt = formatQuotaDate(pool.expiresAt)
const dateNote = resetAt
? i18n.t("options.videoSubtitles.aiQuota.resetsOn", [resetAt])
: expiresAt
? i18n.t("options.videoSubtitles.aiQuota.expiresOn", [expiresAt])
: null
return (
<div className="flex flex-col gap-1.5">
<Progress
value={percent}
className={cn(isNearLimit && "[&_[data-slot=progress-indicator]]:bg-destructive")}
/>
<p className="text-sm text-muted-foreground tabular-nums">
{i18n.t("options.videoSubtitles.aiQuota.summary", [
usedMinutes,
limitMinutes,
remainingMinutes,
])}
</p>
value={remainingPercent}
className={cn(
"gap-x-3 gap-y-1.5",
isNearLimit && "[&_[data-slot=progress-indicator]]:bg-destructive",
)}
// Announce "X of Y min left", not a bare percentage that reads as consumption.
getAriaValueText={() => remainingText}
>
<ProgressLabel>{label}</ProgressLabel>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{remainingText}</span>
</Progress>
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-muted-foreground">
<span className="tabular-nums">
{i18n.t("options.videoSubtitles.aiQuota.used", [usedMinutes])}
</span>
{dateNote && <span>{dateNote}</span>}
</div>
</div>
)
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { SUBTITLES_SOURCE } from "@/utils/constants/subtitles"
import { OverlaySubtitlesError, ToastSubtitlesError } from "@/utils/subtitles/errors"
import {
adPlayingAtom,
currentTimeMsAtom,
@@ -16,6 +17,13 @@ const mocks = vi.hoisted(() => ({
fetchSubtitlesSummary: vi.fn<(...args: any[]) => any>().mockResolvedValue(undefined),
translateSubtitles: vi.fn<(...args: any[]) => any>(),
resolveSubtitlesProviderRef: vi.fn<(...args: any[]) => any>(),
showSubtitlesErrorToast: vi.fn<(...args: any[]) => any>(),
showAiSubtitlesWallToast: vi.fn<(...args: any[]) => any>(),
}))
vi.mock("@/utils/subtitles/toast", () => ({
showSubtitlesErrorToast: mocks.showSubtitlesErrorToast,
showAiSubtitlesWallToast: mocks.showAiSubtitlesWallToast,
}))
vi.mock("@/utils/config/storage", async (importOriginal) => {
@@ -248,6 +256,92 @@ describe("universalVideoAdapter", () => {
await expect((adapter as any).startTranslation()).resolves.toBe(false)
})
// The loading state has no auto-hide of its own, so a wall that only raises a
// toast used to leave "Loading AI subtitles" pinned to the player forever.
it("clears the loading state and anchors the AI wall to its trigger", async () => {
const { adapter, subtitlesFetcher } = createAdapter([])
const action = { label: "action.upgrade", url: "https://readfrog.app/pricing" }
;(adapter as any).source = SUBTITLES_SOURCE.AI
subtitlesFetcher.fetch.mockRejectedValue(
new ToastSubtitlesError("subtitles.errors.aiSubscriptionRequired", action),
)
const scheduler = attachScheduler(adapter, true)
await expect((adapter as any).startTranslation()).resolves.toBe(false)
expect(scheduler.setState).toHaveBeenLastCalledWith("idle")
expect(mocks.showAiSubtitlesWallToast).toHaveBeenCalledWith(
"subtitles.errors.aiSubscriptionRequired",
action,
)
})
it("raises a toast without an action when the error carries none", async () => {
const { adapter, subtitlesFetcher } = createAdapter([])
;(adapter as any).source = SUBTITLES_SOURCE.AI
subtitlesFetcher.fetch.mockRejectedValue(
new ToastSubtitlesError("subtitles.errors.aiVideoTooLong"),
)
attachScheduler(adapter, true)
await expect((adapter as any).startTranslation()).resolves.toBe(false)
expect(mocks.showAiSubtitlesWallToast).toHaveBeenCalledWith(
"subtitles.errors.aiVideoTooLong",
undefined,
)
})
// Only the AI request has a control on screen to point at. Anything else has
// nothing on the player that would explain a toast pinned to that button.
it("leaves a non-AI toast error docked in the page corner", async () => {
const { adapter, subtitlesFetcher } = createAdapter([])
subtitlesFetcher.fetch.mockRejectedValue(
new ToastSubtitlesError("subtitles.errors.noSubtitlesFound"),
)
attachScheduler(adapter, true)
await expect((adapter as any).startTranslation()).resolves.toBe(false)
expect(mocks.showSubtitlesErrorToast).toHaveBeenCalledWith(
"subtitles.errors.noSubtitlesFound",
undefined,
)
expect(mocks.showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
// A superseded switch or a navigation rejects with DOMException("Aborted"),
// whose message is not user copy and must never be painted on the player.
it("stays silent when the run was aborted", async () => {
const { adapter, subtitlesFetcher } = createAdapter([])
subtitlesFetcher.fetch.mockRejectedValue(new DOMException("Aborted", "AbortError"))
const scheduler = attachScheduler(adapter, true)
await expect((adapter as any).startTranslation()).resolves.toBe(false)
expect(mocks.showSubtitlesErrorToast).not.toHaveBeenCalled()
expect(mocks.showAiSubtitlesWallToast).not.toHaveBeenCalled()
expect(scheduler.setState).not.toHaveBeenCalledWith("error", expect.anything())
})
// The overlay path already replaces the loading state and auto-hides itself;
// resetting it here would wipe the message the user needs to read.
it("keeps rendering overlay errors on the player instead of toasting them", async () => {
const { adapter, subtitlesFetcher } = createAdapter([])
subtitlesFetcher.fetch.mockRejectedValue(
new OverlaySubtitlesError("subtitles.errors.aiRequestFailed"),
)
const scheduler = attachScheduler(adapter, true)
await expect((adapter as any).startTranslation()).resolves.toBe(false)
expect(scheduler.setState).toHaveBeenLastCalledWith("error", {
message: "subtitles.errors.aiRequestFailed",
})
expect(mocks.showSubtitlesErrorToast).not.toHaveBeenCalled()
expect(mocks.showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
it("reverts the source back to native so a failed AI switch can be retried", () => {
const { adapter, subtitlesFetcher } = createAdapter([])
const aiFetcher = { cleanup: vi.fn<(...args: any[]) => any>() }
@@ -22,7 +22,20 @@ const SHORTS_ACTIVE_PLAYER = "#reel-overlay-container .html5-video-player"
function createYoutubeAiSubtitlesContext() {
const videoId = getYoutubeVideoId()
return videoId ? { videoId, url: location.href } : null
if (!videoId) {
return null
}
// The player's own duration, sent as `durationSec` with the create request.
// An untrusted admission hint only — the server measures the real duration
// in its worker before billing — so the ad-playback edge (where `duration`
// briefly reports the ad's length) is harmless. NaN until metadata loads,
// which cannot happen behind an open settings panel.
const video = document.querySelector<HTMLVideoElement>("video.html5-main-video")
const duration = video?.duration
if (!duration || !Number.isFinite(duration) || duration <= 0) {
return null
}
return { videoId, url: location.href, durationSec: Math.ceil(duration) }
}
/** YouTube marks mid-rolls / pre-rolls on the html5 player with these classes. */
@@ -1,5 +1,7 @@
import { useAtomValue } from "jotai"
import { use } from "react"
import { AnchoredToastProvider } from "@/components/ui/base-ui/toast"
import { ShadowWrapperContext } from "@/utils/react-shadow-host/create-shadow-host"
import { subtitlesDisplayAtom, subtitlesShowContentAtom, subtitlesShowStateAtom } from "../atoms"
import { StateMessage } from "./state-message"
import { SubtitlesSettingsPanel } from "./subtitles-settings-panel"
@@ -11,6 +13,10 @@ export function SubtitlesContainer() {
const showState = useAtomValue(subtitlesShowStateAtom)
const showContent = useAtomValue(subtitlesShowContentAtom)
const ui = use(SubtitlesUIContext)
// Portals into this host rather than the docked toast host, which hangs off
// document.body: this one lives inside the player, so an anchored toast can
// reach its trigger and survives the player going fullscreen.
const shadowWrapper = use(ShadowWrapperContext)
return (
<div className="pointer-events-none absolute inset-0 overflow-visible">
@@ -28,6 +34,8 @@ export function SubtitlesContainer() {
<SubtitlesSettingsPanel />
</div>
)}
<AnchoredToastProvider portalProps={{ container: shadowWrapper }} />
</div>
)
}
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { cleanup, renderHook } from "@testing-library/react"
import { createRef } from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { useSubtitlesPanelDismiss } from "../use-subtitles-panel-dismiss"
function mount(onClose: () => void, panel: HTMLElement) {
const panelRef = createRef<HTMLElement>() as { current: HTMLElement | null }
panelRef.current = panel
return renderHook(() => useSubtitlesPanelDismiss({ enabled: true, onClose, panelRef }))
}
/** jsdom has no PointerEvent constructor; MouseEvent carries the same composedPath. */
function pressOn(target: Element) {
target.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }))
}
describe("useSubtitlesPanelDismiss", () => {
afterEach(() => {
cleanup()
document.body.innerHTML = ""
})
it("closes on a press that lands outside the panel", () => {
const panel = document.createElement("div")
const outside = document.createElement("div")
document.body.append(panel, outside)
const onClose = vi.fn<() => void>()
mount(onClose, panel)
pressOn(outside)
expect(onClose).toHaveBeenCalledOnce()
})
it("keeps the panel open for a press inside it", () => {
const panel = document.createElement("div")
const inside = document.createElement("button")
panel.append(inside)
document.body.append(panel)
const onClose = vi.fn<() => void>()
mount(onClose, panel)
pressOn(inside)
expect(onClose).not.toHaveBeenCalled()
})
// The anchored toast portals out of the panel, so it reads as "outside".
// Dismissing here hides the toast's anchor mid-press, which turns the button
// the press was aimed at invisible before its click can land.
it("keeps the panel open for a press on the anchored toast", () => {
const panel = document.createElement("div")
const positioner = document.createElement("div")
positioner.dataset.slot = "toast-positioner"
const action = document.createElement("button")
positioner.append(action)
document.body.append(panel, positioner)
const onClose = vi.fn<() => void>()
mount(onClose, panel)
pressOn(action)
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -6,6 +6,7 @@ import { SUBTITLES_SOURCE } from "@/utils/constants/subtitles"
import { i18n } from "@/utils/i18n"
import { cn } from "@/utils/styles/utils"
import { ensureAiSubtitlesAccess } from "@/utils/subtitles/ai/access-guard"
import { setAiSubtitlesToastAnchor } from "@/utils/subtitles/toast"
import { subtitlesSourceAtom, subtitlesStore, subtitlesVisibleAtom } from "../../../atoms"
import { useSubtitlesUI } from "../../subtitles-ui-context"
import { SubtitlesSettingsItem } from "./subtitles-settings-item"
@@ -47,6 +48,9 @@ export function RequestAiSubtitlesItem() {
>
<Button
id={buttonId}
// Anchors the refusal toast, so "you need a plan" lands on the control
// that was pressed instead of in the far corner of the page.
ref={setAiSubtitlesToastAnchor}
type="button"
variant="ghost-secondary"
size="icon-sm"
@@ -20,6 +20,11 @@ const PORTALLED_PANEL_POPUP_SELECTOR = [
"[data-slot='select-content']",
"[data-slot='color-picker-content']",
"[data-slot='color-picker-format-content']",
// The anchored toast, which hangs off a control inside the panel. Dismissing
// on a press here is worse than the usual symptom: closing the panel hides
// its anchor, base-ui marks the toast anchor-hidden, and the button the press
// was aimed at goes `visibility: hidden` before the click can land on it.
"[data-slot='toast-positioner']",
].join(",")
function isTranslateTriggerTarget(path: EventTarget[]) {
@@ -28,6 +28,7 @@ import {
fetchSubtitlesSummary,
} from "@/utils/subtitles/processor/translator"
import { downloadSubtitlesAsSrt } from "@/utils/subtitles/srt"
import { showAiSubtitlesWallToast, showSubtitlesErrorToast } from "@/utils/subtitles/toast"
import {
adPlayingAtom,
currentTimeMsAtom,
@@ -678,10 +679,27 @@ export class UniversalVideoAdapter implements SubtitlesProvidersAdapter {
})
}
// A deliberate teardown — a superseded switch or a navigation — not a
// failure the user should read about. Its message is the literal string
// "Aborted", which would otherwise be painted onto the player untranslated.
if (error instanceof DOMException && error.name === "AbortError") {
return false
}
const errorMessage = error instanceof Error ? error.message : String(error)
if (error instanceof ToastSubtitlesError) {
toastManager.add({ type: "error", title: errorMessage })
// The loading state has no auto-hide of its own (unlike "error"), so
// the toast branch has to clear it — otherwise the "Loading AI
// subtitles" pill stays on the player forever after a wall.
this.subtitlesScheduler?.setState("idle")
// Only the AI request has a control on screen to point at; the source
// is still AI here because reverting to native happens after this.
if (this.source === SUBTITLES_SOURCE.AI) {
showAiSubtitlesWallToast(errorMessage, error.action)
} else {
showSubtitlesErrorToast(errorMessage, error.action)
}
} else {
this.subtitlesScheduler?.setState("error", {
message: this.config.silentErrors ? "" : errorMessage,
+18 -6
View File
@@ -1065,13 +1065,18 @@ options:
description: Lets AI re-cut the subtitles into lines that read as sentences. It sets their timing too, so lines can drift out of sync. Needs an LLM translation provider.
aiQuota:
title: AI subtitles quota
description: Monthly usage of your AI subtitle transcription quota
description: AI speech recognition gets you accurate subtitles for any video
launchBonusPromo: Subscribe before $1 for an extra 300 transcription minutes
loginRequired: Log in to view your AI subtitles quota
logIn: Log in
betaRequired: AI subtitles are in beta and not yet enabled for your account
betaApply: Apply for access
upgradeRequired: AI subtitles require a Pro or Ultra subscription
pools:
subscription: Monthly quota
launchBonus: Launch gift
resetsOn: Resets on $1
expiresOn: Expires on $1
loadError: Unable to load your quota. Please try again later.
summary: Used $1 of $2 minutes, $3 remaining
remainingOf: $1 / $2 min left
used: Used $1 min
style:
title: Subtitle style
description: Display mode, position, fonts, colors and background, set against a live preview
@@ -1215,6 +1220,7 @@ action:
saveToNotebasePendingLoginDescription: After login, Read Frog will create the Notebase and save this result automatically.
saveToNotebasePendingConnectedLoginDescription: After login, Read Frog will save to the connected Notebase when possible. If this account cannot use it, Read Frog will create a new Notebase and save this result.
upgrade: Upgrade
updatePayment: Update payment
openCustomActions: Open Custom AI Actions
openNotebase: Open Notebase
contextDetailsTitleLabel: Title
@@ -1238,7 +1244,7 @@ shortcutKeySelector:
placeholder: Please enter the shortcut
subtitles:
loadingAiSubtitles: Loading AI subtitles… (estimated 3 min)
requestAiSubtitles: Request AI subtitles (Beta)
requestAiSubtitles: Request AI subtitles
usingAiSubtitles: Using AI subtitles
toggle:
enabled: Subtitle translation on
@@ -1270,6 +1276,12 @@ subtitles:
aiAuthFailed: API authentication failed, please check your API key
aiServiceUnavailable: Translation service temporarily unavailable
aiQuotaExceeded: Monthly AI subtitle quota used up
aiQuotaExceededWithReset: Monthly AI subtitle quota used up · resets on $1
aiLoginRequired: Log in to view your AI subtitles quota
aiSubscriptionRequired: AI subtitles require a Pro or Ultra subscription
aiPaymentRequired: Update your payment method to keep generating AI subtitles
aiVideoTooLong: This video is too long for AI subtitles
aiStillProcessing: Transcription is still running — click again in a few minutes to pick it up
aiRequestFailed: AI subtitle request failed, please try again later
aiNoResponse: AI service did not respond, using original text
translatedExportFailed: Failed to generate translated subtitles
+18 -6
View File
@@ -1065,13 +1065,18 @@ options:
description: La IA vuelve a cortar los subtítulos en líneas que se leen como frases. También fija sus tiempos, así que pueden desajustarse. Requiere un proveedor de traducción LLM.
aiQuota:
title: Cuota de subtítulos con IA
description: Uso mensual de tu cuota de transcripción de subtítulos con IA
description: El reconocimiento de voz con IA te da subtítulos precisos para cualquier vídeo
launchBonusPromo: Suscríbete antes del $1 y obtén 300 minutos de transcripción adicionales
loginRequired: Inicia sesión para ver tu cuota de subtítulos con IA
logIn: Iniciar sesión
betaRequired: Los subtítulos con IA están en beta y aún no están habilitados para tu cuenta
betaApply: Solicitar acceso
upgradeRequired: Los subtítulos con IA requieren una suscripción Pro o Ultra
pools:
subscription: Cuota mensual
launchBonus: Regalo de lanzamiento
resetsOn: Se restablece el $1
expiresOn: Caduca el $1
loadError: No se pudo cargar tu cuota. Inténtalo de nuevo más tarde.
summary: Usados $1 de $2 minutos, quedan $3
remainingOf: Quedan $1 / $2 min
used: Usados $1 min
style:
title: Estilo de subtítulos
description: Modo de visualización, posición, fuentes, colores y fondo, ajustados sobre una vista previa en vivo
@@ -1215,6 +1220,7 @@ action:
saveToNotebasePendingLoginDescription: Después de iniciar sesión, Read Frog creará la Notebase y guardará este resultado automáticamente.
saveToNotebasePendingConnectedLoginDescription: Después de iniciar sesión, Read Frog guardará en la Notebase conectada si es posible. Si esta cuenta no puede usarla, creará una nueva Notebase y guardará este resultado.
upgrade: Mejorar plan
updatePayment: Actualizar pago
openCustomActions: Abrir Custom AI Actions
openNotebase: Abrir Notebase
contextDetailsTitleLabel: Título
@@ -1238,7 +1244,7 @@ shortcutKeySelector:
placeholder: Introduce el atajo
subtitles:
loadingAiSubtitles: Cargando subtítulos con IA… (estimado 3 min)
requestAiSubtitles: Solicitar subtítulos con IA (Beta)
requestAiSubtitles: Solicitar subtítulos con IA
usingAiSubtitles: Usando subtítulos con IA
toggle:
enabled: Traducción de subtítulos activada
@@ -1270,6 +1276,12 @@ subtitles:
aiAuthFailed: Error de autenticación de API; revisa tu clave API
aiServiceUnavailable: Servicio de traducción temporalmente no disponible
aiQuotaExceeded: Cuota mensual de subtítulos con IA agotada
aiQuotaExceededWithReset: Cuota mensual de subtítulos con IA agotada · se restablece el $1
aiLoginRequired: Inicia sesión para ver tu cuota de subtítulos con IA
aiSubscriptionRequired: Los subtítulos con IA requieren una suscripción Pro o Ultra
aiPaymentRequired: Actualiza tu método de pago para seguir generando subtítulos con IA
aiVideoTooLong: Este video es demasiado largo para los subtítulos con IA
aiStillProcessing: La transcripción sigue en curso — vuelve a hacer clic en unos minutos para retomarla
aiRequestFailed: Error al solicitar subtítulos con IA, inténtalo de nuevo más tarde
aiNoResponse: El servicio AI no respondió; se usa el texto original
translatedExportFailed: No se pudieron generar los subtítulos traducidos
+18 -6
View File
@@ -949,13 +949,18 @@ options:
description: AI が字幕を文として読める形に切り直します。タイミングも AI が決めるため、字幕がずれることがあります。LLM 翻訳プロバイダーが必要です。
aiQuota:
title: AI字幕の利用枠
description: 今月のAI字幕文字起こし利用枠の使用状況
description: AIの音声認識で、どんな動画にも正確な字幕を生成します
launchBonusPromo: $1 までに加入すると、文字起こし 300 分を追加でプレゼント
loginRequired: ログインしてAI字幕の利用枠を確認
logIn: ログイン
betaRequired: AI字幕はベータ版で、まだあなたのアカウントでは利用できません
betaApply: 利用を申請
upgradeRequired: AI字幕には Pro または Ultra プランが必要です
pools:
subscription: 月間クォータ
launchBonus: リリース記念ギフト
resetsOn: $1 にリセット
expiresOn: $1 に失効
loadError: 利用枠を読み込めませんでした。しばらくしてから再試行してください。
summary: $2 分中 $1 分を使用、残り $3
remainingOf: 残り $1 / $2
used: 使用済み $1 分
style:
title: 字幕スタイル
description: 表示モード・位置・フォント・色・背景を、プレビューを見ながら設定します
@@ -1099,6 +1104,7 @@ action:
saveToNotebasePendingLoginDescription: ログイン後、Read Frog が Notebase を作成し、この結果を自動で保存します。
saveToNotebasePendingConnectedLoginDescription: ログイン後、Read Frog は可能であれば接続済み Notebase に保存します。このアカウントで利用できない場合は、新しい Notebase を作成して保存します。
upgrade: アップグレード
updatePayment: 支払いを更新
openCustomActions: Custom AI Actions を開く
openNotebase: Notebase を開く
contextDetailsTitleLabel: タイトル
@@ -1122,7 +1128,7 @@ shortcutKeySelector:
placeholder: ショートカットを入力してください
subtitles:
loadingAiSubtitles: AI字幕を読み込み中…(推定3分)
requestAiSubtitles: AI字幕をリクエストBeta
requestAiSubtitles: AI字幕をリクエスト
usingAiSubtitles: AI字幕を使用中
toggle:
enabled: 字幕翻訳をオンにしました
@@ -1154,6 +1160,12 @@ subtitles:
aiAuthFailed: API認証に失敗しました。APIキーを確認してください
aiServiceUnavailable: 翻訳サービスは一時的に利用できません
aiQuotaExceeded: 今月のAI字幕の利用枠を使い切りました
aiQuotaExceededWithReset: 今月のAI字幕の利用枠を使い切りました・$1 にリセット
aiLoginRequired: ログインしてAI字幕の利用枠を確認
aiSubscriptionRequired: AI字幕には Pro または Ultra プランが必要です
aiPaymentRequired: AI字幕の生成を続けるにはお支払い方法を更新してください
aiVideoTooLong: この動画は長すぎるためAI字幕を生成できません
aiStillProcessing: 文字起こしは進行中です。数分後にもう一度クリックしてください
aiRequestFailed: AI字幕のリクエストに失敗しました。しばらくしてから再試行してください
aiNoResponse: AIサービスが応答しませんでした。原文を表示します
translatedExportFailed: 翻訳字幕の生成に失敗しました
+18 -6
View File
@@ -949,13 +949,18 @@ options:
description: AI가 자막을 문장처럼 읽히도록 다시 나눕니다. 타이밍도 AI가 정하므로 싱크가 어긋날 수 있습니다. LLM 번역 공급자가 필요합니다.
aiQuota:
title: AI 자막 사용량
description: 이번 달 AI 자막 전사 사용량 현황
description: AI 음성 인식으로 모든 동영상에 정확한 자막을 만들어 줍니다
launchBonusPromo: $1 이전에 구독하면 전사 300분을 추가로 드립니다
loginRequired: 로그인하여 AI 자막 사용량 확인
logIn: 로그인
betaRequired: AI 자막은 베타 기능이며 아직 계정에 활성화되지 않았습니다
betaApply: 사용 신청
upgradeRequired: AI 자막은 Pro 또는 Ultra 구독이 필요합니다
pools:
subscription: 월간 할당량
launchBonus: 출시 기념 보너스
resetsOn: $1에 초기화
expiresOn: $1에 만료
loadError: 사용량을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.
summary: $2분 중 $1분 사용, $3분 남음
remainingOf: 남은 시간 $1 / $2분
used: $1분 사용됨
style:
title: 자막 스타일
description: 표시 모드, 위치, 글꼴, 색상, 배경을 미리보기로 확인하며 설정합니다
@@ -1099,6 +1104,7 @@ action:
saveToNotebasePendingLoginDescription: 로그인 후 Read Frog가 Notebase를 만들고 이 결과를 자동으로 저장합니다.
saveToNotebasePendingConnectedLoginDescription: 로그인 후 Read Frog는 가능하면 연결된 Notebase에 저장합니다. 이 계정에서 사용할 수 없으면 새 Notebase를 만들고 저장합니다.
upgrade: 업그레이드
updatePayment: 결제 수단 업데이트
openCustomActions: Custom AI Actions 열기
openNotebase: Notebase 열기
contextDetailsTitleLabel: 제목
@@ -1122,7 +1128,7 @@ shortcutKeySelector:
placeholder: 단축키를 입력해 주세요
subtitles:
loadingAiSubtitles: AI 자막 불러오는 중… (예상 3분)
requestAiSubtitles: AI 자막 요청 (Beta)
requestAiSubtitles: AI 자막 요청
usingAiSubtitles: AI 자막 사용 중
toggle:
enabled: 자막 번역을 켰습니다
@@ -1154,6 +1160,12 @@ subtitles:
aiAuthFailed: API 인증에 실패했습니다. API 키를 확인해 주세요
aiServiceUnavailable: 번역 서비스를 일시적으로 사용할 수 없습니다
aiQuotaExceeded: 이번 달 AI 자막 사용량을 모두 사용했습니다
aiQuotaExceededWithReset: 이번 달 AI 자막 사용량을 모두 사용했습니다 · $1에 초기화
aiLoginRequired: 로그인하여 AI 자막 사용량 확인
aiSubscriptionRequired: AI 자막은 Pro 또는 Ultra 구독이 필요합니다
aiPaymentRequired: AI 자막을 계속 생성하려면 결제 수단을 업데이트하세요
aiVideoTooLong: 이 영상은 너무 길어 AI 자막을 만들 수 없습니다
aiStillProcessing: 아직 전사가 진행 중입니다. 몇 분 후 다시 클릭해 주세요
aiRequestFailed: AI 자막 요청에 실패했습니다. 잠시 후 다시 시도해 주세요
aiNoResponse: AI 서비스가 응답하지 않았습니다. 원문을 표시합니다
translatedExportFailed: 번역 자막 생성에 실패했습니다
+18 -6
View File
@@ -949,13 +949,18 @@ options:
description: ИИ заново режет субтитры на строки, читающиеся как предложения. Тайминг он задаёт сам, поэтому строки могут расходиться с речью. Нужен LLM-провайдер перевода.
aiQuota:
title: Лимит ИИ-субтитров
description: Использование вашего месячного лимита транскрипции ИИ-субтитров
description: ИИ распознаёт речь и создаёт точные субтитры для любого видео
launchBonusPromo: Оформите подписку до $1 и получите дополнительно 300 минут расшифровки
loginRequired: Войдите, чтобы посмотреть лимит ИИ-субтитров
logIn: Войти
betaRequired: ИИ-субтитры находятся в бете и пока недоступны для вашего аккаунта
betaApply: Подать заявку на доступ
upgradeRequired: Для ИИ-субтитров нужна подписка Pro или Ultra
pools:
subscription: Месячная квота
launchBonus: Подарок к запуску
resetsOn: Сброс $1
expiresOn: Истекает $1
loadError: Не удалось загрузить лимит. Повторите попытку позже.
summary: Использовано $1 из $2 минут, осталось $3
remainingOf: Осталось $1 / $2 мин
used: Использовано $1 мин
style:
title: Стиль субтитров
description: Режим отображения, положение, шрифты, цвета и фон — всё настраивается по живому предпросмотру
@@ -1099,6 +1104,7 @@ action:
saveToNotebasePendingLoginDescription: После входа Read Frog автоматически создаст Notebase и сохранит этот результат.
saveToNotebasePendingConnectedLoginDescription: После входа Read Frog сохранит в подключённую Notebase, если это возможно. Если аккаунт не может её использовать, будет создана новая Notebase.
upgrade: Повысить тариф
updatePayment: Обновить оплату
openCustomActions: Открыть Custom AI Actions
openNotebase: Открыть Notebase
contextDetailsTitleLabel: Заголовок
@@ -1122,7 +1128,7 @@ shortcutKeySelector:
placeholder: Пожалуйста, введите горячую клавишу
subtitles:
loadingAiSubtitles: Загрузка ИИ-субтитров… (ожидается 3 мин)
requestAiSubtitles: Запросить ИИ-субтитры (Beta)
requestAiSubtitles: Запросить ИИ-субтитры
usingAiSubtitles: Используются ИИ-субтитры
toggle:
enabled: Перевод субтитров включён
@@ -1154,6 +1160,12 @@ subtitles:
aiAuthFailed: Ошибка аутентификации API, проверьте ваш API ключ
aiServiceUnavailable: Служба перевода временно недоступна
aiQuotaExceeded: Месячный лимит ИИ-субтитров исчерпан
aiQuotaExceededWithReset: Месячный лимит ИИ-субтитров исчерпан · сброс $1
aiLoginRequired: Войдите, чтобы посмотреть лимит ИИ-субтитров
aiSubscriptionRequired: Для ИИ-субтитров нужна подписка Pro или Ultra
aiPaymentRequired: Обновите способ оплаты, чтобы продолжить создание ИИ-субтитров
aiVideoTooLong: Это видео слишком длинное для ИИ-субтитров
aiStillProcessing: Транскрипция ещё идёт — нажмите ещё раз через несколько минут
aiRequestFailed: Не удалось запросить ИИ-субтитры, повторите попытку позже
aiNoResponse: Служба AI не ответила, отображается оригинальный текст
translatedExportFailed: Не удалось создать переведённые субтитры
+18 -6
View File
@@ -949,13 +949,18 @@ options:
description: Yapay zekâ altyazıları cümle gibi okunan satırlara yeniden böler. Zamanlamayı da kendisi belirlediği için satırlar kayabilir. LLM çeviri sağlayıcısı gerekir.
aiQuota:
title: Yapay zekâ altyazı kotası
description: Yapay zeka altyazı deşifre kotanızın aylık kullanımı
description: Yapay zeka konuşma tanıma ile her video için doğru altyazı üretir
launchBonusPromo: $1 tarihinden önce abone olun, 300 dakika ek deşifre hediyesi kazanın
loginRequired: Yapay zeka altyazı kotanızı görmek için giriş yapın
logIn: Giriş yap
betaRequired: Yapay zeka altyazıları beta aşamasında ve hesabınız için henüz etkin değil
betaApply: Erişim başvurusu yap
upgradeRequired: Yapay zeka altyazıları için Pro veya Ultra aboneliği gerekir
pools:
subscription: Aylık kota
launchBonus: Lansman hediyesi
resetsOn: $1 tarihinde sıfırlanır
expiresOn: $1 tarihinde sona erer
loadError: Kotanız yüklenemedi. Lütfen daha sonra tekrar deneyin.
summary: $2 dakikanın $1 dakikası kullanıldı, $3 kaldı
remainingOf: $1 / $2 dk kaldı
used: $1 dk kullanıldı
style:
title: Altyazı stili
description: Görüntüleme modu, konum, yazı tipleri, renkler ve arka plan — canlı önizlemeye bakarak ayarlanır
@@ -1099,6 +1104,7 @@ action:
saveToNotebasePendingLoginDescription: Giriş yaptıktan sonra Read Frog Notebase'i oluşturur ve bu sonucu otomatik olarak kaydeder.
saveToNotebasePendingConnectedLoginDescription: Girişten sonra Read Frog mümkünse bağlı Notebase'e kaydeder. Bu hesap onu kullanamazsa yeni bir Notebase oluşturup kaydeder.
upgrade: Yükselt
updatePayment: Ödemeyi güncelle
openCustomActions: Custom AI Actions'ı
openNotebase: Notebase'i aç
contextDetailsTitleLabel: Başlık
@@ -1122,7 +1128,7 @@ shortcutKeySelector:
placeholder: Lütfen kısayolu girin
subtitles:
loadingAiSubtitles: Yapay zeka altyazıları yükleniyor… (tahmini 3 dk)
requestAiSubtitles: Yapay zeka altyazısı iste (Beta)
requestAiSubtitles: Yapay zeka altyazısı iste
usingAiSubtitles: Yapay zeka altyazısı kullanılıyor
toggle:
enabled: Altyazı çevirisi açık
@@ -1154,6 +1160,12 @@ subtitles:
aiAuthFailed: API kimlik doğrulaması başarısız, lütfen API anahtarınızı kontrol edin
aiServiceUnavailable: Çeviri hizmeti geçici olarak kullanılamıyor
aiQuotaExceeded: Aylık yapay zeka altyazı kotanız doldu
aiQuotaExceededWithReset: Aylık yapay zeka altyazı kotanız doldu · $1 tarihinde sıfırlanır
aiLoginRequired: Yapay zeka altyazı kotanızı görmek için giriş yapın
aiSubscriptionRequired: Yapay zeka altyazıları için Pro veya Ultra aboneliği gerekir
aiPaymentRequired: Yapay zeka altyazıları oluşturmaya devam etmek için ödeme yönteminizi güncelleyin
aiVideoTooLong: Bu video yapay zeka altyazıları için çok uzun
aiStillProcessing: Yazıya dökme hâlâ sürüyor — birkaç dakika sonra tekrar tıklayın
aiRequestFailed: Yapay zeka altyazı isteği başarısız oldu, lütfen daha sonra tekrar deneyin
aiNoResponse: AI hizmeti yanıt vermedi, orijinal metin gösteriliyor
translatedExportFailed: Çevrilmiş altyazılar oluşturulamadı
+18 -6
View File
@@ -949,13 +949,18 @@ options:
description: AI cắt lại phụ đề thành những dòng đọc như câu hoàn chỉnh. AI cũng quyết định thời điểm hiện, nên dòng có thể lệch. Cần nhà cung cấp dịch LLM.
aiQuota:
title: Hạn mức phụ đề AI
description: Mức sử dụng hạn mức chuyển lời phụ đề AI trong tháng của bạn
description: AI dùng công nghệ nhận dạng giọng nói để tạo phụ đề chính xác cho mọi video
launchBonusPromo: Đăng ký trước $1 để nhận thêm 300 phút chuyển lời nói thành văn bản
loginRequired: Đăng nhập để xem hạn mức phụ đề AI của bạn
logIn: Đăng nhập
betaRequired: Phụ đề AI đang ở giai đoạn beta và chưa được bật cho tài khoản của bạn
betaApply: Đăng ký sử dụng
upgradeRequired: Phụ đề AI cần gói Pro hoặc Ultra
pools:
subscription: Hạn mức hằng tháng
launchBonus: Quà ra mắt
resetsOn: Đặt lại vào $1
expiresOn: Hết hạn vào $1
loadError: Không thể tải hạn mức. Vui lòng thử lại sau.
summary: Đã dùng $1 trong $2 phút, còn lại $3
remainingOf: Còn $1 / $2 phút
used: Đã dùng $1 phút
style:
title: Kiểu phụ đề
description: Chế độ hiển thị, vị trí, phông chữ, màu sắc và nền, chỉnh ngay trên bản xem trước
@@ -1099,6 +1104,7 @@ action:
saveToNotebasePendingLoginDescription: Sau khi đăng nhập, Read Frog sẽ tự động tạo Notebase và lưu kết quả này.
saveToNotebasePendingConnectedLoginDescription: Sau khi đăng nhập, Read Frog sẽ lưu vào Notebase đã kết nối nếu có thể. Nếu tài khoản này không dùng được, Read Frog sẽ tạo Notebase mới và lưu kết quả này.
upgrade: Nâng cấp
updatePayment: Cập nhật thanh toán
openCustomActions: Mở Custom AI Actions
openNotebase: Mở Notebase
contextDetailsTitleLabel: Tiêu đề
@@ -1122,7 +1128,7 @@ shortcutKeySelector:
placeholder: Vui lòng nhập phím tắt
subtitles:
loadingAiSubtitles: Đang tải phụ đề AI… (dự kiến 3 phút)
requestAiSubtitles: Yêu cầu phụ đề AI (Beta)
requestAiSubtitles: Yêu cầu phụ đề AI
usingAiSubtitles: Đang dùng phụ đề AI
toggle:
enabled: Đã bật dịch phụ đề
@@ -1154,6 +1160,12 @@ subtitles:
aiAuthFailed: Xác thực API thất bại, vui lòng kiểm tra API key của bạn
aiServiceUnavailable: Dịch vụ dịch thuật tạm thời không khả dụng
aiQuotaExceeded: Đã dùng hết hạn mức phụ đề AI trong tháng
aiQuotaExceededWithReset: Đã dùng hết hạn mức phụ đề AI trong tháng · đặt lại vào $1
aiLoginRequired: Đăng nhập để xem hạn mức phụ đề AI của bạn
aiSubscriptionRequired: Phụ đề AI cần gói Pro hoặc Ultra
aiPaymentRequired: Cập nhật phương thức thanh toán để tiếp tục tạo phụ đề AI
aiVideoTooLong: Video này quá dài để tạo phụ đề AI
aiStillProcessing: Quá trình phiên âm vẫn đang chạy — hãy bấm lại sau vài phút
aiRequestFailed: Yêu cầu phụ đề AI thất bại, vui lòng thử lại sau
aiNoResponse: Dịch vụ AI không phản hồi, hiển thị văn bản gốc
translatedExportFailed: Không thể tạo phụ đề đã dịch
+18 -6
View File
@@ -1065,13 +1065,18 @@ options:
description: 让 AI 把字幕重新切成读起来完整的句子。时间轴也由 AI 判断,可能出现错位。需要配置 LLM 翻译服务。
aiQuota:
title: AI 字幕额度
description: 你账户本月的 AI 字幕转写额度使用情况
description: AI 利用语音转文字技术获取任何视频的准确字幕
launchBonusPromo: $1 之前购买会员额外赠送 300 分钟转录额度
loginRequired: 登录后查看你的 AI 字幕额度
logIn: 登录
betaRequired: AI 字幕内测中,你的账号暂未获得使用资格
betaApply: 申请资格
upgradeRequired: AI 字幕需要 Pro 或 Ultra 订阅
pools:
subscription: 每月额度
launchBonus: 上线赠送
resetsOn: $1 重置
expiresOn: $1 过期
loadError: 无法加载额度,请稍后重试。
summary: 已用 $1 分钟 / 共 $2 分钟,剩余 $3 分钟
remainingOf: 剩余 $1 / $2 分钟
used: 已用 $1 分钟
style:
title: 字幕样式
description: 显示模式、位置、字体、颜色和背景,都对着预览调
@@ -1215,6 +1220,7 @@ action:
saveToNotebasePendingLoginDescription: 登录完成后,Read Frog 会自动创建笔记库并保存当前结果。
saveToNotebasePendingConnectedLoginDescription: 登录完成后,Read Frog 会优先保存到已连接的笔记库;如果当前账号无法使用它,会创建新的笔记库并保存当前结果。
upgrade: 升级
updatePayment: 更新支付方式
openCustomActions: 打开 Custom AI Actions
openNotebase: 打开笔记库
contextDetailsTitleLabel: 标题
@@ -1238,7 +1244,7 @@ shortcutKeySelector:
placeholder: 请输入快捷键
subtitles:
loadingAiSubtitles: 正在加载 AI 字幕中(预计 3 分钟)
requestAiSubtitles: 请求 AI 字幕Beta
requestAiSubtitles: 请求 AI 字幕
usingAiSubtitles: 正在使用 AI 字幕中
toggle:
enabled: 字幕翻译已开启
@@ -1270,6 +1276,12 @@ subtitles:
aiAuthFailed: API 认证失败,请检查您的 API 密钥
aiServiceUnavailable: 翻译服务暂时不可用
aiQuotaExceeded: 本月 AI 字幕额度已用完
aiQuotaExceededWithReset: 本月 AI 字幕额度已用完,$1 重置
aiLoginRequired: 登录后查看你的 AI 字幕额度
aiSubscriptionRequired: AI 字幕需要 Pro 或 Ultra 订阅
aiPaymentRequired: 请更新支付方式后继续生成 AI 字幕
aiVideoTooLong: 视频过长,暂不支持生成 AI 字幕
aiStillProcessing: 转录仍在进行中,几分钟后再点一次即可继续获取
aiRequestFailed: AI 字幕请求失败,请稍后重试
aiNoResponse: AI 服务未响应,使用原文显示
translatedExportFailed: 生成翻译字幕失败
+18 -6
View File
@@ -1065,13 +1065,18 @@ options:
description: 讓 AI 把字幕重新切成讀起來完整的句子。時間軸也由 AI 判斷,可能出現錯位。需要設定 LLM 翻譯服務。
aiQuota:
title: AI 字幕額度
description: 你帳號本月的 AI 字幕轉寫額度使用情況
description: AI 利用語音轉文字技術取得任何影片的準確字幕
launchBonusPromo: $1 之前購買會員額外贈送 300 分鐘轉錄額度
loginRequired: 登入後查看你的 AI 字幕額度
logIn: 登入
betaRequired: AI 字幕測試中,你的帳號尚未取得使用資格
betaApply: 申請資格
upgradeRequired: AI 字幕需要 Pro 或 Ultra 訂閱
pools:
subscription: 每月額度
launchBonus: 上線贈送
resetsOn: $1 重置
expiresOn: $1 過期
loadError: 無法載入額度,請稍後重試。
summary: 已用 $1 分鐘 / 共 $2 分鐘,剩餘 $3 分鐘
remainingOf: 剩餘 $1 / $2 分鐘
used: 已用 $1 分鐘
style:
title: 字幕樣式
description: 顯示模式、位置、字型、顏色和背景,都對著預覽調
@@ -1215,6 +1220,7 @@ action:
saveToNotebasePendingLoginDescription: 登入完成後,陪讀蛙會自動建立筆記庫並儲存目前結果。
saveToNotebasePendingConnectedLoginDescription: 登入完成後,陪讀蛙會優先儲存到已連線的筆記庫;如果目前帳號無法使用它,會建立新的筆記庫並儲存目前結果。
upgrade: 升級
updatePayment: 更新付款方式
openCustomActions: 開啟自訂 AI 指令
openNotebase: 開啟筆記庫
contextDetailsTitleLabel: 標題
@@ -1238,7 +1244,7 @@ shortcutKeySelector:
placeholder: 請輸入快速鍵
subtitles:
loadingAiSubtitles: 正在載入 AI 字幕(預計 3 分鐘)
requestAiSubtitles: 請求 AI 字幕Beta
requestAiSubtitles: 請求 AI 字幕
usingAiSubtitles: 正在使用 AI 字幕
toggle:
enabled: 字幕翻譯已開啟
@@ -1270,6 +1276,12 @@ subtitles:
aiAuthFailed: API 認證失敗,請檢查 API 金鑰
aiServiceUnavailable: 翻譯服務暫時無法使用
aiQuotaExceeded: 本月 AI 字幕額度已用完
aiQuotaExceededWithReset: 本月 AI 字幕額度已用完,$1 重置
aiLoginRequired: 登入後查看你的 AI 字幕額度
aiSubscriptionRequired: AI 字幕需要 Pro 或 Ultra 訂閱
aiPaymentRequired: 請更新付款方式後繼續產生 AI 字幕
aiVideoTooLong: 影片過長,暫不支援產生 AI 字幕
aiStillProcessing: 轉錄仍在進行中,幾分鐘後再點一次即可繼續取得
aiRequestFailed: AI 字幕請求失敗,請稍後重試
aiNoResponse: AI 服務未回應,使用原文顯示
translatedExportFailed: 產生翻譯字幕失敗
-1
View File
@@ -84,4 +84,3 @@ export const SUBTITLE_FONT_FAMILIES = {
// Subtitles source
export const SUBTITLES_SOURCE = { NATIVE: "native", AI: "ai" } as const
export type SubtitlesSource = (typeof SUBTITLES_SOURCE)[keyof typeof SUBTITLES_SOURCE]
export const VIDEO_TRANSCRIPTION_APPLY_URL = "https://tally.so/r/7Rzb96"
@@ -1,12 +1,14 @@
// @vitest-environment jsdom
import { ORPCError } from "@orpc/client"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { VIDEO_TRANSCRIPTION_APPLY_URL } from "@/utils/constants/subtitles"
const getSession = vi.fn<(...args: unknown[]) => Promise<unknown>>()
const betaAccessStatus = vi.fn<(...args: unknown[]) => Promise<unknown>>()
const openLogIn = vi.fn<(...args: unknown[]) => void>()
const windowOpen = vi.fn<(...args: unknown[]) => void>()
const getUsage = vi.fn<(...args: unknown[]) => Promise<unknown>>()
const showAiSubtitlesWallToast = vi.fn<(...args: unknown[]) => void>()
vi.mock("@/env", () => ({
env: { WXT_WEBSITE_URL: "https://readfrog.app" },
}))
vi.mock("@/utils/auth/auth-client", () => ({
authClient: {
@@ -14,77 +16,177 @@ vi.mock("@/utils/auth/auth-client", () => ({
},
}))
vi.mock("@/components/user-account-menu/shared", () => ({
openLogIn: (...args: unknown[]) => openLogIn(...args),
}))
vi.mock("@/utils/orpc/client", () => ({
orpcClient: {
betaAccess: {
status: (...args: unknown[]) => betaAccessStatus(...args),
videoTranscript: {
getUsage: (...args: unknown[]) => getUsage(...args),
},
},
}))
vi.mock("@read-frog/definitions", () => ({
VIDEO_TRANSCRIPTION_BETA_FEATURE_KEY: "videoTranscription",
vi.mock("@/utils/subtitles/toast", () => ({
showAiSubtitlesWallToast: (...args: unknown[]) => showAiSubtitlesWallToast(...args),
}))
const { ensureAiSubtitlesAccess, ensureBetaAllowed, ensureSignedIn } =
const { ensureAiSubtitlesAccess, ensureAiSubtitlesEntitled, ensureSignedIn } =
await import("../access-guard")
const SIGNED_IN = { data: { user: { id: "u1" } } }
function usage(overrides: Record<string, unknown> = {}) {
return {
usedMinutes: 10,
limitMinutes: 250,
remainingMinutes: 240,
plan: "pro",
pools: [
{
id: "subscription",
usedMinutes: 10,
limitMinutes: 250,
remainingMinutes: 240,
resetAt: "2026-09-01T00:00:00.000Z",
expiresAt: null,
},
],
...overrides,
}
}
/** The (title, action) pair the guard handed the toast. */
function lastToast() {
const call = showAiSubtitlesWallToast.mock.calls.at(-1)
return { title: call?.[0] as string, action: call?.[1] as { label: string; url: string } }
}
describe("ai subtitles access guard", () => {
beforeEach(() => {
getSession.mockReset()
betaAccessStatus.mockReset()
openLogIn.mockReset()
windowOpen.mockReset()
vi.stubGlobal("open", windowOpen)
getUsage.mockReset()
showAiSubtitlesWallToast.mockReset()
})
it("opens the log-in page and short-circuits when signed out", async () => {
it("prompts to log in and short-circuits when signed out", async () => {
getSession.mockResolvedValue({ data: null })
await expect(ensureAiSubtitlesAccess()).resolves.toBe(false)
expect(openLogIn).toHaveBeenCalledOnce()
expect(betaAccessStatus).not.toHaveBeenCalled()
expect(getUsage).not.toHaveBeenCalled()
expect(lastToast().title).toBe("subtitles.errors.aiLoginRequired")
expect(lastToast().action).toEqual({
label: "account.login",
url: "https://readfrog.app/log-in",
})
})
it("opens the application page when beta access is not allowed", async () => {
getSession.mockResolvedValue({ data: { user: { id: "u1" } } })
betaAccessStatus.mockResolvedValue({ featureKey: "videoTranscription", allowed: false })
it("offers the upgrade without starting the flow when the plan is free", async () => {
getSession.mockResolvedValue(SIGNED_IN)
getUsage.mockResolvedValue(usage({ plan: "free", remainingMinutes: 0, pools: [] }))
await expect(ensureAiSubtitlesAccess()).resolves.toBe(false)
expect(windowOpen).toHaveBeenCalledWith(VIDEO_TRANSCRIPTION_APPLY_URL, "_blank")
expect(lastToast().title).toBe("subtitles.errors.aiSubscriptionRequired")
expect(lastToast().action).toEqual({
label: "action.upgrade",
url: "https://readfrog.app/pricing",
})
})
it("allows access when signed in and beta is granted", async () => {
getSession.mockResolvedValue({ data: { user: { id: "u1" } } })
betaAccessStatus.mockResolvedValue({ featureKey: "videoTranscription", allowed: true })
it("names the reset date when a subscriber has run the quota dry", async () => {
getUsage.mockResolvedValue(
usage({
remainingMinutes: 0,
pools: [
{
id: "subscription",
usedMinutes: 250,
limitMinutes: 250,
remainingMinutes: 0,
resetAt: "2026-09-01T00:00:00.000Z",
expiresAt: null,
},
],
}),
)
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(false)
expect(lastToast().title).toBe("subtitles.errors.aiQuotaExceededWithReset")
})
// The launch gift expires and never refills, so its date cannot answer
// "when does my quota come back".
it("falls back to the dateless message when only the launch gift has a date", async () => {
getUsage.mockResolvedValue(
usage({
remainingMinutes: 0,
pools: [
{
id: "launchBonus",
usedMinutes: 60,
limitMinutes: 60,
remainingMinutes: 0,
resetAt: null,
expiresAt: "2026-10-01T00:00:00.000Z",
},
],
}),
)
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(false)
expect(lastToast().title).toBe("subtitles.errors.aiQuotaExceeded")
})
// Ultra is the top plan — there is nothing left to sell someone already on it.
it("offers no upgrade when an Ultra subscriber runs the quota dry", async () => {
getUsage.mockResolvedValue(usage({ plan: "ultra", remainingMinutes: 0 }))
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(false)
expect(lastToast().title).toBe("subtitles.errors.aiQuotaExceededWithReset")
expect(lastToast().action).toBeUndefined()
})
it("reads a pre-launch server's beta 403 as needing a plan", async () => {
getUsage.mockRejectedValue(new ORPCError("FORBIDDEN", { status: 403 }))
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(false)
expect(lastToast().title).toBe("subtitles.errors.aiSubscriptionRequired")
expect(lastToast().action?.url).toBe("https://readfrog.app/pricing")
})
// Responses are not runtime-validated, so a server that predates `plan` must
// fall through rather than read as free.
it("lets a response without a plan field through", async () => {
getUsage.mockResolvedValue({ usedMinutes: 0, limitMinutes: 250, remainingMinutes: 250 })
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(true)
expect(showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
it("lets a subscriber with quota left through without a toast", async () => {
getSession.mockResolvedValue(SIGNED_IN)
getUsage.mockResolvedValue(usage())
await expect(ensureAiSubtitlesAccess()).resolves.toBe(true)
expect(windowOpen).not.toHaveBeenCalled()
expect(showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
it("falls through to allow when the beta status check errors (network)", async () => {
betaAccessStatus.mockRejectedValue(new Error("network down"))
// A network blip must not wall off a paying subscriber; `create` stays the authority.
it("fails open when the usage check errors", async () => {
getUsage.mockRejectedValue(new Error("network down"))
await expect(ensureBetaAllowed()).resolves.toBe(true)
expect(windowOpen).not.toHaveBeenCalled()
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(true)
expect(showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
it("treats a 401 from the beta check as unauthenticated (stale session) and opens login", async () => {
betaAccessStatus.mockRejectedValue(new ORPCError("UNAUTHORIZED", { status: 401 }))
it("treats a 401 from the usage check as a stale session and prompts to log in", async () => {
getUsage.mockRejectedValue(new ORPCError("UNAUTHORIZED", { status: 401 }))
await expect(ensureBetaAllowed()).resolves.toBe(false)
expect(openLogIn).toHaveBeenCalledOnce()
expect(windowOpen).not.toHaveBeenCalled()
await expect(ensureAiSubtitlesEntitled()).resolves.toBe(false)
expect(lastToast().title).toBe("subtitles.errors.aiLoginRequired")
})
it("returns true from ensureSignedIn when a user session exists", async () => {
getSession.mockResolvedValue({ data: { user: { id: "u1" } } })
getSession.mockResolvedValue(SIGNED_IN)
await expect(ensureSignedIn()).resolves.toBe(true)
expect(openLogIn).not.toHaveBeenCalled()
expect(showAiSubtitlesWallToast).not.toHaveBeenCalled()
})
})
@@ -5,6 +5,10 @@ const create = vi.fn<(...args: unknown[]) => Promise<unknown>>()
const get = vi.fn<(...args: unknown[]) => Promise<unknown>>()
const getSubtitles = vi.fn<(...args: unknown[]) => Promise<unknown>>()
vi.mock("@/env", () => ({
env: { WXT_WEBSITE_URL: "https://readfrog.app" },
}))
vi.mock("@/utils/orpc/client", () => ({
orpcClient: {
videoTranscript: {
@@ -17,7 +21,15 @@ vi.mock("@/utils/orpc/client", () => ({
const { requestAiSubtitles } = await import("../request-ai-subtitles")
const ctx = { videoId: "abc", url: "https://youtube.com/watch?v=abc" }
const ctx = { videoId: "abc", url: "https://youtube.com/watch?v=abc", durationSec: 600 }
/** Resolves with the rejection value so its shape (action included) can be asserted. */
function rejection(promise: Promise<unknown>): Promise<unknown> {
return promise.then(
() => null,
(error: unknown) => error,
)
}
describe("requestAiSubtitles", () => {
beforeEach(() => {
@@ -45,6 +57,7 @@ describe("requestAiSubtitles", () => {
expect(create).toHaveBeenCalledWith({
url: "https://youtube.com/watch?v=abc",
durationSec: 600,
})
expect(get).not.toHaveBeenCalled()
expect(result).toEqual({
@@ -87,7 +100,10 @@ describe("requestAiSubtitles", () => {
expect(getSubtitles).not.toHaveBeenCalled()
})
it("throws a timeout error when the job never completes", async () => {
// The deadline bounds the wait, not the job: the server keeps transcribing and
// caches the result, so expiry is a "still working" toast, never a failure
// overlay. For a 600s video the deadline is 8min + 60s = 9 minutes.
it("reports still-processing (not failure) when the deadline expires", async () => {
vi.useFakeTimers()
create.mockResolvedValue({ id: "job-4", status: "pending", detectedLanguage: null })
get.mockResolvedValue({ id: "job-4", status: "processing", detectedLanguage: null })
@@ -96,9 +112,15 @@ describe("requestAiSubtitles", () => {
() => null,
(settledError: unknown) => settledError,
)
await vi.advanceTimersByTimeAsync(6 * 60 * 1_000)
await vi.advanceTimersByTimeAsync(8 * 60 * 1_000)
expect(await Promise.race([captured, Promise.resolve("waiting")])).toBe("waiting")
expect(await captured).toMatchObject({ message: "subtitles.errors.fetchSubTimeout" })
await vi.advanceTimersByTimeAsync(2 * 60 * 1_000)
expect(await captured).toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiStillProcessing",
})
expect(getSubtitles).not.toHaveBeenCalled()
})
@@ -110,17 +132,104 @@ describe("requestAiSubtitles", () => {
expect(create).not.toHaveBeenCalled()
})
it("converts a quota error into a localized toast error", async () => {
const error = new ORPCError("VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED", { defined: true })
create.mockRejectedValue(error)
it("converts a quota error into a toast offering the upgrade", async () => {
create.mockRejectedValue(new ORPCError("VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED", { defined: true }))
await expect(requestAiSubtitles(ctx)).rejects.toThrow("subtitles.errors.aiQuotaExceeded")
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiQuotaExceeded",
action: { label: "action.upgrade", url: "https://readfrog.app/pricing" },
})
expect(get).not.toHaveBeenCalled()
})
it("shows a localized generic error for other create failures (login/beta are pre-checked)", async () => {
// Nothing navigates on its own: the error only describes the button, and the
// player's toast host is what opens the tab once the user presses it.
it("offers pricing as an action when a subscription is required", async () => {
create.mockRejectedValue(
new ORPCError("VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED", { defined: true }),
)
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiSubscriptionRequired",
action: { url: "https://readfrog.app/pricing" },
})
expect(get).not.toHaveBeenCalled()
})
it("points a dunning account at billing, not at pricing", async () => {
// They already subscribe; the card just failed. Pricing would invite them
// to subscribe a second time.
create.mockRejectedValue(
new ORPCError("VIDEO_TRANSCRIPTION_PAYMENT_REQUIRED", { defined: true }),
)
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiPaymentRequired",
// "Upgrade" would say the wrong thing to someone who already subscribes.
action: { label: "action.updatePayment", url: "https://readfrog.app/home" },
})
expect(get).not.toHaveBeenCalled()
})
it("offers no upsell when the video itself is too long", async () => {
create.mockRejectedValue(
new ORPCError("VIDEO_TRANSCRIPTION_UNSUPPORTED_LENGTH", { defined: true }),
)
const error = await rejection(requestAiSubtitles(ctx))
expect(error).toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiVideoTooLong",
})
// No plan and no reset makes this video work, so neither is suggested.
expect((error as { action?: unknown }).action).toBeUndefined()
expect(get).not.toHaveBeenCalled()
})
it("shows a localized generic error for other create failures", async () => {
create.mockRejectedValue(new ORPCError("VIDEO_TRANSCRIPT_NOT_FOUND", { defined: true }))
await expect(requestAiSubtitles(ctx)).rejects.toThrow("subtitles.errors.aiRequestFailed")
expect(get).not.toHaveBeenCalled()
})
// `get` and `getSubtitles` sit behind the same entitlement middleware as
// `create`, so a subscription that lapses mid-poll surfaces here.
it("offers the upgrade when the plan wall arrives while polling", async () => {
create.mockResolvedValue({ id: "job-5", status: "pending", detectedLanguage: null })
get.mockRejectedValue(
new ORPCError("VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED", { defined: true }),
)
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiSubscriptionRequired",
action: { url: "https://readfrog.app/pricing" },
})
expect(getSubtitles).not.toHaveBeenCalled()
})
it("reports a not-ready transcript as still processing rather than a failure", async () => {
create.mockResolvedValue({ id: "job-6", status: "completed", detectedLanguage: "en" })
getSubtitles.mockRejectedValue(new ORPCError("VIDEO_TRANSCRIPT_NOT_READY", { defined: true }))
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "ToastSubtitlesError",
message: "subtitles.errors.aiStillProcessing",
})
})
// Raw ORPCError messages are the server's untranslated English; they must
// never reach the player overlay.
it("localizes a missing transcript instead of leaking the server message", async () => {
create.mockResolvedValue({ id: "job-7", status: "completed", detectedLanguage: "en" })
getSubtitles.mockRejectedValue(new ORPCError("VIDEO_TRANSCRIPT_NOT_FOUND", { defined: true }))
await expect(rejection(requestAiSubtitles(ctx))).resolves.toMatchObject({
name: "OverlaySubtitlesError",
message: "subtitles.errors.aiRequestFailed",
})
})
})
+65 -22
View File
@@ -1,46 +1,89 @@
import { ORPCError } from "@orpc/client"
import { VIDEO_TRANSCRIPTION_BETA_FEATURE_KEY } from "@read-frog/definitions"
import { openLogIn } from "@/components/user-account-menu/shared"
import { authClient } from "@/utils/auth/auth-client"
import { VIDEO_TRANSCRIPTION_APPLY_URL } from "@/utils/constants/subtitles"
import { i18n } from "@/utils/i18n"
import { isORPCForbiddenError, isORPCUnauthorizedError } from "@/utils/notebase/errors"
import { orpcClient } from "@/utils/orpc/client"
import { showAiSubtitlesWallToast } from "@/utils/subtitles/toast"
import { formatQuotaDate, logInAction, quotaResetAt, upgradeAction } from "./entitlement"
function promptLogIn(): void {
showAiSubtitlesWallToast(i18n.t("subtitles.errors.aiLoginRequired"), logInAction())
}
function promptUpgrade(): void {
showAiSubtitlesWallToast(i18n.t("subtitles.errors.aiSubscriptionRequired"), upgradeAction())
}
export async function ensureSignedIn(): Promise<boolean> {
const { data } = await authClient.getSession()
if (!data?.user) {
openLogIn()
promptLogIn()
return false
}
return true
}
export async function ensureBetaAllowed(): Promise<boolean> {
/**
* Checked before the subtitles flow starts, not after: switching the fetcher
* tears down whatever session is playing, so a denial that arrives from
* `create` has already cost the user their running translation. Clicking the
* sparkles is often just curiosity — it must not destroy the track they were
* watching.
*
* `getUsage` is auth-only on the server and answers a free account with
* `plan: "free"` rather than a 403, so entitlement is read off the shape of a
* successful response.
*/
export async function ensureAiSubtitlesEntitled(): Promise<boolean> {
let usage: Awaited<ReturnType<typeof orpcClient.videoTranscript.getUsage>>
try {
const { allowed } = await orpcClient.betaAccess.status({
featureKey: VIDEO_TRANSCRIPTION_BETA_FEATURE_KEY,
})
if (!allowed) {
// silently send them to the application form; content scripts can't use chrome.tabs
window.open(VIDEO_TRANSCRIPTION_APPLY_URL, "_blank")
return false
}
return true
usage = await orpcClient.videoTranscript.getUsage()
} catch (error) {
// A stale cached session can pass ensureSignedIn but 401 here; treat as unauthenticated.
if (error instanceof ORPCError && error.status === 401) {
openLogIn()
// A stale cached session can pass ensureSignedIn but 401 here.
if (isORPCUnauthorizedError(error)) {
promptLogIn()
return false
}
// The launch server keeps getUsage auth-only, but a pre-launch one still
// answers the beta allow-list with a 403. Either way it means the same
// thing now: this account needs a plan.
if (isORPCForbiddenError(error)) {
promptUpgrade()
return false
}
// Fail open: a network blip must not wall off a paying subscriber. `create`
// stays the authority and answers with its own code.
return true
}
// Strict equality, never `!== "pro" && !== "ultra"`: responses are not
// runtime-validated, so an older server that omits `plan` must fall through
// rather than read as free.
if (usage.plan === "free") {
promptUpgrade()
return false
}
if (usage.remainingMinutes <= 0) {
// `getUsage` is the only call that knows when the quota comes back, and it
// is already in hand here — cheaper than asking again once `create` has
// refused.
const resetAt = formatQuotaDate(quotaResetAt(usage.pools))
showAiSubtitlesWallToast(
resetAt
? i18n.t("subtitles.errors.aiQuotaExceededWithReset", [resetAt])
: i18n.t("subtitles.errors.aiQuotaExceeded"),
// Ultra is the top plan; there is nothing left to sell someone already on it.
usage.plan === "ultra" ? undefined : upgradeAction(),
)
return false
}
return true
}
export async function ensureAiSubtitlesAccess(): Promise<boolean> {
if (!(await ensureSignedIn())) {
return false
}
if (!(await ensureBetaAllowed())) {
return false
}
return true
return ensureAiSubtitlesEntitled()
}
+97
View File
@@ -0,0 +1,97 @@
import type { VideoTranscriptUsagePool } from "@read-frog/api-contract"
import type { SubtitlesErrorAction } from "@/utils/subtitles/errors"
import { env } from "@/env"
import { i18n } from "@/utils/i18n"
/**
* The single place that knows where an AI-subtitles denial sends the user and
* what its button says. Every wall — the click-time pre-flight and the server's
* own error codes — builds its call to action from here, so moving a landing
* page is one edit rather than a grep across the subtitles pipeline.
*/
function websiteUrl(path: string): string {
return new URL(path, env.WXT_WEBSITE_URL).toString()
}
export function pricingUrl(): string {
return websiteUrl("/pricing")
}
/** Billing lives in the app's settings dialog, not on the marketing page. */
export function billingUrl(): string {
return websiteUrl("/home")
}
export function logInUrl(): string {
return websiteUrl("/log-in")
}
export function upgradeAction(): SubtitlesErrorAction {
return { label: i18n.t("action.upgrade"), url: pricingUrl() }
}
/**
* Dunning, not cancellation: they already pay and the card just failed, so
* sending them to pricing would invite an existing subscriber to subscribe
* again — and a button reading "Upgrade" would say the wrong thing to someone
* who already did.
*/
export function billingAction(): SubtitlesErrorAction {
return { label: i18n.t("action.updatePayment"), url: billingUrl() }
}
export function logInAction(): SubtitlesErrorAction {
return { label: i18n.t("account.login"), url: logInUrl() }
}
/**
* When the quota runs dry, "when does it come back" is the earliest date any
* pool refills on. Keyed off `resetAt` rather than a pool id so a renamed or
* added pool still answers: the launch gift carries only `expiresAt` because it
* never refills, and a date that just runs out answers a different question.
*/
export function quotaResetAt(pools: VideoTranscriptUsagePool[] | undefined): string | null {
const resets = (pools ?? [])
.map((pool) => pool.resetAt)
.filter((resetAt): resetAt is string => !!resetAt)
.sort()
return resets[0] ?? null
}
/**
* Mirrors `TRANSCRIPTION_LAUNCH_BONUS_CUTOFF_AT` in the server's minute policy.
* The grant is decided entirely server-side; this copy only decides whether to
* advertise it, so drift shows up as a banner that stops (or keeps) offering
* something — never as a wrong grant.
*/
export const LAUNCH_BONUS_CUTOFF_AT = "2026-09-14T00:00:00Z"
/**
* The formatted cutoff while the launch offer is still open, else null. The
* offer is time-boxed on purpose: past the cutoff the server stops issuing the
* grant, so an ungated banner would age into a promise we no longer keep.
*
* The label is local, so a reader west of UTC sees the last date that is still
* safe for them rather than one that has already passed by their clock. The
* gate compares instants, so it flips at the same moment everywhere.
*/
export function launchBonusCutoffLabel(now: Date = new Date()): string | null {
return now < new Date(LAUNCH_BONUS_CUTOFF_AT) ? formatQuotaDate(LAUNCH_BONUS_CUTOFF_AT) : null
}
/** Renders a pool's `resetAt` / `expiresAt` in the reader's own locale. */
export function formatQuotaDate(iso: string | null | undefined): string | null {
if (!iso) {
return null
}
const date = new Date(iso)
if (Number.isNaN(date.getTime())) {
return null
}
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
+81 -15
View File
@@ -1,13 +1,18 @@
import type { VideoTranscriptStatus } from "@read-frog/api-contract"
import type { SubtitlesError } from "@/utils/subtitles/errors"
import type { SubtitlesFragment } from "@/utils/subtitles/types"
import { ORPCError, safe } from "@orpc/client"
import { safe } from "@orpc/client"
import { i18n } from "@/utils/i18n"
import { isORPCPublicAppError } from "@/utils/notebase/errors"
import { orpcClient } from "@/utils/orpc/client"
import { OverlaySubtitlesError, ToastSubtitlesError } from "@/utils/subtitles/errors"
import { billingAction, upgradeAction } from "./entitlement"
export interface AiSubtitlesContext {
videoId: string
url: string
/** Player-reported duration; an untrusted admission pre-check, never the billing basis. */
durationSec: number
}
interface VideoTranscriptJob {
@@ -19,13 +24,62 @@ interface VideoTranscriptJob {
}
const POLL_INTERVAL_MS = 1_000
const POLL_TIMEOUT_MS = 5 * 60 * 1_000
const POLL_BASE_TIMEOUT_MS = 8 * 60 * 1_000
const POLL_MAX_TIMEOUT_MS = 20 * 60 * 1_000
const MS_PER_SECOND = 1_000
/**
* Transcription wall time barely tracks video length (chunks run on Azure in
* parallel; a 20-minute video typically settles in ~1 minute) — the dominant
* variance is audio-download flakiness plus the worker's retry chain, which is
* why the base term is the big one and the per-length term is small.
*/
function pollTimeoutMs(durationSec: number): number {
return Math.min(POLL_MAX_TIMEOUT_MS, POLL_BASE_TIMEOUT_MS + durationSec * 100)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Turns a transcription endpoint's refusal into something the player can say.
* Shared by `create`, the poll's `get` and `getSubtitles` — all three are
* behind the same entitlement middleware, so any of them can answer with the
* plan wall once a subscription lapses mid-flight.
*
* Walls the user can act on become toasts carrying a button; everything else
* is an overlay. Nothing here navigates on its own.
*/
function mapTranscriptError(error: unknown): SubtitlesError {
if (isORPCPublicAppError(error, "VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED")) {
return new ToastSubtitlesError(
i18n.t("subtitles.errors.aiSubscriptionRequired"),
upgradeAction(),
)
}
if (isORPCPublicAppError(error, "VIDEO_TRANSCRIPTION_PAYMENT_REQUIRED")) {
return new ToastSubtitlesError(i18n.t("subtitles.errors.aiPaymentRequired"), billingAction())
}
if (isORPCPublicAppError(error, "VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED")) {
// The pre-flight normally catches this and can name the reset date; by the
// time the server refuses, only it knows the remainder and we do not.
return new ToastSubtitlesError(i18n.t("subtitles.errors.aiQuotaExceeded"), upgradeAction())
}
if (isORPCPublicAppError(error, "VIDEO_TRANSCRIPTION_UNSUPPORTED_LENGTH")) {
// A property of the video, not of the account — no upgrade and no waiting
// for the quota to reset makes this one work, so offer neither.
return new ToastSubtitlesError(i18n.t("subtitles.errors.aiVideoTooLong"))
}
if (isORPCPublicAppError(error, "VIDEO_TRANSCRIPT_NOT_READY")) {
// The job is fine, the file just is not written yet.
return new ToastSubtitlesError(i18n.t("subtitles.errors.aiStillProcessing"))
}
// NOT_FOUND and everything else: a real failure — and never the server's
// untranslated English, which is what an unmapped ORPCError would render.
return new OverlaySubtitlesError(i18n.t("subtitles.errors.aiRequestFailed"))
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new DOMException("Aborted", "AbortError")
@@ -34,6 +88,7 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
async function pollUntilCompleted(
initial: VideoTranscriptJob,
durationSec: number,
signal: AbortSignal | undefined,
): Promise<VideoTranscriptJob> {
if (initial.status === "completed") {
@@ -43,14 +98,18 @@ async function pollUntilCompleted(
throw new OverlaySubtitlesError(i18n.t("subtitles.errors.aiServiceUnavailable"))
}
const deadline = Date.now() + POLL_TIMEOUT_MS
const startedAt = Date.now()
const deadline = startedAt + pollTimeoutMs(durationSec)
while (Date.now() < deadline) {
throwIfAborted(signal)
await sleep(POLL_INTERVAL_MS)
throwIfAborted(signal)
const job: VideoTranscriptJob = await orpcClient.videoTranscript.get({ id: initial.id })
const { error, data: job } = await safe(orpcClient.videoTranscript.get({ id: initial.id }))
if (error) {
throw mapTranscriptError(error)
}
if (job.status === "completed") {
return job
}
@@ -59,33 +118,40 @@ async function pollUntilCompleted(
}
}
throw new OverlaySubtitlesError(i18n.t("subtitles.errors.fetchSubTimeout"))
// The deadline bounds this wait, not the job: the server keeps transcribing
// and caches the result, and a later click resumes the same row. So report
// "still working" as a toast — never a failure overlay.
throw new ToastSubtitlesError(i18n.t("subtitles.errors.aiStillProcessing"))
}
export async function requestAiSubtitles(
ctx: AiSubtitlesContext,
opts?: { signal?: AbortSignal },
): Promise<{ segments: SubtitlesFragment[]; detectedLanguage: string }> {
const { url } = ctx
const { url, durationSec } = ctx
const signal = opts?.signal
throwIfAborted(signal)
const { error, data } = await safe(orpcClient.videoTranscript.create({ url }))
const { error, data } = await safe(orpcClient.videoTranscript.create({ url, durationSec }))
if (error) {
// Login + beta are pre-checked before create is called, so only quota (not pre-checked)
// and unexpected failures can reach here.
if (error instanceof ORPCError && error.code === "VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED") {
throw new ToastSubtitlesError(i18n.t("subtitles.errors.aiQuotaExceeded"))
}
throw new OverlaySubtitlesError(i18n.t("subtitles.errors.aiRequestFailed"))
// Sign-in, plan and quota are pre-checked before create is called, so the
// walls reaching here are races (a subscription that lapsed since the
// pre-flight) and the codes the pre-flight cannot see, like an unsupported
// video length.
throw mapTranscriptError(error)
}
const completed = await pollUntilCompleted(data, signal)
const completed = await pollUntilCompleted(data, durationSec, signal)
throwIfAborted(signal)
const subtitles = await orpcClient.videoTranscript.getSubtitles({ id: completed.id })
const { error: subtitlesError, data: subtitles } = await safe(
orpcClient.videoTranscript.getSubtitles({ id: completed.id }),
)
if (subtitlesError) {
throw mapTranscriptError(subtitlesError)
}
const segments: SubtitlesFragment[] = subtitles.segments.map(
(segment: { start: number; end: number; text: string }) => ({
+17 -1
View File
@@ -1,3 +1,16 @@
/**
* A call to action carried alongside a toast error — "Upgrade", "Log in".
* Described as data rather than a callback so the layer that raises the error
* stays free of UI concerns and the tests can assert on it without comparing
* functions; the content script's toast host turns it into a button.
*/
export interface SubtitlesErrorAction {
/** Already localized. */
label: string
/** Absolute URL, opened through the background worker on click. */
url: string
}
export class SubtitlesError extends Error {
readonly code: string
@@ -9,9 +22,12 @@ export class SubtitlesError extends Error {
}
export class ToastSubtitlesError extends SubtitlesError {
constructor(code: string) {
readonly action?: SubtitlesErrorAction
constructor(code: string, action?: SubtitlesErrorAction) {
super(code)
this.name = "ToastSubtitlesError"
this.action = action
}
}
@@ -12,7 +12,7 @@ vi.mock("@/utils/subtitles/ai/request-ai-subtitles", () => ({
const { AiSubtitlesFetcher } = await import("../index")
function contextFor(videoId: string): AiSubtitlesContext {
return { videoId, url: `https://youtube.com/watch?v=${videoId}` }
return { videoId, url: `https://youtube.com/watch?v=${videoId}`, durationSec: 600 }
}
describe("aiSubtitlesFetcher", () => {
+80
View File
@@ -0,0 +1,80 @@
import type { SubtitlesErrorAction } from "./errors"
import { anchoredToastManager, toastManager } from "@/components/ui/base-ui/toast"
import { sendMessage } from "@/utils/message"
/** Stable, so a double-click refreshes one toast instead of stacking two. */
const WALL_TOAST_ID = "read-frog-subtitles-wall"
/**
* Longer than the shared defaults (5s docked, 3s anchored): this toast is the
* only place the refusal is explained, and it asks the reader to aim at a
* button rather than just acknowledge a line of text.
*/
const WALL_TOAST_TIMEOUT_MS = 10_000
let aiRequestAnchor: HTMLElement | null = null
/**
* The "Request AI subtitles" control, registered by the panel item that owns
* it. Only refusals of that request anchor to it — every other subtitles toast
* stays docked in the page corner, because nothing on screen would explain
* what a toast pinned to this button had to do with them.
*/
export function setAiSubtitlesToastAnchor(element: HTMLElement | null): void {
aiRequestAnchor = element
}
function usableAnchor(): HTMLElement | null {
if (!aiRequestAnchor?.isConnected) {
return null
}
// The panel keeps its DOM mounted while closed (React `Activity` only hides
// it), so being connected is not enough — a display:none anchor measures 0×0
// and base-ui would mark the toast anchor-hidden, silently showing nothing.
const { width, height } = aiRequestAnchor.getBoundingClientRect()
return width > 0 && height > 0 ? aiRequestAnchor : null
}
function show(title: string, action: SubtitlesErrorAction | undefined, anchor: HTMLElement | null) {
const manager = anchor ? anchoredToastManager : toastManager
const toastId = manager.add({
id: WALL_TOAST_ID,
type: "error",
title,
timeout: WALL_TOAST_TIMEOUT_MS,
// base-ui's positioner reads side/align off this; its defaults (top,
// center) are what "above the button" means.
...(anchor && { positionerProps: { anchor, sideOffset: 8 } }),
...(action && {
actionProps: {
children: action.label,
onClick: () => {
manager.close(toastId)
// Content scripts cannot use chrome.tabs — route through the background.
void sendMessage("openPage", { url: action.url, active: true })
},
},
}),
})
}
/**
* The one shape every subtitles denial takes: a sentence saying what happened,
* plus an optional button the user chooses to press. Deliberately never
* navigates on its own — stealing focus with a new tab in the middle of a
* video is what this replaces.
*/
export function showSubtitlesErrorToast(title: string, action?: SubtitlesErrorAction): void {
show(title, action, null)
}
/**
* The same toast, raised over the control the reader just pressed. Only the AI
* subtitles request uses it: the docked corner is a long way from the player
* and easy to miss when you are looking at the panel you just clicked in.
*
* Falls back to the docked corner when that control is off screen — a refusal
* arriving after the panel closed still has to be seen.
*/
export function showAiSubtitlesWallToast(title: string, action?: SubtitlesErrorAction): void {
show(title, action, usableAnchor())
}