fix: add video captions on the video detail page#3284
Conversation
507606b to
61b2031
Compare
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
Pull request overview
This PR adds caption support to the video playback experience in the Video Playlist/Collection pages by wiring caption URLs from the API into the video.js player, and by exposing caption resources for indexing (plus adding VideoObject JSON-LD on the OCW series detail page).
Changes:
- Pass
video.video.caption_urlsintoVideoJsPlayeras remote text tracks (captions). - Add a visually-hidden list of caption (VTT) links to both video detail page variants so crawlers (and screen readers) can discover them.
- Add VideoObject JSON-LD structured data on
VideoSeriesDetailPage(including a captions accessibility signal).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx | Adds caption URL extraction, passes tracks to the player, adds caption link list, and emits VideoObject JSON-LD. |
| frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoJsPlayer.tsx | Extends the player wrapper to accept caption tracks and manage remote text tracks on init/update. |
| frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx | Adds caption URL extraction, passes tracks to the player, and adds a caption link list. |
| // 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) | ||
|
|
| // Update sources / poster / tracks when props change without re-creating the player. | ||
| // Skip on first mount — the init effect's ready callback already handled it. | ||
| useEffect(() => { | ||
| const player = playerRef.current | ||
| if (!player) return | ||
| if (!player || !isMountedRef.current) return | ||
| player.src(sources) | ||
| player.poster(poster ?? "") | ||
| }, [sources, poster]) | ||
| addTracks(player, tracks) | ||
| }, [sources, poster, tracks]) |
| // TextTrackList is array-like at runtime but lacks an index signature in types | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| player.removeRemoteTextTrack((existing as any)[i]) |
| thumbnailUrl: | ||
| video.video?.cover_image_url || video.image?.url || undefined, | ||
| contentUrl: video.url ?? undefined, | ||
| ...(video.video?.duration ? { duration: video.video.duration } : {}), | ||
| ...(captionUrls.length > 0 | ||
| ? { accessibilityFeature: ["captions"] } | ||
| : {}), |
There was a problem hiding this comment.
@ahtesham-quraish my inclination is to trust the API... I expect the API duration strings are valid, just the OpenAPI spec doesn't reflect that. It might be worth checking. Not that validating here hurts, but we should in general trust the API / improve the spec rather than patch that sort of thing on the frontend.
Don't let this block you, captions are important to get out.
| <p>Captions available for this video:</p> | ||
| <ul> | ||
| {captionUrls.map((track) => ( | ||
| <li key={track.language}> |
| <p>Captions available for this video:</p> | ||
| <ul> | ||
| {captionUrls.map((track) => ( | ||
| <li key={track.language}> |
| {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, "<\\/"), | ||
| }} | ||
| /> |
| const structuredData = | ||
| !isLoading && video | ||
| ? { | ||
| "@context": "https://schema.org", |
There was a problem hiding this comment.
Looks like you are missing uploadDate. Google requires name, description, thumbnailUrl, and uploadDate for a VideoObject to qualify as a video rich result. Without uploadDate, the entire <script type="application/ld+json"> block is silently ignored for rich results.
There was a problem hiding this comment.
I have used the last_modified date as upload date because in video resource object we don't have any such field.
daniellefrappier18
left a comment
There was a problem hiding this comment.
Looks good but please see my comment regarding the missing uploadDate
|
We would like to get the captions merged and deployed asap. The metadata is secondary and can be fixed up in a follow-up PR if necessary. |
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
I'm going to leave the testing / re-review to @daniellefrappier18 , but I added a few comments.
Overall suggestion: This PR adds some SEO work in additions to captions. It would be helpful to mention that in the title / PR description. (It could also have been a separate PR, which often helps things get merged faster.)
That said, thanks for teaching me about JSON-LD!
| thumbnailUrl: | ||
| video.video?.cover_image_url || video.image?.url || undefined, | ||
| contentUrl: video.url ?? undefined, | ||
| ...(video.video?.duration ? { duration: video.video.duration } : {}), | ||
| ...(captionUrls.length > 0 | ||
| ? { accessibilityFeature: ["captions"] } | ||
| : {}), |
There was a problem hiding this comment.
@ahtesham-quraish my inclination is to trust the API... I expect the API duration strings are valid, just the OpenAPI spec doesn't reflect that. It might be worth checking. Not that validating here hurts, but we should in general trust the API / improve the spec rather than patch that sort of thing on the frontend.
Don't let this block you, captions are important to get out.
| <Styled.PageWrapper> | ||
| {structuredData && ( | ||
| <script | ||
| type="application/ld+json" |
There was a problem hiding this comment.
I was not familiar with JSON-LD objects. This seems like a good addition! 👍
Suggestion: It might be worth dropping a reference (here's the nextjs docs reference, https://nextjs.org/docs/app/guides/json-ld ... but maybe there's a more canonical one from google, since this certainly isn't nextjs-specific).
BTW: The nextjs docs use a slightly broader regex escape.
| {/* Caption track links – visually hidden but present in the DOM so | ||
| Googlebot can follow each VTT URL and index the caption text, | ||
| associating the transcript content with this video page. */} |
There was a problem hiding this comment.
Curious—Was there a source that encourages implementation of track data this way?
Request: I'm hesitant to add this—it could actually be detrimental to screenreader users. The purpose of sr-only visually hidden content is to assist screenreader users / assistive tech, not to make content available to Googlebot. Unless there's a good reference indicating this pattern, I think we should skip it for now.
Alternatives:
- Rely on the
trackelemens in added by VideoJS; SEO bots can see those already (Though as far as I know, google doesn't explicitly tell us they use them.) - Document by the captions track in JSON-LD https://schema.org/VideoObject
- The schema does have
captions, but as far as i can tell, they are not mentioned in https://developers.google.com/search/docs/appearance/structured-data/video
- The schema does have
| src: track.url, | ||
| srclang: track.language, | ||
| label: track.language_name || track.language, | ||
| default: index === 0, |
There was a problem hiding this comment.
This makes captions on by default; i dunno if that's our intent.
What are the relevant tickets?
https://github.com/mitodl/hq/issues/11118
Description (What does it do?)
Screenshots (if appropriate):
Screen.Recording.2026-05-04.at.7.36.12.PM.mov
How can this be tested?
if you don't have playlist data locally then in frontend env file you should add the following variable
NEXT_PUBLIC_MITOL_API_BASE_URL="https://api.learn.mit.edu"it will connect with prod learn backendthen we need to enable the flag which is
video-playlist-pageand then visit the following urlhttp://open.odl.local:8062/video-playlist/detail/88444?playlist=88443Additional Context