Repository navigation
feat(playback-core): Support apple JSON chapters from com.apple.hls.chapters session data - #1360
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
luwes
left a comment
There was a problem hiding this comment.
Nice work on this. The session-vs-user chapter precedence (WeakSet), redirect-aware URL resolution on both paths, and single fetch feeding both chapters and muxmetadata all look good. A few things I noticed, roughly in priority order:
1. Open last chapter ends at Number.MAX_SAFE_INTEGER and leaks through the public API
Session chapters go through addCuesToTextTrack, which uses mediaEl.duration for a missing endTime only if it's already finite; otherwise it writes MAX_SAFE_INTEGER, and the cue is never amended. Both call sites (updateStreamInfoFromSrc for native, MANIFEST_LOADED for hls.js) run before metadata, so the sentinel is the common case, not the edge case. getChapters(), getActiveChapter(), and the chapterchange detail then report endTime: 9007199254740991.
User chapters had the same behavior, but only if addChapters was called before metadata. Now it happens by default whenever a stream ships chapters. Options: clamp to a finite duration in vttCueToChapter, or update the session cue's end on durationchange. For reference, Video.js 10 (videojs/v10#2737) keeps the sentinel on the track but clamps to the media duration on read and re-syncs on durationchange. It might also be worth double-checking that media-chrome's time range clamps cue ends to the duration (I haven't verified that).
2. Possible race with forceHiddenTracks on the hls.js path (unverified)
With initialize(), setupChapters creates an empty label="chapters" track on loadstart. On MANIFEST_LOADED, forceHiddenTracks sees a chapters track with no cues and, one tick later, does setAttribute('src', ''). Per the HTML spec, setting src empties the cue list and restarts the track's load. The chapters fetch kicks off on the same MANIFEST_LOADED, so a fast (e.g. cached) response could add cues before that reset, or before the restarted empty load fails. In v10 we measured Chromium and WebKit dropping cues added before a srcless track's load settles.
The MANIFEST_LOADED tests trigger the event directly without initialize(), so no chapters track exists yet and this path isn't covered. Probably rare in practice, but cheap to guard: e.g. skip tracks without a src in forceHiddenTracks, or wait for the track to settle (load/error) before adding cues.
3. Small teardown gap after the fetch resolves
fetchJsonUntilTeardown stops watching for teardown once the JSON resolves, but setSessionDataChapters → addCuesToTextTrack can still await a setTimeout(0) when it creates the track. A teardown or source change in that tick would add the old source's chapters to a track on the new source. Re-checking signal.aborted after the await (or threading the signal through) would close it.
Minor
playlistUrl ?? src: ifresponse.urlis''(opaque or mocked responses),??won't fall back tosrc, and relative URIs silently resolve toundefined.||would handle it.- Only one title is used (
und, else the first), so a document that listsesfirst shows Spanish chapters to everyone. Might be worth preferring the player'slang/navigator.languagebefore falling back. - The native parser (
getMultivariantPlaylistSessionData) keeps the last entry for a repeatedDATA-ID, while picking the first entry with a URI would be more predictable for per-LANGUAGEplaylists. Edge case. MANIFEST_LOADEDcan fire again after network recovery, which re-fetches the JSON and re-dispatchesmuxmetadata. Harmless (cues are replaced, not duplicated), and the oldMANIFEST_PARSEDhandler behaved the same way. Just flagging it.
6c44d73 to
7136e1d
Compare
Load session-data chapters once, per load, without overriding user chapters Fetch the com.apple.hls.chapters document a single time for both the Mux metadata and the chapters, abort it on teardown, resolve relative URIs, skip untitled entries, and keep chapters added through addChapters() from being merged with or truncated by the stream's.
Native playback resolved a relative chapters URI against the original src instead of the multivariant playlist's response URL, so it missed redirected documents. Media playlists already used response.url for this; chapters now do the same, and the hls.js call site listens on MANIFEST_LOADED (which carries both sessionData and the response URL) instead of MANIFEST_PARSED + hls.url, which never reflected redirects.
The open last chapter keeps MAX_SAFE_INTEGER on the track, but getChapters(), getActiveChapter() and chapterchange now report the media duration once it is finite and omit endTime until then.
Resetting src on the srcless chapters track emptied the cue list, so in WebKit session chapters that resolved right after MANIFEST_LOADED were dropped.
…urrent Keep watching teardown until the chapters are on the track, not just until the JSON resolves. Callers that find a track still settling now wait for it, and the user-chapters precedence check runs right before the cues are added, so addChapters() during that tick is neither dropped nor merged.
…s empty Synthetic responses have an empty url, which broke resolving relative chapters and media playlist URIs on the native path.
Prefer the browser languages, then a lang set on the media element or its shadow hosts (never one inherited from the page), matching the full tag before the primary subtag, before falling back to und and then the first title.
Omitting endTime for an open chapter changed the public type of chapters and activeChapter, which broke TypeScript consumers such as the Next.js demo. The open chapter now ends at Infinity until the media duration is known and greater than zero.
7136e1d to
af32d90
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit af32d90. Configure here.
luwes
left a comment
There was a problem hiding this comment.
Thanks for working through all of these. Every point from my first review is addressed, most with tests: the clamp when chapters are read, leaving srcless tracks alone in forceHiddenTracks, the signal carried through track creation (plus getOrCreateTextTrack stopping two callers from creating the track at once), the || fallback, and title language selection. Leaving "last entry wins" for a repeated DATA-ID as is seems fine to me.
A couple of things remain after the latest push:
1. Stale chapters after a source change on native playback (Bugbot's high-severity finding, confirmed)
updateStreamInfoFromSrc waits for the playlist fetch without watching for teardown, and untilTeardown only starts once fetchAndApplyChaptersSessionData runs. If the source changes while the playlist is still loading:
emptiedremoves the old chapters track.- The new source's
loadstartcreates a fresh one. - The old playlist resolves and fetches the old chapters document, and nothing aborts that fetch.
- The old chapters land on the new track. They're marked as session chapters, so they can overwrite the new source's chapters, or be overwritten by them, depending on which fetch finishes first.
The hls.js path is fine, since the old instance is destroyed and sends no more events. A fix is to set up the teardown abort when updateStreamInfoFromSrc starts and pass its signal to both the playlist fetch and the chapters fetch. That would also fix an older bug on the same path: this function already sends a stale streamtypechange and writes liveEdgeStartOffset for the old source.
2. Infinity can't round-trip through addChapters() (Bugbot rated this low, I'd call it medium)
When the duration is unknown, zero, or infinite (live), getChapters(), getActiveChapter(), and the chapterchange detail now return endTime: Infinity. But addCuesToTrack passes any endTime straight to new VTTCue(...), which throws for a value that isn't finite. So addChapters(el, getChapters(el)) fails on live streams or before metadata loads, and Infinity becomes null if someone serializes the event detail to JSON.
Smallest fix: treat an endTime that isn't finite as missing in addCuesToTrack (Number.isFinite(cuePoint.endTime)), so the existing next-cue/duration fallback applies. Returning Infinity where these used to return MAX_SAFE_INTEGER is also a public API change, so it's worth a note in the changeset.
3. Question: language order
getPreferredLanguages puts navigator.languages ahead of the player's lang, and a test pins that order. A lang on <mux-player> / <mux-video> is an explicit author choice, though, and since every browser reports at least one language, it will usually lose. I'd expect the element's lang to come first. Is this order intentional?
Nit
With a preference of en-US and titles [en-GB, en], the primary-subtag fallback returns en-GB because it comes first. Preferring the bare primary tag (en) over a sibling region would be the better match.
Unverified
I haven't checked whether media-chrome's time range clamps cue ends to the duration. Its chapters state reads the track's cues directly, so on a live stream with session chapters the UI may still see the MAX_SAFE_INTEGER end.
I'd fix 1 before merging; 2 is a one-line guard.
updateStreamInfoFromSrc now watches teardown from the start and passes its signal to the playlist and chapters fetches, so a source change while the playlist loads no longer applies the old source's chapters, stream type or live edge offset to the new one.
…s missing getChapters() reports Infinity for an open chapter while the duration is not finite, and WebKit's VTTCue rejects it (every engine rejects NaN), so feeding those chapters back to addChapters() threw. Such an endTime now falls back to the next cue or the media duration.
…guages A lang set on the media element or its shadow hosts is an explicit author choice, so it now comes before navigator.languages. A lang inherited from light DOM ancestors or the document is still ignored.
For en-US with titles [en-GB, en], pick en: exact tag first, then the bare primary tag, then any title with the same primary subtag.
Thanks again. All points are fixed in the latest push.
About media-chrome: its time range reads the cues from the track, but |
🤖 I have created a release *beep* *boop* --- <details><summary>@mux/mux-audio: 0.16.5</summary> ## [0.16.5](https://github.com/muxinc/elements/compare/@mux/mux-audio@0.16.4...@mux/mux-audio@0.16.5) (2026-10-02) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-audio-react: 0.16.5</summary> ## [0.16.5](https://github.com/muxinc/elements/compare/@mux/mux-audio-react@0.16.4...@mux/mux-audio-react@0.16.5) (2026-10-02) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/playback-core: 0.36.0</summary> ## [0.36.0](https://github.com/muxinc/elements/compare/@mux/playback-core@0.35.4...@mux/playback-core@0.36.0) (2026-10-02) ### Features * **playback-core:** Support apple JSON chapters from com.apple.hls.chapters session data ([#1360](#1360)) ([d1de13a](d1de13a)) ### Bug Fixes * bump mux-embed from 5.16.1 to 5.18.1 ([#1323](#1323)) ([a440f55](a440f55)) </details> <details><summary>@mux/mux-player: 3.14.0</summary> ## [3.14.0](https://github.com/muxinc/elements/compare/@mux/mux-player@3.13.4...@mux/mux-player@3.14.0) (2026-10-02) ### Bug Fixes * bump the prod-dependencies group across 1 directory with 2 updates ([#1336](#1336)) ([9176ea2](9176ea2)) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/mux-video bumped from 0.31.4 to 0.31.5 * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-player-astro: 3.14.0</summary> ## [3.14.0](https://github.com/muxinc/elements/compare/@mux/mux-player-astro@3.13.4...@mux/mux-player-astro@3.14.0) (2026-10-02) ### Features * **mux-player-astro, mux-uploader-astro:** support astro v7 ([#1361](#1361)) ([9002d41](9002d41)) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/mux-player bumped from 3.13.4 to 3.14.0 * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-player-react: 3.14.0</summary> ## [3.14.0](https://github.com/muxinc/elements/compare/@mux/mux-player-react@3.13.4...@mux/mux-player-react@3.14.0) (2026-10-02) ### Miscellaneous Chores * **@mux/mux-player-react:** Synchronize player versions ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/mux-player bumped from 3.13.4 to 3.14.0 * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-video: 0.31.5</summary> ## [0.31.5](https://github.com/muxinc/elements/compare/@mux/mux-video@0.31.4...@mux/mux-video@0.31.5) (2026-10-02) ### Bug Fixes * bump @mux/mux-data-google-ima from 0.3.4 to 0.3.17 ([#1324](#1324)) ([5c96dac](5c96dac)) * bump the prod-dependencies group across 1 directory with 2 updates ([#1336](#1336)) ([9176ea2](9176ea2)) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-video-react: 0.31.5</summary> ## [0.31.5](https://github.com/muxinc/elements/compare/@mux/mux-video-react@0.31.4...@mux/mux-video-react@0.31.5) (2026-10-02) ### Miscellaneous Chores * **@mux/mux-video-react:** Synchronize video versions ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/playback-core bumped from 0.35.4 to 0.36.0 </details> <details><summary>@mux/mux-uploader: 1.6.0</summary> ## [1.6.0](https://github.com/muxinc/elements/compare/@mux/mux-uploader@1.5.0...@mux/mux-uploader@1.6.0) (2026-10-02) ### Miscellaneous Chores * **@mux/mux-uploader:** Synchronize uploader versions </details> <details><summary>@mux/mux-uploader-astro: 1.6.0</summary> ## [1.6.0](https://github.com/muxinc/elements/compare/@mux/mux-uploader-astro@1.5.0...@mux/mux-uploader-astro@1.6.0) (2026-10-02) ### Features * **mux-player-astro, mux-uploader-astro:** support astro v7 ([#1361](#1361)) ([9002d41](9002d41)) ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/mux-uploader bumped from 1.5.0 to 1.6.0 </details> <details><summary>@mux/mux-uploader-react: 1.6.0</summary> ## [1.6.0](https://github.com/muxinc/elements/compare/@mux/mux-uploader-react@1.5.0...@mux/mux-uploader-react@1.6.0) (2026-10-02) ### Miscellaneous Chores * **@mux/mux-uploader-react:** Synchronize uploader versions ### Dependencies * The following workspace dependencies were updated * dependencies * @mux/mux-uploader bumped from 1.5.0 to 1.6.0 </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Releases playback-core chapter handling and mux-embed/IMA dependency updates across all consumer packages; Astro peer range expansion may affect install resolution for Astro apps. > > **Overview** > This is a **Release Please** cut that bumps published versions across the monorepo and aligns workspace dependencies (notably **`@mux/playback-core` → 0.36.0**), plus updates the release manifest, `package-lock.json`, and per-package changelogs. > > **`@mux/playback-core` 0.36.0** is the main functional release: Apple JSON chapters from HLS session data (`com.apple.hls.chapters`) and an updated **mux-embed** dependency. Player, video, and audio packages pick that up via version bumps (**`@mux/mux-player` 3.14.0**, **`@mux/mux-video` 0.31.5**, **`@mux/mux-audio` 0.16.5**, and matching React wrappers). > > **`@mux/mux-player-astro` 3.14.0** and **`@mux/mux-uploader-astro` 1.6.0** document **Astro v7** peer support. **`@mux/mux-video` 0.31.5** also notes dependency bumps (including **`@mux/mux-data-google-ima`**). Uploader packages move to **1.6.0** as a synchronized release. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6371102. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

Closes #1359
Summary
Mux Video now publishes chapters as Apple JSON Chapters Notation, referenced from the multivariant playlist by
#EXT-X-SESSION-DATA:DATA-ID="com.apple.hls.chapters". We already fetched that document for the Mux metadata but ignored its chapters. It's now parsed into the existingchapterstext track, so the time range chapters UI works with no player-side change, for both native HLS and hls.js playback.Changes
parseAppleJsonChaptersturns the document intoChapter[]:start-time, or at their owndurationwhen they have oneund, then the first title.languageare kept[{ "start-time": 0 }]document Mux serves for assets without metadata adds no chapters.muxmetadata, unchanged) and the chapters.teardown, so a source change never leaves the previous source's chapters or metadata behind.addChapters()always take precedence:addChapters()replaces them instead of merging with them or truncating themaddChapters()call made while the chapters track is being created waits for it and still wins.URIs are resolved against the playlist URL after redirects:response.urlon native (falling back tosrcwhen it is empty), andMANIFEST_LOADEDwithdata.urlon hls.js. TheURI/VALUEcheck no longer throws when both are missing.Behavior changes
Number.MAX_SAFE_INTEGER. It ends at Infinity until the duration is known and greater than zero. This is a public API change. AnendTimethat is not finite is treated as missing byaddChapters()andaddCuePoints().forceHiddenTracksno longer resetssrcon tracks without asrc. Resetting it emptied their cues, and in WebKit it dropped the stream chapters.fetchAndDispatchMuxMetadatais now aborted onteardown, on the mp4 path too.Not changed on purpose
DATA-IDkeeps the last entry, same as hls.js, so native and hls.js pick the same document.MANIFEST_LOADEDcan fire again after network recovery. It re-fetches the document and replaces the cues without duplicating them.Testing
parseAppleJsonChapters)start-time, or at their owndurationwhen present, the last one stays openundcase-insensitive, titles without alanguage[{ "start-time": 0 }]document yields no chaptersstart-timeare ignoredtoChaptersSessionDataUrl)URIpreferred overVALUE, aVALUEthat is a URL is used as a fallbackURIs resolve against the playlist URL, including after a redirect and with an emptyresponse.urlundefinedinstead of throwingfetchAndApplyChaptersSessionData)muxmetadataand the chaptersteardownwhile the document is loading, or right after it resolves, applies nothingaddChapters()before the document arrives, or while the track is being created, are kept intact. Chapters added after it replace the stream chaptersInfinitywhile the duration is unknown, zero or unbounded, and at the media duration once it is known, includingactiveChapterand thechapterchangedetailupdateStreamInfoFromSrcwith a multivariant playlist carrying the session data, including a relativeURIand a redirectMANIFEST_LOADEDon a realsetupHlsinstance, and throughinitialize()to cover the chapters track created onloadstartchapters.test.jsalso passes in WebKit.addChapters()precedence, and a multi-language document.Notes
chapterstrack for now.Note
Medium Risk
Touches async playlist/chapter loading, text-track cue lifecycle, and public chapter read APIs; behavior is well covered by tests but races with teardown and hls.js manifest reloads warrant careful review.
Overview
Enables chapter UI from Mux’s
com.apple.hls.chapterssession data by parsing the same JSON document already fetched for Mux metadata and populating the existing chapters text track (native HLS and hls.js).Adds
parseAppleJsonChapterswith timing fromstart-time, optionalduration, or the next entry, localized titles viagetPreferredLanguages(element/shadowlang, thennavigator.languages), and drops untitled entries.fetchAndApplyChaptersSessionDataresolves the session-data URL against the playlist response URL (redirects / relative URIs), fetches once, dispatchesmuxmetadata, and applies chapters throughsetSessionDataChapters. Playlist and chapter fetches honorAbortSignaltied to mediateardownso source changes don’t leave stale chapters or metadata. hls.js loads chapters onMANIFEST_LOADEDinstead ofMANIFEST_PARSED.Chapter track behavior: stream chapters are tagged separately from
addChapters()(user chapters always win; re-apply replaces stream cues without duplicating).getChapters/getActiveChapter/chapterchangeclamp ends to media duration; open last chapter reportsInfinityuntil duration is known. FixesforceHiddenTracksso src-less chapter tracks aren’t reset (WebKit cue loss).Reviewed by Cursor Bugbot for commit e67a3df. Bugbot is set up for automated code reviews on this repo. Configure here.