Skip to content
Merged
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 RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release Notes
=============

Version 0.66.7
--------------

- fix: add video captions on the video detail page (#3284)

Version 0.66.6 (Released May 06, 2026)
--------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { FeatureFlags } from "@/common/feature_flags"
import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
import { notFound } from "next/navigation"
import SharePopover from "@/components/SharePopover/SharePopover"
import { buildVideoStructuredData } from "./videoStructuredData"

const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN

Expand Down Expand Up @@ -388,7 +389,6 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({

const playlist = playlistData as VideoPlaylistResource | undefined
const video = resource as VideoResource | undefined

const sources = useMemo(
() =>
video
Expand All @@ -406,6 +406,7 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
: null

const topics = video?.topics ?? []
const captionUrls = video?.video?.caption_urls ?? []

const playlistLabel = playlist?.title || "Video Collection"

Expand Down Expand Up @@ -442,12 +443,26 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
}
}, [isLoading, videoId])

// VideoObject JSON-LD for Google search indexing.
// See: https://developers.google.com/search/docs/appearance/structured-data/video
const structuredData = !isLoading ? buildVideoStructuredData(video) : null

if (!showVideoPlaylistPage) {
return flagsLoaded ? notFound() : null
}

return (
<PageWrapper>
{structuredData && (
<script
type="application/ld+json"
// JSON.stringify does not escape </ by default; replace prevents
// a malicious title/description from breaking out of the script tag.
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData).replace(/<\//g, "<\\/"),
}}
/>
)}
<SkipLinksNav aria-label="Skip links">
<SkipLink href="#video-detail-main">Skip to main content</SkipLink>
<SkipLink href="#video-player-region">Skip to video player</SkipLink>
Expand Down Expand Up @@ -530,6 +545,7 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
<VideoJsPlayer
key={videoId}
sources={sources}
tracks={captionUrls}
poster={
sources[0]?.type === "video/youtube"
? undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Player from "video.js/dist/types/player"
import "video.js/dist/video-js.css"
// Register YouTube tech so video.js can play youtube:// sources
import "videojs-youtube"
import type { CaptionUrl } from "api/v1"

export type VideoJsSource = {
src: string
Expand All @@ -14,6 +15,7 @@ export type VideoJsSource = {

export type VideoJsPlayerProps = {
sources: VideoJsSource[]
tracks?: CaptionUrl[]
poster?: string | null
autoplay?: boolean
controls?: boolean
Expand All @@ -29,6 +31,7 @@ export type VideoJsPlayerProps = {
*/
const VideoJsPlayer: React.FC<VideoJsPlayerProps> = ({
sources,
tracks = [],
poster,
autoplay = true,
controls = true,
Expand All @@ -39,6 +42,35 @@ const VideoJsPlayer: React.FC<VideoJsPlayerProps> = ({
}) => {
const videoRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<Player | null>(null)
// Prevent the update effect from running on the very first mount —
// the init effect already handles the initial sources/tracks setup.
const isMountedRef = useRef(false)

const addTracks = (player: Player, trackList: CaptionUrl[]) => {
// Remove any existing remote text tracks first.
// Snapshot into a plain array before mutating (TextTrackList is live).
// video.js's TextTrackList TS type lacks a numeric index signature, so
// cast via unknown to a Record — safer than `any` and no eslint-disable needed.
type TrackArg = Parameters<typeof player.removeRemoteTextTrack>[0]
const existing = player.remoteTextTracks()
const snapshot = Array.from(
{ length: existing.length },
(_, i) => (existing as unknown as Record<number, TrackArg>)[i],
)
snapshot.forEach((t) => player.removeRemoteTextTrack(t))
trackList.forEach((track, index) => {
player.addRemoteTextTrack(
{
kind: "captions",
src: track.url,
srclang: track.language,
label: track.language_name || track.language,
default: index === 0,
},
false,
)
})
}

useEffect(() => {
// Only initialise once
Expand Down Expand Up @@ -67,9 +99,18 @@ const VideoJsPlayer: React.FC<VideoJsPlayerProps> = ({
poster: poster ?? undefined,
sources,
techOrder: ["youtube", "html5"],
// Always set crossOrigin so the browser can fetch VTT files from
// a different origin (e.g. CloudFront CDN) without CORS errors.
crossOrigin: "anonymous",
},
function (this: Player) {
// Add tracks inside the ready callback — this is the earliest safe
// point; adding them before ready can silently fail on some browsers.
addTracks(this, tracks)
onReady?.(this)
// Set the flag here so the update effect only runs after the player
// is truly ready and the initial setup is complete.
isMountedRef.current = true
},
)

Expand All @@ -83,16 +124,30 @@ const VideoJsPlayer: React.FC<VideoJsPlayerProps> = ({
onReady,
poster,
sources,
tracks,
])

// Update sources / poster when props change without re-creating the player
// Update sources / poster when they change without re-creating the player.
// Kept separate from the tracks effect so a captions-only change does not
// call player.src() and unexpectedly reload / interrupt playback.
useEffect(() => {
const player = playerRef.current
if (!player) return
player.src(sources)
player.poster(poster ?? "")
if (!player || !isMountedRef.current) return
player.ready(() => {
player.src(sources)
player.poster(poster ?? "")
})
}, [sources, poster])

// Update tracks independently so caption changes never trigger a media reload.
useEffect(() => {
const player = playerRef.current
if (!player || !isMountedRef.current) return
player.ready(() => {
addTracks(player, tracks)
})
}, [tracks])

// Dispose on unmount
useEffect(() => {
return () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ jest.mock("next-nprogress-bar", () => ({
// Stub VideoJsPlayer to avoid loading video.js in the test environment
jest.mock("./VideoJsPlayer", () => ({
__esModule: true,
default: (props: { ariaLabel?: string }) => (
<div data-testid="video-js-player" aria-label={props.ariaLabel} />
default: (props: {
ariaLabel?: string
tracks?: { language: string; language_name: string; url: string }[]
}) => (
<div
data-testid="video-js-player"
aria-label={props.ariaLabel}
data-tracks={JSON.stringify(props.tracks ?? [])}
/>
),
}))

Expand Down Expand Up @@ -423,6 +430,28 @@ describe("VideoSeriesDetailPage", () => {
"No playable source available for this video.",
)
})

test("passes caption_urls as tracks prop to VideoJsPlayer", async () => {
const captionUrls = [
{ language: "en", language_name: "English", url: "/captions/en.vtt" },
]
const video = makeVideo({
video: {
id: 1,
caption_urls: captionUrls,
streaming_url: "https://www.youtube.com/watch?v=abc123",
duration: "",
cover_image_url: null,
},
})
renderPage({ video })

const player = await screen.findByTestId("video-js-player")
const tracks = JSON.parse(player.getAttribute("data-tracks") ?? "[]")
expect(tracks).toHaveLength(1)
expect(tracks[0].language).toBe("en")
expect(tracks[0].url).toBe("/captions/en.vtt")
})
})

describe("loading state", () => {
Expand Down Expand Up @@ -475,4 +504,103 @@ describe("VideoSeriesDetailPage", () => {
).toHaveAttribute("href", "#video-player-region")
})
})

describe("JSON-LD structured data", () => {
test("renders an application/ld+json script tag with VideoObject data", async () => {
const video = makeVideo({
title: "Deep Learning Lecture",
description: "An intro to deep learning.",
last_modified: "2024-01-15T00:00:00Z",
video: {
id: 1,
caption_urls: [],
streaming_url: "https://www.youtube.com/watch?v=abc123",
duration: "PT1H30M",
cover_image_url: null,
},
})
renderPage({ video })

await screen.findByRole("heading", { name: video.title })

const script = document.querySelector(
"script[type='application/ld+json']",
)
expect(script).toBeInTheDocument()
const data = JSON.parse(script!.textContent ?? "{}")
expect(data["@type"]).toBe("VideoObject")
expect(data.name).toBe("Deep Learning Lecture")
expect(data.description).toBe("An intro to deep learning.")
expect(data.duration).toBe("PT1H30M")
})

test("omits duration from JSON-LD when it is not ISO-8601", async () => {
const video = makeVideo({
last_modified: "2024-01-15T00:00:00Z",
video: {
id: 1,
caption_urls: [],
streaming_url: "https://www.youtube.com/watch?v=abc123",
duration: "120", // plain seconds — not ISO-8601
cover_image_url: null,
},
})
renderPage({ video })

await screen.findByRole("heading", { name: video.title })

const script = document.querySelector(
"script[type='application/ld+json']",
)
const data = JSON.parse(script!.textContent ?? "{}")
expect(data.duration).toBeUndefined()
})

test("includes accessibilityFeature captions in JSON-LD when caption_urls is non-empty", async () => {
const captionUrls = [
{ language: "en", language_name: "English", url: "/captions/en.vtt" },
]
const video = makeVideo({
last_modified: "2024-01-15T00:00:00Z",
video: {
id: 1,
caption_urls: captionUrls,
streaming_url: "https://www.youtube.com/watch?v=abc123",
duration: "PT10M",
cover_image_url: null,
},
})
renderPage({ video })

await screen.findByRole("heading", { name: video.title })

const script = document.querySelector(
"script[type='application/ld+json']",
)
const data = JSON.parse(script!.textContent ?? "{}")
expect(data.accessibilityFeature).toContain("captions")
})

test("omits accessibilityFeature from JSON-LD when caption_urls is empty", async () => {
const video = makeVideo({
last_modified: "2024-01-15T00:00:00Z",
video: {
id: 1,
caption_urls: [],
streaming_url: "https://www.youtube.com/watch?v=abc123",
duration: "PT10M",
cover_image_url: null,
},
})
renderPage({ video })

await screen.findByRole("heading", { name: video.title })

const script = document.querySelector(
"script[type='application/ld+json']",
)
const data = JSON.parse(script!.textContent ?? "{}")
expect(data.accessibilityFeature).toBeUndefined()
})
})
})
Loading
Loading