Skip to content

feat(web): show text and ASS subtitles in offline playback - #233

Open
mia-riezebos wants to merge 4 commits into
feat/175-offline-remuxfrom
feat/177-offline-text-subs
Open

mia-riezebos wants to merge 4 commits into
feat/175-offline-remuxfrom
feat/177-offline-text-subs

Conversation

@mia-riezebos

Copy link
Copy Markdown
Member

Offline playback (#225) showed no subtitles: the online player gets WebVTT from the server and has ASS and image subtitles burned in, and a download has no server. This adds text and ASS subtitles to offline playback, through the same subtitle menu as online.

Embedded tracks. Mediabunny, which does the remux, skips subtitle tracks, so lib/offline/matroska.ts reads them from the downloaded file in place: the track list (with zlib and header-stripping compression), the cue index for cluster positions (or a walk of cluster headers when a file has no cues), font attachments, and the clusters from 10 s behind to 30 s ahead of the playhead, each once. SubRip and WebVTT blocks become VTTCues on a <track>, so they render and position like online captions and move up when the controls show. ASS blocks go to libass through JASSUB (processChunk), with the track header as the script and the file's attached fonts.

Stored subtitle files. The item detail now lists the Item's stored subtitle files with their URLs. Starting a download saves each one next to the media in OPFS as <versionId>.<track>; Delete removes them with it. They join the menu after the embedded tracks: SubRip and WebVTT as a <track>, ASS through JASSUB.

Which shows. The menu is the online one (PlayerSettings). With no choice made, a forced track shows. Image tracks (PGS, VobSub) aren't listed yet; that is #178. MP4 downloads list only the stored files, since their embedded text tracks (mov_text) aren't read.

Also fixed (in #225's branch). Reopening a session at a position started two MSE runs in one tick; the aborted run's queued updateend released the next run's first append early, and the following append threw mid-update ("The video stopped loading"). Appends now wait while the buffer is updating, not for the next updateend. Switching subtitles exposed it; switching audio and Version online could hit it too.

Closes #177

New dependency

jassub 2.5.23 (LGPL-2.1+, libass in WebAssembly), the renderer the issue names. Maintained (released two days ago), renders every ASS feature libass does on an OffscreenCanvas in a worker, and takes Matroska chunks and attached fonts directly. It loads only when an ASS track shows. Its two WebAssembly builds (2.1 MB each) and default font join the service worker's shell cache, so it works offline; that grows the shell by about 4.4 MB.

No library reads Matroska subtitles from a Blob with random access (matroska-subtitles streams the whole file and was last released in 2022), so the reader is ~300 lines of our own EBML walking, limited to what subtitles need.

Screenshots

Server stopped, playing downloads:

SubRip, forced by default Subtitle menu ASS, italic style from the track
srt menu ass

Tests

  • apps/server/src/api/offline-browser.test.ts (headless Chromium, server stopped), new cases. 9 pass, three runs in a row:
    • A forced SubRip cue at the very start shows.
    • A forced SubRip track shows its cue after a seek 5 s into a line past the buffered 90 s, with the line's own start time. Fails without the 10 s look-behind.
    • An ASS track picked from the settings menu draws nothing before its line and draws in the bottom quarter, none in the top half, during it.
    • A stored SubRip file saved with the download is picked from the same menu and shows its cue.
    • OPFS holds the media and the saved subtitle file, one copy each.
  • store.test.ts: proves stored subtitles save with the download, a failed one is left out, and Delete removes them. 25 pass.
  • subtitles.test.ts: the item detail lists a stored track with the URL the subtitle route serves.
  • player-browser, playback-settings-browser, downloads-browser, browse.test, src/subtitles against a local Postgres: 20 pass. Web bun test: 292 pass. bun run lint: 0 errors. bun run check: 0 errors.

Made with claude-opus-5.5 using Claude Code in T3 Code.

🤖 Generated with Claude Code

Embedded SubRip and WebVTT tracks of a Matroska download become native
text track cues, and ASS tracks draw through libass (JASSUB) with the
file's attached fonts. A small reader walks the file's cue index and
the clusters near the playhead, since Mediabunny skips subtitle
tracks. The Item's stored subtitle files now save with the download
and join the same subtitle menu.

Closes #177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8d5c60c0-5db9-49dd-9455-a70510c09d1d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixed the CI lint failure caused by formatting in two new assertions in offline-browser.test.ts, with no behavior changes. Pushed signed commit 071986a to this PR; bun run lint now passes with only the existing CSS warnings, and the working tree is clean.

Task list (3/3 completed)
  • Inspect the PR and failed CI logs; confirm the failure is introduced by this PR
  • Reproduce the failure and apply the smallest correct fix
  • Run CI verification, review the diff, and push the signed fix to the PR

Pullfrog  | View workflow run | via Pullfrog | Using gpt-6.1-sol | 𝕏

@pullfrog pullfrog Bot left a comment

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.

Important

Three subtitle-decoding issues should be fixed before merging: sparse cue indexes and CRC-prefixed clusters can lose subtitles, and embedded WebVTT metadata is rendered as dialogue.

Reviewed changes Full review of the offline subtitle path, from Item detail and download storage through track selection and rendering.

  • Stored subtitles: Item detail exposes subtitle URLs; downloads save sidecars in OPFS and remove them with the media.
  • Embedded subtitles: A new Matroska reader discovers tracks, reads nearby subtitle blocks, handles basic compression, and extracts attached fonts.
  • Playback: The shared subtitle menu selects native text tracks or JASSUB-rendered ASS, with forced-track defaults and controls-aware positioning.
  • Verification: All 292 web unit tests, the production web build, and Svelte diagnostics pass; targeted structural probes reproduced the findings below. Browser fixtures were not rerun because ffmpeg is unavailable.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6-astra | 𝕏

}
}
}
if (clusters.size === 0 && firstCluster !== null)

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] Do not treat Cues as a complete cluster inventory. When a file has cues for only some clusters, this condition disables the physical scan and read() never visits the others, permanently dropping their subtitles; a structural fixture with clusters at 0/5/10 seconds loses the middle subtitle when only its cue is removed.

Technical details
# Read clusters between cue anchors

## Affected sites
- apps/web/src/lib/offline/matroska.ts:259-288 builds the cluster index exclusively from Cues whenever any exist.
- apps/web/src/lib/offline/matroska.ts:364-375 reads only those indexed clusters.

## Required outcome
- Return subtitle blocks in the requested interval even when their containing clusters are not referenced by Cues.
- Use actual cluster timestamps for range boundaries rather than assuming CueTime is the containing cluster's Timestamp.
- Add a regression fixture containing an uncued cluster between two cued clusters.

## Suggested approach
- Treat cues as seek anchors and walk subsequent physical cluster elements through the requested interval, retaining the existing deduplication.
- The Matroska specification explicitly describes Cues as an index of "certain" clusters; indexing every subtitle frame is recommended, not mandatory: https://www.matroska.org/technical/cues.html

Comment on lines +280 to +282
const head = await slice(file, element.start, element.start + 16);
const [first] = children(head);
if (first?.id === ids.timestamp) clusters.set(at, uint(head, first));

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] Skip CRC-32 before looking for a cluster Timestamp. In a file without Cues, a cluster beginning with CRC-32 never enters clusters, so all its subtitles disappear; the Matroska ordering rules require CRC-32 to come first when present and recommend it for top-level elements. Walk the header children until Timestamp is found and cover CRC-prefixed clusters in the fallback test.

new VTTCue(
block.start,
block.end ?? block.start + fallbackSeconds,
decoder.decode(block.data).replace(overrides, ""),

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] Decode the WebM WebVTT envelope before creating the cue. A D_WEBVTT/SUBTITLES block contains an identifier line and a settings line before the actual dialogue, so passing the entire payload here displays those fields as subtitle text and loses their positioning semantics.

Technical details
# Separate embedded WebVTT metadata from cue text

## Affected sites
- apps/web/src/lib/offline/subtitles.ts:162-174 uses the same raw-text conversion for every non-ASS embedded codec.
- apps/web/src/lib/offline/session.ts:37,47-68 advertises embedded webvtt tracks in WebM/Matroska downloads.

## Required outcome
- For D_WEBVTT/SUBTITLES and D_WEBVTT/CAPTIONS, assign only the payload to VTTCue.text and apply the identifier/settings as cue metadata.
- Preserve the separate mappings for SubRip and other supported codec IDs.
- Cover an embedded WebVTT cue with a nonempty identifier and position/alignment settings.

## Reproduction
- A structural WebM subtitle block containing `cue-id\nalign:start position:10%\nHello` reaches VTTCue with that entire string as its text; only `Hello` should be dialogue.
- The envelope is specified here: https://www.webmproject.org/docs/container/#storing-webvtt-cue-in-a-webm-block

…g keys

bits-ui remounts a menu's focus scope when its opening frame ends, which
moves focus to the trigger and back to the menu. chooseSubtitles focused
a row and pressed Enter right after the click, so the key sometimes landed
on the menu itself or, after the remount, on its back row. The Subtitles
submenu never opened, or the choice never happened, and the offline
subtitle tests timed out about one open in seven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

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.

Important

No new issues found in these test changes. The three decoding findings from the previous review remain unresolved, so approval is still withheld.

Reviewed changes Reviewed the browser-test changes since the previous review at ba36bc2.

  • Added focus synchronization: Waited for focus inside the opened settings menu, after its starting-style state, before sending subtitle-selection keys.
  • Corrected cue-result types: Included start in the evaluated cue type and formatted the assertions without changing their expectations.

Checked the focus wait against the test helper and installed bits-ui implementation. Browser fixtures were not rerun because ffmpeg is unavailable.

Pullfrog  | Fix it ➔ | View workflow run | Using gpt-6-astra | 𝕏

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.

1 participant