Skip to content

feat(web): play a download offline by remuxing it in the browser - #225

Open
mia-riezebos wants to merge 2 commits into
mainfrom
feat/175-offline-remux
Open

mia-riezebos wants to merge 2 commits into
mainfrom
feat/175-offline-remux

Conversation

@mia-riezebos

@mia-riezebos mia-riezebos commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

A download could be saved to the browser (#174) but not played: the player only knew how to ask the server for a stream. This is the playback slice of #173: a finished download plays from browser storage with the server off, and seeks.

How it plays. The player opens the download's OPFS file as a File and Mediabunny remuxes it to fragmented MP4 in the page. The fragments go through the same MSE attachment the server's progressive stream uses (progressive.ts), which now takes "runs" instead of URLs: the online player hands it fetchRuns(url) (the old fetch, 503 queueing and x-stream-offset moved there unchanged), the offline one hands it a remux run. A seek outside the buffer cancels the run and starts a new one at the keyframe before the target, found through the file's own index (Matroska cues, MP4 sample tables). The remux pulls one fragment at a time and reads the file in place with Blob.slice, so nothing is copied: OPFS holds one copy per download.

Which audio. The first audio track the browser plays natively. The audio menu lists every track; picking one that can't play shows why.

What can't play says why. The app checks the download's probed codecs against MediaSource.isTypeSupported before offering Play. The Downloads page shows Play only for downloads this browser plays, and names the reason in the caption otherwise: "This browser can't play HEVC", "Needs audio conversion" (Dolby, DTS, PCM; #176 turns this into conversion), or "Needs a server copy" (TrueHD, VC-1, MPEG-2, which no browser decodes). The player shows the same reason if opened anyway.

Entry point. Play on the Downloads page opens /play/{item}?version={version}&offline. That route skips the session check, so with the service worker's cached shell it opens with the server down. The offline session reports no progress; offline progress is #180.

MSE append fix. Reopening a session at a position (a track or Version switch) starts two MSE runs in one tick; the aborted run's queued updateend released the next append early and the one after threw mid-update. Appends now wait while the buffer is updating. Found while testing #233; it affected the online progressive player too.

Closes #175

New dependency

mediabunny 1.62.0 (MPL-2.0), the library the PRD chose. It is maintained (released yesterday), has no dependencies, reads Matroska, MP4 and MPEG-TS with their indexes, and writes fragmented MP4 with per-fragment callbacks, which is what MSE needs. It loads as a lazy chunk only when a download plays. The alternative, ffmpeg.wasm, is ~30 MB and copies the input into its own memory filesystem.

Screenshots

Downloads, one download that plays and one this Chromium can't (no HEVC decoder):

Light Dark
1440 downloads 1440 light downloads 1440 dark
390 downloads 390 light downloads 390 dark

Player with the server stopped, playing the H.264 download, and opening the HEVC one:

Playing Can't play
player offline player hevc

Tests

  • apps/server/src/api/offline-browser.test.ts (headless Chromium, no database; needs apps/web built): serves the built shell, seeds OPFS with real ffmpeg fixtures, stops the server, then opens the player through the service worker. Proves a 4-minute H.264/AAC MKV plays, seeks past the buffered 90 s and reaches the end; an MP4 plays to the end; HEVC plays where the browser decodes it and otherwise shows "This browser can't play HEVC."; an AC-3-only download shows "Needs audio conversion."; and OPFS holds exactly one file per download after playing. A mutation that restarts every seek from 0 fails the seek test. 5 pass.
  • apps/web/src/lib/offline/playable.test.ts: proves the audio choice and each reason for the codec mixes in the PRD's table. 8 pass.
  • Online progressive playback after the progressive.ts change: player-browser, playback-settings-browser, downloads-browser, next-episode-browser pass against a local Postgres.
  • bun test (apps/web): 291 pass. bun run lint: 0 errors (the 9 existing warnings). bun run check: 0 errors.

Not covered by an automated test: HEVC playing, since neither this machine's Chromium nor CI's Chrome has an HEVC decoder; Safari and Chrome-with-hardware need a manual check.


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

🤖 Generated with Claude Code

A finished download plays from browser storage: Mediabunny remuxes the
original file to fragmented MP4 in the page and the existing MSE
attachment feeds it, now driven by runs instead of URLs. Seeks restart
the remux at the keyframe before the target, read through the file's
own index, so nothing is copied.

The Downloads page offers Play for a download this browser plays and
names the reason for one it can't.

Closes #175

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

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1a322083-9089-4f5b-b586-4f85a4b33201
📥 Commits

Reviewing files that changed from the base of the PR and between 8eb2b7d and f46c6c7.

⛔ Files ignored due to path filters: 1
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing: 15
  • apps/server/src/api/offline-browser.test.ts
  • apps/web/package.json
  • apps/web/src/lib/components/Player.svelte
  • apps/web/src/lib/downloads/index.ts
  • apps/web/src/lib/downloads/store.ts
  • apps/web/src/lib/offline/playable.test.ts
  • apps/web/src/lib/offline/playable.ts
  • apps/web/src/lib/offline/remux.ts
  • apps/web/src/lib/offline/session.ts
  • apps/web/src/lib/playback.ts
  • apps/web/src/lib/player.ts
  • apps/web/src/lib/progressive.ts
  • apps/web/src/routes/(browse)/downloads/+page.svelte
  • apps/web/src/routes/play/[id]/+page.svelte
  • apps/web/src/routes/play/[id]/+page.ts

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.

@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

The offline player needs a reachable cold-start entry point, and backpressured remux runs need abort-aware cleanup before this is ready to merge.

Reviewed changes Reviewed the offline playback flow, its browser-storage and codec integration, and the shared progressive-player refactor.

  • Local playback: Reads downloaded Files from OPFS and remuxes selected video/audio into fragmented MP4 with indexed seeks.
  • Player integration: Adds offline session loading, local audio selection, codec refusal messages, and Downloads Play links.
  • Shared streaming: Separates server fetch/retry handling from MSE attachment so online and offline playback consume the same run interface.
  • Coverage: Adds codec-planning and offline browser tests. The 291 web unit tests, production build, and Svelte checks passed locally; the media browser suite was blocked by Executable not found in $PATH: "ffmpeg". Safari HEVC playback remains unverified.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

variant="ghost"
size="icon-sm"
aria-label="Play"
href={offlineHref(record)}

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.

[P1] Make the offline Play entry point reachable without the server. A cold load of /downloads still runs the browse layout's loadSession() and libraries.viewable(), so with the API down it shows only “Server unreachable” and “Try again”, not these Play links. The offline player's Back link also returns to that failing route, leaving downloaded Items inaccessible unless the viewer already knows their player URLs.

Technical details
# Offline Downloads navigation

## Affected sites
- `apps/web/src/routes/(browse)/downloads/+page.svelte:171`: the new offline playback entry point is inside the server-dependent browse layout.
- `apps/web/src/routes/(browse)/+layout.ts:9-12`: always loads the session and viewable Libraries before rendering Downloads.
- `apps/web/src/lib/components/Player.svelte:117`: offline Back targets `/downloads`.
- `apps/server/src/api/offline-browser.test.ts:49-52`: tests navigate directly to the player and bypass the user-facing entry point.

## Required outcome
- A viewer reopening the cached app with the server unavailable can reach their local downloads and start playback without knowing an Item or Version id.
- Returning from offline playback can display those downloads without requiring the API.
- Add a cold-navigation browser test through Downloads, rather than only opening a known player URL.

## Reproduction
- Serve the built shell with `/rpc/*` returning a non-JSON 503.
- Open `/downloads` in a fresh page.
- Observed: `Server unreachable`, connection explanation, and `Try again`; no Downloads list.

return;
} finally {
// A superseded run stops producing; a fetch body is already aborted.
if (stopped()) void reader.cancel().catch(() => {});

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] Cancel the reader even when its buffer wait has not completed. This finally is unreachable while waitWhileFull() is awaiting timeupdate: that wait ignores the run's abort, and closing removes its listener without resolving the promise. A full-buffer backward seek retains the superseded remux run, and closing while backpressured never invokes its producer's cancel().

Technical details
# Backpressured run cancellation

## Affected sites
- `apps/web/src/lib/progressive.ts:145-153`: `waitWhileFull()` checks only the closed signal between waits, not the current run's signal; aborting the listener does not settle its promise.
- `apps/web/src/lib/progressive.ts:231-238`: reader cancellation depends on that wait completing.
- `apps/web/src/lib/offline/session.ts:105`: local producers ignore the openRun signal and rely on stream cancellation.
- `apps/web/src/lib/offline/remux.ts:206-208`: producer cleanup runs only when the stream is cancelled.

## Required outcome
- Superseding or closing a run promptly cancels its reader, including while paused with a full buffer.
- Buffer waits settle on both per-run and attachment abort; stale runs must not remain waiting for playback to catch up.
- Add cancellation coverage for a full-buffer seek and a full-buffer close without subsequent `timeupdate` events.

## Reproduction
- Fake MSE harness with buffered range 100..200 and initial position 100 enters the high-water wait.
- Seek backward to 0, then close the attachment without dispatching `timeupdate`.
- Observed: two runs opened, zero stream cancel callbacks, including after close.

Two runs starting in one tick, as a session reopening at a position
does, left the aborted run's updateend queued; the new run's first
append resolved on it and the next append threw mid-update, stopping
playback.

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 this commit. The two unresolved findings from the previous review still need addressing before approval.

Reviewed changes Reviewed the MSE synchronization fix in f46c6c7 against the previously reviewed 3cbaa85.

  • Replaced completion waits: Waited for SourceBuffer.updating to clear after appends, removals, and run-restart aborts, preventing stale updateend events from releasing an active append early.
  • Verified event ordering: Exercised the stale-event race with a fake-MSE harness; confirmed actual completion permits the next append. All 291 web unit tests and Svelte diagnostics passed. Real-browser media playback was not rerun for this delta.

Pullfrog  | Fix it ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

@pullfrog

pullfrog Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Investigated the failed CI job: the only failure is the existing access-browser.test.ts Group-page test, which timed out after a coordinate-based click left the “Shows” combobox open. This matches the documented Access-panel test flake; the relevant test and UI are unchanged by this PR, and all five new offline playback browser tests passed. No changes were made or pushed because the failure appears unrelated to this PR; the working tree is clean, and rerunning the failed checks job is the appropriate next step.

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

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

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.

Offline: play a download by remuxing it in the browser

1 participant