Skip to content

fix(web): initialize caption tracks after load#1885

Merged
richiemcilroy merged 1 commit into
CapSoftware:mainfrom
puneetdixit200:fix-self-hosted-captions-track-load
Jun 18, 2026
Merged

fix(web): initialize caption tracks after load#1885
richiemcilroy merged 1 commit into
CapSoftware:mainfrom
puneetdixit200:fix-self-hosted-captions-track-load

Conversation

@puneetdixit200

@puneetdixit200 puneetdixit200 commented Jun 4, 2026

Copy link
Copy Markdown

Summary

  • add a shared caption-track binding helper for the web video players
  • keep caption/subtitle tracks hidden for the custom overlay while listening for async <track> load events
  • cover the self-hosted async VTT load path with a focused unit test

Fixes #1773.

Tests

  • pnpm --dir apps/web exec vitest run tests/unit/caption-tracks.test.ts
  • pnpm --dir apps/web exec vitest run tests/unit/caption-tracks.test.ts tests/unit/caption-cues.test.ts
  • pnpm exec biome check "apps/web/app/s/[videoId]/_components/caption-tracks.ts" "apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx" "apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx" apps/web/tests/unit/caption-tracks.test.ts
  • git diff --check

Note: a broad pnpm --dir apps/web exec tsc --noEmit --pretty false --incremental false run was attempted, but it stayed silent for several minutes and was stopped to avoid tying up the sweep environment.

Greptile Summary

This PR refactors caption/subtitle track binding into a new shared bindCaptionTrackCueText helper and fixes a bug where captions failed to initialize when <track> elements loaded their VTT file asynchronously after loadedmetadata.

  • caption-tracks.ts – New helper that uses a MutationObserver, per-<track>-element load listeners, and textTracks list events to cover both the synchronous and async VTT-load paths; cleanup function returned to callers handles all teardown.
  • CapVideoPlayer / HLSVideoPlayer – Inline track-management code (~35 and ~65 lines respectively) replaced with a single bindCaptionTrackCueText call; cleanup wired correctly in both useEffect return functions.
  • caption-tracks.test.ts – Focused unit test for the async load path using lightweight fakes for HTMLVideoElement, TextTrackList, and HTMLTrackElement.

Confidence Score: 5/5

Safe to merge — the refactor is a net simplification and the async-VTT bug fix is well-scoped with no regressions on the single-track path.

The change consolidates duplicated track-management logic into a single, well-tested helper. Cleanup is correctly wired in both players, and the async load path that was the root of the original bug is now explicitly covered by a unit test. The only observations are minor efficiency nits (extra redundant syncTracks call when elements are already loaded, and all-tracks cuechange subscriptions that read only the first track), neither of which affects correctness in the common single-track case.

caption-tracks.ts has two style-level observations worth a quick read-through; the player files are straightforward call-site changes.

Important Files Changed

Filename Overview
apps/web/app/s/[videoId]/_components/caption-tracks.ts New shared helper that fixes async VTT load bug; contains a redundant syncTracks() call and subscribes all caption tracks to cuechange but reads only from the first one
apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx Replaces inline track management with bindCaptionTrackCueText; cleanup is correctly wired and both loadedmetadata listeners co-exist safely
apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx Replaces 64-line inline track setup/teardown with a single bindCaptionTrackCueText call; return value used correctly as the useEffect cleanup
apps/web/tests/unit/caption-tracks.test.ts Covers the async VTT-load path with a focused integration test; synchronous-init path not tested (flagged in a prior review thread)

Reviews (2): Last reviewed commit: "fix(web): initialize caption tracks afte..." | Re-trigger Greptile

Comment on lines +61 to +63
const bindTrackElementLoads = (): void => {
for (const trackElement of video.querySelectorAll("track")) {
if (trackLoadHandlers.has(trackElement)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 When handleTrackUpdate fires after a <track> element is removed from the DOM (via MutationObserver or removetrack), syncTracks correctly cleans up the TextTrack from cueTracks, but trackLoadHandlers is never pruned. The detached HTMLTrackElement and its handler closure are held in memory until the returned cleanup function runs. Pruning stale entries in bindTrackElementLoads keeps the Map tidy across the binding's lifetime.

Suggested change
const bindTrackElementLoads = (): void => {
for (const trackElement of video.querySelectorAll("track")) {
if (trackLoadHandlers.has(trackElement)) continue;
const bindTrackElementLoads = (): void => {
const liveElements = new Set(video.querySelectorAll("track"));
for (const [el, handler] of trackLoadHandlers) {
if (!liveElements.has(el)) {
el.removeEventListener("load", handler);
trackLoadHandlers.delete(el);
}
}
for (const trackElement of liveElements) {
if (trackLoadHandlers.has(trackElement)) continue;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/caption-tracks.ts
Line: 61-63

Comment:
When `handleTrackUpdate` fires after a `<track>` element is removed from the DOM (via `MutationObserver` or `removetrack`), `syncTracks` correctly cleans up the `TextTrack` from `cueTracks`, but `trackLoadHandlers` is never pruned. The detached `HTMLTrackElement` and its handler closure are held in memory until the returned cleanup function runs. Pruning stale entries in `bindTrackElementLoads` keeps the Map tidy across the binding's lifetime.

```suggestion
	const bindTrackElementLoads = (): void => {
		const liveElements = new Set(video.querySelectorAll("track"));

		for (const [el, handler] of trackLoadHandlers) {
			if (!liveElements.has(el)) {
				el.removeEventListener("load", handler);
				trackLoadHandlers.delete(el);
			}
		}

		for (const trackElement of liveElements) {
			if (trackLoadHandlers.has(trackElement)) continue;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +86 to +111
describe("bindCaptionTrackCueText", () => {
it("initializes caption cues when a track element loads after metadata", () => {
const textTracks = new FakeTextTrackList();
const trackElement = new FakeTrackElement();
const video = createVideo(textTracks, [trackElement]);
const captionTrack = new FakeTextTrack(
"captions",
createCueList("Loaded caption"),
);
const cueTexts: string[] = [];

const cleanup = bindCaptionTrackCueText(video, (text) => {
cueTexts.push(text);
});

video.dispatch("loadedmetadata");
textTracks.addSilently(captionTrack);
trackElement.readyState = 2;
trackElement.dispatch("load");
captionTrack.dispatch("cuechange");

expect(captionTrack.mode).toBe("hidden");
expect(cueTexts.at(-1)).toBe("Loaded caption");

cleanup();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing synchronous-init test case

The suite only covers the async VTT-load path (track element loads after loadedmetadata). The synchronous path — where <track> elements are already in the DOM with readyState === 2 before bindCaptionTrackCueText is called — is exercised by the immediate handleTrackUpdate() call at the end of setup, but is not tested. A regression here (e.g., the sync syncTracks call being dropped) would go undetected.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/caption-tracks.test.ts
Line: 86-111

Comment:
**Missing synchronous-init test case**

The suite only covers the async VTT-load path (track element loads after `loadedmetadata`). The synchronous path — where `<track>` elements are already in the DOM with `readyState === 2` before `bindCaptionTrackCueText` is called — is exercised by the immediate `handleTrackUpdate()` call at the end of setup, but is not tested. A regression here (e.g., the sync `syncTracks` call being dropped) would go undetected.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@richiemcilroy

Copy link
Copy Markdown
Member

hey @greptileai please re-review

@richiemcilroy richiemcilroy merged commit d6d5fcf into CapSoftware:main Jun 18, 2026
6 of 7 checks passed
@richiemcilroy

Copy link
Copy Markdown
Member

Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Captions toggle in player UI doesn't activate text track on self-hosted

3 participants