@bubo-squared/gyroview 0.1.0 → 0.2.0

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 (32) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +7 -2
  3. package/dist/index.d.ts +23 -2
  4. package/dist/packages/adapters/fetch/src/httpRequest.js +10 -1
  5. package/dist/packages/adapters/three/src/shaders/rawLenses.frag.js +1 -1
  6. package/dist/packages/core/src/domain/view/lensTilesView.js +2 -2
  7. package/dist/packages/core/src/domain/view/magnification.js +1 -1
  8. package/dist/packages/core/src/domain/view/panoramaView.js +1 -1
  9. package/dist/packages/player/src/composition/browserPorts.js +16 -2
  10. package/dist/packages/player/src/composition/openInputs.js +2 -4
  11. package/dist/packages/player/src/composition/openRecording.js +13 -2
  12. package/dist/packages/player/src/controls/ChoiceMenu.js +16 -4
  13. package/dist/packages/player/src/controls/PictureMenus.js +22 -8
  14. package/dist/packages/player/src/controls/SeekBar.js +6 -3
  15. package/dist/packages/player/src/controls/SoundControls.js +2 -0
  16. package/dist/packages/player/src/controls/TransportButtons.js +2 -5
  17. package/dist/packages/player/src/controls/Wording.js +11 -9
  18. package/dist/packages/player/src/controls/controlParts.js +5 -3
  19. package/dist/packages/player/src/controls/controls.js +1 -1
  20. package/dist/packages/player/src/controls/controlsBar.js +4 -1
  21. package/dist/packages/player/src/controls/controlsMarkup.js +36 -21
  22. package/dist/packages/player/src/controls/icons.js +32 -8
  23. package/dist/packages/player/src/controls/messages.js +26 -4
  24. package/dist/packages/player/src/controls/sliderFill.js +16 -0
  25. package/dist/packages/player/src/element/ElementLoads.js +7 -0
  26. package/dist/packages/player/src/element/GyroViewElement.js +8 -3
  27. package/dist/packages/player/src/element/attributeNames.js +12 -1
  28. package/dist/packages/player/src/element/attributes.js +29 -7
  29. package/dist/packages/player/src/element/reflectedProperties.js +19 -1
  30. package/dist/packages/player/src/element/styles.js +1 -1
  31. package/dist/standalone.js +385 -126
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,40 @@
3
3
  What changed for a page using the package, newest first. Until 1.0, a minor version may change
4
4
  the API.
5
5
 
6
+ ## 0.2.0 (2026-09-29)
7
+
8
+ What a page may notice:
9
+
10
+ - The controls are restyled, and fit the player's own width and the pointer (ADR 0028): a
11
+ shaded bar with a thin seek bar, Stabilization and View buttons that show the icon of the
12
+ choice in effect and open menus of the choices with their icons and a line describing each,
13
+ and on a narrow player menus over the whole player and fewer parts; on touch, 44-pixel
14
+ targets.
15
+ - The Stop button is gone; `stop()` and the S key remain. `labels.stop` goes with it.
16
+ - The theme's defaults follow the new look: `--gyro-view-accent` and `--gyro-view-text` are
17
+ white, `--gyro-view-controls-background` is `rgb(0 0 0 / 60%)` and `--gyro-view-radius` is
18
+ `8px`; `--gyro-view-menu-background` is new. A page's own values keep working.
19
+
20
+ New:
21
+
22
+ - `crossorigin` on `<gyro-view>`, with a media element's keywords: `use-credentials` fetches the
23
+ recording, its byte ranges and the other lens's file of a split pair with the visitor's
24
+ cookies, for recordings kept private behind cookies on another origin. `crossOrigin` reflects
25
+ it, `null` while absent. Changing it reloads a recording named by URL; the iframe embed never
26
+ carries it. Nothing changes for a page that does not set it.
27
+ - `credentials` on a `UrlInput` (`player.load({ main: { url, credentials: 'include' } })`), in
28
+ place of the player's shared `http.requestInit.credentials` for that recording.
29
+ - A `cors` error for a request sent with credentials names what the host must add: the page's
30
+ origin by name, not `*`, and `Access-Control-Allow-Credentials: true`.
31
+ - The equirectangular panorama and the raw lenses zoom up to eight times, from four, for fine
32
+ detail in 5.7K and 8K recordings. The normal view keeps its 30 to 120 degrees.
33
+ - `messages` takes `stabilizationModeDescriptions`, `viewModeDescriptions` and `labels.close`.
34
+
35
+ Fixed:
36
+
37
+ - The raw lenses no longer show a faint grey line along the tiles' edges on Apple GPUs, where a
38
+ player's size put an edge between two pixels the GPU shades together.
39
+
6
40
  ## 0.1.0 (2026-09-29)
7
41
 
8
42
  The first release:
package/README.md CHANGED
@@ -42,10 +42,10 @@ A page without a bundler loads the standalone file, which has Three.js and media
42
42
  ```html
43
43
  <script
44
44
  type="module"
45
- src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.1/dist/standalone.js"
45
+ src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.2/dist/standalone.js"
46
46
  ></script>
47
47
  <script type="module">
48
- import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.1/dist/standalone.js';
48
+ import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.2/dist/standalone.js';
49
49
  </script>
50
50
  ```
51
51
 
@@ -188,6 +188,11 @@ loads a recording only for the player in view, removing `src` from the others.
188
188
  Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges
189
189
  ```
190
190
 
191
+ Recordings kept behind the visitor's cookies take `crossorigin="use-credentials"` on the
192
+ element, or `credentials: 'include'` on a URL given to `createBrowserPlayer`'s `load`, and a
193
+ host that names the page's origin, never `*`, and adds
194
+ `Access-Control-Allow-Credentials: true`.
195
+
191
196
  - **A hardware HEVC decoder.** 5.7K plays on recent laptops and phones; 8K needs a Level 6
192
197
  decoder (Apple Silicon, recent NVIDIA and Intel). A recording the browser cannot decode fails
193
198
  with the `codec-unsupported` error.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  */
4
4
  export interface UrlInput {
5
5
  readonly url: string;
6
+ /**
7
+ * Whether every request for the recording carries the visitor's cookies, in fetch's terms:
8
+ * `include` sends them to any origin, which must then allow the page's origin by name and
9
+ * with `Access-Control-Allow-Credentials: true`. Unset, the player's shared request settings
10
+ * decide, and fetch's `same-origin` when they do not. The element's
11
+ * `crossorigin="use-credentials"` sets `include`.
12
+ */
13
+ readonly credentials?: RequestCredentials | undefined;
6
14
  }
7
15
  /**
8
16
  * A recording already in the browser: a `File` from a picker or a drop.
@@ -592,7 +600,6 @@ export interface GyroViewLabels {
592
600
  readonly loading: string;
593
601
  readonly play: string;
594
602
  readonly pause: string;
595
- readonly stop: string;
596
603
  readonly seek: string;
597
604
  /**
598
605
  * How the seek bar tells assistive technology where it is: `{time}` and `{duration}` are
@@ -605,6 +612,10 @@ export interface GyroViewLabels {
605
612
  readonly stabilization: string;
606
613
  readonly viewMode: string;
607
614
  readonly fullscreen: string;
615
+ /**
616
+ * The button that closes a menu shown over the whole player, as it is on a narrow one.
617
+ */
618
+ readonly close: string;
608
619
  }
609
620
  type LabelName = keyof GyroViewLabels;
610
621
  /**
@@ -614,7 +625,15 @@ type LabelName = keyof GyroViewLabels;
614
625
  export interface GyroViewMessages {
615
626
  readonly labels: GyroViewLabels;
616
627
  readonly stabilizationModes: Readonly<Record<StabilizationMode, string>>;
628
+ /**
629
+ * One line under each choice of the Stabilization menu, where there is room for it.
630
+ */
631
+ readonly stabilizationModeDescriptions: Readonly<Record<StabilizationMode, string>>;
617
632
  readonly viewModes: Readonly<Record<ViewMode, string>>;
633
+ /**
634
+ * One line under each choice of the View menu, where there is room for it.
635
+ */
636
+ readonly viewModeDescriptions: Readonly<Record<ViewMode, string>>;
618
637
  /**
619
638
  * What a visitor reads when a recording cannot play, by the failure's code. The `error`
620
639
  * event carries the developer's account of it.
@@ -648,7 +667,7 @@ declare class Wording {
648
667
  write(root: ParentNode): void;
649
668
  private writeLabel;
650
669
  private writeText;
651
- private writeChoices;
670
+ private writeChoiceText;
652
671
  private writeError;
653
672
  }
654
673
  interface ControlsHost {
@@ -779,6 +798,7 @@ export declare class GyroViewElement extends TypedEventElement implements LiveSe
779
798
  src: string | null;
780
799
  src2: string | null;
781
800
  poster: string | null;
801
+ crossOrigin: "anonymous" | "use-credentials" | null;
782
802
  preload: "none" | "auto";
783
803
  gainMatch: "on" | "off";
784
804
  autoplay: boolean;
@@ -929,6 +949,7 @@ export declare class GyroViewElement extends TypedEventElement implements LiveSe
929
949
  export interface GyroViewAttributes {
930
950
  readonly src?: string | undefined;
931
951
  readonly src2?: string | undefined;
952
+ readonly crossorigin?: "anonymous" | "use-credentials" | "" | undefined;
932
953
  readonly poster?: string | undefined;
933
954
  readonly preload?: "none" | "auto" | undefined;
934
955
  readonly "gain-match"?: "on" | "off" | undefined;
@@ -11,6 +11,12 @@ var FIRST_BYTE_RANGE = "bytes=0-0";
11
11
  */
12
12
  var EXPOSED_HEADERS_ADVICE = "Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges";
13
13
  /**
14
+ * What a server must add for a page to read it across origins. A request that carries the
15
+ * visitor's cookies is held to more: the Fetch standard refuses a wildcard origin for it.
16
+ */
17
+ var ANONYMOUS_CORS_ADVICE = `Access-Control-Allow-Origin, ${EXPOSED_HEADERS_ADVICE}`;
18
+ var CREDENTIALED_CORS_ADVICE = `Access-Control-Allow-Origin naming this page's origin, not *; Access-Control-Allow-Credentials: true; ${EXPOSED_HEADERS_ADVICE}`;
19
+ /**
14
20
  * Browsers keep byte ranges of one URL as a sparse cache entry and revalidate them with
15
21
  * conditional requests. A 304 to such a request makes the browser answer from that entry, and
16
22
  * Chrome has been seen to hand back an empty body for a range it believed it held (ADR 0013).
@@ -83,7 +89,10 @@ function eitherSignal(first, second) {
83
89
  * reachable, so its outcome tells the two apart.
84
90
  */
85
91
  async function diagnoseFailure(url, options, cause) {
86
- return await isReachableWithoutCors(url, options) ? new GyroViewError("cors", `${url} answered, but its server does not allow this page's origin to read it; add CORS headers (Access-Control-Allow-Origin, ${EXPOSED_HEADERS_ADVICE})`, { cause }) : new GyroViewError("source-unreadable", `${url} could not be fetched; check the URL and the network`, { cause });
92
+ return await isReachableWithoutCors(url, options) ? new GyroViewError("cors", `${url} answered, but its server does not allow this page's origin to read it; add CORS headers (${corsAdviceFor(options)})`, { cause }) : new GyroViewError("source-unreadable", `${url} could not be fetched; check the URL and the network`, { cause });
93
+ }
94
+ function corsAdviceFor(options) {
95
+ return options.requestInit?.credentials === "include" ? CREDENTIALED_CORS_ADVICE : ANONYMOUS_CORS_ADVICE;
87
96
  }
88
97
  /**
89
98
  * The probe ends with the request it diagnoses: an abort of the load ends it too.
@@ -1,4 +1,4 @@
1
1
  //#region ../../packages/adapters/three/src/shaders/rawLenses.frag.glsl?raw
2
- var rawLenses_frag_default = "// The decoded lens images on their own, unstitched: each lens's region of its frame exactly as\n// the decoder delivered it, in the lens's screen area, with no pose, stabilization or gain. Black\n// around the areas. Every pixel takes each tile's footprint before the area decides, so the\n// derivatives are defined.\nin vec2 vNdc;\nout vec4 outColor;\n\nvoid main() {\n vec3 color = vec3(0.0);\n for (int i = 0; i < MAX_LENSES; i++) {\n if (i >= uLensCount) break;\n vec2 point = pointInArea(vNdc, uScreenArea[i]);\n Footprint footprint = footprintOf(i, point);\n if (isInArea(point)) color = sampleLens(uLensTexture[i], footprint, uSampling).rgb;\n }\n outColor = vec4(color, 1.0);\n}\n";
2
+ var rawLenses_frag_default = "// The decoded lens images on their own, unstitched: each lens's region of its frame exactly as\n// the decoder delivered it, in the lens's screen area, with no pose, stabilization or gain. Black\n// around the areas. Every pixel reads every tile, and the area picks one without a branch: a\n// read inside a branch that some pixels of a 2x2 quad skip took a far too coarse footprint on\n// Apple GPUs (Chrome through Metal), a grey line along a tile's edge wherever the edge fell\n// inside a quad.\nin vec2 vNdc;\nout vec4 outColor;\n\nvoid main() {\n vec3 color = vec3(0.0);\n for (int i = 0; i < MAX_LENSES; i++) {\n if (i >= uLensCount) break;\n vec2 point = pointInArea(vNdc, uScreenArea[i]);\n Footprint footprint = footprintOf(i, point);\n vec3 sampled = sampleLens(uLensTexture[i], footprint, uSampling).rgb;\n color = mix(color, sampled, float(isInArea(point)));\n }\n outColor = vec4(color, 1.0);\n}\n";
3
3
  //#endregion
4
4
  export { rawLenses_frag_default as default };
@@ -44,8 +44,8 @@ function magnifiedTiles(framing, context) {
44
44
  }));
45
45
  }
46
46
  /**
47
- * The decoded images as the camera recorded them, magnified up to four times toward the pointer
48
- * and moved within their edges once zoomed; never turned or stitched.
47
+ * The decoded images as the camera recorded them, magnified up to `MAX_MAGNIFICATION` toward the
48
+ * pointer and moved within their edges once zoomed; never turned or stitched.
49
49
  */
50
50
  var LENS_TILES_VIEW = {
51
51
  isStabilized: false,
@@ -40,7 +40,7 @@ function magnifiedArea(fitted, magnification) {
40
40
  * keeps the picture centred along an axis where it is smaller.
41
41
  */
42
42
  function clampMagnification(fitted, magnification) {
43
- const limited = clamp(magnification.scale, 1, 4);
43
+ const limited = clamp(magnification.scale, 1, 8);
44
44
  const scale = limited - 1 < FITTED_TOLERANCE ? 1 : limited;
45
45
  return {
46
46
  scale,
@@ -74,7 +74,7 @@ function zoomPanorama(framing, zoom, context) {
74
74
  }
75
75
  /**
76
76
  * Level, as an exported equirectangular video: the yaw picks the direction at the centre, and the
77
- * panorama magnifies up to four times and moves up and down within itself.
77
+ * panorama magnifies up to `MAX_MAGNIFICATION` and moves up and down within itself.
78
78
  */
79
79
  var PANORAMA_VIEW = {
80
80
  isStabilized: true,
@@ -22,7 +22,7 @@ function browserPorts(options = {}) {
22
22
  sources: browserSources(http),
23
23
  demuxer: new MediabunnyDemuxer(),
24
24
  decoderPort: new WebCodecsVideoDecoderPort(),
25
- locator: new HttpResourceLocator(http),
25
+ locatorFor: (input) => new HttpResourceLocator(requestOptionsFor(http, input)),
26
26
  probeDeadline: () => deadlineIn(probeTimeoutMs)
27
27
  };
28
28
  }
@@ -30,7 +30,21 @@ function browserPorts(options = {}) {
30
30
  * URLs are read with HTTP ranges, blobs by slicing.
31
31
  */
32
32
  function browserSources(http) {
33
- return { open: (input, signal) => isUrlInput(input) ? new HttpRangeSource(input.url, http, signal) : new BlobRandomAccessSource(input.blob) };
33
+ return { open: (input, signal) => isUrlInput(input) ? new HttpRangeSource(input.url, requestOptionsFor(http, input), signal) : new BlobRandomAccessSource(input.blob) };
34
+ }
35
+ /**
36
+ * The shared request settings, with the input's own credentials in place of theirs when it names
37
+ * any: how the element's `crossorigin` reaches every request for its recording (ADR 0027).
38
+ */
39
+ function requestOptionsFor(http, input) {
40
+ const { credentials } = input;
41
+ return credentials === void 0 ? http : {
42
+ ...http,
43
+ requestInit: {
44
+ ...http.requestInit,
45
+ credentials
46
+ }
47
+ };
34
48
  }
35
49
  /**
36
50
  * A deadline that passes after `ms`: the core has no timers, so the host supplies deadlines.
@@ -65,11 +65,9 @@ async function demuxInputs(inputs, attempt, disposables) {
65
65
  */
66
66
  async function declaredSecond(given, recording, attempt) {
67
67
  if (!(given.length === 1 && recording.info.fileLayout === "split-files") || !attempt.findSecondFile) return [];
68
- const url = await attempt.findSecondFile();
68
+ const input = await attempt.findSecondFile();
69
69
  attempt.signal.throwIfAborted();
70
- if (url === void 0) return [];
71
- const input = { url };
72
- return [{
70
+ return input === void 0 ? [] : [{
73
71
  input,
74
72
  source: attempt.ports.sources.open(input, attempt.signal)
75
73
  }];
@@ -21,7 +21,7 @@ async function openRecording(source, ports, signal) {
21
21
  const second = await findSecondFile();
22
22
  signal.throwIfAborted();
23
23
  if (!second) throw error;
24
- return openInputs([source.main, { url: second }], attempt);
24
+ return openInputs([source.main, second], attempt);
25
25
  }
26
26
  }
27
27
  /**
@@ -32,11 +32,22 @@ function attemptFor(source, ports, signal) {
32
32
  return source.second === void 0 && isUrlInput(main) ? {
33
33
  ports,
34
34
  signal,
35
- findSecondFile: lazy(() => locateOtherLensFile(main.url, ports.locator))
35
+ findSecondFile: lazy(() => otherLensFileOf(main, ports))
36
36
  } : {
37
37
  ports,
38
38
  signal
39
39
  };
40
40
  }
41
+ /**
42
+ * The other lens file beside a lone one, when the server has it: one recording, so it is read
43
+ * as the lone file is, with the same credentials.
44
+ */
45
+ async function otherLensFileOf(main, ports) {
46
+ const url = await locateOtherLensFile(main.url, ports.locatorFor(main));
47
+ return url === void 0 ? void 0 : {
48
+ ...main,
49
+ url
50
+ };
51
+ }
41
52
  //#endregion
42
53
  export { openRecording };
@@ -14,22 +14,27 @@ var FOCUS_STEPS = /* @__PURE__ */ new Map([
14
14
  var ON_THE_WAY_IN = { capture: true };
15
15
  /**
16
16
  * A button opening a popup of choices with the current one checked, as the ARIA menu pattern
17
- * has it. The button toggles it; a choice, Escape, Tab, a press outside it or the focus moving
18
- * elsewhere closes it; the arrow keys move between the choices, which are out of the tab order.
17
+ * has it; the button shows the checked one. The button toggles it; a choice, its close button,
18
+ * Escape, Tab, a press outside it or the focus moving elsewhere closes it; the arrow keys move between the choices, which are out of the tab order.
19
19
  * A press outside only closes it, so a tap on the picture does not also toggle play, and keys
20
20
  * pressed in it stay with it, so one Escape does not also leave fullscreen and the arrows do not
21
21
  * turn the view.
22
22
  */
23
23
  var ChoiceMenu = class {
24
24
  parts;
25
+ host;
25
26
  items;
26
- constructor(root, parts, choose) {
27
+ constructor(root, parts, host) {
27
28
  this.parts = parts;
29
+ this.host = host;
28
30
  this.items = [...parts.popup.querySelectorAll(":scope [role=\"menuitemradio\"]")];
29
31
  for (const item of this.items) item.addEventListener("click", () => {
30
32
  this.closeToButton();
31
33
  const { choice } = item.dataset;
32
- if (choice !== void 0) choose(choice);
34
+ if (choice !== void 0) host.choose(choice);
35
+ });
36
+ parts.close.addEventListener("click", () => {
37
+ this.closeToButton();
33
38
  });
34
39
  this.bindButton();
35
40
  this.bindDismissal(root);
@@ -38,8 +43,15 @@ var ChoiceMenu = class {
38
43
  get isOpen() {
39
44
  return !this.parts.popup.hidden;
40
45
  }
46
+ /**
47
+ * Checks `choice` and shows it on the button: its icon, and the words the button is named by.
48
+ */
41
49
  markChosen(choice) {
42
50
  for (const item of this.items) item.setAttribute("aria-checked", String(item.dataset["choice"] === choice));
51
+ const icon = this.items.find((item) => item.dataset["choice"] === choice)?.querySelector(":scope .choice-icon > svg");
52
+ this.parts.icon.replaceChildren(...icon ? [icon.cloneNode(true)] : []);
53
+ this.parts.button.dataset["choice"] = choice;
54
+ this.host.wording.write(this.parts.button);
43
55
  }
44
56
  /**
45
57
  * Shows or hides the button; a menu that goes away closes.
@@ -10,19 +10,33 @@ import { ChoiceMenu } from "./ChoiceMenu.js";
10
10
  * shown in a stitched view mode.
11
11
  */
12
12
  var PictureMenus = class {
13
- player;
14
13
  viewMode;
15
14
  stabilization;
16
- constructor(root, parts, player) {
15
+ player;
16
+ constructor(root, parts, host) {
17
+ const { player, wording } = host;
17
18
  this.player = player;
18
- this.viewMode = new ChoiceMenu(root, parts.viewMode, (choice) => {
19
- const mode = viewModeOf(choice);
20
- if (mode) player.setViewMode(mode);
19
+ this.viewMode = new ChoiceMenu(root, parts.viewMode, {
20
+ wording,
21
+ choose: (choice) => {
22
+ const mode = viewModeOf(choice);
23
+ if (mode) player.setViewMode(mode);
24
+ }
21
25
  });
22
- this.stabilization = new ChoiceMenu(root, parts.stabilization, (choice) => {
23
- const mode = stabilizationModeOf(choice);
24
- if (mode) player.setStabilization(mode);
26
+ this.stabilization = new ChoiceMenu(root, parts.stabilization, {
27
+ wording,
28
+ choose: (choice) => {
29
+ const mode = stabilizationModeOf(choice);
30
+ if (mode) player.setStabilization(mode);
31
+ }
25
32
  });
33
+ this.followPlayer();
34
+ }
35
+ /**
36
+ * Each menu checks the mode in effect, now and whenever it changes.
37
+ */
38
+ followPlayer() {
39
+ const { player } = this;
26
40
  player.events.on("viewmodechange", (mode) => {
27
41
  this.viewMode.markChosen(mode);
28
42
  this.offerStabilization();
@@ -2,6 +2,7 @@ import { messageOf } from "../../../core/src/shared/errors/GyroViewError.js";
2
2
  import "../../../core/src/index.js";
3
3
  import "./keyboard.js";
4
4
  import { formatTime } from "./formatTime.js";
5
+ import { showSliderFill } from "./sliderFill.js";
5
6
  //#region ../../packages/player/src/controls/SeekBar.ts
6
7
  /**
7
8
  * The slider's arrow keys and the direction each seeks in: the native step is the one the
@@ -110,15 +111,17 @@ var SeekBar = class {
110
111
  this.host.player.seek(Number(this.parts.seek.value));
111
112
  }
112
113
  /**
113
- * The time beside the bar, and the same time for screen readers, which would read the slider's
114
- * raw seconds.
114
+ * The time beside the bar and the bar filled up to it, and the same time for screen readers,
115
+ * which would read the slider's raw seconds.
115
116
  */
116
117
  showTime(time) {
117
118
  const [shown, total] = [formatTime(time), formatTime(this.host.player.duration)];
118
- this.parts.time.textContent = `${shown} / ${total}`;
119
+ this.parts.elapsed.textContent = shown;
120
+ this.parts.total.textContent = `/ ${total}`;
119
121
  const { position } = this.host.wording.current.labels;
120
122
  const spoken = position.replaceAll("{time}", () => shown).replaceAll("{duration}", () => total);
121
123
  this.parts.seek.setAttribute("aria-valuetext", spoken);
124
+ showSliderFill(this.parts.seek);
122
125
  }
123
126
  };
124
127
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { iconNode } from "./icons.js";
2
+ import { showSliderFill } from "./sliderFill.js";
2
3
  //#region ../../packages/player/src/controls/SoundControls.ts
3
4
  var PERCENT = 100;
4
5
  /**
@@ -35,6 +36,7 @@ var SoundControls = class {
35
36
  this.parts.mute.setAttribute("aria-pressed", String(isMuted));
36
37
  this.parts.volume.value = String(shown);
37
38
  this.parts.volume.setAttribute("aria-valuetext", `${Math.round(shown * PERCENT)}%`);
39
+ showSliderFill(this.parts.volume);
38
40
  }
39
41
  };
40
42
  //#endregion
@@ -1,8 +1,8 @@
1
1
  import { iconNode } from "./icons.js";
2
2
  //#region ../../packages/player/src/controls/TransportButtons.ts
3
3
  /**
4
- * Play and pause, on the bar and over the picture, and stop; the play buttons say what a press
5
- * does.
4
+ * Play and pause, on the bar and over the picture; the buttons say what a press does. Stopping
5
+ * is left to the S key and `stop()`.
6
6
  */
7
7
  var TransportButtons = class {
8
8
  parts;
@@ -14,9 +14,6 @@ var TransportButtons = class {
14
14
  for (const button of [parts.play, parts.bigPlay]) button.addEventListener("click", () => {
15
15
  host.togglePlay();
16
16
  });
17
- parts.stop.addEventListener("click", () => {
18
- player.stop();
19
- });
20
17
  player.events.on("statuschange", () => {
21
18
  this.reflect(player.isPaused);
22
19
  });
@@ -4,12 +4,13 @@ import { DEFAULT_MESSAGES, isChoiceTable, isLabelName, messagesWith } from "./me
4
4
  //#region ../../packages/player/src/controls/Wording.ts
5
5
  var LABEL = "data-label";
6
6
  var TEXT = "data-text";
7
- var CHOICES = "data-choices";
7
+ var CHOICE_TEXT = "data-choice-text";
8
8
  var ERROR = "data-error";
9
9
  var SELECTORS = {
10
10
  label: "[data-label]",
11
11
  text: "[data-text]",
12
- choices: "[data-choices]",
12
+ choiceText: "[data-choice-text]",
13
+ choice: "[data-choice]",
13
14
  error: "[data-error]"
14
15
  };
15
16
  /**
@@ -18,7 +19,8 @@ var SELECTORS = {
18
19
  * of words.
19
20
  * - `data-label`: the label for its `aria-label`;
20
21
  * - `data-text`: the label for its text;
21
- * - `data-choices` on a menu: the table its items' `data-choice` are read from;
22
+ * - `data-choice-text`: for its text, the table's word for the choice the nearest `data-choice`,
23
+ * its own or an ancestor's, names;
22
24
  * - `data-error`: the visitor's words for that failure code.
23
25
  */
24
26
  var Wording = class {
@@ -52,7 +54,7 @@ var Wording = class {
52
54
  write(root) {
53
55
  for (const element of root.querySelectorAll(SELECTORS.label)) this.writeLabel(element);
54
56
  for (const element of root.querySelectorAll(SELECTORS.text)) this.writeText(element);
55
- for (const menu of root.querySelectorAll(SELECTORS.choices)) this.writeChoices(menu);
57
+ for (const element of root.querySelectorAll(SELECTORS.choiceText)) this.writeChoiceText(element);
56
58
  for (const element of root.querySelectorAll(SELECTORS.error)) this.writeError(element);
57
59
  }
58
60
  writeLabel(element) {
@@ -63,11 +65,11 @@ var Wording = class {
63
65
  const name = element.getAttribute(TEXT);
64
66
  if (isLabelName(name)) element.textContent = this.words.labels[name];
65
67
  }
66
- writeChoices(menu) {
67
- const name = menu.getAttribute(CHOICES);
68
- if (!isChoiceTable(name)) return;
69
- const table = this.words[name];
70
- for (const item of menu.querySelectorAll("[data-choice]")) item.textContent = table[item.dataset["choice"] ?? ""] ?? "";
68
+ writeChoiceText(element) {
69
+ const name = element.getAttribute(CHOICE_TEXT);
70
+ const choice = element.closest(SELECTORS.choice)?.dataset["choice"];
71
+ if (choice === void 0 || !isChoiceTable(name)) return;
72
+ element.textContent = this.words[name][choice] ?? "";
71
73
  }
72
74
  writeError(element) {
73
75
  const code = element.getAttribute(ERROR);
@@ -14,7 +14,9 @@ function queryChoiceMenu(root, name) {
14
14
  const classes = choiceMenuClasses(name);
15
15
  return {
16
16
  button: queryShadow(root, `.${classes.button}`, HTMLButtonElement),
17
- popup: queryShadow(root, `.${classes.popup}`, HTMLElement)
17
+ icon: queryShadow(root, `.${classes.button} .setting-icon`, HTMLElement),
18
+ popup: queryShadow(root, `.${classes.popup}`, HTMLElement),
19
+ close: queryShadow(root, `.${classes.popup} .popup-close`, HTMLButtonElement)
18
20
  };
19
21
  }
20
22
  function queryControlParts(root) {
@@ -22,8 +24,8 @@ function queryControlParts(root) {
22
24
  seek: queryShadow(root, ".seek", HTMLInputElement),
23
25
  play: queryShadow(root, ".play", HTMLButtonElement),
24
26
  bigPlay: queryShadow(root, ".big-play", HTMLButtonElement),
25
- stop: queryShadow(root, ".stop", HTMLButtonElement),
26
- time: queryShadow(root, ".time", HTMLElement),
27
+ elapsed: queryShadow(root, ".time .elapsed", HTMLElement),
28
+ total: queryShadow(root, ".time .total", HTMLElement),
27
29
  mute: queryShadow(root, ".mute", HTMLButtonElement),
28
30
  volume: queryShadow(root, ".volume", HTMLInputElement),
29
31
  resetView: queryShadow(root, ".reset-view", HTMLButtonElement),
@@ -1,4 +1,4 @@
1
1
  //#region ../../packages/player/src/controls/controls.css?raw
2
- var controls_default = "/*\n * The controls' own styles: the big play button, the bar, its buttons, sliders and menus, and the\n * grab hand over the picture. The host's custom properties (`--gyro-view-*`) theme them.\n */\n\n.big-play {\n position: absolute;\n top: 50%;\n left: 50%;\n width: 4.5rem;\n height: 4.5rem;\n transform: translate(-50%, -50%);\n border: 0;\n border-radius: 50%;\n background: var(--gyro-view-controls-background);\n color: var(--gyro-view-text);\n cursor: pointer;\n}\n\n.big-play .icon {\n width: 2rem;\n height: 2rem;\n}\n\n:host(:not([data-status='ready']):not([data-status='paused'])) .big-play,\n:host(:not([controls])) .big-play {\n display: none;\n}\n\n.controls {\n position: absolute;\n inset: auto 0 0 0;\n display: flex;\n flex-direction: column;\n padding: 0.25rem 0.5rem 0.5rem;\n background: linear-gradient(to top, var(--gyro-view-controls-background), transparent);\n transition: opacity 0.25s;\n}\n\n/* A failed player has nothing to control: the error says so, and a press would only fail again. */\n:host(:not([controls])) .controls,\n:host([data-status='idle']) .controls,\n:host([data-status='loading']) .controls,\n:host([data-status='error']) .controls {\n display: none;\n}\n\n/* Controls with an open menu stay up: the viewer is reading it. So do controls holding the\n keyboard's focus, which would otherwise fade with its ring out of sight. */\n:host([data-idle]) .controls:not(:has(.popup:not([hidden]))):not(:has(:focus-visible)) {\n opacity: 0;\n pointer-events: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.spacer {\n flex: 1;\n}\n\n.time {\n font-size: 0.8rem;\n font-variant-numeric: tabular-nums;\n padding: 0 0.5rem;\n white-space: nowrap;\n}\n\nbutton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 2.25rem;\n height: 2.25rem;\n border: 0;\n border-radius: var(--gyro-view-radius);\n background: transparent;\n color: var(--gyro-view-text);\n font: inherit;\n cursor: pointer;\n}\n\n.icon {\n width: 1.25rem;\n height: 1.25rem;\n pointer-events: none;\n}\n\nbutton:hover,\nbutton:focus-visible {\n background: rgb(255 255 255 / 15%);\n}\n\nbutton:focus-visible,\ninput:focus-visible {\n outline: 2px solid var(--gyro-view-accent);\n outline-offset: 1px;\n}\n\ninput[type='range'] {\n accent-color: var(--gyro-view-accent);\n margin: 0;\n}\n\n.seek {\n width: 100%;\n height: 1.25rem;\n}\n\n.volume {\n width: 5rem;\n}\n\nbutton[hidden] {\n display: none;\n}\n\n.choice {\n position: relative;\n display: flex;\n}\n\n/* A menu opens upward from the bar and never past the player's top, which would hide its first\n items out of reach: what does not fit scrolls. The bar's row and gaps take the 3.75rem. */\n.popup {\n position: absolute;\n right: 0;\n bottom: calc(100% + 0.5rem);\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n max-height: calc(100cqh - 3.75rem);\n overflow-y: auto;\n min-width: 9rem;\n padding: 0.35rem;\n border-radius: var(--gyro-view-radius);\n background: var(--gyro-view-controls-background);\n}\n\n.popup[hidden] {\n display: none;\n}\n\n.popup-title {\n margin: 0.2rem 0.5rem 0.35rem;\n font-size: 0.75rem;\n opacity: 0.7;\n}\n\n[role='menu'] {\n display: flex;\n flex-direction: column;\n}\n\n[role='menuitemradio'] {\n justify-content: flex-start;\n gap: 0.4rem;\n height: 2rem;\n padding: 0 0.75rem 0 0.4rem;\n font-size: 0.9rem;\n white-space: nowrap;\n}\n\n[role='menuitemradio']::before {\n content: '\\2713';\n width: 1rem;\n color: var(--gyro-view-accent);\n visibility: hidden;\n}\n\n[role='menuitemradio'][aria-checked='true']::before {\n visibility: visible;\n}\n\n/* A narrowing player, on a phone or in a narrow column, gives up in turn the stop button (play\n pauses), the volume slider (mute stays), the time and Reset view (the 0 key stays), so the\n other buttons stay in reach. Each width is where the bar stops fitting with the part, with\n times up to an hour, in rem like the bar itself, so larger page text moves them with it (490,\n 450, 360 and 256 px at 16 px a rem). */\n@container (max-width: 30.625rem) {\n .stop {\n display: none;\n }\n}\n\n@container (max-width: 28.125rem) {\n .volume {\n display: none;\n }\n}\n\n@container (max-width: 22.5rem) {\n .time {\n display: none;\n }\n}\n\n@container (max-width: 16rem) {\n .reset-view {\n display: none;\n }\n}\n\n/* The hand only where a drag moves the picture (`data-draggable`, set by the view gestures). */\ncanvas[data-draggable] {\n cursor: grab;\n}\n\ncanvas[data-draggable]:active {\n cursor: grabbing;\n}\n\n/* A viewer who asked for less motion sees the controls come and go without fading. */\n@media (prefers-reduced-motion: reduce) {\n .controls {\n transition: none;\n }\n}\n";
2
+ var controls_default = "/*\n * The controls' own styles: the big play button, the bar, its buttons, sliders and menus, and the\n * grab hand over the picture. The host's custom properties (`--gyro-view-*`) theme them. Secondary\n * words, fills and tracks are the text colour at lower strengths, so a page that changes the text\n * colour changes them with it.\n *\n * Two things change the bar. A coarse pointer makes every target 44 pixels and leaves the\n * volume to the device's own buttons. A narrow player, whatever the pointer, gives up the volume\n * slider, shows the menus over the whole player, and past that gives up the time and then Reset\n * view (the widths below). Sizes are in rem like the page's text, so larger text moves the widths\n * with it.\n */\n\n.big-play {\n position: absolute;\n top: 50%;\n left: 50%;\n width: 4.5rem;\n height: 4.5rem;\n transform: translate(-50%, -50%);\n border-radius: 50%;\n background: var(--gyro-view-controls-background);\n}\n\n.big-play .icon {\n width: 2rem;\n height: 2rem;\n}\n\n:host(:not([data-status='ready']):not([data-status='paused'])) .big-play,\n:host(:not([controls])) .big-play {\n display: none;\n}\n\n.controls {\n /* The buttons, and the seek bar's hit area, which reaches up over the shade. */\n --target: 2.25rem;\n --seek-height: 1rem;\n\n position: absolute;\n inset: auto 0 0 0;\n isolation: isolate;\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0 1rem 0.625rem;\n transition: opacity 0.25s;\n}\n\n/* The shade behind the bar, reaching 3rem above it without taking the picture's presses there. */\n.controls::before {\n content: '';\n position: absolute;\n inset: -3rem 0 0;\n z-index: -1;\n background: linear-gradient(to top, var(--gyro-view-controls-background), transparent);\n pointer-events: none;\n}\n\n/* A failed player has nothing to control: the error says so, and a press would only fail again. */\n:host(:not([controls])) .controls,\n:host([data-status='idle']) .controls,\n:host([data-status='loading']) .controls,\n:host([data-status='error']) .controls {\n display: none;\n}\n\n/* Controls with an open menu stay up: the viewer is reading it. So do controls holding the\n keyboard's focus, which would otherwise fade with its ring out of sight. */\n:host([data-idle]) .controls:not(:has(.popup:not([hidden]))):not(:has(:focus-visible)) {\n opacity: 0;\n pointer-events: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.spacer {\n flex: 1;\n}\n\nbutton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: var(--gyro-view-radius);\n background: transparent;\n color: var(--gyro-view-text);\n font: inherit;\n cursor: pointer;\n}\n\nbutton[hidden] {\n display: none;\n}\n\n.icon {\n flex: none;\n width: 1.25rem;\n height: 1.25rem;\n pointer-events: none;\n}\n\n.icon-button {\n flex: none;\n width: var(--target);\n height: var(--target);\n}\n\n.icon-button:hover,\n.icon-button:focus-visible {\n background: color-mix(in srgb, var(--gyro-view-text) 12%, transparent);\n}\n\n:focus-visible {\n outline: 2px solid var(--gyro-view-accent);\n outline-offset: 2px;\n}\n\n.time {\n padding: 0 0.5rem;\n font-size: 0.8125rem;\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n}\n\n.elapsed {\n opacity: 0.85;\n}\n\n.total {\n opacity: 0.5;\n}\n\n/* The sliders: a 3-pixel track in the text colour, filled and handled in the accent. Chrome and\n Safari draw the filled part from `--fill`, which the controls keep; Firefox draws it itself. */\ninput[type='range'] {\n --track: color-mix(in srgb, var(--gyro-view-text) 28%, transparent);\n\n margin: 0;\n padding: 0;\n background: transparent;\n cursor: pointer;\n appearance: none;\n}\n\ninput[type='range']::-webkit-slider-runnable-track {\n height: 3px;\n border-radius: 1.5px;\n background: linear-gradient(\n to right,\n var(--gyro-view-accent) calc(var(--fill, 0) * 100%),\n var(--track) 0\n );\n}\n\ninput[type='range']::-webkit-slider-thumb {\n width: 0.75rem;\n height: 0.75rem;\n margin-top: calc((3px - 0.75rem) / 2);\n border: 0;\n border-radius: 50%;\n background: var(--gyro-view-accent);\n appearance: none;\n}\n\ninput[type='range']::-moz-range-track {\n height: 3px;\n border-radius: 1.5px;\n background: var(--track);\n}\n\ninput[type='range']::-moz-range-progress {\n height: 3px;\n border-radius: 1.5px;\n background: var(--gyro-view-accent);\n}\n\ninput[type='range']::-moz-range-thumb {\n width: 0.75rem;\n height: 0.75rem;\n border: 0;\n border-radius: 50%;\n background: var(--gyro-view-accent);\n}\n\n/* Taller than it looks: the hit area grows upward over the shade, leaving the bar's layout be. */\n.seek {\n width: 100%;\n height: var(--seek-height);\n margin-top: calc(1rem - var(--seek-height));\n}\n\n.volume {\n flex: none;\n width: 4rem;\n height: var(--target);\n}\n\n/* A setting button shows the icon of the choice in effect, and stays lit while its menu is open. */\n.setting-icon {\n display: inline-flex;\n}\n\n.setting[aria-expanded='true'] {\n background: color-mix(in srgb, var(--gyro-view-text) 20%, transparent);\n}\n\n.choice {\n position: relative;\n display: flex;\n}\n\n/* A menu opens upward, right-aligned with its button, as wide as its longest line within limits,\n so a description fits on one line unless a translation makes it long; and never past the\n player's top, which would put its first choices out of reach: what does not fit scrolls. */\n.popup {\n position: absolute;\n right: 0;\n bottom: calc(100% + 0.625rem);\n z-index: 1;\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n width: max-content;\n min-width: 16.25rem;\n max-width: min(22rem, 100cqw - 2rem);\n max-height: calc(100cqh - var(--target) - 2rem);\n overflow-y: auto;\n padding: 0.375rem;\n border-radius: calc(var(--gyro-view-radius) * 1.5);\n background: var(--gyro-view-menu-background);\n box-shadow: 0 8px 30px rgb(0 0 0 / 35%);\n -webkit-backdrop-filter: blur(16px);\n backdrop-filter: blur(16px);\n}\n\n.popup[hidden] {\n display: none;\n}\n\n.popup-header {\n display: flex;\n flex: none;\n align-items: center;\n justify-content: space-between;\n}\n\n.popup-title {\n margin: 0.375rem 0.5rem 0.25rem;\n font-size: 0.75rem;\n opacity: 0.55;\n}\n\n.popup-close {\n display: none;\n}\n\n[role='menu'] {\n display: flex;\n flex-direction: column;\n}\n\n/* A choice: its icon, its name over a line describing it, and a check on the right while it is in\n effect. */\n[role='menuitemradio'] {\n display: grid;\n grid-template-columns: 1.25rem 1fr 1rem;\n column-gap: 0.75rem;\n align-items: center;\n padding: 0.5rem 0.625rem;\n text-align: start;\n}\n\n[role='menuitemradio']:hover,\n[role='menuitemradio']:focus-visible {\n background: color-mix(in srgb, var(--gyro-view-text) 8%, transparent);\n}\n\n[role='menuitemradio']::after {\n content: '\\2713';\n grid-area: 1 / 3 / span 2;\n font-size: 0.875rem;\n color: var(--gyro-view-accent);\n visibility: hidden;\n}\n\n[role='menuitemradio'][aria-checked='true']::after {\n visibility: visible;\n}\n\n.choice-icon {\n display: inline-flex;\n grid-area: 1 / 1 / span 2;\n}\n\n.choice-name {\n grid-column: 2;\n font-size: 0.875rem;\n}\n\n.choice-description {\n grid-column: 2;\n font-size: 0.75rem;\n opacity: 0.55;\n}\n\n/* Touch: 44-pixel targets, and the volume left to the device. */\n@media (pointer: coarse) {\n .controls {\n --target: 2.75rem;\n --seek-height: 2.75rem;\n }\n\n .volume {\n display: none;\n }\n}\n\n/* A narrow player: no volume slider, and menus over the whole player, each with a close button\n and 44-pixel rows. */\n@container (max-width: 37.5rem) {\n .volume {\n display: none;\n }\n\n .choice {\n position: static;\n }\n\n .popup {\n inset: auto 0 0;\n width: auto;\n max-width: none;\n height: 100cqh;\n max-height: none;\n padding: 0 0.5rem 0.5rem;\n border-radius: 0;\n box-shadow: none;\n -webkit-backdrop-filter: blur(14px);\n backdrop-filter: blur(14px);\n }\n\n .popup-header {\n padding-left: 0.5rem;\n }\n\n .popup-title {\n margin: 0;\n font-size: 0.8125rem;\n }\n\n .popup-close {\n display: inline-flex;\n width: 2.75rem;\n height: 2.75rem;\n }\n\n [role='menuitemradio'] {\n min-height: 2.75rem;\n padding-block: 0;\n }\n\n /* Without the descriptions a choice has one line, which its icon and check keep level with. */\n .choice-icon,\n [role='menuitemradio']::after {\n grid-row: 1;\n }\n\n .choice-name {\n font-size: 0.9375rem;\n }\n\n .choice-description {\n display: none;\n }\n}\n\n/*\n * Narrower still, the bar gives up the time (the seek bar still shows where playback is), then\n * Reset view (the 0 key stays). Each width is where the bar stops fitting with the part, measured\n * with stabilization offered and times up to an hour; touch's larger targets move each width out.\n */\n@container (max-width: 23.5rem) {\n .time {\n display: none;\n }\n}\n\n@container (max-width: 17.5rem) {\n .controls {\n padding-inline: 0.5rem;\n }\n\n .reset-view {\n display: none;\n }\n}\n\n@media (pointer: coarse) {\n @container (max-width: 26.5rem) {\n .time {\n display: none;\n }\n }\n\n @container (max-width: 20.5rem) {\n .controls {\n padding-inline: 0.5rem;\n }\n\n .reset-view {\n display: none;\n }\n }\n}\n\n/* Without a blur behind it, or for a viewer who asked for less transparency, a menu is laid\n twice over itself: its own colour, near enough opaque. */\n@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {\n .popup {\n background:\n linear-gradient(var(--gyro-view-menu-background), var(--gyro-view-menu-background)),\n var(--gyro-view-menu-background);\n }\n}\n\n@media (prefers-reduced-transparency: reduce) {\n .popup {\n background:\n linear-gradient(var(--gyro-view-menu-background), var(--gyro-view-menu-background)),\n var(--gyro-view-menu-background);\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n }\n}\n\n/* Forced colours: the sliders keep a visible track and handle, and the menus an edge, in the\n system's colours. */\n@media (forced-colors: active) {\n input[type='range'] {\n --track: GrayText;\n --gyro-view-accent: Highlight;\n\n forced-color-adjust: none;\n }\n\n .popup {\n border: 1px solid CanvasText;\n }\n}\n\n/* The hand only where a drag moves the picture (`data-draggable`, set by the view gestures). */\ncanvas[data-draggable] {\n cursor: grab;\n}\n\ncanvas[data-draggable]:active {\n cursor: grabbing;\n}\n\n/* A viewer who asked for less motion sees the controls come and go without fading. */\n@media (prefers-reduced-motion: reduce) {\n .controls {\n transition: none;\n }\n}\n";
3
3
  //#endregion
4
4
  export { controls_default as default };
@@ -17,7 +17,10 @@ function bindControlsBar(root, host) {
17
17
  bindViewButtons(parts, host);
18
18
  new SeekBar(parts, host);
19
19
  new SoundControls(parts, host.player);
20
- new PictureMenus(root, parts, host.player);
20
+ new PictureMenus(root, parts, {
21
+ player: host.player,
22
+ wording: host.wording
23
+ });
21
24
  }
22
25
  //#endregion
23
26
  export { bindControlsBar };