@videojs/spf 10.0.0-beta.29 → 10.0.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/default/media/dom/screen.js +7 -24
  2. package/dist/default/media/dom/screen.js.map +1 -1
  3. package/dist/default/media/primitives/resolution.js +29 -0
  4. package/dist/default/media/primitives/resolution.js.map +1 -0
  5. package/dist/default/media/primitives/select-tracks.js +17 -1
  6. package/dist/default/media/primitives/select-tracks.js.map +1 -1
  7. package/dist/default/playback/adapters/hls-background-video/adapter.js +3 -3
  8. package/dist/default/playback/adapters/hls-background-video/adapter.js.map +1 -1
  9. package/dist/default/playback/behaviors/collect-errors.js +8 -8
  10. package/dist/default/playback/behaviors/collect-errors.js.map +1 -1
  11. package/dist/default/playback/behaviors/dom/track-player-resolution.js +57 -0
  12. package/dist/default/playback/behaviors/dom/track-player-resolution.js.map +1 -0
  13. package/dist/default/playback/behaviors/select-tracks.js.map +1 -1
  14. package/dist/default/playback/behaviors/track-switching.js +48 -6
  15. package/dist/default/playback/behaviors/track-switching.js.map +1 -1
  16. package/dist/default/playback/engines/hls/engine-background-video.js +1 -1
  17. package/dist/default/playback/engines/hls/engine-background-video.js.map +1 -1
  18. package/dist/default/playback/engines/hls/engine.js +2 -0
  19. package/dist/default/playback/engines/hls/engine.js.map +1 -1
  20. package/dist/default/playback/primitives/selection-rules.js +4 -3
  21. package/dist/default/playback/primitives/selection-rules.js.map +1 -1
  22. package/dist/dev/media/dom/screen.d.ts +2 -21
  23. package/dist/dev/media/dom/screen.d.ts.map +1 -1
  24. package/dist/dev/media/dom/screen.js +7 -24
  25. package/dist/dev/media/dom/screen.js.map +1 -1
  26. package/dist/dev/media/primitives/resolution.d.ts +24 -0
  27. package/dist/dev/media/primitives/resolution.d.ts.map +1 -0
  28. package/dist/dev/media/primitives/resolution.js +29 -0
  29. package/dist/dev/media/primitives/resolution.js.map +1 -0
  30. package/dist/dev/media/primitives/select-tracks.js +17 -1
  31. package/dist/dev/media/primitives/select-tracks.js.map +1 -1
  32. package/dist/dev/playback/adapters/hls-background-video/adapter.js +3 -3
  33. package/dist/dev/playback/adapters/hls-background-video/adapter.js.map +1 -1
  34. package/dist/dev/playback/behaviors/collect-errors.js +8 -8
  35. package/dist/dev/playback/behaviors/collect-errors.js.map +1 -1
  36. package/dist/dev/playback/behaviors/dom/track-player-resolution.d.ts +8 -0
  37. package/dist/dev/playback/behaviors/dom/track-player-resolution.d.ts.map +1 -0
  38. package/dist/dev/playback/behaviors/dom/track-player-resolution.js +57 -0
  39. package/dist/dev/playback/behaviors/dom/track-player-resolution.js.map +1 -0
  40. package/dist/dev/playback/behaviors/select-tracks.d.ts.map +1 -1
  41. package/dist/dev/playback/behaviors/select-tracks.js.map +1 -1
  42. package/dist/dev/playback/behaviors/track-switching.js +48 -6
  43. package/dist/dev/playback/behaviors/track-switching.js.map +1 -1
  44. package/dist/dev/playback/engines/hls/engine-background-video.d.ts +3 -3
  45. package/dist/dev/playback/engines/hls/engine-background-video.js +1 -1
  46. package/dist/dev/playback/engines/hls/engine-background-video.js.map +1 -1
  47. package/dist/dev/playback/engines/hls/engine.d.ts +19 -0
  48. package/dist/dev/playback/engines/hls/engine.d.ts.map +1 -1
  49. package/dist/dev/playback/engines/hls/engine.js +2 -0
  50. package/dist/dev/playback/engines/hls/engine.js.map +1 -1
  51. package/dist/dev/playback/primitives/selection-rules.d.ts.map +1 -1
  52. package/dist/dev/playback/primitives/selection-rules.js +4 -3
  53. package/dist/dev/playback/primitives/selection-rules.js.map +1 -1
  54. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
- import { listen } from "@videojs/utils/dom";
1
+ import { scaleResolution } from "../primitives/resolution.js";
2
+ import { getDevicePixelRatio, listen, watchDevicePixelRatio } from "@videojs/utils/dom";
2
3
  import { isFunction } from "@videojs/utils/predicate";
3
4
  import { shallowEqual } from "@videojs/utils/object";
4
5
  //#region src/media/dom/screen.ts
@@ -36,13 +37,7 @@ import { shallowEqual } from "@videojs/utils/object";
36
37
  function getScreenResolution({ useDevicePixelRatio } = { useDevicePixelRatio: true }) {
37
38
  const screen = globalThis.screen;
38
39
  if (!screen) return void 0;
39
- const ratio = useDevicePixelRatio ? globalThis.devicePixelRatio || 1 : 1;
40
- const width = Math.round(screen.width * ratio);
41
- const height = Math.round(screen.height * ratio);
42
- return width > 0 && height > 0 ? {
43
- width,
44
- height
45
- } : void 0;
40
+ return scaleResolution(screen, useDevicePixelRatio ? getDevicePixelRatio() : 1);
46
41
  }
47
42
  /**
48
43
  * Call `onChange` whenever {@link getScreenResolution} would start answering
@@ -72,10 +67,9 @@ function getScreenResolution({ useDevicePixelRatio } = { useDevicePixelRatio: tr
72
67
  * when the display it was on goes away.
73
68
  * - **`screen.orientation` change** — rotation, which swaps the axes without
74
69
  * necessarily resizing the window.
75
- * - **a `(resolution: <ratio>dppx)` media query** — the device pixel ratio
76
- * changing under a window that kept its size, which is the cross-display drag
77
- * between displays of different density. Each query only answers about the ratio
78
- * it was built for, so it reports one change and its handler arms the next.
70
+ * - **a `(resolution: <ratio>dppx)` media query** (`watchDevicePixelRatio`) — the
71
+ * device pixel ratio changing under a window that kept its size, which is the
72
+ * cross-display drag between displays of different density.
79
73
  *
80
74
  * Worth keeping despite looking redundant, because it is the only coverage that
81
75
  * case has in WebKit and Firefox: neither implements `screen`'s change event,
@@ -101,18 +95,7 @@ function watchScreenResolution(onChange, options = { useDevicePixelRatio: true }
101
95
  onChange(next);
102
96
  };
103
97
  onChange(current);
104
- const watchRatio = () => {
105
- const query = globalThis.matchMedia?.(`(resolution: ${globalThis.devicePixelRatio}dppx)`);
106
- if (!query) return;
107
- listen(query, "change", () => {
108
- watchRatio();
109
- check();
110
- }, {
111
- once: true,
112
- signal
113
- });
114
- };
115
- watchRatio();
98
+ watchDevicePixelRatio(check, signal);
116
99
  const screen = globalThis.screen;
117
100
  const orientation = screen?.orientation;
118
101
  if (globalThis.window) listen(globalThis.window, "resize", check, { signal });
@@ -1 +1 @@
1
- {"version":3,"file":"screen.js","names":[],"sources":["../../../../src/media/dom/screen.ts"],"sourcesContent":["/**\n * Screen resolution, as the signal source for a screen-size rendition cap.\n *\n * Reported as a width and a height rather than a `\"720p\"`-style tier, because\n * the cap that consumes it compares against real track dimensions. A tier only\n * describes a track once you assume its aspect ratio, and that assumption\n * mis-measures an anamorphic or otherwise non-16:9 rendition.\n *\n * The signal source for the screen-size cap in\n * `internal/design/spf/features/rendition-selection-caps.md`.\n *\n * The screen underneath a window is not stable: rotating a device swaps the axes,\n * and unplugging a monitor or dragging the window to another display changes the\n * numbers *and* which physical screen they describe. So `getScreenResolution`\n * reads at call time and caches nothing, and `watchScreenResolution` layers the\n * reacting on top rather than the reader holding state of its own.\n */\n\nimport { listen } from '@videojs/utils/dom';\nimport { shallowEqual } from '@videojs/utils/object';\nimport { isFunction } from '@videojs/utils/predicate';\n\n/** A screen's pixel dimensions. */\nexport interface ScreenResolution {\n readonly width: number;\n readonly height: number;\n}\n\nexport interface ScreenResolutionOptions {\n /**\n * Scale the reading from CSS pixels into device pixels. On by default, since\n * device pixels are what the screen actually has, and a rendition's dimensions\n * are in the same units.\n *\n * Opt out for CSS pixels. Note that derating — a 3x phone rarely wanting 3x the\n * pixels of its layout — is a scale applied over this reading rather than a\n * reason to turn it off.\n *\n * ⚠️ Chromium and Gecko fold page zoom into `devicePixelRatio`, so with this on,\n * zooming moves the reading even though the screen didn't change. WebKit holds\n * the ratio independent of zoom and is unaffected. Whether a cap should track\n * zoom is the cap's call; this flag is only what puts zoom in scope.\n */\n useDevicePixelRatio: boolean;\n}\n\n/**\n * Read the screen's resolution, or `undefined` where there isn't one to read.\n *\n * `undefined` means \"unknown\", which is the answer a cap needs in order to not\n * cap. `screenResolutionCap` reads it that way and declines to narrow, so an\n * unknown screen is \"no cap\" rather than a cap of zero — the reading a naive\n * `?? 0` would produce, which would pin every source to its smallest rendition on\n * exactly the environments we know least about.\n *\n * Dimensions are reported as-is, including the axis swap a rotated device\n * applies to them. Normalizing orientation away is a policy question — whether a\n * cap should flap on rotation, or hold the larger budget across both — and\n * belongs to the cap rather than to the reading.\n */\nexport function getScreenResolution(\n { useDevicePixelRatio }: ScreenResolutionOptions = { useDevicePixelRatio: true }\n): ScreenResolution | undefined {\n const screen = globalThis.screen;\n if (!screen) return undefined;\n\n // `|| 1` covers a missing or nonsense ratio: a CSS-pixel reading is still true\n // and still cappable, so it isn't worth failing the whole answer over.\n const ratio = useDevicePixelRatio ? globalThis.devicePixelRatio || 1 : 1;\n\n // Rounded because device pixels are whole and a fractional ratio doesn't divide\n // a screen evenly. `NaN` from a nonsense dimension fails the check below, since\n // no comparison against it holds.\n const width = Math.round(screen.width * ratio);\n const height = Math.round(screen.height * ratio);\n\n return width > 0 && height > 0 ? { width, height } : undefined;\n}\n\n/**\n * Call `onChange` whenever {@link getScreenResolution} would start answering\n * differently. Returns a function that stops watching.\n *\n * There is no single event for \"the screen changed\", so this subscribes to every\n * signal that implies one and compares readings to decide whether anything\n * actually moved. Comparing is what makes that safe: the signals overlap and\n * `resize` in particular is noisy, so over-subscribing costs a discarded read\n * rather than a spurious call.\n *\n * `onChange` is called once on subscribe with the starting value — including\n * `undefined` where there is no screen — and after that only on a genuine change.\n * So a consumer gets its initial state from the watcher and never has to pair it\n * with a separate {@link getScreenResolution} call.\n *\n * The signals, and what each one is here for:\n *\n * - **`screen`'s own `change`** — the screen itself being reconfigured, or the\n * window landing on a different one. The direct signal, and the only one that\n * catches a window moving between two same-size, same-ratio displays. From the\n * Window Management API, but on the base `Screen` rather than behind\n * `getScreenDetails()`, so it needs no permission — only a secure context.\n * Measured present in Chromium and absent in WebKit and Firefox, hence the\n * three below rather than this alone.\n * - **`resize`** — the window changing size, which is also what the OS does to it\n * when the display it was on goes away.\n * - **`screen.orientation` change** — rotation, which swaps the axes without\n * necessarily resizing the window.\n * - **a `(resolution: <ratio>dppx)` media query** — the device pixel ratio\n * changing under a window that kept its size, which is the cross-display drag\n * between displays of different density. Each query only answers about the ratio\n * it was built for, so it reports one change and its handler arms the next.\n *\n * Worth keeping despite looking redundant, because it is the only coverage that\n * case has in WebKit and Firefox: neither implements `screen`'s change event,\n * and the drag doesn't resize the window. It is also a cleaner signal in Safari\n * than elsewhere — WebKit holds `devicePixelRatio` independent of page zoom, so\n * there it moves only on a real density change, where Chromium and Gecko fold\n * zoom into it as well.\n *\n * ⚠️ Known gap, on engines without `screen`'s change event: dragging a window\n * between two different-size displays that share a ratio, without the window\n * resizing, changes the reading with nothing firing. Closing it there would mean\n * polling, whose interval and battery cost are a policy decision this function\n * shouldn't be making.\n */\nexport function watchScreenResolution(\n onChange: (resolution: ScreenResolution | undefined) => void,\n options: ScreenResolutionOptions = { useDevicePixelRatio: true }\n): () => void {\n // One signal for every listener, so stopping is one call rather than a handle\n // per subscription. Also makes a late `watchRatio` inert: `addEventListener`\n // drops a listener whose signal has already aborted.\n const disconnect = new AbortController();\n const { signal } = disconnect;\n let current = getScreenResolution(options);\n\n const check = () => {\n const next = getScreenResolution(options);\n if (shallowEqual(current, next)) return;\n\n current = next;\n onChange(next);\n };\n\n // Deliver the starting value up front, so a consumer gets its initial state from\n // the watcher rather than having to pair it with a separate read. Unconditional,\n // rather than falling out of comparing against an empty `current`: an unknown\n // reading is a value too, and a consumer that only ever heard from us about a\n // *known* screen couldn't tell \"there is no screen\" from \"not called yet\".\n //\n // Before the listeners rather than after, so a callback that throws takes\n // nothing with it — there is no subscription yet to strand.\n onChange(current);\n\n // A `dppx` query only answers about the ratio it was built for, so each one\n // reports a single change and the handler builds the next. `once: true` is what\n // keeps that from accumulating listeners: the fired one is gone before the\n // replacement is armed, with no handle to track. Same shape as MDN's snippet\n // for this, whose earlier non-re-arming version fired exactly once and stopped.\n const watchRatio = () => {\n const query = globalThis.matchMedia?.(`(resolution: ${globalThis.devicePixelRatio}dppx)`);\n if (!query) return;\n\n listen(\n query,\n 'change',\n () => {\n watchRatio();\n check();\n },\n { once: true, signal }\n );\n };\n\n watchRatio();\n\n // Each signal is optional for the same reason the reading is: an environment\n // missing one has nothing to report from it, which is not a reason to fail.\n // `screen`'s own change event is subscribed without feature-detecting — where\n // it isn't implemented it simply never fires, and a signal that never fires\n // costs nothing under comparison.\n const screen = globalThis.screen;\n const orientation = screen?.orientation;\n\n if (globalThis.window) listen(globalThis.window, 'resize', check, { signal });\n // NOTE: Chromium browsers support screen change event.\n // See: https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event\n if (isEventTarget(screen)) listen(screen, 'change', check, { signal });\n if (orientation) listen(orientation, 'change', check, { signal });\n\n return () => disconnect.abort();\n}\n\nfunction isEventTarget(value: any): value is EventTarget {\n return isFunction(value?.addEventListener);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DA,SAAgB,oBACd,EAAE,wBAAiD,EAAE,qBAAqB,KAAK,GACjD;CAC9B,MAAM,SAAS,WAAW;CAC1B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAIpB,MAAM,QAAQ,sBAAsB,WAAW,oBAAoB,IAAI;CAKvE,MAAM,QAAQ,KAAK,MAAM,OAAO,QAAQ,KAAK;CAC7C,MAAM,SAAS,KAAK,MAAM,OAAO,SAAS,KAAK;CAE/C,OAAO,QAAQ,KAAK,SAAS,IAAI;EAAE;EAAO;CAAO,IAAI,KAAA;AACvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,SAAgB,sBACd,UACA,UAAmC,EAAE,qBAAqB,KAAK,GACnD;CAIZ,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,EAAE,WAAW;CACnB,IAAI,UAAU,oBAAoB,OAAO;CAEzC,MAAM,cAAc;EAClB,MAAM,OAAO,oBAAoB,OAAO;EACxC,IAAI,aAAa,SAAS,IAAI,GAAG;EAEjC,UAAU;EACV,SAAS,IAAI;CACf;CAUA,SAAS,OAAO;CAOhB,MAAM,mBAAmB;EACvB,MAAM,QAAQ,WAAW,aAAa,gBAAgB,WAAW,iBAAiB,MAAM;EACxF,IAAI,CAAC,OAAO;EAEZ,OACE,OACA,gBACM;GACJ,WAAW;GACX,MAAM;EACR,GACA;GAAE,MAAM;GAAM;EAAO,CACvB;CACF;CAEA,WAAW;CAOX,MAAM,SAAS,WAAW;CAC1B,MAAM,cAAc,QAAQ;CAE5B,IAAI,WAAW,QAAQ,OAAO,WAAW,QAAQ,UAAU,OAAO,EAAE,OAAO,CAAC;CAG5E,IAAI,cAAc,MAAM,GAAG,OAAO,QAAQ,UAAU,OAAO,EAAE,OAAO,CAAC;CACrE,IAAI,aAAa,OAAO,aAAa,UAAU,OAAO,EAAE,OAAO,CAAC;CAEhE,aAAa,WAAW,MAAM;AAChC;AAEA,SAAS,cAAc,OAAkC;CACvD,OAAO,WAAW,OAAO,gBAAgB;AAC3C"}
1
+ {"version":3,"file":"screen.js","names":[],"sources":["../../../../src/media/dom/screen.ts"],"sourcesContent":["/**\n * Screen resolution, as the signal source for a screen-size rendition cap.\n *\n * Reported as a width and a height rather than a `\"720p\"`-style tier, because\n * the cap that consumes it compares against real track dimensions. A tier only\n * describes a track once you assume its aspect ratio, and that assumption\n * mis-measures an anamorphic or otherwise non-16:9 rendition.\n *\n * The signal source for the screen-size cap in\n * `internal/design/spf/features/rendition-selection-caps.md`.\n *\n * The screen underneath a window is not stable: rotating a device swaps the axes,\n * and unplugging a monitor or dragging the window to another display changes the\n * numbers *and* which physical screen they describe. So `getScreenResolution`\n * reads at call time and caches nothing, and `watchScreenResolution` layers the\n * reacting on top rather than the reader holding state of its own.\n */\n\nimport { getDevicePixelRatio, listen, watchDevicePixelRatio } from '@videojs/utils/dom';\nimport { shallowEqual } from '@videojs/utils/object';\nimport { isFunction } from '@videojs/utils/predicate';\nimport { type Resolution, scaleResolution } from '../primitives/resolution';\n\n/** A screen's pixel dimensions. */\nexport type ScreenResolution = Resolution;\n\nexport interface ScreenResolutionOptions {\n /**\n * Scale the reading from CSS pixels into device pixels. On by default, since\n * device pixels are what the screen actually has, and a rendition's dimensions\n * are in the same units.\n *\n * Opt out for CSS pixels. Note that derating — a 3x phone rarely wanting 3x the\n * pixels of its layout — is a scale applied over this reading rather than a\n * reason to turn it off.\n *\n * ⚠️ Chromium and Gecko fold page zoom into `devicePixelRatio`, so with this on,\n * zooming moves the reading even though the screen didn't change. WebKit holds\n * the ratio independent of zoom and is unaffected. Whether a cap should track\n * zoom is the cap's call; this flag is only what puts zoom in scope.\n */\n useDevicePixelRatio: boolean;\n}\n\n/**\n * Read the screen's resolution, or `undefined` where there isn't one to read.\n *\n * `undefined` means \"unknown\", which is the answer a cap needs in order to not\n * cap. `screenResolutionCap` reads it that way and declines to narrow, so an\n * unknown screen is \"no cap\" rather than a cap of zero — the reading a naive\n * `?? 0` would produce, which would pin every source to its smallest rendition on\n * exactly the environments we know least about.\n *\n * Dimensions are reported as-is, including the axis swap a rotated device\n * applies to them. Normalizing orientation away is a policy question — whether a\n * cap should flap on rotation, or hold the larger budget across both — and\n * belongs to the cap rather than to the reading.\n */\nexport function getScreenResolution(\n { useDevicePixelRatio }: ScreenResolutionOptions = { useDevicePixelRatio: true }\n): ScreenResolution | undefined {\n const screen = globalThis.screen;\n if (!screen) return undefined;\n\n // A missing or nonsense ratio falls back to 1 (see `getDevicePixelRatio`): a\n // CSS-pixel reading is still true and still cappable, so it isn't worth failing\n // the whole answer over. Rounding, and the nothing-to-report case a `NaN` or\n // zero dimension lands in, belong to `scaleResolution`.\n return scaleResolution(screen, useDevicePixelRatio ? getDevicePixelRatio() : 1);\n}\n\n/**\n * Call `onChange` whenever {@link getScreenResolution} would start answering\n * differently. Returns a function that stops watching.\n *\n * There is no single event for \"the screen changed\", so this subscribes to every\n * signal that implies one and compares readings to decide whether anything\n * actually moved. Comparing is what makes that safe: the signals overlap and\n * `resize` in particular is noisy, so over-subscribing costs a discarded read\n * rather than a spurious call.\n *\n * `onChange` is called once on subscribe with the starting value — including\n * `undefined` where there is no screen — and after that only on a genuine change.\n * So a consumer gets its initial state from the watcher and never has to pair it\n * with a separate {@link getScreenResolution} call.\n *\n * The signals, and what each one is here for:\n *\n * - **`screen`'s own `change`** — the screen itself being reconfigured, or the\n * window landing on a different one. The direct signal, and the only one that\n * catches a window moving between two same-size, same-ratio displays. From the\n * Window Management API, but on the base `Screen` rather than behind\n * `getScreenDetails()`, so it needs no permission — only a secure context.\n * Measured present in Chromium and absent in WebKit and Firefox, hence the\n * three below rather than this alone.\n * - **`resize`** — the window changing size, which is also what the OS does to it\n * when the display it was on goes away.\n * - **`screen.orientation` change** — rotation, which swaps the axes without\n * necessarily resizing the window.\n * - **a `(resolution: <ratio>dppx)` media query** (`watchDevicePixelRatio`) — the\n * device pixel ratio changing under a window that kept its size, which is the\n * cross-display drag between displays of different density.\n *\n * Worth keeping despite looking redundant, because it is the only coverage that\n * case has in WebKit and Firefox: neither implements `screen`'s change event,\n * and the drag doesn't resize the window. It is also a cleaner signal in Safari\n * than elsewhere — WebKit holds `devicePixelRatio` independent of page zoom, so\n * there it moves only on a real density change, where Chromium and Gecko fold\n * zoom into it as well.\n *\n * ⚠️ Known gap, on engines without `screen`'s change event: dragging a window\n * between two different-size displays that share a ratio, without the window\n * resizing, changes the reading with nothing firing. Closing it there would mean\n * polling, whose interval and battery cost are a policy decision this function\n * shouldn't be making.\n */\nexport function watchScreenResolution(\n onChange: (resolution: ScreenResolution | undefined) => void,\n options: ScreenResolutionOptions = { useDevicePixelRatio: true }\n): () => void {\n // One signal for every listener, so stopping is one call rather than a handle\n // per subscription. Also makes a late ratio re-arm inert: `addEventListener`\n // drops a listener whose signal has already aborted.\n const disconnect = new AbortController();\n const { signal } = disconnect;\n let current = getScreenResolution(options);\n\n const check = () => {\n const next = getScreenResolution(options);\n if (shallowEqual(current, next)) return;\n\n current = next;\n onChange(next);\n };\n\n // Deliver the starting value up front, so a consumer gets its initial state from\n // the watcher rather than having to pair it with a separate read. Unconditional,\n // rather than falling out of comparing against an empty `current`: an unknown\n // reading is a value too, and a consumer that only ever heard from us about a\n // *known* screen couldn't tell \"there is no screen\" from \"not called yet\".\n //\n // Before the listeners rather than after, so a callback that throws takes\n // nothing with it — there is no subscription yet to strand.\n onChange(current);\n\n watchDevicePixelRatio(check, signal);\n\n // Each signal is optional for the same reason the reading is: an environment\n // missing one has nothing to report from it, which is not a reason to fail.\n // `screen`'s own change event is subscribed without feature-detecting — where\n // it isn't implemented it simply never fires, and a signal that never fires\n // costs nothing under comparison.\n const screen = globalThis.screen;\n const orientation = screen?.orientation;\n\n if (globalThis.window) listen(globalThis.window, 'resize', check, { signal });\n // NOTE: Chromium browsers support screen change event.\n // See: https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event\n if (isEventTarget(screen)) listen(screen, 'change', check, { signal });\n if (orientation) listen(orientation, 'change', check, { signal });\n\n return () => disconnect.abort();\n}\n\nfunction isEventTarget(value: any): value is EventTarget {\n return isFunction(value?.addEventListener);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,SAAgB,oBACd,EAAE,wBAAiD,EAAE,qBAAqB,KAAK,GACjD;CAC9B,MAAM,SAAS,WAAW;CAC1B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAMpB,OAAO,gBAAgB,QAAQ,sBAAsB,oBAAoB,IAAI,CAAC;AAChF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CA,SAAgB,sBACd,UACA,UAAmC,EAAE,qBAAqB,KAAK,GACnD;CAIZ,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,EAAE,WAAW;CACnB,IAAI,UAAU,oBAAoB,OAAO;CAEzC,MAAM,cAAc;EAClB,MAAM,OAAO,oBAAoB,OAAO;EACxC,IAAI,aAAa,SAAS,IAAI,GAAG;EAEjC,UAAU;EACV,SAAS,IAAI;CACf;CAUA,SAAS,OAAO;CAEhB,sBAAsB,OAAO,MAAM;CAOnC,MAAM,SAAS,WAAW;CAC1B,MAAM,cAAc,QAAQ;CAE5B,IAAI,WAAW,QAAQ,OAAO,WAAW,QAAQ,UAAU,OAAO,EAAE,OAAO,CAAC;CAG5E,IAAI,cAAc,MAAM,GAAG,OAAO,QAAQ,UAAU,OAAO,EAAE,OAAO,CAAC;CACrE,IAAI,aAAa,OAAO,aAAa,UAAU,OAAO,EAAE,OAAO,CAAC;CAEhE,aAAa,WAAW,MAAM;AAChC;AAEA,SAAS,cAAc,OAAkC;CACvD,OAAO,WAAW,OAAO,gBAAgB;AAC3C"}
@@ -0,0 +1,29 @@
1
+ //#region src/media/primitives/resolution.ts
2
+ /**
3
+ * Apply `scale` to a size and normalize it to whole pixels, or `undefined` where
4
+ * that leaves nothing to describe.
5
+ *
6
+ * Rounded because pixels are whole and a fractional scale doesn't divide a
7
+ * surface evenly. A non-positive or non-finite axis yields `undefined` rather
8
+ * than a zero-area reading: an element that isn't being rendered reports `0 × 0`
9
+ * and a nonsense dimension reports `NaN`, and the caps consuming this read
10
+ * absence as "unknown, don't cap" while an area of zero would read as a cap of
11
+ * zero — pinning every source to its smallest rendition.
12
+ *
13
+ * @param size - Dimensions to project, in the units `scale` converts from
14
+ * @param scale - Multiplier for both axes, e.g. `devicePixelRatio`; defaults to 1
15
+ * @returns The scaled, whole-pixel resolution, or `undefined` when either axis
16
+ * doesn't survive it
17
+ */
18
+ function scaleResolution(size, scale = 1) {
19
+ const width = Math.round(size.width * scale);
20
+ const height = Math.round(size.height * scale);
21
+ return width > 0 && height > 0 ? {
22
+ width,
23
+ height
24
+ } : void 0;
25
+ }
26
+ //#endregion
27
+ export { scaleResolution };
28
+
29
+ //# sourceMappingURL=resolution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolution.js","names":[],"sources":["../../../../src/media/primitives/resolution.ts"],"sourcesContent":["/**\n * Pixel dimensions, and the one projection every reader of them needs.\n *\n * Shared by the surfaces a rendition cap measures — a screen (`media/dom/screen`),\n * a player element (`behaviors/dom/track-player-resolution`) — and by the caps\n * that compare renditions against them. Lives outside the DOM layer because\n * scaling and rounding two numbers needs no DOM, which is also what lets the\n * DOM-free selection rules name the type they read instead of restating its shape.\n */\n\n/**\n * A surface's pixel dimensions.\n *\n * A width and a height rather than a `\"720p\"`-style tier, because a tier only\n * describes a surface once you assume its aspect ratio — the assumption that\n * mis-measures an anamorphic or otherwise non-16:9 rendition.\n */\nexport interface Resolution {\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Apply `scale` to a size and normalize it to whole pixels, or `undefined` where\n * that leaves nothing to describe.\n *\n * Rounded because pixels are whole and a fractional scale doesn't divide a\n * surface evenly. A non-positive or non-finite axis yields `undefined` rather\n * than a zero-area reading: an element that isn't being rendered reports `0 × 0`\n * and a nonsense dimension reports `NaN`, and the caps consuming this read\n * absence as \"unknown, don't cap\" while an area of zero would read as a cap of\n * zero — pinning every source to its smallest rendition.\n *\n * @param size - Dimensions to project, in the units `scale` converts from\n * @param scale - Multiplier for both axes, e.g. `devicePixelRatio`; defaults to 1\n * @returns The scaled, whole-pixel resolution, or `undefined` when either axis\n * doesn't survive it\n */\nexport function scaleResolution(size: Resolution, scale = 1): Resolution | undefined {\n const width = Math.round(size.width * scale);\n const height = Math.round(size.height * scale);\n\n return width > 0 && height > 0 ? { width, height } : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsCA,SAAgB,gBAAgB,MAAkB,QAAQ,GAA2B;CACnF,MAAM,QAAQ,KAAK,MAAM,KAAK,QAAQ,KAAK;CAC3C,MAAM,SAAS,KAAK,MAAM,KAAK,SAAS,KAAK;CAE7C,OAAO,QAAQ,KAAK,SAAS,IAAI;EAAE;EAAO;CAAO,IAAI,KAAA;AACvD"}
@@ -36,6 +36,22 @@ function tracksUnderPixelArea(tracks, maxPixelArea = Number.POSITIVE_INFINITY) {
36
36
  return tracks.filter((track) => pixelArea(track) <= maxPixelArea);
37
37
  }
38
38
  /**
39
+ * The smallest track area that still covers `minPixelArea`, or `undefined` when
40
+ * no track reaches it.
41
+ *
42
+ * The cap a surface-size rule wants when it should round *up* to the ladder: a
43
+ * surface between two tiers is covered by the upper one, and capping at the
44
+ * surface's own area instead would serve a picture smaller than the surface and
45
+ * upscale it.
46
+ *
47
+ * Tracks declaring no dimensions compare as area `0` and so never cover anything,
48
+ * which keeps them out of the cap rather than pinning it to zero.
49
+ */
50
+ function smallestCoveringPixelArea(tracks, minPixelArea) {
51
+ const covering = tracks.map(pixelArea).filter((area) => area >= minPixelArea);
52
+ return covering.length ? Math.min(...covering) : void 0;
53
+ }
54
+ /**
39
55
  * Compare two tracks by resolution, largest first, with bandwidth as the tiebreak
40
56
  * for renditions of identical dimensions. Missing dimensions are treated as area
41
57
  * `0`, so a track without them sorts last.
@@ -86,6 +102,6 @@ function pickTextTrackFromTracks(tracks, config) {
86
102
  }
87
103
  }
88
104
  //#endregion
89
- export { byDescendingResolution, matchesPartialTrack, pickAudioTrackFromTracks, pickTextTrackFromTracks, tracksUnderPixelArea };
105
+ export { byDescendingResolution, matchesPartialTrack, pickAudioTrackFromTracks, pickTextTrackFromTracks, smallestCoveringPixelArea, tracksUnderPixelArea };
90
106
 
91
107
  //# sourceMappingURL=select-tracks.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/media/primitives/select-tracks.ts"],"sourcesContent":["import type { MaybeResolvedPresentation, PartiallyResolvedTextTrack, TextTrack } from '../types';\n\n/**\n * State shape for track selection.\n */\nexport interface TrackSelectionState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/**\n * Configuration for audio track selection.\n */\nexport interface AudioSelectionConfig {\n /**\n * Preferred audio language (ISO 639 code, e.g., \"en\", \"es\").\n * If not specified, selects first audio track.\n */\n preferredAudioLanguage?: string;\n}\n\n/**\n * Configuration for text track selection.\n */\nexport interface TextSelectionConfig {\n /**\n * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching track if available.\n */\n preferredSubtitleLanguage?: string;\n\n /**\n * Include FORCED subtitle tracks in selection.\n * Default: false (follows hls.js/http-streaming pattern)\n *\n * Note: Per Apple's HLS spec, if content has forced and regular subtitles\n * in the same language, the regular track MUST contain both forced and\n * regular content. Therefore, forced-only tracks are redundant and excluded\n * by default.\n */\n includeForcedTracks?: boolean;\n\n /**\n * Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).\n * Default: false (user opt-in, matches hls.js/http-streaming)\n *\n * When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES\n * will be automatically selected if no user preference matches.\n */\n enableDefaultTrack?: boolean;\n}\n\n// =============================================================================\n// Helper Functions (Pure Selection Logic)\n//\n// Candidate-list policies and track geometry, for the selection rules in\n// `playback/behaviors/select-tracks.ts` and `playback/behaviors/track-switching.ts`\n// to compose. Nothing here consults a whole presentation or returns a single id:\n// narrowing a list and ordering a list are the two shapes a rule can take, and the\n// rule chain takes the head of what they leave.\n// =============================================================================\n\n/**\n * Test whether a track matches a partial-track description: every present,\n * defined field of `filter` equals the track's. Absent or `undefined` filter\n * fields don't constrain. Used to narrow candidates by a user selection\n * (`{ id }`, `{ language }`, `{ height }`, …).\n *\n * @param track - The track to test\n * @param filter - Partial-track description; only present, defined fields constrain\n * @returns `true` when the track matches every constraining field\n */\nexport function matchesPartialTrack<T>(track: T, filter: Partial<T>): boolean {\n for (const key in filter) {\n const filterValue = filter[key as keyof T];\n if (filterValue !== undefined && track[key as keyof T] !== filterValue) return false;\n }\n return true;\n}\n\ntype RankableTrack = { id: string; width?: number; height?: number; bandwidth?: number };\n\n/** Missing dimensions are treated as area `0`, so a track without them ranks last. */\nfunction pixelArea(track: RankableTrack): number {\n return (track.width ?? 0) * (track.height ?? 0);\n}\n\n/**\n * Narrow to the tracks at or below `maxPixelArea`, and nothing else: no ordering,\n * no fallback. Survivors keep their incoming order, and an empty result is a real\n * answer — \"none of these fit\".\n *\n * Deliberately only the filter, because as a selection rule this composes under\n * `applyRules`, which already owns both halves a caller might expect here: an empty\n * result is skipped, so a preference can never narrow the candidate set to nothing;\n * and ordering the survivors is a separate rule's job\n * ({@link byDescendingResolution}, bandwidth ABR). Doing either here would duplicate\n * the composer and give one rule two responsibilities.\n */\nexport function tracksUnderPixelArea<T extends RankableTrack>(\n tracks: readonly T[],\n maxPixelArea: number = Number.POSITIVE_INFINITY\n): readonly T[] {\n return tracks.filter((track) => pixelArea(track) <= maxPixelArea);\n}\n\n/**\n * Compare two tracks by resolution, largest first, with bandwidth as the tiebreak\n * for renditions of identical dimensions. Missing dimensions are treated as area\n * `0`, so a track without them sorts last.\n *\n * A comparator rather than a \"highest track\" function: the selection-rule chain\n * takes the head of the list it produces, so ranking never has to collapse to a\n * single track. `preferHighestResolution` is `sort` over this and nothing more.\n */\nexport function byDescendingResolution(a: RankableTrack, b: RankableTrack): number {\n return pixelArea(b) - pixelArea(a) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0);\n}\n\n/**\n * Default audio policy over a candidate list: the three-tier pick a selection-rule\n * chain applies once it has narrowed the candidates.\n *\n * Priority: `preferredAudioLanguage` match → `DEFAULT=YES` → first track.\n */\nexport function pickAudioTrackFromTracks(\n tracks: readonly { id: string; language?: string | undefined; default?: boolean | undefined }[],\n config?: AudioSelectionConfig\n): string | undefined {\n // Try preferred language first\n if (config?.preferredAudioLanguage) {\n const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);\n if (languageMatch) {\n return languageMatch.id;\n }\n }\n\n // Try default track\n const defaultTrack = tracks.find((track) => track.default === true);\n if (defaultTrack) {\n return defaultTrack.id;\n }\n\n // Fall back to first track\n return tracks[0]?.id;\n}\n\n/**\n * Default text-track policy over a candidate list: the opt-in three-tier pick\n * `switchTextTrack`'s terminal applies once it has narrowed the renditions to the\n * constrained, CDN-scoped set.\n *\n * Priority: `preferredSubtitleLanguage` match → `DEFAULT=YES + AUTOSELECT=YES`\n * (only when `enableDefaultTrack`) → `undefined` (opt-in). FORCED tracks are\n * excluded unless `includeForcedTracks` (Apple-spec: a regular track must carry\n * forced content when both exist, so a forced-only track is redundant).\n */\nexport function pickTextTrackFromTracks(\n tracks: readonly (PartiallyResolvedTextTrack | TextTrack)[],\n config?: TextSelectionConfig\n): string | undefined {\n const availableTracks = config?.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);\n if (availableTracks.length === 0) return undefined;\n\n const { preferredSubtitleLanguage, enableDefaultTrack = false } = config ?? {};\n\n if (preferredSubtitleLanguage) {\n const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);\n if (languageMatch) return languageMatch.id;\n }\n\n if (enableDefaultTrack) {\n const defaultTrack = availableTracks.find((track) => track.default === true);\n if (defaultTrack) return defaultTrack.id;\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;AA0EA,SAAgB,oBAAuB,OAAU,QAA6B;CAC5E,KAAK,MAAM,OAAO,QAAQ;EACxB,MAAM,cAAc,OAAO;EAC3B,IAAI,gBAAgB,KAAA,KAAa,MAAM,SAAoB,aAAa,OAAO;CACjF;CACA,OAAO;AACT;;AAKA,SAAS,UAAU,OAA8B;CAC/C,QAAQ,MAAM,SAAS,MAAM,MAAM,UAAU;AAC/C;;;;;;;;;;;;;AAcA,SAAgB,qBACd,QACA,eAAuB,OAAO,mBAChB;CACd,OAAO,OAAO,QAAQ,UAAU,UAAU,KAAK,KAAK,YAAY;AAClE;;;;;;;;;;AAWA,SAAgB,uBAAuB,GAAkB,GAA0B;CACjF,OAAO,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,EAAE,aAAa,MAAM,EAAE,aAAa;AAC7E;;;;;;;AAQA,SAAgB,yBACd,QACA,QACoB;CAEpB,IAAI,QAAQ,wBAAwB;EAClC,MAAM,gBAAgB,OAAO,MAAM,UAAU,MAAM,aAAa,OAAO,sBAAsB;EAC7F,IAAI,eACF,OAAO,cAAc;CAEzB;CAGA,MAAM,eAAe,OAAO,MAAM,UAAU,MAAM,YAAY,IAAI;CAClE,IAAI,cACF,OAAO,aAAa;CAItB,OAAO,OAAO,EAAE,EAAE;AACpB;;;;;;;;;;;AAYA,SAAgB,wBACd,QACA,QACoB;CACpB,MAAM,kBAAkB,QAAQ,sBAAsB,SAAS,OAAO,QAAQ,UAAU,CAAC,MAAM,MAAM;CACrG,IAAI,gBAAgB,WAAW,GAAG,OAAO,KAAA;CAEzC,MAAM,EAAE,2BAA2B,qBAAqB,UAAU,UAAU,CAAC;CAE7E,IAAI,2BAA2B;EAC7B,MAAM,gBAAgB,gBAAgB,MAAM,UAAU,MAAM,aAAa,yBAAyB;EAClG,IAAI,eAAe,OAAO,cAAc;CAC1C;CAEA,IAAI,oBAAoB;EACtB,MAAM,eAAe,gBAAgB,MAAM,UAAU,MAAM,YAAY,IAAI;EAC3E,IAAI,cAAc,OAAO,aAAa;CACxC;AAGF"}
1
+ {"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/media/primitives/select-tracks.ts"],"sourcesContent":["import type { MaybeResolvedPresentation, PartiallyResolvedTextTrack, TextTrack } from '../types';\n\n/**\n * State shape for track selection.\n */\nexport interface TrackSelectionState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/**\n * Configuration for audio track selection.\n */\nexport interface AudioSelectionConfig {\n /**\n * Preferred audio language (ISO 639 code, e.g., \"en\", \"es\").\n * If not specified, selects first audio track.\n */\n preferredAudioLanguage?: string;\n}\n\n/**\n * Configuration for text track selection.\n */\nexport interface TextSelectionConfig {\n /**\n * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching track if available.\n */\n preferredSubtitleLanguage?: string;\n\n /**\n * Include FORCED subtitle tracks in selection.\n * Default: false (follows hls.js/http-streaming pattern)\n *\n * Note: Per Apple's HLS spec, if content has forced and regular subtitles\n * in the same language, the regular track MUST contain both forced and\n * regular content. Therefore, forced-only tracks are redundant and excluded\n * by default.\n */\n includeForcedTracks?: boolean;\n\n /**\n * Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).\n * Default: false (user opt-in, matches hls.js/http-streaming)\n *\n * When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES\n * will be automatically selected if no user preference matches.\n */\n enableDefaultTrack?: boolean;\n}\n\n// =============================================================================\n// Helper Functions (Pure Selection Logic)\n//\n// Candidate-list policies and track geometry, for the selection rules in\n// `playback/behaviors/select-tracks.ts` and `playback/behaviors/track-switching.ts`\n// to compose. Nothing here consults a whole presentation or returns a single id:\n// narrowing a list and ordering a list are the two shapes a rule can take, and the\n// rule chain takes the head of what they leave.\n// =============================================================================\n\n/**\n * Test whether a track matches a partial-track description: every present,\n * defined field of `filter` equals the track's. Absent or `undefined` filter\n * fields don't constrain. Used to narrow candidates by a user selection\n * (`{ id }`, `{ language }`, `{ height }`, …).\n *\n * @param track - The track to test\n * @param filter - Partial-track description; only present, defined fields constrain\n * @returns `true` when the track matches every constraining field\n */\nexport function matchesPartialTrack<T>(track: T, filter: Partial<T>): boolean {\n for (const key in filter) {\n const filterValue = filter[key as keyof T];\n if (filterValue !== undefined && track[key as keyof T] !== filterValue) return false;\n }\n return true;\n}\n\ntype RankableTrack = { id: string; width?: number; height?: number; bandwidth?: number };\n\n/** Missing dimensions are treated as area `0`, so a track without them ranks last. */\nfunction pixelArea(track: RankableTrack): number {\n return (track.width ?? 0) * (track.height ?? 0);\n}\n\n/**\n * Narrow to the tracks at or below `maxPixelArea`, and nothing else: no ordering,\n * no fallback. Survivors keep their incoming order, and an empty result is a real\n * answer — \"none of these fit\".\n *\n * Deliberately only the filter, because as a selection rule this composes under\n * `applyRules`, which already owns both halves a caller might expect here: an empty\n * result is skipped, so a preference can never narrow the candidate set to nothing;\n * and ordering the survivors is a separate rule's job\n * ({@link byDescendingResolution}, bandwidth ABR). Doing either here would duplicate\n * the composer and give one rule two responsibilities.\n */\nexport function tracksUnderPixelArea<T extends RankableTrack>(\n tracks: readonly T[],\n maxPixelArea: number = Number.POSITIVE_INFINITY\n): readonly T[] {\n return tracks.filter((track) => pixelArea(track) <= maxPixelArea);\n}\n\n/**\n * The smallest track area that still covers `minPixelArea`, or `undefined` when\n * no track reaches it.\n *\n * The cap a surface-size rule wants when it should round *up* to the ladder: a\n * surface between two tiers is covered by the upper one, and capping at the\n * surface's own area instead would serve a picture smaller than the surface and\n * upscale it.\n *\n * Tracks declaring no dimensions compare as area `0` and so never cover anything,\n * which keeps them out of the cap rather than pinning it to zero.\n */\nexport function smallestCoveringPixelArea(tracks: readonly RankableTrack[], minPixelArea: number): number | undefined {\n const covering = tracks.map(pixelArea).filter((area) => area >= minPixelArea);\n\n return covering.length ? Math.min(...covering) : undefined;\n}\n\n/**\n * Compare two tracks by resolution, largest first, with bandwidth as the tiebreak\n * for renditions of identical dimensions. Missing dimensions are treated as area\n * `0`, so a track without them sorts last.\n *\n * A comparator rather than a \"highest track\" function: the selection-rule chain\n * takes the head of the list it produces, so ranking never has to collapse to a\n * single track. `preferHighestResolution` is `sort` over this and nothing more.\n */\nexport function byDescendingResolution(a: RankableTrack, b: RankableTrack): number {\n return pixelArea(b) - pixelArea(a) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0);\n}\n\n/**\n * Default audio policy over a candidate list: the three-tier pick a selection-rule\n * chain applies once it has narrowed the candidates.\n *\n * Priority: `preferredAudioLanguage` match → `DEFAULT=YES` → first track.\n */\nexport function pickAudioTrackFromTracks(\n tracks: readonly { id: string; language?: string | undefined; default?: boolean | undefined }[],\n config?: AudioSelectionConfig\n): string | undefined {\n // Try preferred language first\n if (config?.preferredAudioLanguage) {\n const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);\n if (languageMatch) {\n return languageMatch.id;\n }\n }\n\n // Try default track\n const defaultTrack = tracks.find((track) => track.default === true);\n if (defaultTrack) {\n return defaultTrack.id;\n }\n\n // Fall back to first track\n return tracks[0]?.id;\n}\n\n/**\n * Default text-track policy over a candidate list: the opt-in three-tier pick\n * `switchTextTrack`'s terminal applies once it has narrowed the renditions to the\n * constrained, CDN-scoped set.\n *\n * Priority: `preferredSubtitleLanguage` match → `DEFAULT=YES + AUTOSELECT=YES`\n * (only when `enableDefaultTrack`) → `undefined` (opt-in). FORCED tracks are\n * excluded unless `includeForcedTracks` (Apple-spec: a regular track must carry\n * forced content when both exist, so a forced-only track is redundant).\n */\nexport function pickTextTrackFromTracks(\n tracks: readonly (PartiallyResolvedTextTrack | TextTrack)[],\n config?: TextSelectionConfig\n): string | undefined {\n const availableTracks = config?.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);\n if (availableTracks.length === 0) return undefined;\n\n const { preferredSubtitleLanguage, enableDefaultTrack = false } = config ?? {};\n\n if (preferredSubtitleLanguage) {\n const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);\n if (languageMatch) return languageMatch.id;\n }\n\n if (enableDefaultTrack) {\n const defaultTrack = availableTracks.find((track) => track.default === true);\n if (defaultTrack) return defaultTrack.id;\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;AA0EA,SAAgB,oBAAuB,OAAU,QAA6B;CAC5E,KAAK,MAAM,OAAO,QAAQ;EACxB,MAAM,cAAc,OAAO;EAC3B,IAAI,gBAAgB,KAAA,KAAa,MAAM,SAAoB,aAAa,OAAO;CACjF;CACA,OAAO;AACT;;AAKA,SAAS,UAAU,OAA8B;CAC/C,QAAQ,MAAM,SAAS,MAAM,MAAM,UAAU;AAC/C;;;;;;;;;;;;;AAcA,SAAgB,qBACd,QACA,eAAuB,OAAO,mBAChB;CACd,OAAO,OAAO,QAAQ,UAAU,UAAU,KAAK,KAAK,YAAY;AAClE;;;;;;;;;;;;;AAcA,SAAgB,0BAA0B,QAAkC,cAA0C;CACpH,MAAM,WAAW,OAAO,IAAI,SAAS,CAAC,CAAC,QAAQ,SAAS,QAAQ,YAAY;CAE5E,OAAO,SAAS,SAAS,KAAK,IAAI,GAAG,QAAQ,IAAI,KAAA;AACnD;;;;;;;;;;AAWA,SAAgB,uBAAuB,GAAkB,GAA0B;CACjF,OAAO,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,EAAE,aAAa,MAAM,EAAE,aAAa;AAC7E;;;;;;;AAQA,SAAgB,yBACd,QACA,QACoB;CAEpB,IAAI,QAAQ,wBAAwB;EAClC,MAAM,gBAAgB,OAAO,MAAM,UAAU,MAAM,aAAa,OAAO,sBAAsB;EAC7F,IAAI,eACF,OAAO,cAAc;CAEzB;CAGA,MAAM,eAAe,OAAO,MAAM,UAAU,MAAM,YAAY,IAAI;CAClE,IAAI,cACF,OAAO,aAAa;CAItB,OAAO,OAAO,EAAE,EAAE;AACpB;;;;;;;;;;;AAYA,SAAgB,wBACd,QACA,QACoB;CACpB,MAAM,kBAAkB,QAAQ,sBAAsB,SAAS,OAAO,QAAQ,UAAU,CAAC,MAAM,MAAM;CACrG,IAAI,gBAAgB,WAAW,GAAG,OAAO,KAAA;CAEzC,MAAM,EAAE,2BAA2B,qBAAqB,UAAU,UAAU,CAAC;CAE7E,IAAI,2BAA2B;EAC7B,MAAM,gBAAgB,gBAAgB,MAAM,UAAU,MAAM,aAAa,yBAAyB;EAClG,IAAI,eAAe,OAAO,cAAc;CAC1C;CAEA,IAAI,oBAAoB;EACtB,MAAM,eAAe,gBAAgB,MAAM,UAAU,MAAM,YAAY,IAAI;EAC3E,IAAI,cAAc,OAAO,aAAa;CACxC;AAGF"}
@@ -21,9 +21,9 @@ const hlsBackgroundVideoMediaDefaultProps = { src: "" };
21
21
  * 1004 and an encrypted one 4008, each with no verdict behind it, and the element
22
22
  * then sits at `readyState 0` with `error` null forever.
23
23
  *
24
- * The verdict is still listed, for the one shape that reports nothing else: a
25
- * source offering no video renditions at all, which `reportAbsentTrackType`
26
- * reports from the head of the constraint chain.
24
+ * The verdict is still listed, for the shapes that report nothing else: no video
25
+ * renditions at all, or a ladder pruned before anything resolves — both of which
26
+ * `reportAbsentTrackType` covers from the tail of the constraint chain.
27
27
  *
28
28
  * First-fatal-wins then surfaces the cause rather than the verdict when both are
29
29
  * present, which is the more specific of the two.
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.js","names":["#config","#engine","#createEngine","#stopErrorSync","#signals","#setError","#error","#reportedCode","#cancelPendingPlay","#loadstartListener"],"sources":["../../../../../src/playback/adapters/hls-background-video/adapter.ts"],"sourcesContent":["import type { Constructor, MixinReturn } from '@videojs/utils/types';\nimport type { Composition } from '../../../core/composition/create-composition';\nimport { effect } from '../../../core/signals/effect';\nimport {\n SVTA_NO_SUPPORTED_VIDEO_TRACK,\n SVTA_UNSUPPORTED_DRM_SYSTEM,\n SVTA_UNSUPPORTED_PLAYBACK_FEATURE,\n SVTA_UNSUPPORTED_VIDEO_FORMAT,\n type SvtaError,\n} from '../../../media/errors';\nimport {\n type BackgroundVideoEngineConfig,\n type BackgroundVideoEngineContext,\n type BackgroundVideoEngineSignals,\n type BackgroundVideoEngineState,\n createBackgroundVideoEngine,\n} from '../../engines/hls/engine-background-video';\nimport { UNPLAYABLE_SOURCE_MESSAGE } from '../../primitives/error-messages';\nimport { firstFatal, type HlsVideoMediaError, hasUnsupportedFeatureCause } from '../hls-video/error-surface';\n\n// The same error shape the video and audio Medias expose, under the name they\n// publish it as — one type for all three surfaces rather than a background-flavored\n// copy of it.\nexport type { HlsVideoMediaError } from '../hls-video/error-surface';\n\nexport interface HlsBackgroundVideoMediaProps {\n src: string;\n}\n\nexport const hlsBackgroundVideoMediaDefaultProps: HlsBackgroundVideoMediaProps = {\n src: '',\n};\n\nexport interface HlsBackgroundVideoMediaAPI extends HlsBackgroundVideoMediaProps {\n readonly engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n readonly error: HlsVideoMediaError | null;\n attach(mediaElement: HTMLMediaElement): void;\n detach(): void;\n destroy(): void;\n play(): Promise<void>;\n}\n\n/**\n * Which reported conditions this composition treats as **fatal** — the ones that\n * reach `error` and fire `'error'`. Severity isn't part of an SVTA code\n * (§Approach: \"impact varies with player implementation\"), so it's decided at\n * this boundary rather than by the reporter.\n *\n * **Causes are fatal here, unlike on the other two adapters.** There, a cause is\n * context — one unplayable rendition doesn't fail a source whose others still\n * play, and a verdict follows if the type empties. In the pinned variant a cause\n * *is* the verdict: only the pinned rendition's playlist is ever resolved, so a\n * cause can only be about the pick itself, and dropping that pick is final —\n * nothing here re-picks (that is what `switchVideoTrack` exists for, and this\n * engine doesn't compose it). Measured on Chromium: an MPEG-TS source reports\n * 1004 and an encrypted one 4008, each with no verdict behind it, and the element\n * then sits at `readyState 0` with `error` null forever.\n *\n * The verdict is still listed, for the one shape that reports nothing else: a\n * source offering no video renditions at all, which `reportAbsentTrackType`\n * reports from the head of the constraint chain.\n *\n * First-fatal-wins then surfaces the cause rather than the verdict when both are\n * present, which is the more specific of the two.\n */\nconst FATAL_SVTA_CODES: ReadonlySet<number> = new Set<number>([\n SVTA_NO_SUPPORTED_VIDEO_TRACK,\n SVTA_UNSUPPORTED_VIDEO_FORMAT,\n SVTA_UNSUPPORTED_DRM_SYSTEM,\n]);\n\n/**\n * Mixin that adds the background-video SPF playback engine to any base class,\n * for an HLS URL.\n *\n * `src` is the whole input surface, and `error` is the one output: nothing about\n * an unplayable source reaches the media element on its own here — an unsupported\n * container, encryption with no EME, and an undecodable codec all leave\n * `HTMLMediaElement.error` null with the element stalled at `readyState 0`\n * (measured on Chromium and WebKit) — so a consumer that watched only the\n * `<video>` would see a source that never appears and never says why. The engine\n * reports each condition onto `engine.state.errors` and logs it; this adapter\n * promotes the first fatal one, mapping it the same way the video and audio Medias\n * map theirs. See `internal/design/spf/features/errors.md`.\n *\n * Selection pins the largest rendition that *fits the screen*, and holds it for\n * the session. The manifest is still the better place to narrow further: a\n * delivery param — `?max_resolution=720p` on a Mux stream URL, for one — keeps\n * the renditions it excludes out of the manifest entirely, rather than\n * fetched-then-unpicked.\n *\n * The pin is given up, never moved, if the pick turns out to be unplayable: the\n * container is only known once a media playlist resolves, which is after the pick\n * is made, so the selection clears rather than quietly appending bytes nothing can\n * decode.\n *\n * `@videojs/spf/mux-background-video` is this same Media under a Mux-flavored\n * name — an alias, not a variant. Nothing about the surface changes with the\n * import path.\n *\n * Everything else the use case fixes rather than exposes: video-only, looping,\n * muted, autoplaying, loading as soon as there is a source. `attach` writes that\n * onto the element and nothing here declares `loop` / `muted` / `autoplay` /\n * `preload` of its own — a host-bound Media inherits all four from the host\n * already, and shadowing them with fixed values would only make reads describe\n * an intention rather than what the element is doing.\n *\n * A new src re-resolves the presentation, tearing down the state, SourceBuffers,\n * and in-flight requests the previous one built before the next begins. The\n * engine instance and the attached media element are both kept, so neither has to\n * be rewired.\n *\n * @fires error - Fired when a fatal condition is reported. Read `error` for it.\n *\n * @example\n * class HlsBackgroundVideoMedia extends HlsBackgroundVideoMediaMixin(BackgroundVideoHost) {}\n *\n * const media = new HlsBackgroundVideoMedia();\n * media.attach(document.querySelector('video'));\n * media.src = 'https://stream.mux.com/PLAYBACK_ID.m3u8?max_resolution=720p';\n * media.play();\n */\nexport function HlsBackgroundVideoMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {\n class HlsBackgroundVideoMediaImpl extends BaseClass {\n #engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n #config: BackgroundVideoEngineConfig;\n #signals!: BackgroundVideoEngineSignals;\n #error: HlsVideoMediaError | null = null;\n /**\n * The *reported* condition currently surfaced, which is what the re-fire latch\n * keys on. Not `#error.code`: that's the code this adapter chose to surface,\n * and the substitution below can make the two differ.\n */\n #reportedCode: number | null = null;\n #stopErrorSync: () => void;\n\n /** Pending loadstart listener from a deferred play() retry, if any. */\n #loadstartListener: (() => void) | null = null;\n\n constructor(...args: any[]) {\n super(...args);\n\n const { config } = args?.[0] ?? {};\n this.#config = config;\n this.#engine = this.#createEngine();\n\n // Promote the first fatal condition out of the engine's reported sequence\n // onto this surface. Clearing rides the same signal: `collectErrors` resets\n // the slot per source, so a new source starts with no error without this\n // needing its own source-change hook.\n this.#stopErrorSync = effect(() => {\n const errors = this.#signals.state.errors.get();\n this.#setError(firstFatal(errors, FATAL_SVTA_CODES), errors);\n });\n }\n\n get engine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n return this.#engine;\n }\n\n /**\n * The current fatal condition, or `null`. Only *fatal* ones appear here — the\n * engine reports non-fatal ones too (they stay in `engine.state.errors`), and\n * promoting them would say playback had failed when it hadn't. Which ones are\n * fatal is wider here than on the video and audio Medias; see\n * {@link FATAL_SVTA_CODES}. Resets per source. Fires `'error'` when set.\n *\n * Mapped the same way theirs are: a sequence holding an\n * unimplemented-capability cause surfaces as\n * {@link SVTA_UNSUPPORTED_PLAYBACK_FEATURE} (99001) with the specifics logged,\n * because \"this player can't play this source\" is what a consumer can act on,\n * where a raw container or DRM code only says what to go and look up.\n */\n get error(): HlsVideoMediaError | null {\n return this.#error;\n }\n\n #setError(reported: SvtaError | undefined, errors: readonly SvtaError[] | undefined): void {\n if (!reported) {\n // Cleared (new source). No event: `'error'` announces a failure, and\n // consumers reset their own copy on source change.\n this.#error = null;\n this.#reportedCode = null;\n return;\n }\n // Keyed on the code, not the object: a later append re-runs this effect\n // with an equal-but-new array, and re-firing `'error'` for a condition\n // already surfaced would look like a second failure.\n if (this.#reportedCode === reported.code) return;\n this.#reportedCode = reported.code;\n\n // Logged for every fatal condition, not just the substituted ones: a source\n // with no video renditions is as dead as an unplayable container, and it\n // would otherwise reach a developer as a bare code. One generic sentence\n // rather than one per case — the conditions beside it carry the specifics.\n //\n // Prose stays here rather than on `error.message`, matching the other two:\n // viewer-facing copy is the consumer's to localize, and a background video\n // has no chrome to put it in anyway.\n console.error(UNPLAYABLE_SOURCE_MESSAGE, { conditions: errors });\n\n this.#error = {\n code: hasUnsupportedFeatureCause(errors) ? SVTA_UNSUPPORTED_PLAYBACK_FEATURE : reported.code,\n message: reported.message ?? '',\n ...(reported.data === undefined ? {} : { data: reported.data }),\n };\n // Optional-chained: with an EventTarget-less base (`HlsBackgroundVideoMediaElement`\n // standalone) there's nowhere to dispatch.\n this.dispatchEvent?.(new Event('error'));\n }\n\n // -------------------------------------------------------------------------\n // Media element lifecycle\n // -------------------------------------------------------------------------\n\n attach(mediaElement: HTMLMediaElement): void {\n super.attach?.(mediaElement);\n // The one place the fixed behavior is stated. Muted and autoplay are what\n // let it start without a gesture, loop is the defining behavior, and\n // `preload` says out loud what the engine does regardless — it subtracts\n // preload monitoring and loads from the moment it has a source.\n mediaElement.loop = true;\n mediaElement.muted = true;\n mediaElement.autoplay = true;\n mediaElement.preload = 'auto';\n\n this.#signals.context.mediaElement.set(mediaElement);\n }\n\n detach(): void {\n this.#cancelPendingPlay();\n this.#signals.context.mediaElement.set(undefined);\n super.detach?.();\n }\n\n destroy(): void {\n this.#cancelPendingPlay();\n this.#stopErrorSync();\n this.#engine.destroy();\n }\n\n // -------------------------------------------------------------------------\n // src — synchronous IDL attribute (WHATWG §4.8.11.2)\n // -------------------------------------------------------------------------\n\n get src(): string {\n return this.#signals.state.presentation.get()?.url ?? '';\n }\n\n set src(value: string) {\n // Same line the HLS Medias draw: the presentation is set from a fresh\n // object every time, so re-resolving a URL already playing would restart\n // it for no reason.\n if (value === this.src) return;\n\n this.#cancelPendingPlay();\n this.#signals.state.presentation.set(value ? { url: value } : undefined);\n }\n\n // -------------------------------------------------------------------------\n // play() — WHATWG §4.8.11.8\n // Delegates to the attached media element's native play().\n // -------------------------------------------------------------------------\n\n async play(): Promise<void> {\n const mediaElement = this.#signals.context.mediaElement.get();\n if (!mediaElement) {\n return Promise.reject(new Error('HlsBackgroundVideoMediaElement: no media element attached'));\n }\n\n try {\n return await mediaElement.play();\n } catch (err) {\n // If we have a pending HLS source, the rejection may be because MSE\n // hasn't attached a blob URL yet. Wait for loadstart (src assigned by\n // MSE setup) and retry once.\n if (this.src) {\n return new Promise<void>((resolve, reject) => {\n const listener = () => {\n this.#loadstartListener = null;\n mediaElement.play().then(resolve, reject);\n };\n this.#loadstartListener = listener;\n mediaElement.addEventListener('loadstart', listener, { once: true });\n });\n }\n throw err;\n }\n }\n\n // -------------------------------------------------------------------------\n // Private\n // -------------------------------------------------------------------------\n\n #createEngine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n // No selection config of its own: the engine's default rule chain already\n // narrows to the largest rendition that fits the screen, which is exactly\n // what this adapter used to hand over as a bespoke picker.\n return createBackgroundVideoEngine({\n ...this.#config,\n onSignalsReady: (signals) => {\n this.#signals = signals;\n },\n });\n }\n\n #cancelPendingPlay(): void {\n if (!this.#loadstartListener) return;\n const mediaElement = this.#signals.context.mediaElement.get();\n mediaElement?.removeEventListener('loadstart', this.#loadstartListener);\n this.#loadstartListener = null;\n }\n }\n\n return HlsBackgroundVideoMediaImpl as unknown as MixinReturn<Base, HlsBackgroundVideoMediaAPI>;\n}\n\n/** Standalone SPF background-video adapter with no base class. */\nexport class HlsBackgroundVideoMediaElement extends HlsBackgroundVideoMediaMixin(class {}) {}\n"],"mappings":";;;;;;AA6BA,MAAa,sCAAoE,EAC/E,KAAK,GACP;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAM,mCAAwC,IAAI,IAAY;CAC5D;CACA;CACA;AACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDD,SAAgB,6BAA4D,WAAiB;CAC3F,MAAM,oCAAoC,UAAU;EAClD;EACA;EACA;EACA,SAAoC;;;;;;EAMpC,gBAA+B;EAC/B;;EAGA,qBAA0C;EAE1C,YAAY,GAAG,MAAa;GAC1B,MAAM,GAAG,IAAI;GAEb,MAAM,EAAE,WAAW,OAAO,MAAM,CAAC;GACjC,KAAKA,UAAU;GACf,KAAKC,UAAU,KAAKC,cAAc;GAMlC,KAAKC,iBAAiB,aAAa;IACjC,MAAM,SAAS,KAAKC,SAAS,MAAM,OAAO,IAAI;IAC9C,KAAKC,UAAU,WAAW,QAAQ,gBAAgB,GAAG,MAAM;GAC7D,CAAC;EACH;EAEA,IAAI,SAAgF;GAClF,OAAO,KAAKJ;EACd;;;;;;;;;;;;;;EAeA,IAAI,QAAmC;GACrC,OAAO,KAAKK;EACd;EAEA,UAAU,UAAiC,QAAgD;GACzF,IAAI,CAAC,UAAU;IAGb,KAAKA,SAAS;IACd,KAAKC,gBAAgB;IACrB;GACF;GAIA,IAAI,KAAKA,kBAAkB,SAAS,MAAM;GAC1C,KAAKA,gBAAgB,SAAS;GAU9B,QAAQ,MAAM,2BAA2B,EAAE,YAAY,OAAO,CAAC;GAE/D,KAAKD,SAAS;IACZ,MAAM,2BAA2B,MAAM,IAAI,oCAAoC,SAAS;IACxF,SAAS,SAAS,WAAW;IAC7B,GAAI,SAAS,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,SAAS,KAAK;GAC/D;GAGA,KAAK,gBAAgB,IAAI,MAAM,OAAO,CAAC;EACzC;EAMA,OAAO,cAAsC;GAC3C,MAAM,SAAS,YAAY;GAK3B,aAAa,OAAO;GACpB,aAAa,QAAQ;GACrB,aAAa,WAAW;GACxB,aAAa,UAAU;GAEvB,KAAKF,SAAS,QAAQ,aAAa,IAAI,YAAY;EACrD;EAEA,SAAe;GACb,KAAKI,mBAAmB;GACxB,KAAKJ,SAAS,QAAQ,aAAa,IAAI,KAAA,CAAS;GAChD,MAAM,SAAS;EACjB;EAEA,UAAgB;GACd,KAAKI,mBAAmB;GACxB,KAAKL,eAAe;GACpB,KAAKF,QAAQ,QAAQ;EACvB;EAMA,IAAI,MAAc;GAChB,OAAO,KAAKG,SAAS,MAAM,aAAa,IAAI,CAAC,EAAE,OAAO;EACxD;EAEA,IAAI,IAAI,OAAe;GAIrB,IAAI,UAAU,KAAK,KAAK;GAExB,KAAKI,mBAAmB;GACxB,KAAKJ,SAAS,MAAM,aAAa,IAAI,QAAQ,EAAE,KAAK,MAAM,IAAI,KAAA,CAAS;EACzE;EAOA,MAAM,OAAsB;GAC1B,MAAM,eAAe,KAAKA,SAAS,QAAQ,aAAa,IAAI;GAC5D,IAAI,CAAC,cACH,OAAO,QAAQ,uBAAO,IAAI,MAAM,2DAA2D,CAAC;GAG9F,IAAI;IACF,OAAO,MAAM,aAAa,KAAK;GACjC,SAAS,KAAK;IAIZ,IAAI,KAAK,KACP,OAAO,IAAI,SAAe,SAAS,WAAW;KAC5C,MAAM,iBAAiB;MACrB,KAAKK,qBAAqB;MAC1B,aAAa,KAAK,CAAC,CAAC,KAAK,SAAS,MAAM;KAC1C;KACA,KAAKA,qBAAqB;KAC1B,aAAa,iBAAiB,aAAa,UAAU,EAAE,MAAM,KAAK,CAAC;IACrE,CAAC;IAEH,MAAM;GACR;EACF;EAMA,gBAAuF;GAIrF,OAAO,4BAA4B;IACjC,GAAG,KAAKT;IACR,iBAAiB,YAAY;KAC3B,KAAKI,WAAW;IAClB;GACF,CAAC;EACH;EAEA,qBAA2B;GACzB,IAAI,CAAC,KAAKK,oBAAoB;GAE9B,KAD0BL,SAAS,QAAQ,aAAa,IAC7C,CAAC,EAAE,oBAAoB,aAAa,KAAKK,kBAAkB;GACtE,KAAKA,qBAAqB;EAC5B;CACF;CAEA,OAAO;AACT;;AAGA,IAAa,iCAAb,cAAoD,6BAA6B,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"adapter.js","names":["#config","#engine","#createEngine","#stopErrorSync","#signals","#setError","#error","#reportedCode","#cancelPendingPlay","#loadstartListener"],"sources":["../../../../../src/playback/adapters/hls-background-video/adapter.ts"],"sourcesContent":["import type { Constructor, MixinReturn } from '@videojs/utils/types';\nimport type { Composition } from '../../../core/composition/create-composition';\nimport { effect } from '../../../core/signals/effect';\nimport {\n SVTA_NO_SUPPORTED_VIDEO_TRACK,\n SVTA_UNSUPPORTED_DRM_SYSTEM,\n SVTA_UNSUPPORTED_PLAYBACK_FEATURE,\n SVTA_UNSUPPORTED_VIDEO_FORMAT,\n type SvtaError,\n} from '../../../media/errors';\nimport {\n type BackgroundVideoEngineConfig,\n type BackgroundVideoEngineContext,\n type BackgroundVideoEngineSignals,\n type BackgroundVideoEngineState,\n createBackgroundVideoEngine,\n} from '../../engines/hls/engine-background-video';\nimport { UNPLAYABLE_SOURCE_MESSAGE } from '../../primitives/error-messages';\nimport { firstFatal, type HlsVideoMediaError, hasUnsupportedFeatureCause } from '../hls-video/error-surface';\n\n// The same error shape the video and audio Medias expose, under the name they\n// publish it as — one type for all three surfaces rather than a background-flavored\n// copy of it.\nexport type { HlsVideoMediaError } from '../hls-video/error-surface';\n\nexport interface HlsBackgroundVideoMediaProps {\n src: string;\n}\n\nexport const hlsBackgroundVideoMediaDefaultProps: HlsBackgroundVideoMediaProps = {\n src: '',\n};\n\nexport interface HlsBackgroundVideoMediaAPI extends HlsBackgroundVideoMediaProps {\n readonly engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n readonly error: HlsVideoMediaError | null;\n attach(mediaElement: HTMLMediaElement): void;\n detach(): void;\n destroy(): void;\n play(): Promise<void>;\n}\n\n/**\n * Which reported conditions this composition treats as **fatal** — the ones that\n * reach `error` and fire `'error'`. Severity isn't part of an SVTA code\n * (§Approach: \"impact varies with player implementation\"), so it's decided at\n * this boundary rather than by the reporter.\n *\n * **Causes are fatal here, unlike on the other two adapters.** There, a cause is\n * context — one unplayable rendition doesn't fail a source whose others still\n * play, and a verdict follows if the type empties. In the pinned variant a cause\n * *is* the verdict: only the pinned rendition's playlist is ever resolved, so a\n * cause can only be about the pick itself, and dropping that pick is final —\n * nothing here re-picks (that is what `switchVideoTrack` exists for, and this\n * engine doesn't compose it). Measured on Chromium: an MPEG-TS source reports\n * 1004 and an encrypted one 4008, each with no verdict behind it, and the element\n * then sits at `readyState 0` with `error` null forever.\n *\n * The verdict is still listed, for the shapes that report nothing else: no video\n * renditions at all, or a ladder pruned before anything resolves — both of which\n * `reportAbsentTrackType` covers from the tail of the constraint chain.\n *\n * First-fatal-wins then surfaces the cause rather than the verdict when both are\n * present, which is the more specific of the two.\n */\nconst FATAL_SVTA_CODES: ReadonlySet<number> = new Set<number>([\n SVTA_NO_SUPPORTED_VIDEO_TRACK,\n SVTA_UNSUPPORTED_VIDEO_FORMAT,\n SVTA_UNSUPPORTED_DRM_SYSTEM,\n]);\n\n/**\n * Mixin that adds the background-video SPF playback engine to any base class,\n * for an HLS URL.\n *\n * `src` is the whole input surface, and `error` is the one output: nothing about\n * an unplayable source reaches the media element on its own here — an unsupported\n * container, encryption with no EME, and an undecodable codec all leave\n * `HTMLMediaElement.error` null with the element stalled at `readyState 0`\n * (measured on Chromium and WebKit) — so a consumer that watched only the\n * `<video>` would see a source that never appears and never says why. The engine\n * reports each condition onto `engine.state.errors` and logs it; this adapter\n * promotes the first fatal one, mapping it the same way the video and audio Medias\n * map theirs. See `internal/design/spf/features/errors.md`.\n *\n * Selection pins the largest rendition that *fits the screen*, and holds it for\n * the session. The manifest is still the better place to narrow further: a\n * delivery param — `?max_resolution=720p` on a Mux stream URL, for one — keeps\n * the renditions it excludes out of the manifest entirely, rather than\n * fetched-then-unpicked.\n *\n * The pin is given up, never moved, if the pick turns out to be unplayable: the\n * container is only known once a media playlist resolves, which is after the pick\n * is made, so the selection clears rather than quietly appending bytes nothing can\n * decode.\n *\n * `@videojs/spf/mux-background-video` is this same Media under a Mux-flavored\n * name — an alias, not a variant. Nothing about the surface changes with the\n * import path.\n *\n * Everything else the use case fixes rather than exposes: video-only, looping,\n * muted, autoplaying, loading as soon as there is a source. `attach` writes that\n * onto the element and nothing here declares `loop` / `muted` / `autoplay` /\n * `preload` of its own — a host-bound Media inherits all four from the host\n * already, and shadowing them with fixed values would only make reads describe\n * an intention rather than what the element is doing.\n *\n * A new src re-resolves the presentation, tearing down the state, SourceBuffers,\n * and in-flight requests the previous one built before the next begins. The\n * engine instance and the attached media element are both kept, so neither has to\n * be rewired.\n *\n * @fires error - Fired when a fatal condition is reported. Read `error` for it.\n *\n * @example\n * class HlsBackgroundVideoMedia extends HlsBackgroundVideoMediaMixin(BackgroundVideoHost) {}\n *\n * const media = new HlsBackgroundVideoMedia();\n * media.attach(document.querySelector('video'));\n * media.src = 'https://stream.mux.com/PLAYBACK_ID.m3u8?max_resolution=720p';\n * media.play();\n */\nexport function HlsBackgroundVideoMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {\n class HlsBackgroundVideoMediaImpl extends BaseClass {\n #engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n #config: BackgroundVideoEngineConfig;\n #signals!: BackgroundVideoEngineSignals;\n #error: HlsVideoMediaError | null = null;\n /**\n * The *reported* condition currently surfaced, which is what the re-fire latch\n * keys on. Not `#error.code`: that's the code this adapter chose to surface,\n * and the substitution below can make the two differ.\n */\n #reportedCode: number | null = null;\n #stopErrorSync: () => void;\n\n /** Pending loadstart listener from a deferred play() retry, if any. */\n #loadstartListener: (() => void) | null = null;\n\n constructor(...args: any[]) {\n super(...args);\n\n const { config } = args?.[0] ?? {};\n this.#config = config;\n this.#engine = this.#createEngine();\n\n // Promote the first fatal condition out of the engine's reported sequence\n // onto this surface. Clearing rides the same signal: `collectErrors` resets\n // the slot per source, so a new source starts with no error without this\n // needing its own source-change hook.\n this.#stopErrorSync = effect(() => {\n const errors = this.#signals.state.errors.get();\n this.#setError(firstFatal(errors, FATAL_SVTA_CODES), errors);\n });\n }\n\n get engine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n return this.#engine;\n }\n\n /**\n * The current fatal condition, or `null`. Only *fatal* ones appear here — the\n * engine reports non-fatal ones too (they stay in `engine.state.errors`), and\n * promoting them would say playback had failed when it hadn't. Which ones are\n * fatal is wider here than on the video and audio Medias; see\n * {@link FATAL_SVTA_CODES}. Resets per source. Fires `'error'` when set.\n *\n * Mapped the same way theirs are: a sequence holding an\n * unimplemented-capability cause surfaces as\n * {@link SVTA_UNSUPPORTED_PLAYBACK_FEATURE} (99001) with the specifics logged,\n * because \"this player can't play this source\" is what a consumer can act on,\n * where a raw container or DRM code only says what to go and look up.\n */\n get error(): HlsVideoMediaError | null {\n return this.#error;\n }\n\n #setError(reported: SvtaError | undefined, errors: readonly SvtaError[] | undefined): void {\n if (!reported) {\n // Cleared (new source). No event: `'error'` announces a failure, and\n // consumers reset their own copy on source change.\n this.#error = null;\n this.#reportedCode = null;\n return;\n }\n // Keyed on the code, not the object: a later append re-runs this effect\n // with an equal-but-new array, and re-firing `'error'` for a condition\n // already surfaced would look like a second failure.\n if (this.#reportedCode === reported.code) return;\n this.#reportedCode = reported.code;\n\n // Logged for every fatal condition, not just the substituted ones: a source\n // with no video renditions is as dead as an unplayable container, and it\n // would otherwise reach a developer as a bare code. One generic sentence\n // rather than one per case — the conditions beside it carry the specifics.\n //\n // Prose stays here rather than on `error.message`, matching the other two:\n // viewer-facing copy is the consumer's to localize, and a background video\n // has no chrome to put it in anyway.\n console.error(UNPLAYABLE_SOURCE_MESSAGE, { conditions: errors });\n\n this.#error = {\n code: hasUnsupportedFeatureCause(errors) ? SVTA_UNSUPPORTED_PLAYBACK_FEATURE : reported.code,\n message: reported.message ?? '',\n ...(reported.data === undefined ? {} : { data: reported.data }),\n };\n // Optional-chained: with an EventTarget-less base (`HlsBackgroundVideoMediaElement`\n // standalone) there's nowhere to dispatch.\n this.dispatchEvent?.(new Event('error'));\n }\n\n // -------------------------------------------------------------------------\n // Media element lifecycle\n // -------------------------------------------------------------------------\n\n attach(mediaElement: HTMLMediaElement): void {\n super.attach?.(mediaElement);\n // The one place the fixed behavior is stated. Muted and autoplay are what\n // let it start without a gesture, loop is the defining behavior, and\n // `preload` says out loud what the engine does regardless — it subtracts\n // preload monitoring and loads from the moment it has a source.\n mediaElement.loop = true;\n mediaElement.muted = true;\n mediaElement.autoplay = true;\n mediaElement.preload = 'auto';\n\n this.#signals.context.mediaElement.set(mediaElement);\n }\n\n detach(): void {\n this.#cancelPendingPlay();\n this.#signals.context.mediaElement.set(undefined);\n super.detach?.();\n }\n\n destroy(): void {\n this.#cancelPendingPlay();\n this.#stopErrorSync();\n this.#engine.destroy();\n }\n\n // -------------------------------------------------------------------------\n // src — synchronous IDL attribute (WHATWG §4.8.11.2)\n // -------------------------------------------------------------------------\n\n get src(): string {\n return this.#signals.state.presentation.get()?.url ?? '';\n }\n\n set src(value: string) {\n // Same line the HLS Medias draw: the presentation is set from a fresh\n // object every time, so re-resolving a URL already playing would restart\n // it for no reason.\n if (value === this.src) return;\n\n this.#cancelPendingPlay();\n this.#signals.state.presentation.set(value ? { url: value } : undefined);\n }\n\n // -------------------------------------------------------------------------\n // play() — WHATWG §4.8.11.8\n // Delegates to the attached media element's native play().\n // -------------------------------------------------------------------------\n\n async play(): Promise<void> {\n const mediaElement = this.#signals.context.mediaElement.get();\n if (!mediaElement) {\n return Promise.reject(new Error('HlsBackgroundVideoMediaElement: no media element attached'));\n }\n\n try {\n return await mediaElement.play();\n } catch (err) {\n // If we have a pending HLS source, the rejection may be because MSE\n // hasn't attached a blob URL yet. Wait for loadstart (src assigned by\n // MSE setup) and retry once.\n if (this.src) {\n return new Promise<void>((resolve, reject) => {\n const listener = () => {\n this.#loadstartListener = null;\n mediaElement.play().then(resolve, reject);\n };\n this.#loadstartListener = listener;\n mediaElement.addEventListener('loadstart', listener, { once: true });\n });\n }\n throw err;\n }\n }\n\n // -------------------------------------------------------------------------\n // Private\n // -------------------------------------------------------------------------\n\n #createEngine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n // No selection config of its own: the engine's default rule chain already\n // narrows to the largest rendition that fits the screen, which is exactly\n // what this adapter used to hand over as a bespoke picker.\n return createBackgroundVideoEngine({\n ...this.#config,\n onSignalsReady: (signals) => {\n this.#signals = signals;\n },\n });\n }\n\n #cancelPendingPlay(): void {\n if (!this.#loadstartListener) return;\n const mediaElement = this.#signals.context.mediaElement.get();\n mediaElement?.removeEventListener('loadstart', this.#loadstartListener);\n this.#loadstartListener = null;\n }\n }\n\n return HlsBackgroundVideoMediaImpl as unknown as MixinReturn<Base, HlsBackgroundVideoMediaAPI>;\n}\n\n/** Standalone SPF background-video adapter with no base class. */\nexport class HlsBackgroundVideoMediaElement extends HlsBackgroundVideoMediaMixin(class {}) {}\n"],"mappings":";;;;;;AA6BA,MAAa,sCAAoE,EAC/E,KAAK,GACP;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAM,mCAAwC,IAAI,IAAY;CAC5D;CACA;CACA;AACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDD,SAAgB,6BAA4D,WAAiB;CAC3F,MAAM,oCAAoC,UAAU;EAClD;EACA;EACA;EACA,SAAoC;;;;;;EAMpC,gBAA+B;EAC/B;;EAGA,qBAA0C;EAE1C,YAAY,GAAG,MAAa;GAC1B,MAAM,GAAG,IAAI;GAEb,MAAM,EAAE,WAAW,OAAO,MAAM,CAAC;GACjC,KAAKA,UAAU;GACf,KAAKC,UAAU,KAAKC,cAAc;GAMlC,KAAKC,iBAAiB,aAAa;IACjC,MAAM,SAAS,KAAKC,SAAS,MAAM,OAAO,IAAI;IAC9C,KAAKC,UAAU,WAAW,QAAQ,gBAAgB,GAAG,MAAM;GAC7D,CAAC;EACH;EAEA,IAAI,SAAgF;GAClF,OAAO,KAAKJ;EACd;;;;;;;;;;;;;;EAeA,IAAI,QAAmC;GACrC,OAAO,KAAKK;EACd;EAEA,UAAU,UAAiC,QAAgD;GACzF,IAAI,CAAC,UAAU;IAGb,KAAKA,SAAS;IACd,KAAKC,gBAAgB;IACrB;GACF;GAIA,IAAI,KAAKA,kBAAkB,SAAS,MAAM;GAC1C,KAAKA,gBAAgB,SAAS;GAU9B,QAAQ,MAAM,2BAA2B,EAAE,YAAY,OAAO,CAAC;GAE/D,KAAKD,SAAS;IACZ,MAAM,2BAA2B,MAAM,IAAI,oCAAoC,SAAS;IACxF,SAAS,SAAS,WAAW;IAC7B,GAAI,SAAS,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,SAAS,KAAK;GAC/D;GAGA,KAAK,gBAAgB,IAAI,MAAM,OAAO,CAAC;EACzC;EAMA,OAAO,cAAsC;GAC3C,MAAM,SAAS,YAAY;GAK3B,aAAa,OAAO;GACpB,aAAa,QAAQ;GACrB,aAAa,WAAW;GACxB,aAAa,UAAU;GAEvB,KAAKF,SAAS,QAAQ,aAAa,IAAI,YAAY;EACrD;EAEA,SAAe;GACb,KAAKI,mBAAmB;GACxB,KAAKJ,SAAS,QAAQ,aAAa,IAAI,KAAA,CAAS;GAChD,MAAM,SAAS;EACjB;EAEA,UAAgB;GACd,KAAKI,mBAAmB;GACxB,KAAKL,eAAe;GACpB,KAAKF,QAAQ,QAAQ;EACvB;EAMA,IAAI,MAAc;GAChB,OAAO,KAAKG,SAAS,MAAM,aAAa,IAAI,CAAC,EAAE,OAAO;EACxD;EAEA,IAAI,IAAI,OAAe;GAIrB,IAAI,UAAU,KAAK,KAAK;GAExB,KAAKI,mBAAmB;GACxB,KAAKJ,SAAS,MAAM,aAAa,IAAI,QAAQ,EAAE,KAAK,MAAM,IAAI,KAAA,CAAS;EACzE;EAOA,MAAM,OAAsB;GAC1B,MAAM,eAAe,KAAKA,SAAS,QAAQ,aAAa,IAAI;GAC5D,IAAI,CAAC,cACH,OAAO,QAAQ,uBAAO,IAAI,MAAM,2DAA2D,CAAC;GAG9F,IAAI;IACF,OAAO,MAAM,aAAa,KAAK;GACjC,SAAS,KAAK;IAIZ,IAAI,KAAK,KACP,OAAO,IAAI,SAAe,SAAS,WAAW;KAC5C,MAAM,iBAAiB;MACrB,KAAKK,qBAAqB;MAC1B,aAAa,KAAK,CAAC,CAAC,KAAK,SAAS,MAAM;KAC1C;KACA,KAAKA,qBAAqB;KAC1B,aAAa,iBAAiB,aAAa,UAAU,EAAE,MAAM,KAAK,CAAC;IACrE,CAAC;IAEH,MAAM;GACR;EACF;EAMA,gBAAuF;GAIrF,OAAO,4BAA4B;IACjC,GAAG,KAAKT;IACR,iBAAiB,YAAY;KAC3B,KAAKI,WAAW;IAClB;GACF,CAAC;EACH;EAEA,qBAA2B;GACzB,IAAI,CAAC,KAAKK,oBAAoB;GAE9B,KAD0BL,SAAS,QAAQ,aAAa,IAC7C,CAAC,EAAE,oBAAoB,aAAa,KAAKK,kBAAkB;GACtE,KAAKA,qBAAqB;EAC5B;CACF;CAEA,OAAO;AACT;;AAGA,IAAa,iCAAb,cAAoD,6BAA6B,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC"}
@@ -59,14 +59,14 @@ function emitError(state, error) {
59
59
  * composition opts in by adding it to `constraints` — nothing to thread through
60
60
  * config, and no cost at all to a composition that leaves it out.
61
61
  *
62
- * **Belongs first in the chain.** A constraint sees the list as it stands at its
63
- * own position, so only at the head does an empty input mean "the source offers
64
- * none of this type" rather than "the constraints ahead of me pruned them all."
62
+ * **Belongs last in the chain.** A constraint sees the list as it stands at its
63
+ * own position, so only at the tail does an empty input mean "nothing playable
64
+ * here" — none of this type offered, or the constraints ahead pruned them all.
65
65
  *
66
- * This is the one failure no per-rendition cause can report: causes come from
67
- * `reportUnsupportedTrackConditions` as each media playlist resolves, and here
68
- * nothing resolves, because there is nothing to resolve. Everything else already
69
- * reports something more specific than a verdict.
66
+ * These are the failures no per-rendition cause can report: causes come from
67
+ * `reportUnsupportedTrackConditions` as each media playlist resolves, and a
68
+ * rendition pruned before selection never resolves. Container and encryption
69
+ * aren't knowable until one does; CODECS is, so an undecodable ladder isn't.
70
70
  *
71
71
  * Idempotent because the constraint chain runs inside a `computed` that re-derives
72
72
  * on every `presentation` write — segment appends and live reloads included — and
@@ -75,7 +75,7 @@ function emitError(state, error) {
75
75
  *
76
76
  * @example
77
77
  * // engine-background-video.ts — video-only, so a source with none can't play
78
- * constraints: [reportAbsentTrackType(SVTA_NO_SUPPORTED_VIDEO_TRACK), excludeUnplayableTracks]
78
+ * constraints: [excludeUnplayableTracks, reportAbsentTrackType(SVTA_NO_SUPPORTED_VIDEO_TRACK)]
79
79
  */
80
80
  function reportAbsentTrackType(code) {
81
81
  return (tracks, { state }) => {
@@ -1 +1 @@
1
- {"version":3,"file":"collect-errors.js","names":[],"sources":["../../../../src/playback/behaviors/collect-errors.ts"],"sourcesContent":["/**\n * **Owns the engine's error sequence.** Reporters append through\n * {@link emitError}; this behavior owns the slot and its per-source lifecycle,\n * clearing it on exit so a new source starts clean and the sequence can't grow\n * unbounded across a session.\n *\n * Same split as `setupFailoverMonitor` and `failedCdns`: writes come from\n * wherever the condition is detected, one behavior owns the slot. Deliberately\n * has no `effects` — it holds no policy and derives nothing. Severity is decided\n * at the adapter, not here (see `internal/design/spf/features/errors.md`), which\n * is why this is a lifecycle owner rather than an error *handler*.\n *\n * Clearing binds to *exit* of `presentation-resolved`, mirroring the sibling\n * mixins' clear-on-teardown (`emptied` / `MEDIA_DETACHED`). A live reload swaps\n * the presentation object without leaving the resolved state, so it doesn't\n * clear — only an actual source change or destroy does. Known gap: a\n * resolved→resolved source swap that never passes through unresolved carries the\n * prior source's errors forward; `resolve-track` guards the same transition with\n * a commit-time id check, and doing likewise here is a follow-up.\n *\n * The vocabulary itself ({@link SvtaError} and the codes) is DOM- and\n * signal-free in `media/errors`; only the write seam lives here, with the slot\n * it writes.\n */\n\nimport { defineBehavior } from '../../core/composition/create-composition';\nimport { createMachineReactor } from '../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';\nimport type { SvtaError } from '../../media/errors';\nimport { isResolvedPresentation, type MaybeResolvedPresentation } from '../../media/types';\nimport type { SelectionRule } from '../primitives/selection-rules';\n\nexport interface CollectErrorsState {\n presentation?: MaybeResolvedPresentation;\n errors?: SvtaError[];\n}\n\n/**\n * State an error reporter writes into. The slot is *optional*: a behavior reports\n * through this seam without declaring ownership in its own typed slice, and\n * emission no-ops when `collectErrors` isn't composed. Same contract as\n * `failedCdns` / `failoverFetch`.\n */\nexport interface ErrorEmitterState {\n errors?: Signal<SvtaError[] | undefined>;\n}\n\n/**\n * Append `error` to the engine's error sequence. No-op when no owner is\n * composed. Replaces the array rather than mutating it, so signal consumers\n * notify; duplicates are kept, since a repeated condition is a real observation.\n * Writes go through `update` so concurrent reporters can't lose each other's\n * appends.\n *\n * Every emission is logged, deliberately *before* the owner check. A condition\n * emitted with no `collectErrors` composed is dropped on the floor — that's the\n * case where a log is the only evidence it happened at all, so gating the log on\n * the same check would hide exactly what's worth seeing. Emissions that *are*\n * collected still get logged, because reaching `state.errors` is no guarantee of\n * reaching a person: only *verdicts* are promoted to the media surface, so every\n * cause (and any non-fatal notice) is otherwise invisible outside a debugger.\n *\n * Ungated rather than `__DEV__`-only, matching the other reporting paths in this\n * package (`resolve-presentation`, `track-switching`, the segment actors).\n */\nexport function emitError(state: ErrorEmitterState, error: SvtaError): void {\n console.error('[spf] reported condition', error);\n if (!state.errors) return;\n update(state.errors, (errors) => [...(errors ?? []), error]);\n}\n\n/**\n * A \"constraint\" that reports a type the source carries **no** renditions of, for\n * a composition that can't play without it.\n *\n * Strange on purpose, and the strangeness is the point: it never constrains\n * anything, always returning its input untouched. It is shaped as a rule so a\n * composition opts in by adding it to `constraints` — nothing to thread through\n * config, and no cost at all to a composition that leaves it out.\n *\n * **Belongs first in the chain.** A constraint sees the list as it stands at its\n * own position, so only at the head does an empty input mean \"the source offers\n * none of this type\" rather than \"the constraints ahead of me pruned them all.\"\n *\n * This is the one failure no per-rendition cause can report: causes come from\n * `reportUnsupportedTrackConditions` as each media playlist resolves, and here\n * nothing resolves, because there is nothing to resolve. Everything else already\n * reports something more specific than a verdict.\n *\n * Idempotent because the constraint chain runs inside a `computed` that re-derives\n * on every `presentation` write — segment appends and live reloads included — and\n * the sequence deliberately keeps duplicates. `peek` is what keeps that computed\n * from subscribing to the slot this writes.\n *\n * @example\n * // engine-background-video.ts — video-only, so a source with none can't play\n * constraints: [reportAbsentTrackType(SVTA_NO_SUPPORTED_VIDEO_TRACK), excludeUnplayableTracks]\n */\nexport function reportAbsentTrackType<T>(code: number): SelectionRule<T, ErrorEmitterState> {\n return (tracks, { state }) => {\n const reported = state.errors && peek(state.errors);\n if (!tracks.length && !reported?.some((error) => error.code === code)) {\n emitError(state, { code });\n }\n return tracks;\n };\n}\n\n/**\n * Own `errors` for the resolved source's lifetime.\n *\n * @example\n * const reactor = collectErrors.setup({ state });\n */\nexport const collectErrors = defineBehavior({\n stateKeys: ['presentation', 'errors'],\n contextKeys: [],\n setup: ({\n state,\n }: {\n state: {\n presentation: ReadonlySignal<CollectErrorsState['presentation']>;\n errors: Signal<CollectErrorsState['errors']>;\n };\n }) => {\n const derivedStateSignal = computed(() =>\n isResolvedPresentation(state.presentation.get())\n ? ('presentation-resolved' as const)\n : ('presentation-unresolved' as const)\n );\n\n return createMachineReactor({\n initial: 'presentation-unresolved',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'presentation-unresolved': {},\n 'presentation-resolved': {\n // Cleanup-binds-to-setup: reset for the next source on exit (src\n // unload + destroy).\n entry: () => () => state.errors.set(undefined),\n },\n },\n });\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiEA,SAAgB,UAAU,OAA0B,OAAwB;CAC1E,QAAQ,MAAM,4BAA4B,KAAK;CAC/C,IAAI,CAAC,MAAM,QAAQ;CACnB,OAAO,MAAM,SAAS,WAAW,CAAC,GAAI,UAAU,CAAC,GAAI,KAAK,CAAC;AAC7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,sBAAyB,MAAmD;CAC1F,QAAQ,QAAQ,EAAE,YAAY;EAC5B,MAAM,WAAW,MAAM,UAAU,KAAK,MAAM,MAAM;EAClD,IAAI,CAAC,OAAO,UAAU,CAAC,UAAU,MAAM,UAAU,MAAM,SAAS,IAAI,GAClE,UAAU,OAAO,EAAE,KAAK,CAAC;EAE3B,OAAO;CACT;AACF;;;;;;;AAQA,MAAa,gBAAgB,eAAe;CAC1C,WAAW,CAAC,gBAAgB,QAAQ;CACpC,aAAa,CAAC;CACd,QAAQ,EACN,YAMI;EACJ,MAAM,qBAAqB,eACzB,uBAAuB,MAAM,aAAa,IAAI,CAAC,IAC1C,0BACA,yBACP;EAEA,OAAO,qBAAqB;GAC1B,SAAS;GACT,eAAe,mBAAmB,IAAI;GACtC,QAAQ;IACN,2BAA2B,CAAC;IAC5B,yBAAyB,EAGvB,mBAAmB,MAAM,OAAO,IAAI,KAAA,CAAS,EAC/C;GACF;EACF,CAAC;CACH;AACF,CAAC"}
1
+ {"version":3,"file":"collect-errors.js","names":[],"sources":["../../../../src/playback/behaviors/collect-errors.ts"],"sourcesContent":["/**\n * **Owns the engine's error sequence.** Reporters append through\n * {@link emitError}; this behavior owns the slot and its per-source lifecycle,\n * clearing it on exit so a new source starts clean and the sequence can't grow\n * unbounded across a session.\n *\n * Same split as `setupFailoverMonitor` and `failedCdns`: writes come from\n * wherever the condition is detected, one behavior owns the slot. Deliberately\n * has no `effects` — it holds no policy and derives nothing. Severity is decided\n * at the adapter, not here (see `internal/design/spf/features/errors.md`), which\n * is why this is a lifecycle owner rather than an error *handler*.\n *\n * Clearing binds to *exit* of `presentation-resolved`, mirroring the sibling\n * mixins' clear-on-teardown (`emptied` / `MEDIA_DETACHED`). A live reload swaps\n * the presentation object without leaving the resolved state, so it doesn't\n * clear — only an actual source change or destroy does. Known gap: a\n * resolved→resolved source swap that never passes through unresolved carries the\n * prior source's errors forward; `resolve-track` guards the same transition with\n * a commit-time id check, and doing likewise here is a follow-up.\n *\n * The vocabulary itself ({@link SvtaError} and the codes) is DOM- and\n * signal-free in `media/errors`; only the write seam lives here, with the slot\n * it writes.\n */\n\nimport { defineBehavior } from '../../core/composition/create-composition';\nimport { createMachineReactor } from '../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';\nimport type { SvtaError } from '../../media/errors';\nimport { isResolvedPresentation, type MaybeResolvedPresentation } from '../../media/types';\nimport type { SelectionRule } from '../primitives/selection-rules';\n\nexport interface CollectErrorsState {\n presentation?: MaybeResolvedPresentation;\n errors?: SvtaError[];\n}\n\n/**\n * State an error reporter writes into. The slot is *optional*: a behavior reports\n * through this seam without declaring ownership in its own typed slice, and\n * emission no-ops when `collectErrors` isn't composed. Same contract as\n * `failedCdns` / `failoverFetch`.\n */\nexport interface ErrorEmitterState {\n errors?: Signal<SvtaError[] | undefined>;\n}\n\n/**\n * Append `error` to the engine's error sequence. No-op when no owner is\n * composed. Replaces the array rather than mutating it, so signal consumers\n * notify; duplicates are kept, since a repeated condition is a real observation.\n * Writes go through `update` so concurrent reporters can't lose each other's\n * appends.\n *\n * Every emission is logged, deliberately *before* the owner check. A condition\n * emitted with no `collectErrors` composed is dropped on the floor — that's the\n * case where a log is the only evidence it happened at all, so gating the log on\n * the same check would hide exactly what's worth seeing. Emissions that *are*\n * collected still get logged, because reaching `state.errors` is no guarantee of\n * reaching a person: only *verdicts* are promoted to the media surface, so every\n * cause (and any non-fatal notice) is otherwise invisible outside a debugger.\n *\n * Ungated rather than `__DEV__`-only, matching the other reporting paths in this\n * package (`resolve-presentation`, `track-switching`, the segment actors).\n */\nexport function emitError(state: ErrorEmitterState, error: SvtaError): void {\n console.error('[spf] reported condition', error);\n if (!state.errors) return;\n update(state.errors, (errors) => [...(errors ?? []), error]);\n}\n\n/**\n * A \"constraint\" that reports a type the source carries **no** renditions of, for\n * a composition that can't play without it.\n *\n * Strange on purpose, and the strangeness is the point: it never constrains\n * anything, always returning its input untouched. It is shaped as a rule so a\n * composition opts in by adding it to `constraints` — nothing to thread through\n * config, and no cost at all to a composition that leaves it out.\n *\n * **Belongs last in the chain.** A constraint sees the list as it stands at its\n * own position, so only at the tail does an empty input mean \"nothing playable\n * here\" — none of this type offered, or the constraints ahead pruned them all.\n *\n * These are the failures no per-rendition cause can report: causes come from\n * `reportUnsupportedTrackConditions` as each media playlist resolves, and a\n * rendition pruned before selection never resolves. Container and encryption\n * aren't knowable until one does; CODECS is, so an undecodable ladder isn't.\n *\n * Idempotent because the constraint chain runs inside a `computed` that re-derives\n * on every `presentation` write — segment appends and live reloads included — and\n * the sequence deliberately keeps duplicates. `peek` is what keeps that computed\n * from subscribing to the slot this writes.\n *\n * @example\n * // engine-background-video.ts — video-only, so a source with none can't play\n * constraints: [excludeUnplayableTracks, reportAbsentTrackType(SVTA_NO_SUPPORTED_VIDEO_TRACK)]\n */\nexport function reportAbsentTrackType<T>(code: number): SelectionRule<T, ErrorEmitterState> {\n return (tracks, { state }) => {\n const reported = state.errors && peek(state.errors);\n if (!tracks.length && !reported?.some((error) => error.code === code)) {\n emitError(state, { code });\n }\n return tracks;\n };\n}\n\n/**\n * Own `errors` for the resolved source's lifetime.\n *\n * @example\n * const reactor = collectErrors.setup({ state });\n */\nexport const collectErrors = defineBehavior({\n stateKeys: ['presentation', 'errors'],\n contextKeys: [],\n setup: ({\n state,\n }: {\n state: {\n presentation: ReadonlySignal<CollectErrorsState['presentation']>;\n errors: Signal<CollectErrorsState['errors']>;\n };\n }) => {\n const derivedStateSignal = computed(() =>\n isResolvedPresentation(state.presentation.get())\n ? ('presentation-resolved' as const)\n : ('presentation-unresolved' as const)\n );\n\n return createMachineReactor({\n initial: 'presentation-unresolved',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'presentation-unresolved': {},\n 'presentation-resolved': {\n // Cleanup-binds-to-setup: reset for the next source on exit (src\n // unload + destroy).\n entry: () => () => state.errors.set(undefined),\n },\n },\n });\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiEA,SAAgB,UAAU,OAA0B,OAAwB;CAC1E,QAAQ,MAAM,4BAA4B,KAAK;CAC/C,IAAI,CAAC,MAAM,QAAQ;CACnB,OAAO,MAAM,SAAS,WAAW,CAAC,GAAI,UAAU,CAAC,GAAI,KAAK,CAAC;AAC7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,sBAAyB,MAAmD;CAC1F,QAAQ,QAAQ,EAAE,YAAY;EAC5B,MAAM,WAAW,MAAM,UAAU,KAAK,MAAM,MAAM;EAClD,IAAI,CAAC,OAAO,UAAU,CAAC,UAAU,MAAM,UAAU,MAAM,SAAS,IAAI,GAClE,UAAU,OAAO,EAAE,KAAK,CAAC;EAE3B,OAAO;CACT;AACF;;;;;;;AAQA,MAAa,gBAAgB,eAAe;CAC1C,WAAW,CAAC,gBAAgB,QAAQ;CACpC,aAAa,CAAC;CACd,QAAQ,EACN,YAMI;EACJ,MAAM,qBAAqB,eACzB,uBAAuB,MAAM,aAAa,IAAI,CAAC,IAC1C,0BACA,yBACP;EAEA,OAAO,qBAAqB;GAC1B,SAAS;GACT,eAAe,mBAAmB,IAAI;GACtC,QAAQ;IACN,2BAA2B,CAAC;IAC5B,yBAAyB,EAGvB,mBAAmB,MAAM,OAAO,IAAI,KAAA,CAAS,EAC/C;GACF;EACF,CAAC;CACH;AACF,CAAC"}
@@ -0,0 +1,57 @@
1
+ import { defineBehavior } from "../../../core/composition/create-composition.js";
2
+ import { effect } from "../../../core/signals/effect.js";
3
+ import { scaleResolution } from "../../../media/primitives/resolution.js";
4
+ import { observeElementSize, observeRenderedSize } from "@videojs/utils/dom";
5
+ import { shallowEqual } from "@videojs/utils/object";
6
+ //#region src/playback/behaviors/dom/track-player-resolution.ts
7
+ /**
8
+ * Mirror the player element's rendered pixel dimensions into reactive state, so
9
+ * a rendition cap can narrow candidates to what the element can actually show
10
+ * without reading the DOM at pick time — which would make the picker impure, and
11
+ * would never re-pick when the element resized.
12
+ *
13
+ * The player-element half of the caps in
14
+ * `internal/design/spf/features/rendition-selection-caps.md`, and the tighter
15
+ * half: a small embed on a large display is capped by its own box rather than by
16
+ * the screen behind it (`trackScreenResolution`).
17
+ *
18
+ * Reported as a width and a height in device pixels — the same units, and for the
19
+ * same reason, as `media/dom/screen`'s reading: the cap compares against real
20
+ * track dimensions, and a `"720p"`-style tier only describes a track once you
21
+ * assume its aspect ratio.
22
+ *
23
+ * `undefined` where there is nothing to measure — no element attached, or one
24
+ * that isn't being rendered (detached, `display: none`, not yet laid out) — which
25
+ * is the value the cap reads as "don't cap".
26
+ */
27
+ function trackPlayerResolutionSetup({ state, context, config = {} }) {
28
+ const { capRenditionToPlayerSize = true, useDevicePixelRatio = true } = config;
29
+ return effect(() => {
30
+ const mediaElement = context.mediaElement.get();
31
+ let current;
32
+ state.playerResolution.set(current);
33
+ if (!capRenditionToPlayerSize || !mediaElement) return;
34
+ const write = (size) => {
35
+ const next = scaleResolution(size, size.scale);
36
+ if (shallowEqual(current, next)) return;
37
+ current = next;
38
+ state.playerResolution.set(next);
39
+ };
40
+ return useDevicePixelRatio ? observeRenderedSize(mediaElement, write) : observeElementSize(mediaElement, write);
41
+ });
42
+ }
43
+ /**
44
+ * Track the player element's rendered resolution in `state.playerResolution`.
45
+ *
46
+ * @example
47
+ * const cleanup = trackPlayerResolution.setup({ state, context });
48
+ */
49
+ const trackPlayerResolution = defineBehavior({
50
+ stateKeys: ["playerResolution"],
51
+ contextKeys: ["mediaElement"],
52
+ setup: trackPlayerResolutionSetup
53
+ });
54
+ //#endregion
55
+ export { trackPlayerResolution };
56
+
57
+ //# sourceMappingURL=track-player-resolution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track-player-resolution.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/track-player-resolution.ts"],"sourcesContent":["/**\n * Mirror the player element's rendered pixel dimensions into reactive state, so\n * a rendition cap can narrow candidates to what the element can actually show\n * without reading the DOM at pick time — which would make the picker impure, and\n * would never re-pick when the element resized.\n *\n * The player-element half of the caps in\n * `internal/design/spf/features/rendition-selection-caps.md`, and the tighter\n * half: a small embed on a large display is capped by its own box rather than by\n * the screen behind it (`trackScreenResolution`).\n *\n * Reported as a width and a height in device pixels — the same units, and for the\n * same reason, as `media/dom/screen`'s reading: the cap compares against real\n * track dimensions, and a `\"720p\"`-style tier only describes a track once you\n * assume its aspect ratio.\n *\n * `undefined` where there is nothing to measure — no element attached, or one\n * that isn't being rendered (detached, `display: none`, not yet laid out) — which\n * is the value the cap reads as \"don't cap\".\n */\n\nimport { type ElementSize, observeElementSize, observeRenderedSize } from '@videojs/utils/dom';\nimport { shallowEqual } from '@videojs/utils/object';\nimport { defineBehavior } from '../../../core/composition/create-composition';\nimport { effect } from '../../../core/signals/effect';\nimport type { ReadonlySignal, Signal } from '../../../core/signals/primitives';\nimport { type Resolution, scaleResolution } from '../../../media/primitives/resolution';\n\n/** A player element's rendered pixel dimensions. */\nexport type PlayerResolution = Resolution;\n\nexport interface PlayerResolutionState {\n playerResolution?: PlayerResolution;\n}\n\nexport interface PlayerResolutionContext {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\nexport interface TrackPlayerResolutionConfig {\n /**\n * Whether to cap renditions to the player's rendered size at all. `false`\n * measures nothing, which leaves `state.playerResolution` unset and the cap\n * inert. Defaults to `true`.\n *\n * Named for the policy rather than the measurement because it is the public\n * switch for the feature — the same one hls.js spells `capLevelToPlayerSize`\n * and the Mux Video element spells `cap-rendition-to-player-size`.\n */\n capRenditionToPlayerSize?: boolean;\n /**\n * Whether the reading is scaled into device pixels. Defaults to `true` — see\n * `ScreenResolutionOptions.useDevicePixelRatio` in `media/dom/screen.ts`,\n * including its note on page zoom being folded into the ratio outside WebKit.\n */\n useDevicePixelRatio?: boolean;\n}\n\nfunction trackPlayerResolutionSetup({\n state,\n context,\n config = {},\n}: {\n state: { playerResolution: Signal<PlayerResolutionState['playerResolution']> };\n context: { mediaElement: ReadonlySignal<PlayerResolutionContext['mediaElement']> };\n config?: TrackPlayerResolutionConfig;\n}): () => void {\n const { capRenditionToPlayerSize = true, useDevicePixelRatio = true } = config;\n\n return effect(() => {\n const mediaElement = context.mediaElement.get();\n // Clear before (re-)observing so the previous element's measurement can't\n // stand while the new one's first observation is in flight.\n let current: PlayerResolution | undefined;\n state.playerResolution.set(current);\n if (!capRenditionToPlayerSize || !mediaElement) return;\n\n // Compared rather than written straight through, since the slot holds an\n // object and would otherwise notify on identity alone: a resize that rounds\n // to the same device pixels is not a change worth re-running selection for.\n // Same reason `watchScreenResolution` compares its readings.\n const write = (size: ElementSize & { scale?: number }) => {\n const next = scaleResolution(size, size.scale);\n if (shallowEqual(current, next)) return;\n\n current = next;\n state.playerResolution.set(next);\n };\n\n return useDevicePixelRatio ? observeRenderedSize(mediaElement, write) : observeElementSize(mediaElement, write);\n });\n}\n\n/**\n * Track the player element's rendered resolution in `state.playerResolution`.\n *\n * @example\n * const cleanup = trackPlayerResolution.setup({ state, context });\n */\nexport const trackPlayerResolution = defineBehavior({\n stateKeys: ['playerResolution'],\n contextKeys: ['mediaElement'],\n setup: trackPlayerResolutionSetup,\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,SAAS,2BAA2B,EAClC,OACA,SACA,SAAS,CAAC,KAKG;CACb,MAAM,EAAE,2BAA2B,MAAM,sBAAsB,SAAS;CAExE,OAAO,aAAa;EAClB,MAAM,eAAe,QAAQ,aAAa,IAAI;EAG9C,IAAI;EACJ,MAAM,iBAAiB,IAAI,OAAO;EAClC,IAAI,CAAC,4BAA4B,CAAC,cAAc;EAMhD,MAAM,SAAS,SAA2C;GACxD,MAAM,OAAO,gBAAgB,MAAM,KAAK,KAAK;GAC7C,IAAI,aAAa,SAAS,IAAI,GAAG;GAEjC,UAAU;GACV,MAAM,iBAAiB,IAAI,IAAI;EACjC;EAEA,OAAO,sBAAsB,oBAAoB,cAAc,KAAK,IAAI,mBAAmB,cAAc,KAAK;CAChH,CAAC;AACH;;;;;;;AAQA,MAAa,wBAAwB,eAAe;CAClD,WAAW,CAAC,kBAAkB;CAC9B,aAAa,CAAC,cAAc;CAC5B,OAAO;AACT,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/playback/behaviors/select-tracks.ts"],"sourcesContent":["/**\n * **Default audio/video track selection on src load / unselect on src unload.**\n * When a presentation is resolved, sets `selectedVideoTrackId` /\n * `selectedAudioTrackId` from a per-type default rule chain if no selection\n * already exists. When the presentation is unset/reset (transitions back to unresolved),\n * clears the selection so a stale id from the previous source doesn't persist.\n *\n * Lifecycle-driven: the pick fires once per transition, and nothing re-picks —\n * that is what separates these from the `switch*` variants. External writes (user\n * picks, ABR, programmatic filter-driven re-picks) are left alone, including a\n * write naming a track the manifest never offered.\n *\n * The one thing policed between transitions is a pick the *constraints* turn\n * against: a rendition's container and encryption are only known once its media\n * playlist resolves, which is after the pick was made, so a selection that becomes\n * unplayable is dropped. Dropped, never moved — re-picking is exactly the behavior\n * `switchVideoTrack` exists to provide. Dropping reports nothing on its own, since\n * whatever made the pick unplayable already reported its own, more specific cause.\n *\n * Selection runs the same rule model `switchVideoTrack` does — a hard\n * `constraints` pre-pass, then an ordered `rules` chain, with the pick as the\n * head (see `internal/design/spf/track-switching-model.md`). What differs is\n * reactivity, not the rules: this evaluates the chain once on resolve and pins\n * the result, where `switchVideoTrack` re-evaluates inside an effect so its rules\n * subscribe to bandwidth and user selection. A rule written for one therefore\n * composes into the other unchanged.\n *\n * Both are config-driven, each per-type export wiring a sensible default: audio's\n * three-tier language policy, and for video the *empty* chain — with nothing\n * narrowing or reordering, the head is the first candidate. The behavior's\n * `config` is forwarded to the rules, so options like `preferredAudioLanguage`\n * reach them without an intermediate layer.\n *\n * Note a rule can only pick among real candidates, where the picker it replaced\n * could return any id at all. An id absent from the manifest was never\n * selectable, so that narrowing is the point rather than a limitation.\n *\n * Compose `selectVideoTrack` for the simple \"pick a default video track\"\n * behavior, or `switchVideoTrack` (`./track-switching.ts`) for the\n * ABR-driven variant. Compose `selectAudioTrack` for the simple default\n * pick, or `switchAudioTrack` (`./track-switching.ts`) for the\n * filter-reactive + mid-stream-flush slot-owner variant — when audio-abr\n * lands, `switchAudioTrack` extends into `switchAudioQuality`. Compose\n * only one per type — they're alternatives, not stackable (each writes\n * the same `selected*TrackId` slot). The simple variants tree-shake out\n * the heavier machinery (bandwidth estimator, quality selection, flush\n * orchestration).\n *\n * Text selection has no simple variant here — it's owned by `switchTextTrack`\n * (`./track-switching.ts`), which resolves standing `userTextTrackSelection`\n * intent against the constrained, CDN-scoped renditions.\n */\n\nimport { defineBehavior } from '../../core/composition/create-composition';\nimport { createMachineReactor } from '../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport {\n type AudioSelectionConfig,\n byDescendingResolution,\n pickAudioTrackFromTracks,\n type TrackSelectionState,\n tracksUnderPixelArea,\n} from '../../media/primitives/select-tracks';\nimport { isResolvedPresentation, type TrackType } from '../../media/types';\nimport { getTracksByType } from '../../media/utils/tracks';\nimport {\n applyConstraints,\n applyRules,\n type CapabilityConstraintConfig,\n excludeUnplayableTracks,\n type SelectionRule,\n sameCandidateSet,\n} from '../primitives/selection-rules';\nimport { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';\n\n// ============================================================================\n// Specialization helper\n//\n// `setupTrackSelection` has the same shape as a Behavior `setup` function:\n// `({ state, config }) => Reactor`. Each `selectXTrack` export below calls\n// it from inside its own `defineBehavior` setup, supplying its per-type\n// `selectedKey`, track type, default rule chain, and forwarded config. The lifecycle\n// — pick on entering 'presentation-resolved' if not already selected; clear\n// on entering 'presentation-unresolved' — is shared.\n// ============================================================================\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';\n\ntype SelectStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<TrackSelectionState['presentation']>;\n} & { [P in K]: Signal<TrackSelectionState[P]> };\n\n/** A selection rule over this behavior's candidate tracks. */\nexport type SelectTrackRule<Config> = SelectionRule<SelectableTrack, unknown, unknown, Config | undefined>;\n\n/** What a rule here needs off a candidate: the id it may become the pick by. */\ntype SelectableTrack = { id: string };\n\ninterface TrackSelectionSetupConfig<K extends SelectedTrackKey, RuleConfig> {\n selectedKey: K;\n trackType: TrackType;\n constraints: readonly SelectTrackRule<RuleConfig>[];\n rules: readonly SelectTrackRule<RuleConfig>[];\n ruleConfig?: RuleConfig;\n}\n\nfunction setupTrackSelection<K extends SelectedTrackKey, RuleConfig>({\n state,\n config: { selectedKey, trackType, constraints, rules, ruleConfig },\n}: {\n state: SelectStateMap<K>;\n config: TrackSelectionSetupConfig<K, RuleConfig>;\n}) {\n const derivedStateSignal = computed(() =>\n isResolvedPresentation(state.presentation.get())\n ? ('presentation-resolved' as const)\n : ('presentation-unresolved' as const)\n );\n\n const deps = { state, config: ruleConfig };\n\n // The playable candidate set — the type's tracks after the hard-constraints\n // pre-pass. A `computed` so it re-evaluates when its inputs change, which is what\n // lets a *pinned* selection still notice it has gone unplayable: `resolve-track`\n // relabels the whole type's container from the first resolved media playlist,\n // long after `entry` made its pick under the fMP4 default.\n //\n // The `equals` gates notification on the set of track ids rather than array\n // identity, matching `setupTrackSwitching`'s. Segment appends and live reloads\n // both swap in a new presentation object carrying the same variants; without\n // this the effect below would re-run on every one of them.\n const candidateSet = computed(\n () => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return [];\n return applyConstraints(constraints, getTracksByType(presentation, trackType), deps);\n },\n { equals: sameCandidateSet }\n );\n\n return createMachineReactor({\n initial: 'presentation-unresolved',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'presentation-unresolved': {},\n 'presentation-resolved': {\n // Entry: pick a default on entering presentation-resolved if none\n // is set. External writes (user picks, ABR) that already populated\n // the slot are left alone.\n //\n // The returned cleanup runs on state exit — which fires on src\n // unload (presentation-resolved → presentation-unresolved) AND on\n // behavior destroy (presentation-resolved → destroying →\n // destroyed). Putting the clear here rather than as\n // presentation-unresolved.entry is more cohesive (operation +\n // cleanup co-located) and correctly covers destroy (destroy\n // doesn't pass through presentation-unresolved).\n entry: () => {\n if (!state[selectedKey].get()) {\n // `state.presentation.get()` is non-null inside this entry —\n // the reactor's `'presentation-resolved'` gate is exactly\n // `isResolvedPresentation(state.presentation.get())`, which\n // requires a truthy Presentation.\n //\n // Constraints prune the unplayable, then the chain narrows and ranks;\n // the pick is the head. An empty `rules` chain therefore selects the\n // first candidate, which falls out of the model rather than needing a\n // first-track code path of its own.\n const survivors = applyRules(rules, peek(candidateSet), deps);\n const id = survivors[0]?.id;\n if (id) state[selectedKey].set(id);\n }\n return () => state[selectedKey].set(undefined);\n },\n effects: [\n // Selection is `entry`'s alone — this only ever *de*selects. Keeping the\n // pick out of a reaction is what makes this the pinned variant rather\n // than a worse-spelled `switchVideoTrack`: nothing here re-ranks or moves\n // the pin. It has to be a reaction all the same, because what it watches\n // for is learned late — container and encryption come from a rendition's\n // media playlist, which resolves after the pick was made.\n //\n // Deliberately does *not* report why. Whatever made the pick unplayable\n // reported its own cause as the playlist resolved (1004 container, 4008\n // encryption, via `reportUnsupportedTrackConditions`), which is both more\n // specific than a verdict and already logged. The one condition no cause\n // covers is nothing being playable at all — no rendition resolved, so\n // none reported — which is why that alone emits here. See\n // `internal/design/spf/features/errors.md`.\n () => {\n // Untracked: writing the slot below must not re-enter this reaction.\n const selectedId = peek(state[selectedKey]);\n if (!selectedId) return;\n if (candidateSet.get().some((track) => track.id === selectedId)) return;\n\n // Only a pick the source actually offers is this behavior's to drop. An\n // id absent from the manifest was never selectable, and external writes\n // are left alone — see this module's header.\n const presentation = peek(state.presentation);\n if (\n isResolvedPresentation(presentation) &&\n getTracksByType(presentation, trackType).some((track) => track.id === selectedId)\n ) {\n state[selectedKey].set(undefined);\n }\n },\n ],\n },\n },\n });\n}\n\n// ============================================================================\n// Default rules\n//\n// Each variant resolves its chain as `config?.rules ?? <default>`. The whole\n// behavior config is forwarded as the rules' `config`, so a policy rule reads\n// its own options (`preferredAudioLanguage`) directly off it.\n//\n// Video's default is the *empty* chain: with no rule narrowing or reordering the\n// candidates, the head is the first track — a consequence of the model rather than\n// a first-track code path of its own.\n// ============================================================================\n\n/** Default video chain: none. The first candidate is the pick. */\nconst DEFAULT_VIDEO_RULES: readonly SelectTrackRule<SelectVideoTrackConfig>[] = [];\n\n/**\n * Default audio chain: the three-tier policy (`preferredAudioLanguage` →\n * `DEFAULT=YES` → first) as a single narrowing rule. Returning `[]` when nothing\n * is picked lets `applyRules` fall through to the unnarrowed candidates, so the\n * head stays the first track — the same last tier the policy itself ends on.\n */\nconst preferAudioPolicy: SelectTrackRule<SelectAudioTrackConfig> = (tracks, { config }) => {\n const id = pickAudioTrackFromTracks(tracks as readonly { id: string }[], config);\n const pick = tracks.find((track) => track.id === id);\n return pick ? [pick] : [];\n};\n\nconst DEFAULT_AUDIO_RULES: readonly SelectTrackRule<SelectAudioTrackConfig>[] = [preferAudioPolicy];\n\n/**\n * Order the candidates by resolution, largest first, with bandwidth breaking ties\n * between renditions of identical dimensions. The background-video default — that\n * variant pins one rendition for the session, and absent a cap the largest is the\n * head.\n *\n * A ranker, so it reorders rather than narrowing: the chain's pick is the head of\n * what it returns, which means ranking never has to collapse to one track. Belongs\n * last in a chain — a sort only reorders what survived the filters ahead of it, and\n * leaving it last is what lets `applyRules` early-bail before it runs.\n *\n * Exported because it is a *rule*, not a variant's private policy: the same one\n * composes into `switchVideoTrack`'s chain when a ranker is wanted there.\n */\nexport const preferHighestResolution: SelectTrackRule<unknown> = (tracks) => [...tracks].sort(byDescendingResolution);\n\n/**\n * What {@link screenResolutionCap} reads off the composition state.\n *\n * Structurally compatible with `media/dom/screen`'s `ScreenResolution` rather than\n * importing it: this module sits outside the DOM layer (project references enforce\n * that), and a rule comparing pixel areas needs two numbers, not a screen.\n */\ntype ScreenResolutionRuleState = {\n screenResolution?: ReadonlySignal<{ readonly width: number; readonly height: number } | undefined>;\n};\n\n/**\n * Narrow to the renditions that fit the screen, by pixel area — the screen-size\n * cap from `internal/design/spf/features/rendition-selection-caps.md`, as a scope\n * (soft filter) rather than a constraint: an over-cap rendition is wasteful, not\n * unplayable, so nothing here may make a source unplayable.\n *\n * Narrows only — it neither orders the survivors nor resolves the case where none\n * survive, because `applyRules` owns both. So it needs a ranker behind it to pick\n * within the cap: `[screenResolutionCap, preferHighestResolution]` yields the\n * largest rendition that fits. Composed *last*, the pick would instead be whichever\n * fitting rendition the manifest happened to list first.\n *\n * Reading `state.screenResolution` through its signal is what subscribes a\n * re-evaluating chain (`switchVideoTrack`) to screen changes; `selectVideoTrack`\n * pins the first answer instead, by design.\n *\n * Compares areas rather than matching a `\"1080p\"`-style tier because a tier only\n * describes a rendition once you assume its aspect ratio — the assumption that\n * mis-measures an anamorphic ladder. See `media/dom/screen.ts`.\n *\n * Three ways the cap ends up not applying, all of them fall-through:\n *\n * - **No `screenResolution` signal at all**, because the composition omits\n * `trackScreenResolution`. So composing the cap without its signal source is\n * inert rather than broken.\n * - **A `screenResolution` of `undefined`**, meaning no screen to read. \"Unknown\"\n * has to mean \"don't cap\": treating it as an area of zero would pin every source\n * to its smallest rendition on exactly the environments we know least about.\n * - **No rendition fits**, on a screen smaller than the whole ladder. `applyRules`\n * skips the empty result and the chain proceeds unnarrowed, so the ranker behind\n * the cap decides — for `preferHighestResolution`, the largest rendition. A floor\n * is the fix if that ever matters (`rendition-selection-caps.md` carries one), not\n * a special case here.\n */\nexport const screenResolutionCap: SelectTrackRule<unknown> = (tracks, { state }) => {\n const screenResolution = (state as ScreenResolutionRuleState | undefined)?.screenResolution?.get();\n if (!screenResolution) return [];\n\n return tracksUnderPixelArea(tracks, screenResolution.width * screenResolution.height);\n};\n\n// ============================================================================\n// Specialized exports — one per track type\n// ============================================================================\n\n/**\n * Config for `selectVideoTrack`. Pass `rules` to replace the selection chain, or\n * `constraints` to replace the capability pre-pass; otherwise the chain is empty\n * and the first playable candidate is the pick.\n */\nexport interface SelectVideoTrackConfig extends CapabilityConstraintConfig {\n constraints?: readonly SelectTrackRule<SelectVideoTrackConfig>[];\n rules?: readonly SelectTrackRule<SelectVideoTrackConfig>[];\n}\n\n/**\n * Default video constraints: the capability pre-pass alone. No\n * `excludeFailedCdns` — this variant's compositions run no failover monitor, so\n * `failedCdns` has no writer and the constraint would always pass through.\n */\nconst DEFAULT_VIDEO_CONSTRAINTS: readonly SelectTrackRule<SelectVideoTrackConfig>[] = [excludeUnplayableTracks];\n\n/**\n * Select a video track when a presentation loads. Clears the selection on\n * src unload.\n *\n * This is the simple, non-ABR counterpart to `switchVideoTrack` — compose\n * one or the other, not both (both write `selectedVideoTrackId`). Composing\n * `selectVideoTrack` alone tree-shakes out the ABR code path\n * (bandwidth-estimator, quality-selection); use it for sources without\n * meaningful quality variants, test setups, or players that intentionally\n * pin a quality.\n *\n * @example\n * const reactor = selectVideoTrack.setup({ state });\n */\nexport const selectVideoTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedVideoTrackId'],\n contextKeys: [],\n setup: ({ state, config }: { state: SelectStateMap<'selectedVideoTrackId'>; config?: SelectVideoTrackConfig }) =>\n setupTrackSelection({\n state,\n config: {\n selectedKey: VIDEO_TYPE_CONFIG.selectedKey,\n trackType: 'video',\n constraints: config?.constraints ?? DEFAULT_VIDEO_CONSTRAINTS,\n rules: config?.rules ?? DEFAULT_VIDEO_RULES,\n ruleConfig: config,\n },\n }),\n});\n\n/**\n * Config for `selectAudioTrack`. Pass `rules` to replace the selection chain, or\n * `constraints` to prune candidates before it runs; otherwise the default\n * three-tier policy applies (`preferredAudioLanguage` → `DEFAULT=YES` → first).\n */\nexport interface SelectAudioTrackConfig extends AudioSelectionConfig {\n constraints?: readonly SelectTrackRule<SelectAudioTrackConfig>[];\n rules?: readonly SelectTrackRule<SelectAudioTrackConfig>[];\n}\n\n/**\n * Select an audio track when a presentation loads. Clears the selection\n * on src unload.\n *\n * This is the simple, lifecycle-only counterpart to `switchAudioTrack`\n * (in `./track-switching.ts`) — compose one or the other, not both\n * (both write `selectedAudioTrackId`). `switchAudioTrack` adds\n * filter-reactivity (`userAudioTrackSelection`) and mid-stream-flush\n * orchestration; `selectAudioTrack` covers the default-on-load case\n * without those. Use this variant for test setups, audio-only flows\n * that don't expose language switching, or composition variants that\n * intentionally pin a track.\n *\n * @example\n * const reactor = selectAudioTrack.setup({ state });\n *\n * @example\n * // Language preference, honored by the default audio policy rule\n * const reactor = selectAudioTrack.setup({\n * state,\n * config: { preferredAudioLanguage: 'en' },\n * });\n */\nexport const selectAudioTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedAudioTrackId'],\n contextKeys: [],\n setup: ({ state, config }: { state: SelectStateMap<'selectedAudioTrackId'>; config?: SelectAudioTrackConfig }) =>\n setupTrackSelection({\n state,\n config: {\n selectedKey: AUDIO_TYPE_CONFIG.selectedKey,\n trackType: 'audio',\n constraints: config?.constraints ?? [],\n rules: config?.rules ?? DEFAULT_AUDIO_RULES,\n ruleConfig: config,\n },\n }),\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0GA,SAAS,oBAA4D,EACnE,OACA,QAAQ,EAAE,aAAa,WAAW,aAAa,OAAO,gBAIrD;CACD,MAAM,qBAAqB,eACzB,uBAAuB,MAAM,aAAa,IAAI,CAAC,IAC1C,0BACA,yBACP;CAEA,MAAM,OAAO;EAAE;EAAO,QAAQ;CAAW;CAYzC,MAAM,eAAe,eACb;EACJ,MAAM,eAAe,MAAM,aAAa,IAAI;EAC5C,IAAI,CAAC,uBAAuB,YAAY,GAAG,OAAO,CAAC;EACnD,OAAO,iBAAiB,aAAa,gBAAgB,cAAc,SAAS,GAAG,IAAI;CACrF,GACA,EAAE,QAAQ,iBAAiB,CAC7B;CAEA,OAAO,qBAAqB;EAC1B,SAAS;EACT,eAAe,mBAAmB,IAAI;EACtC,QAAQ;GACN,2BAA2B,CAAC;GAC5B,yBAAyB;IAYvB,aAAa;KACX,IAAI,CAAC,MAAM,YAAY,CAAC,IAAI,GAAG;MAW7B,MAAM,KADY,WAAW,OAAO,KAAK,YAAY,GAAG,IACrC,CAAC,CAAC,EAAE,EAAE;MACzB,IAAI,IAAI,MAAM,YAAY,CAAC,IAAI,EAAE;KACnC;KACA,aAAa,MAAM,YAAY,CAAC,IAAI,KAAA,CAAS;IAC/C;IACA,SAAS,OAeD;KAEJ,MAAM,aAAa,KAAK,MAAM,YAAY;KAC1C,IAAI,CAAC,YAAY;KACjB,IAAI,aAAa,IAAI,CAAC,CAAC,MAAM,UAAU,MAAM,OAAO,UAAU,GAAG;KAKjE,MAAM,eAAe,KAAK,MAAM,YAAY;KAC5C,IACE,uBAAuB,YAAY,KACnC,gBAAgB,cAAc,SAAS,CAAC,CAAC,MAAM,UAAU,MAAM,OAAO,UAAU,GAEhF,MAAM,YAAY,CAAC,IAAI,KAAA,CAAS;IAEpC,CACF;GACF;EACF;CACF,CAAC;AACH;;AAeA,MAAM,sBAA0E,CAAC;;;;;;;AAQjF,MAAM,qBAA8D,QAAQ,EAAE,aAAa;CACzF,MAAM,KAAK,yBAAyB,QAAqC,MAAM;CAC/E,MAAM,OAAO,OAAO,MAAM,UAAU,MAAM,OAAO,EAAE;CACnD,OAAO,OAAO,CAAC,IAAI,IAAI,CAAC;AAC1B;AAEA,MAAM,sBAA0E,CAAC,iBAAiB;;;;;;;;;;;;;;;AAgBlG,MAAa,2BAAqD,WAAW,CAAC,GAAG,MAAM,CAAC,CAAC,KAAK,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CpH,MAAa,uBAAiD,QAAQ,EAAE,YAAY;CAClF,MAAM,mBAAoB,OAAiD,kBAAkB,IAAI;CACjG,IAAI,CAAC,kBAAkB,OAAO,CAAC;CAE/B,OAAO,qBAAqB,QAAQ,iBAAiB,QAAQ,iBAAiB,MAAM;AACtF;;;;;;AAqBA,MAAM,4BAAgF,CAAC,uBAAuB;;;;;;;;;;;;;;;AAgB9G,MAAa,mBAAmB,eAAe;CAC7C,WAAW,CAAC,gBAAgB,sBAAsB;CAClD,aAAa,CAAC;CACd,QAAQ,EAAE,OAAO,aACf,oBAAoB;EAClB;EACA,QAAQ;GACN,aAAa,kBAAkB;GAC/B,WAAW;GACX,aAAa,QAAQ,eAAe;GACpC,OAAO,QAAQ,SAAS;GACxB,YAAY;EACd;CACF,CAAC;AACL,CAAC;AAmC+B,eAAe;CAC7C,WAAW,CAAC,gBAAgB,sBAAsB;CAClD,aAAa,CAAC;CACd,QAAQ,EAAE,OAAO,aACf,oBAAoB;EAClB;EACA,QAAQ;GACN,aAAa,kBAAkB;GAC/B,WAAW;GACX,aAAa,QAAQ,eAAe,CAAC;GACrC,OAAO,QAAQ,SAAS;GACxB,YAAY;EACd;CACF,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/playback/behaviors/select-tracks.ts"],"sourcesContent":["/**\n * **Default audio/video track selection on src load / unselect on src unload.**\n * When a presentation is resolved, sets `selectedVideoTrackId` /\n * `selectedAudioTrackId` from a per-type default rule chain if no selection\n * already exists. When the presentation is unset/reset (transitions back to unresolved),\n * clears the selection so a stale id from the previous source doesn't persist.\n *\n * Lifecycle-driven: the pick fires once per transition, and nothing re-picks —\n * that is what separates these from the `switch*` variants. External writes (user\n * picks, ABR, programmatic filter-driven re-picks) are left alone, including a\n * write naming a track the manifest never offered.\n *\n * The one thing policed between transitions is a pick the *constraints* turn\n * against: a rendition's container and encryption are only known once its media\n * playlist resolves, which is after the pick was made, so a selection that becomes\n * unplayable is dropped. Dropped, never moved — re-picking is exactly the behavior\n * `switchVideoTrack` exists to provide. Dropping reports nothing on its own, since\n * whatever made the pick unplayable already reported its own, more specific cause.\n *\n * Selection runs the same rule model `switchVideoTrack` does — a hard\n * `constraints` pre-pass, then an ordered `rules` chain, with the pick as the\n * head (see `internal/design/spf/track-switching-model.md`). What differs is\n * reactivity, not the rules: this evaluates the chain once on resolve and pins\n * the result, where `switchVideoTrack` re-evaluates inside an effect so its rules\n * subscribe to bandwidth and user selection. A rule written for one therefore\n * composes into the other unchanged.\n *\n * Both are config-driven, each per-type export wiring a sensible default: audio's\n * three-tier language policy, and for video the *empty* chain — with nothing\n * narrowing or reordering, the head is the first candidate. The behavior's\n * `config` is forwarded to the rules, so options like `preferredAudioLanguage`\n * reach them without an intermediate layer.\n *\n * Note a rule can only pick among real candidates, where the picker it replaced\n * could return any id at all. An id absent from the manifest was never\n * selectable, so that narrowing is the point rather than a limitation.\n *\n * Compose `selectVideoTrack` for the simple \"pick a default video track\"\n * behavior, or `switchVideoTrack` (`./track-switching.ts`) for the\n * ABR-driven variant. Compose `selectAudioTrack` for the simple default\n * pick, or `switchAudioTrack` (`./track-switching.ts`) for the\n * filter-reactive + mid-stream-flush slot-owner variant — when audio-abr\n * lands, `switchAudioTrack` extends into `switchAudioQuality`. Compose\n * only one per type — they're alternatives, not stackable (each writes\n * the same `selected*TrackId` slot). The simple variants tree-shake out\n * the heavier machinery (bandwidth estimator, quality selection, flush\n * orchestration).\n *\n * Text selection has no simple variant here — it's owned by `switchTextTrack`\n * (`./track-switching.ts`), which resolves standing `userTextTrackSelection`\n * intent against the constrained, CDN-scoped renditions.\n */\n\nimport { defineBehavior } from '../../core/composition/create-composition';\nimport { createMachineReactor } from '../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport type { Resolution } from '../../media/primitives/resolution';\nimport {\n type AudioSelectionConfig,\n byDescendingResolution,\n pickAudioTrackFromTracks,\n type TrackSelectionState,\n tracksUnderPixelArea,\n} from '../../media/primitives/select-tracks';\nimport { isResolvedPresentation, type TrackType } from '../../media/types';\nimport { getTracksByType } from '../../media/utils/tracks';\nimport {\n applyConstraints,\n applyRules,\n type CapabilityConstraintConfig,\n excludeUnplayableTracks,\n type SelectionRule,\n sameCandidateSet,\n} from '../primitives/selection-rules';\nimport { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';\n\n// ============================================================================\n// Specialization helper\n//\n// `setupTrackSelection` has the same shape as a Behavior `setup` function:\n// `({ state, config }) => Reactor`. Each `selectXTrack` export below calls\n// it from inside its own `defineBehavior` setup, supplying its per-type\n// `selectedKey`, track type, default rule chain, and forwarded config. The lifecycle\n// — pick on entering 'presentation-resolved' if not already selected; clear\n// on entering 'presentation-unresolved' — is shared.\n// ============================================================================\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';\n\ntype SelectStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<TrackSelectionState['presentation']>;\n} & { [P in K]: Signal<TrackSelectionState[P]> };\n\n/** A selection rule over this behavior's candidate tracks. */\nexport type SelectTrackRule<Config> = SelectionRule<SelectableTrack, unknown, unknown, Config | undefined>;\n\n/** What a rule here needs off a candidate: the id it may become the pick by. */\ntype SelectableTrack = { id: string };\n\ninterface TrackSelectionSetupConfig<K extends SelectedTrackKey, RuleConfig> {\n selectedKey: K;\n trackType: TrackType;\n constraints: readonly SelectTrackRule<RuleConfig>[];\n rules: readonly SelectTrackRule<RuleConfig>[];\n ruleConfig?: RuleConfig;\n}\n\nfunction setupTrackSelection<K extends SelectedTrackKey, RuleConfig>({\n state,\n config: { selectedKey, trackType, constraints, rules, ruleConfig },\n}: {\n state: SelectStateMap<K>;\n config: TrackSelectionSetupConfig<K, RuleConfig>;\n}) {\n const derivedStateSignal = computed(() =>\n isResolvedPresentation(state.presentation.get())\n ? ('presentation-resolved' as const)\n : ('presentation-unresolved' as const)\n );\n\n const deps = { state, config: ruleConfig };\n\n // The playable candidate set — the type's tracks after the hard-constraints\n // pre-pass. A `computed` so it re-evaluates when its inputs change, which is what\n // lets a *pinned* selection still notice it has gone unplayable: `resolve-track`\n // relabels the whole type's container from the first resolved media playlist,\n // long after `entry` made its pick under the fMP4 default.\n //\n // The `equals` gates notification on the set of track ids rather than array\n // identity, matching `setupTrackSwitching`'s. Segment appends and live reloads\n // both swap in a new presentation object carrying the same variants; without\n // this the effect below would re-run on every one of them.\n const candidateSet = computed(\n () => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return [];\n return applyConstraints(constraints, getTracksByType(presentation, trackType), deps);\n },\n { equals: sameCandidateSet }\n );\n\n return createMachineReactor({\n initial: 'presentation-unresolved',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'presentation-unresolved': {},\n 'presentation-resolved': {\n // Entry: pick a default on entering presentation-resolved if none\n // is set. External writes (user picks, ABR) that already populated\n // the slot are left alone.\n //\n // The returned cleanup runs on state exit — which fires on src\n // unload (presentation-resolved → presentation-unresolved) AND on\n // behavior destroy (presentation-resolved → destroying →\n // destroyed). Putting the clear here rather than as\n // presentation-unresolved.entry is more cohesive (operation +\n // cleanup co-located) and correctly covers destroy (destroy\n // doesn't pass through presentation-unresolved).\n entry: () => {\n if (!state[selectedKey].get()) {\n // `state.presentation.get()` is non-null inside this entry —\n // the reactor's `'presentation-resolved'` gate is exactly\n // `isResolvedPresentation(state.presentation.get())`, which\n // requires a truthy Presentation.\n //\n // Constraints prune the unplayable, then the chain narrows and ranks;\n // the pick is the head. An empty `rules` chain therefore selects the\n // first candidate, which falls out of the model rather than needing a\n // first-track code path of its own.\n const survivors = applyRules(rules, peek(candidateSet), deps);\n const id = survivors[0]?.id;\n if (id) state[selectedKey].set(id);\n }\n return () => state[selectedKey].set(undefined);\n },\n effects: [\n // Selection is `entry`'s alone — this only ever *de*selects. Keeping the\n // pick out of a reaction is what makes this the pinned variant rather\n // than a worse-spelled `switchVideoTrack`: nothing here re-ranks or moves\n // the pin. It has to be a reaction all the same, because what it watches\n // for is learned late — container and encryption come from a rendition's\n // media playlist, which resolves after the pick was made.\n //\n // Deliberately does *not* report why. Whatever made the pick unplayable\n // reported its own cause as the playlist resolved (1004 container, 4008\n // encryption, via `reportUnsupportedTrackConditions`), which is both more\n // specific than a verdict and already logged. The one condition no cause\n // covers is nothing being playable at all — no rendition resolved, so\n // none reported — which is why that alone emits here. See\n // `internal/design/spf/features/errors.md`.\n () => {\n // Untracked: writing the slot below must not re-enter this reaction.\n const selectedId = peek(state[selectedKey]);\n if (!selectedId) return;\n if (candidateSet.get().some((track) => track.id === selectedId)) return;\n\n // Only a pick the source actually offers is this behavior's to drop. An\n // id absent from the manifest was never selectable, and external writes\n // are left alone — see this module's header.\n const presentation = peek(state.presentation);\n if (\n isResolvedPresentation(presentation) &&\n getTracksByType(presentation, trackType).some((track) => track.id === selectedId)\n ) {\n state[selectedKey].set(undefined);\n }\n },\n ],\n },\n },\n });\n}\n\n// ============================================================================\n// Default rules\n//\n// Each variant resolves its chain as `config?.rules ?? <default>`. The whole\n// behavior config is forwarded as the rules' `config`, so a policy rule reads\n// its own options (`preferredAudioLanguage`) directly off it.\n//\n// Video's default is the *empty* chain: with no rule narrowing or reordering the\n// candidates, the head is the first track — a consequence of the model rather than\n// a first-track code path of its own.\n// ============================================================================\n\n/** Default video chain: none. The first candidate is the pick. */\nconst DEFAULT_VIDEO_RULES: readonly SelectTrackRule<SelectVideoTrackConfig>[] = [];\n\n/**\n * Default audio chain: the three-tier policy (`preferredAudioLanguage` →\n * `DEFAULT=YES` → first) as a single narrowing rule. Returning `[]` when nothing\n * is picked lets `applyRules` fall through to the unnarrowed candidates, so the\n * head stays the first track — the same last tier the policy itself ends on.\n */\nconst preferAudioPolicy: SelectTrackRule<SelectAudioTrackConfig> = (tracks, { config }) => {\n const id = pickAudioTrackFromTracks(tracks as readonly { id: string }[], config);\n const pick = tracks.find((track) => track.id === id);\n return pick ? [pick] : [];\n};\n\nconst DEFAULT_AUDIO_RULES: readonly SelectTrackRule<SelectAudioTrackConfig>[] = [preferAudioPolicy];\n\n/**\n * Order the candidates by resolution, largest first, with bandwidth breaking ties\n * between renditions of identical dimensions. The background-video default — that\n * variant pins one rendition for the session, and absent a cap the largest is the\n * head.\n *\n * A ranker, so it reorders rather than narrowing: the chain's pick is the head of\n * what it returns, which means ranking never has to collapse to one track. Belongs\n * last in a chain — a sort only reorders what survived the filters ahead of it, and\n * leaving it last is what lets `applyRules` early-bail before it runs.\n *\n * Exported because it is a *rule*, not a variant's private policy: the same one\n * composes into `switchVideoTrack`'s chain when a ranker is wanted there.\n */\nexport const preferHighestResolution: SelectTrackRule<unknown> = (tracks) => [...tracks].sort(byDescendingResolution);\n\n/** What {@link screenResolutionCap} reads off the composition state. */\ntype ScreenResolutionRuleState = {\n screenResolution?: ReadonlySignal<Resolution | undefined>;\n};\n\n/**\n * Narrow to the renditions that fit the screen, by pixel area — the screen-size\n * cap from `internal/design/spf/features/rendition-selection-caps.md`, as a scope\n * (soft filter) rather than a constraint: an over-cap rendition is wasteful, not\n * unplayable, so nothing here may make a source unplayable.\n *\n * Narrows only — it neither orders the survivors nor resolves the case where none\n * survive, because `applyRules` owns both. So it needs a ranker behind it to pick\n * within the cap: `[screenResolutionCap, preferHighestResolution]` yields the\n * largest rendition that fits. Composed *last*, the pick would instead be whichever\n * fitting rendition the manifest happened to list first.\n *\n * Reading `state.screenResolution` through its signal is what subscribes a\n * re-evaluating chain (`switchVideoTrack`) to screen changes; `selectVideoTrack`\n * pins the first answer instead, by design.\n *\n * Compares areas rather than matching a `\"1080p\"`-style tier because a tier only\n * describes a rendition once you assume its aspect ratio — the assumption that\n * mis-measures an anamorphic ladder. See `media/dom/screen.ts`.\n *\n * Three ways the cap ends up not applying, all of them fall-through:\n *\n * - **No `screenResolution` signal at all**, because the composition omits\n * `trackScreenResolution`. So composing the cap without its signal source is\n * inert rather than broken.\n * - **A `screenResolution` of `undefined`**, meaning no screen to read. \"Unknown\"\n * has to mean \"don't cap\": treating it as an area of zero would pin every source\n * to its smallest rendition on exactly the environments we know least about.\n * - **No rendition fits**, on a screen smaller than the whole ladder. `applyRules`\n * skips the empty result and the chain proceeds unnarrowed, so the ranker behind\n * the cap decides — for `preferHighestResolution`, the largest rendition. A floor\n * is the fix if that ever matters (`rendition-selection-caps.md` carries one), not\n * a special case here.\n */\nexport const screenResolutionCap: SelectTrackRule<unknown> = (tracks, { state }) => {\n const screenResolution = (state as ScreenResolutionRuleState | undefined)?.screenResolution?.get();\n if (!screenResolution) return [];\n\n return tracksUnderPixelArea(tracks, screenResolution.width * screenResolution.height);\n};\n\n// ============================================================================\n// Specialized exports — one per track type\n// ============================================================================\n\n/**\n * Config for `selectVideoTrack`. Pass `rules` to replace the selection chain, or\n * `constraints` to replace the capability pre-pass; otherwise the chain is empty\n * and the first playable candidate is the pick.\n */\nexport interface SelectVideoTrackConfig extends CapabilityConstraintConfig {\n constraints?: readonly SelectTrackRule<SelectVideoTrackConfig>[];\n rules?: readonly SelectTrackRule<SelectVideoTrackConfig>[];\n}\n\n/**\n * Default video constraints: the capability pre-pass alone. No\n * `excludeFailedCdns` — this variant's compositions run no failover monitor, so\n * `failedCdns` has no writer and the constraint would always pass through.\n */\nconst DEFAULT_VIDEO_CONSTRAINTS: readonly SelectTrackRule<SelectVideoTrackConfig>[] = [excludeUnplayableTracks];\n\n/**\n * Select a video track when a presentation loads. Clears the selection on\n * src unload.\n *\n * This is the simple, non-ABR counterpart to `switchVideoTrack` — compose\n * one or the other, not both (both write `selectedVideoTrackId`). Composing\n * `selectVideoTrack` alone tree-shakes out the ABR code path\n * (bandwidth-estimator, quality-selection); use it for sources without\n * meaningful quality variants, test setups, or players that intentionally\n * pin a quality.\n *\n * @example\n * const reactor = selectVideoTrack.setup({ state });\n */\nexport const selectVideoTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedVideoTrackId'],\n contextKeys: [],\n setup: ({ state, config }: { state: SelectStateMap<'selectedVideoTrackId'>; config?: SelectVideoTrackConfig }) =>\n setupTrackSelection({\n state,\n config: {\n selectedKey: VIDEO_TYPE_CONFIG.selectedKey,\n trackType: 'video',\n constraints: config?.constraints ?? DEFAULT_VIDEO_CONSTRAINTS,\n rules: config?.rules ?? DEFAULT_VIDEO_RULES,\n ruleConfig: config,\n },\n }),\n});\n\n/**\n * Config for `selectAudioTrack`. Pass `rules` to replace the selection chain, or\n * `constraints` to prune candidates before it runs; otherwise the default\n * three-tier policy applies (`preferredAudioLanguage` → `DEFAULT=YES` → first).\n */\nexport interface SelectAudioTrackConfig extends AudioSelectionConfig {\n constraints?: readonly SelectTrackRule<SelectAudioTrackConfig>[];\n rules?: readonly SelectTrackRule<SelectAudioTrackConfig>[];\n}\n\n/**\n * Select an audio track when a presentation loads. Clears the selection\n * on src unload.\n *\n * This is the simple, lifecycle-only counterpart to `switchAudioTrack`\n * (in `./track-switching.ts`) — compose one or the other, not both\n * (both write `selectedAudioTrackId`). `switchAudioTrack` adds\n * filter-reactivity (`userAudioTrackSelection`) and mid-stream-flush\n * orchestration; `selectAudioTrack` covers the default-on-load case\n * without those. Use this variant for test setups, audio-only flows\n * that don't expose language switching, or composition variants that\n * intentionally pin a track.\n *\n * @example\n * const reactor = selectAudioTrack.setup({ state });\n *\n * @example\n * // Language preference, honored by the default audio policy rule\n * const reactor = selectAudioTrack.setup({\n * state,\n * config: { preferredAudioLanguage: 'en' },\n * });\n */\nexport const selectAudioTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedAudioTrackId'],\n contextKeys: [],\n setup: ({ state, config }: { state: SelectStateMap<'selectedAudioTrackId'>; config?: SelectAudioTrackConfig }) =>\n setupTrackSelection({\n state,\n config: {\n selectedKey: AUDIO_TYPE_CONFIG.selectedKey,\n trackType: 'audio',\n constraints: config?.constraints ?? [],\n rules: config?.rules ?? DEFAULT_AUDIO_RULES,\n ruleConfig: config,\n },\n }),\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2GA,SAAS,oBAA4D,EACnE,OACA,QAAQ,EAAE,aAAa,WAAW,aAAa,OAAO,gBAIrD;CACD,MAAM,qBAAqB,eACzB,uBAAuB,MAAM,aAAa,IAAI,CAAC,IAC1C,0BACA,yBACP;CAEA,MAAM,OAAO;EAAE;EAAO,QAAQ;CAAW;CAYzC,MAAM,eAAe,eACb;EACJ,MAAM,eAAe,MAAM,aAAa,IAAI;EAC5C,IAAI,CAAC,uBAAuB,YAAY,GAAG,OAAO,CAAC;EACnD,OAAO,iBAAiB,aAAa,gBAAgB,cAAc,SAAS,GAAG,IAAI;CACrF,GACA,EAAE,QAAQ,iBAAiB,CAC7B;CAEA,OAAO,qBAAqB;EAC1B,SAAS;EACT,eAAe,mBAAmB,IAAI;EACtC,QAAQ;GACN,2BAA2B,CAAC;GAC5B,yBAAyB;IAYvB,aAAa;KACX,IAAI,CAAC,MAAM,YAAY,CAAC,IAAI,GAAG;MAW7B,MAAM,KADY,WAAW,OAAO,KAAK,YAAY,GAAG,IACrC,CAAC,CAAC,EAAE,EAAE;MACzB,IAAI,IAAI,MAAM,YAAY,CAAC,IAAI,EAAE;KACnC;KACA,aAAa,MAAM,YAAY,CAAC,IAAI,KAAA,CAAS;IAC/C;IACA,SAAS,OAeD;KAEJ,MAAM,aAAa,KAAK,MAAM,YAAY;KAC1C,IAAI,CAAC,YAAY;KACjB,IAAI,aAAa,IAAI,CAAC,CAAC,MAAM,UAAU,MAAM,OAAO,UAAU,GAAG;KAKjE,MAAM,eAAe,KAAK,MAAM,YAAY;KAC5C,IACE,uBAAuB,YAAY,KACnC,gBAAgB,cAAc,SAAS,CAAC,CAAC,MAAM,UAAU,MAAM,OAAO,UAAU,GAEhF,MAAM,YAAY,CAAC,IAAI,KAAA,CAAS;IAEpC,CACF;GACF;EACF;CACF,CAAC;AACH;;AAeA,MAAM,sBAA0E,CAAC;;;;;;;AAQjF,MAAM,qBAA8D,QAAQ,EAAE,aAAa;CACzF,MAAM,KAAK,yBAAyB,QAAqC,MAAM;CAC/E,MAAM,OAAO,OAAO,MAAM,UAAU,MAAM,OAAO,EAAE;CACnD,OAAO,OAAO,CAAC,IAAI,IAAI,CAAC;AAC1B;AAEA,MAAM,sBAA0E,CAAC,iBAAiB;;;;;;;;;;;;;;;AAgBlG,MAAa,2BAAqD,WAAW,CAAC,GAAG,MAAM,CAAC,CAAC,KAAK,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCpH,MAAa,uBAAiD,QAAQ,EAAE,YAAY;CAClF,MAAM,mBAAoB,OAAiD,kBAAkB,IAAI;CACjG,IAAI,CAAC,kBAAkB,OAAO,CAAC;CAE/B,OAAO,qBAAqB,QAAQ,iBAAiB,QAAQ,iBAAiB,MAAM;AACtF;;;;;;AAqBA,MAAM,4BAAgF,CAAC,uBAAuB;;;;;;;;;;;;;;;AAgB9G,MAAa,mBAAmB,eAAe;CAC7C,WAAW,CAAC,gBAAgB,sBAAsB;CAClD,aAAa,CAAC;CACd,QAAQ,EAAE,OAAO,aACf,oBAAoB;EAClB;EACA,QAAQ;GACN,aAAa,kBAAkB;GAC/B,WAAW;GACX,aAAa,QAAQ,eAAe;GACpC,OAAO,QAAQ,SAAS;GACxB,YAAY;EACd;CACF,CAAC;AACL,CAAC;AAmC+B,eAAe;CAC7C,WAAW,CAAC,gBAAgB,sBAAsB;CAClD,aAAa,CAAC;CACd,QAAQ,EAAE,OAAO,aACf,oBAAoB;EAClB;EACA,QAAQ;GACN,aAAa,kBAAkB;GAC/B,WAAW;GACX,aAAa,QAAQ,eAAe,CAAC;GACrC,OAAO,QAAQ,SAAS;GACxB,YAAY;EACd;CACF,CAAC;AACL,CAAC"}