@bubo-squared/gyroview 0.5.0 → 0.6.1

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 (36) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -2
  3. package/dist/index.d.ts +62 -15
  4. package/dist/packages/core/src/domain/view/Framing.js +8 -2
  5. package/dist/packages/core/src/domain/view/ViewState.js +13 -8
  6. package/dist/packages/core/src/domain/view/lensTilesView.js +2 -1
  7. package/dist/packages/core/src/domain/view/motionLookView.js +103 -0
  8. package/dist/packages/core/src/domain/view/normalView.js +12 -5
  9. package/dist/packages/core/src/domain/view/panoramaView.js +2 -1
  10. package/dist/packages/core/src/domain/view/screenLook.js +74 -0
  11. package/dist/packages/core/src/domain/view/viewModes.js +18 -1
  12. package/dist/packages/core/src/index.js +5 -3
  13. package/dist/packages/core/src/shared/math/Matrix3.js +12 -1
  14. package/dist/packages/player/src/browserPlayer.js +6 -4
  15. package/dist/packages/player/src/composition/BrowserAttitudeSensor.js +192 -0
  16. package/dist/packages/player/src/composition/attitudeSensor.js +18 -0
  17. package/dist/packages/player/src/controls/MotionLookButton.js +35 -0
  18. package/dist/packages/player/src/controls/controlParts.js +1 -0
  19. package/dist/packages/player/src/controls/controls.js +1 -1
  20. package/dist/packages/player/src/controls/controlsBar.js +5 -3
  21. package/dist/packages/player/src/controls/controlsMarkup.js +1 -0
  22. package/dist/packages/player/src/controls/icons.js +1 -0
  23. package/dist/packages/player/src/controls/messages.js +1 -0
  24. package/dist/packages/player/src/element/ElementLoads.js +4 -7
  25. package/dist/packages/player/src/element/FullscreenToggle.js +35 -0
  26. package/dist/packages/player/src/element/GyroViewElement.js +34 -1
  27. package/dist/packages/player/src/element/styles.js +1 -1
  28. package/dist/packages/player/src/player/AutomaticStarts.js +54 -0
  29. package/dist/packages/player/src/player/MotionLook.js +150 -0
  30. package/dist/packages/player/src/player/Player.js +46 -39
  31. package/dist/packages/player/src/player/PlayerEvents.js +1 -0
  32. package/dist/packages/player/src/player/PlayerView.js +44 -13
  33. package/dist/packages/player/src/player/ensureFinite.js +2 -1
  34. package/dist/packages/player/src/player/viewAngles.js +17 -0
  35. package/dist/standalone.js +129 -62
  36. package/package.json +1 -1
@@ -1,17 +1,19 @@
1
+ import { BrowserAttitudeSensor } from "./composition/BrowserAttitudeSensor.js";
1
2
  import { browserPorts } from "./composition/browserPorts.js";
2
3
  import { buildPipeline } from "./composition/buildPipeline.js";
3
4
  import { Player } from "./player/Player.js";
4
5
  //#region ../../packages/player/src/browserPlayer.ts
5
6
  /**
6
- * A player composed for a browser: HTTP, blob and WebCodecs ports and the GPU pipeline, drawing
7
- * on the host's canvas and sounding through its audio element. What `<gyro-view>` plays with,
8
- * and where a page that wants the player without the element starts.
7
+ * A player composed for a browser: HTTP, blob and WebCodecs ports, the GPU pipeline and the
8
+ * device's attitude, drawing on the host's canvas and sounding through its audio element. What
9
+ * `<gyro-view>` plays with, and where a page that wants the player without the element starts.
9
10
  */
10
11
  function createBrowserPlayer(host, options) {
11
12
  return new Player({
12
13
  host,
13
14
  ports: browserPorts(options),
14
- pipelines: buildPipeline
15
+ pipelines: buildPipeline,
16
+ attitude: new BrowserAttitudeSensor()
15
17
  });
16
18
  }
17
19
  //#endregion
@@ -0,0 +1,192 @@
1
+ import { degrees } from "../../../core/src/shared/units/angle.js";
2
+ import { milliseconds } from "../../../core/src/shared/units/time.js";
3
+ import { screenLookOf } from "../../../core/src/domain/view/screenLook.js";
4
+ import "../../../core/src/index.js";
5
+ import { NOTHING_TO_STOP } from "./attitudeSensor.js";
6
+ //#region ../../packages/player/src/composition/BrowserAttitudeSensor.ts
7
+ /**
8
+ * The event the browser reports the device's attitude with (W3C DeviceOrientation Event
9
+ * Specification). Relative to where the device started, not to the north: no compass jumps.
10
+ */
11
+ var ORIENTATION_EVENT = "deviceorientation";
12
+ /**
13
+ * The page's own window and screen, read when called, never when this module loads: Node
14
+ * imports it with the player.
15
+ */
16
+ function browserAttitudeSource() {
17
+ return {
18
+ target: globalThis,
19
+ mayReadSensors: globalThis.isSecureContext && areSensorsAllowed(),
20
+ orientationEvents: "DeviceOrientationEvent" in globalThis ? DeviceOrientationEvent : void 0,
21
+ touchPoints: navigator.maxTouchPoints,
22
+ screenAngle: () => globalThis.screen.orientation.angle
23
+ };
24
+ }
25
+ /**
26
+ * The sensors Chromium's orientation events need in a frame: without them in the frame's
27
+ * `allow`, it sends no event at all.
28
+ */
29
+ var ORIENTATION_SENSORS = ["accelerometer", "gyroscope"];
30
+ /**
31
+ * Chromium's own say on the frame's policy (`document.featurePolicy`, which no standard type
32
+ * declares); other browsers do not say, and a refusal shows only once access is asked for.
33
+ */
34
+ function areSensorsAllowed() {
35
+ const policy = Reflect.get(document, "featurePolicy");
36
+ const allowsFeature = typeof policy === "object" && policy !== null ? Reflect.get(policy, "allowsFeature") : void 0;
37
+ return typeof allowsFeature !== "function" || ORIENTATION_SENSORS.every((sensor) => Reflect.apply(allowsFeature, policy, [sensor]) === true);
38
+ }
39
+ /**
40
+ * The device's attitude through the browser's `deviceorientation` events.
41
+ *
42
+ * Browsers tell whether a device has the sensors in different ways, which the page's probe hears:
43
+ * Chromium sends an event at once, with the angles on a phone and without them where there is no
44
+ * sensor; iOS sends nothing until the viewer allows it, so a touch device whose browser asks for
45
+ * that permission is taken to have the sensors until it says otherwise; a desktop WebKit sends
46
+ * nothing at all. An unfocused cross-origin frame may hear nothing until a tap.
47
+ */
48
+ var BrowserAttitudeSensor = class {
49
+ source;
50
+ stops = /* @__PURE__ */ new Set();
51
+ /**
52
+ * Starts the page's probe, if no sensor before did, so it hears the event Chromium sends as
53
+ * soon as anyone listens.
54
+ */
55
+ constructor(source = browserAttitudeSource()) {
56
+ this.source = source;
57
+ if (this.canReport()) probeOf(source.target);
58
+ }
59
+ get availability() {
60
+ return this.isAvailable() ? "available" : "unavailable";
61
+ }
62
+ onAvailabilityChange(listener) {
63
+ return this.canReport() ? this.track(probeOf(this.source.target).onChange(listener)) : NOTHING_TO_STOP;
64
+ }
65
+ /**
66
+ * Asks only where the events wait for an answer, and then before anything else, still within
67
+ * the tap: an `await` before it would leave the gesture behind. Chromium answers `prompt`
68
+ * outside a gesture, as iOS rejects the request there.
69
+ */
70
+ async requestAccess() {
71
+ if (!this.canReport()) return "denied";
72
+ const request = this.permissionRequest();
73
+ if (!request || probeOf(this.source.target).heard === "angles") return "granted";
74
+ try {
75
+ return accessOf(await request());
76
+ } catch {
77
+ return "needs-gesture";
78
+ }
79
+ }
80
+ listen(onReading) {
81
+ const { target } = this.source;
82
+ const hear = (event) => {
83
+ const attitude = attitudeOf(event, this.source.screenAngle());
84
+ if (attitude) onReading({
85
+ look: screenLookOf(attitude),
86
+ at: milliseconds(event.timeStamp)
87
+ });
88
+ };
89
+ target.addEventListener(ORIENTATION_EVENT, hear);
90
+ return this.track(() => {
91
+ target.removeEventListener(ORIENTATION_EVENT, hear);
92
+ });
93
+ }
94
+ dispose() {
95
+ for (const stop of this.stops) stop();
96
+ }
97
+ isAvailable() {
98
+ if (!this.canReport()) return false;
99
+ const { heard } = probeOf(this.source.target);
100
+ const canBeWithheld = this.permissionRequest() !== void 0 && this.source.touchPoints > 0;
101
+ return heard === "angles" || heard === "nothing" && canBeWithheld;
102
+ }
103
+ canReport() {
104
+ return this.source.mayReadSensors && this.source.orientationEvents !== void 0;
105
+ }
106
+ /**
107
+ * The orientation event's `requestPermission`, which no standard type declares yet.
108
+ */
109
+ permissionRequest() {
110
+ const events = this.source.orientationEvents;
111
+ const request = events === void 0 ? void 0 : Reflect.get(events, "requestPermission");
112
+ return typeof request === "function" ? () => Reflect.apply(request, events, []) : void 0;
113
+ }
114
+ /**
115
+ * A stop that also forgets itself, so `dispose` stops what is still running and nothing else.
116
+ */
117
+ track(stop) {
118
+ const once = () => {
119
+ if (this.stops.delete(once)) stop();
120
+ };
121
+ this.stops.add(once);
122
+ return once;
123
+ }
124
+ };
125
+ function accessOf(answer) {
126
+ if (answer === "granted") return "granted";
127
+ return answer === "denied" ? "denied" : "needs-gesture";
128
+ }
129
+ /**
130
+ * The attitude an orientation event carries, or none when the browser sends one without its
131
+ * angles.
132
+ */
133
+ function attitudeOf(event, screenAngle) {
134
+ const { alpha, beta, gamma } = event;
135
+ return isAngle(alpha) && isAngle(beta) && isAngle(gamma) ? {
136
+ alpha: degrees(alpha),
137
+ beta: degrees(beta),
138
+ gamma: degrees(gamma),
139
+ screenAngle: degrees(screenAngle)
140
+ } : void 0;
141
+ }
142
+ /**
143
+ * Browsers send `null` for an angle they lack; a value that is not finite is no angle either.
144
+ */
145
+ function isAngle(value) {
146
+ return typeof value === "number" && Number.isFinite(value);
147
+ }
148
+ /**
149
+ * Listens once for every player on the page until it hears the angles, then stops and forgets
150
+ * its listeners, since nothing changes after that: on a phone they come at once; where nothing
151
+ * comes, listening costs nothing.
152
+ */
153
+ var AttitudeProbe = class {
154
+ target;
155
+ heardValue = "nothing";
156
+ listeners = /* @__PURE__ */ new Set();
157
+ constructor(target) {
158
+ this.target = target;
159
+ target.addEventListener(ORIENTATION_EVENT, this.hear);
160
+ }
161
+ get heard() {
162
+ return this.heardValue;
163
+ }
164
+ onChange(listener) {
165
+ this.listeners.add(listener);
166
+ return () => {
167
+ this.listeners.delete(listener);
168
+ };
169
+ }
170
+ hear = (event) => {
171
+ const heard = attitudeOf(event, 0) ? "angles" : "no-angles";
172
+ if (heard === this.heardValue) return;
173
+ this.heardValue = heard;
174
+ const listeners = [...this.listeners];
175
+ if (heard === "angles") {
176
+ this.target.removeEventListener(ORIENTATION_EVENT, this.hear);
177
+ this.listeners.clear();
178
+ }
179
+ for (const listener of listeners) listener();
180
+ };
181
+ };
182
+ var probes = /* @__PURE__ */ new WeakMap();
183
+ function probeOf(target) {
184
+ let probe = probes.get(target);
185
+ if (!probe) {
186
+ probe = new AttitudeProbe(target);
187
+ probes.set(target, probe);
188
+ }
189
+ return probe;
190
+ }
191
+ //#endregion
192
+ export { BrowserAttitudeSensor, browserAttitudeSource };
@@ -0,0 +1,18 @@
1
+ //#region ../../packages/player/src/composition/attitudeSensor.ts
2
+ /**
3
+ * What a listener that never started returns to stop it.
4
+ */
5
+ var NOTHING_TO_STOP = () => void 0;
6
+ /**
7
+ * A device without the sensors, or a player composed without them: motion look stays
8
+ * unavailable.
9
+ */
10
+ var NO_ATTITUDE_SENSOR = {
11
+ availability: "unavailable",
12
+ onAvailabilityChange: () => NOTHING_TO_STOP,
13
+ requestAccess: () => Promise.resolve("denied"),
14
+ listen: () => NOTHING_TO_STOP,
15
+ dispose: NOTHING_TO_STOP
16
+ };
17
+ //#endregion
18
+ export { NOTHING_TO_STOP, NO_ATTITUDE_SENSOR };
@@ -0,0 +1,35 @@
1
+ import { motionLookRulesFor } from "../../../core/src/domain/view/viewModes.js";
2
+ import "../../../core/src/index.js";
3
+ //#region ../../packages/player/src/controls/MotionLookButton.ts
4
+ /**
5
+ * The motion look toggle (ADR 0040): shown where the device reports its attitude and the view
6
+ * mode follows the device, pressed while motion look is on. A press asks for access within the
7
+ * tap itself, as iOS requires.
8
+ */
9
+ var MotionLookButton = class {
10
+ parts;
11
+ player;
12
+ constructor(parts, player) {
13
+ this.parts = parts;
14
+ this.player = player;
15
+ parts.motionLook.addEventListener("click", () => {
16
+ if (player.motionLook === "on") player.stopMotionLook();
17
+ else player.startMotionLook();
18
+ });
19
+ player.events.on("motionlookchange", () => {
20
+ this.reflect();
21
+ });
22
+ player.events.on("viewmodechange", () => {
23
+ this.reflect();
24
+ });
25
+ this.reflect();
26
+ }
27
+ reflect() {
28
+ const { motionLook, viewMode } = this.player;
29
+ const button = this.parts.motionLook;
30
+ button.hidden = motionLook === "unavailable" || motionLookRulesFor(viewMode) === void 0;
31
+ button.setAttribute("aria-pressed", String(motionLook === "on"));
32
+ }
33
+ };
34
+ //#endregion
35
+ export { MotionLookButton };
@@ -29,6 +29,7 @@ function queryControlParts(root) {
29
29
  mute: queryShadow(root, ".mute", HTMLButtonElement),
30
30
  volume: queryShadow(root, ".volume", HTMLInputElement),
31
31
  resetView: queryShadow(root, ".reset-view", HTMLButtonElement),
32
+ motionLook: queryShadow(root, ".motion-look", HTMLButtonElement),
32
33
  stabilization: queryChoiceMenu(root, ChoiceMenuName.Stabilization),
33
34
  viewMode: queryChoiceMenu(root, ChoiceMenuName.ViewMode),
34
35
  fullscreen: queryShadow(root, ".fullscreen", 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. 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";
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 * Three 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). The motion look toggle, shown only where the device reports its\n * attitude, makes the bar one target wider, so each part gives way one target sooner, and on the\n * narrowest players the stabilization menu gives way to it: the toggle never hides, since the\n * device may be turning the view. Sizes are in rem like the page's text, so larger text moves the\n * widths 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.motion-look[aria-pressed='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/* With the motion look toggle each width moves out by one target and its gap; below the\n narrowest bar that fits without it, the stabilization menu gives way to it. */\n@container (max-width: 26rem) {\n .row:has(.motion-look:not([hidden])) .time {\n display: none;\n }\n}\n\n@container (max-width: 20rem) {\n .controls:has(.motion-look:not([hidden])) {\n padding-inline: 0.5rem;\n }\n\n .row:has(.motion-look:not([hidden])) .reset-view {\n display: none;\n }\n}\n\n@container (max-width: 16rem) {\n .row:has(.motion-look:not([hidden])) .choice:has(.stabilization-button) {\n display: none;\n }\n}\n\n@media (pointer: coarse) {\n @container (max-width: 29.5rem) {\n .row:has(.motion-look:not([hidden])) .time {\n display: none;\n }\n }\n\n @container (max-width: 23.5rem) {\n .controls:has(.motion-look:not([hidden])) {\n padding-inline: 0.5rem;\n }\n\n .row:has(.motion-look:not([hidden])) .reset-view {\n display: none;\n }\n }\n\n @container (max-width: 19rem) {\n .row:has(.motion-look:not([hidden])) .choice:has(.stabilization-button) {\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 };
@@ -1,4 +1,5 @@
1
1
  import { queryControlParts } from "./controlParts.js";
2
+ import { MotionLookButton } from "./MotionLookButton.js";
2
3
  import { PictureMenus } from "./PictureMenus.js";
3
4
  import { SeekBar } from "./SeekBar.js";
4
5
  import { SoundControls } from "./SoundControls.js";
@@ -7,9 +8,9 @@ import { bindViewButtons } from "./viewButtons.js";
7
8
  //#region ../../packages/player/src/controls/controlsBar.ts
8
9
  /**
9
10
  * Binds the control bar in the shadow tree: transport buttons, seek bar, sound, Reset view and
10
- * Fullscreen, and the view mode and stabilization menus, each driving the player and showing its
11
- * state. They live as long as
12
- * the element: every listener sits on its shadow tree or its player.
11
+ * Fullscreen, the view mode and stabilization menus and the motion look toggle, each driving the
12
+ * player and showing its state. They live as long as the element: every listener sits on its
13
+ * shadow tree or its player.
13
14
  */
14
15
  function bindControlsBar(root, host) {
15
16
  const parts = queryControlParts(root);
@@ -21,6 +22,7 @@ function bindControlsBar(root, host) {
21
22
  player: host.player,
22
23
  wording: host.wording
23
24
  });
25
+ new MotionLookButton(parts, host.player);
24
26
  }
25
27
  //#endregion
26
28
  export { bindControlsBar };
@@ -91,6 +91,7 @@ function controlsMarkup() {
91
91
  <span class="time"><span class="elapsed">0:00</span> <span class="total">/ 0:00</span></span>
92
92
  <span class="spacer"></span>
93
93
  ${pictureMenusMarkup()}
94
+ <button class="icon-button motion-look" type="button" aria-pressed="false" data-label="motionLook" hidden>${ICONS.motionLook}</button>
94
95
  <button class="icon-button reset-view" type="button" data-label="resetView">${ICONS.resetView}</button>
95
96
  <button class="icon-button fullscreen" type="button" data-label="fullscreen">${ICONS.fullscreen}</button>
96
97
  </div>
@@ -26,6 +26,7 @@ var ICONS = {
26
26
  sound: icon(`${SPEAKER}<path d="M15.5 9a4 4 0 0 1 0 6"/><path d="M18.5 6a8 8 0 0 1 0 12"/>`),
27
27
  muted: icon(`${SPEAKER}<path d="M16 9.5l5 5M21 9.5l-5 5"/>`),
28
28
  resetView: icon("<path d=\"M4.5 12a7.5 7.5 0 1 0 2.2-5.3\"/><path d=\"M4.5 4.5v4h4\"/>"),
29
+ motionLook: icon("<rect x=\"8.5\" y=\"4.5\" width=\"7\" height=\"15\" rx=\"1.5\"/><path d=\"M11 16.5h2\"/><path d=\"M5 8.5a7 7 0 0 0 0 7\"/><path d=\"M19 8.5a7 7 0 0 1 0 7\"/>"),
29
30
  fullscreen: icon("<path d=\"M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5\"/>"),
30
31
  close: icon("<path d=\"M6 6l12 12M18 6L6 18\"/>")
31
32
  };
@@ -23,6 +23,7 @@ var DEFAULT_MESSAGES = {
23
23
  mute: "Mute",
24
24
  volume: "Volume",
25
25
  resetView: "Reset view",
26
+ motionLook: "Look by moving the device",
26
27
  stabilization: "Stabilization",
27
28
  viewMode: "View",
28
29
  fullscreen: "Fullscreen",
@@ -8,8 +8,8 @@ import { elementSourceOf } from "./elementSource.js";
8
8
  * When `<gyro-view>` loads, and what. Attribute changes are read together a microtask after they
9
9
  * arrive, so a `src` set with its `src2` loads once; files handed in replace the attributes until
10
10
  * `src` or `src2` change. Only a connected element loads: one out of the document owes its load
11
- * to the next connection, and lets its recording go a microtask after a removal unless it was
12
- * only moved. A seek asked for while a load waits starts the new recording there.
11
+ * to the next connection, and a removed one lets its recording go. A seek asked for while a load
12
+ * waits starts the new recording there.
13
13
  */
14
14
  var ElementLoads = class {
15
15
  element;
@@ -36,12 +36,9 @@ var ElementLoads = class {
36
36
  this.awaited = void 0;
37
37
  }
38
38
  /**
39
- * A move within the document disconnects and connects again at once: only an element still out
40
- * of it a microtask later lets its recording go.
39
+ * The element left the document rather than moving within it: its recording goes.
41
40
  */
42
- async disconnected() {
43
- await Promise.resolve();
44
- if (this.element.isConnected) return;
41
+ removed() {
45
42
  this.player.unload();
46
43
  this.isOwed = true;
47
44
  }
@@ -1,9 +1,19 @@
1
1
  //#region ../../packages/player/src/element/FullscreenToggle.ts
2
2
  var FILL_ATTRIBUTE = "data-fill";
3
+ var POPOVER_ATTRIBUTE = "popover";
4
+ /**
5
+ * No light dismiss and no Escape of the browser's: the element's own keys and button leave.
6
+ */
7
+ var MANUAL_POPOVER = "manual";
3
8
  /**
4
9
  * Fills the screen with the element through the Fullscreen API where there is one, and by
5
10
  * pinning the element over the page where there is not (iPhone Safari has none for anything
6
11
  * but a video). Escape leaves either way.
12
+ *
13
+ * The pinned element is a manual popover, in the top layer the browser's own fullscreen uses:
14
+ * no transform, clip or stacking context of the page holds it there. Pinned in place, a dialog
15
+ * centred by a transform would make its fixed position fill the dialog, not the viewport. A
16
+ * browser without popovers pins it in place all the same.
7
17
  */
8
18
  var FullscreenToggle = class {
9
19
  element;
@@ -18,9 +28,17 @@ var FullscreenToggle = class {
18
28
  }
19
29
  async exit() {
20
30
  if (this.isNativelyFullscreen()) await document.exitFullscreen();
31
+ this.element.removeAttribute(POPOVER_ATTRIBUTE);
21
32
  this.element.removeAttribute(FILL_ATTRIBUTE);
22
33
  }
23
34
  /**
35
+ * The browser takes a popover out of the top layer when its element leaves the document, so a
36
+ * pinned element the page moves goes back in once it is connected again.
37
+ */
38
+ connected() {
39
+ if (this.element.hasAttribute(FILL_ATTRIBUTE)) this.raiseToTopLayer();
40
+ }
41
+ /**
24
42
  * Asked of the element itself: `document.fullscreenElement` names the outermost shadow host
25
43
  * instead when the player sits inside another component.
26
44
  */
@@ -32,8 +50,25 @@ var FullscreenToggle = class {
32
50
  await this.element.requestFullscreen();
33
51
  return;
34
52
  } catch {}
53
+ if (!this.element.isConnected) return;
35
54
  this.element.setAttribute(FILL_ATTRIBUTE, "");
55
+ this.raiseToTopLayer();
56
+ }
57
+ /**
58
+ * Out of the document there is no top layer to show it in; `connected` raises it on arrival.
59
+ */
60
+ raiseToTopLayer() {
61
+ if (!hasPopovers() || !this.element.isConnected || this.element.matches(":popover-open")) return;
62
+ this.element.setAttribute(POPOVER_ATTRIBUTE, MANUAL_POPOVER);
63
+ this.element.showPopover();
36
64
  }
37
65
  };
66
+ /**
67
+ * The browser's own popovers, the method and the selector both: Safari has them from 17, and
68
+ * before it does not even parse `:popover-open`, which a polyfill's `showPopover` cannot change.
69
+ */
70
+ function hasPopovers() {
71
+ return "showPopover" in HTMLElement.prototype && CSS.supports("selector(:popover-open)");
72
+ }
38
73
  //#endregion
39
74
  export { FullscreenToggle };
@@ -136,6 +136,13 @@ var GyroViewElement = class extends TypedEventElement {
136
136
  return this.player.view;
137
137
  }
138
138
  /**
139
+ * Whether turning the device turns the normal view: `on`, `off`, or `unavailable` where the
140
+ * device reports no attitude or access was refused.
141
+ */
142
+ get motionLook() {
143
+ return this.player.motionLook;
144
+ }
145
+ /**
139
146
  * Every word the element shows or says to assistive technology. Setting it replaces any of
140
147
  * them, table by table (`labels`, `stabilizationModes`, `viewModes`, `errors`); the others keep
141
148
  * their English defaults, and `null` brings them all back.
@@ -155,10 +162,11 @@ var GyroViewElement = class extends TypedEventElement {
155
162
  this.dataset["status"] = this.player.status;
156
163
  this.idle.start();
157
164
  this.loads.connected();
165
+ this.fullscreen.connected();
158
166
  }
159
167
  disconnectedCallback() {
160
168
  this.idle.stop();
161
- this.loads.disconnected();
169
+ this.leaveIfRemoved();
162
170
  }
163
171
  attributeChangedCallback(name, _previous, value) {
164
172
  if (SOURCE_ATTRIBUTES.includes(name)) this.loads.sourceChanged();
@@ -231,6 +239,20 @@ var GyroViewElement = class extends TypedEventElement {
231
239
  this.player.zoom(steps, focus);
232
240
  }
233
241
  /**
242
+ * Lets the device turn the normal view, as a window into the recording. Call it from a tap's
243
+ * handler: iOS asks the viewer for access there and refuses it anywhere else. Resolves with the
244
+ * state it ends in (`motionLook`); a refusal is a `warning`, never a rejection.
245
+ */
246
+ startMotionLook() {
247
+ return this.player.startMotionLook();
248
+ }
249
+ /**
250
+ * Gives the view back to the pointer, level, looking where it looked.
251
+ */
252
+ stopMotionLook() {
253
+ this.player.stopMotionLook();
254
+ }
255
+ /**
234
256
  * Stabilizes the picture in `mode` (`off`, `lock`, `horizon`, `follow`); any other value,
235
257
  * an unset one included, is refused.
236
258
  */
@@ -273,6 +295,17 @@ var GyroViewElement = class extends TypedEventElement {
273
295
  this.loads.loadFiles(files);
274
296
  }
275
297
  /**
298
+ * A move within the document disconnects and connects again at once: only an element still out
299
+ * of it a microtask later was removed. It lets its recording go, and leaves the pinned fill as
300
+ * the browser's fullscreen element leaves fullscreen.
301
+ */
302
+ async leaveIfRemoved() {
303
+ await Promise.resolve();
304
+ if (this.isConnected) return;
305
+ this.loads.removed();
306
+ await this.fullscreen.exit();
307
+ }
308
+ /**
276
309
  * What the controls and the keyboard ask of the element. They live as long as the element:
277
310
  * their listeners sit on its own shadow tree and host, and on its player.
278
311
  */
@@ -1,4 +1,4 @@
1
1
  //#region ../../packages/player/src/element/styles.css?raw
2
- var styles_default = ":host {\n --gyro-view-accent: #fff;\n --gyro-view-controls-background: rgb(0 0 0 / 60%);\n --gyro-view-menu-background: rgb(24 24 23 / 92%);\n --gyro-view-text: #fff;\n --gyro-view-font: system-ui, -apple-system, 'Segoe UI', sans-serif;\n --gyro-view-radius: 8px;\n\n display: block;\n position: relative;\n width: 100%;\n aspect-ratio: 16 / 9;\n background: #000;\n color: var(--gyro-view-text);\n font-family: var(--gyro-view-font);\n overflow: hidden;\n user-select: none;\n -webkit-user-select: none;\n}\n\n:host([hidden]) {\n display: none;\n}\n\n:host(:focus-visible) {\n outline: 2px solid var(--gyro-view-accent);\n outline-offset: 2px;\n}\n\n/*\n * Pinned over the page where the Fullscreen API is refused (iPhone Safari). Like the browser's\n * own fullscreen rules it overrides the size, margins and place the page gave the element, and\n * the insets size it to the visible viewport, where 100vh would reach under Safari's toolbars.\n */\n:host([data-fill]) {\n position: fixed !important;\n inset: 0 !important;\n box-sizing: border-box !important;\n width: auto !important;\n height: auto !important;\n min-width: 0 !important;\n min-height: 0 !important;\n max-width: none !important;\n max-height: none !important;\n margin: 0 !important;\n transform: none !important;\n aspect-ratio: auto !important;\n z-index: 2147483000 !important;\n}\n\n:host(:fullscreen) {\n width: 100%;\n height: 100%;\n aspect-ratio: auto;\n}\n\n/* The size container the controls fit themselves to: the player's size, not the page's. Its\n size never depends on its content, which size containment requires. */\n.stage {\n position: absolute;\n inset: 0;\n container-type: size;\n}\n\ncanvas {\n display: block;\n width: 100%;\n height: 100%;\n touch-action: none;\n}\n\n.poster {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n}\n\n:host([data-has-frame]) .poster,\n:host(:not([poster])) .poster {\n display: none;\n}\n\n.overlay {\n position: absolute;\n inset: 0;\n display: none;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n gap: 0.5rem;\n padding: 1rem;\n text-align: center;\n background: rgb(0 0 0 / 45%);\n}\n\n:host([data-status='loading']) .overlay.loading,\n:host([data-status='buffering']) .overlay.loading,\n:host([data-status='error']) .overlay.error {\n display: flex;\n}\n\n:host([data-status='buffering']) .overlay.loading {\n background: transparent;\n pointer-events: none;\n}\n\n.spinner {\n width: 2.5rem;\n height: 2.5rem;\n border: 3px solid rgb(255 255 255 / 25%);\n border-top-color: var(--gyro-view-accent);\n border-radius: 50%;\n animation: gyro-view-spin 0.9s linear infinite;\n}\n\n@keyframes gyro-view-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n/* A viewer who asked for less motion still sees that it loads, three times slower. */\n@media (prefers-reduced-motion: reduce) {\n .spinner {\n animation-duration: 2.7s;\n }\n}\n\n.error-code {\n font-size: 0.8rem;\n opacity: 0.7;\n}\n";
2
+ var styles_default = ":host {\n --gyro-view-accent: #fff;\n --gyro-view-controls-background: rgb(0 0 0 / 60%);\n --gyro-view-menu-background: rgb(24 24 23 / 92%);\n --gyro-view-text: #fff;\n --gyro-view-font: system-ui, -apple-system, 'Segoe UI', sans-serif;\n --gyro-view-radius: 8px;\n\n display: block;\n position: relative;\n width: 100%;\n aspect-ratio: 16 / 9;\n background: #000;\n color: var(--gyro-view-text);\n font-family: var(--gyro-view-font);\n overflow: hidden;\n user-select: none;\n -webkit-user-select: none;\n}\n\n:host([hidden]) {\n display: none;\n}\n\n:host(:focus-visible) {\n outline: 2px solid var(--gyro-view-accent);\n outline-offset: 2px;\n}\n\n/*\n * Pinned over the page where the Fullscreen API is refused (iPhone Safari). Like the browser's\n * own fullscreen rules it overrides the size, margins and place the page gave the element, and\n * the insets size it to the visible viewport, where 100vh would reach under Safari's toolbars.\n * It keeps no corners or border of the page's, which would show the page behind it, and zero\n * border and padding override a popover's defaults, which the pinned element is (ADR 0041). A\n * page's styles for every popover would outrank the host's overflow, colours and font, so the\n * fill holds them too. The z-index matters only where the browser has no popovers.\n */\n:host([data-fill]) {\n position: fixed !important;\n inset: 0 !important;\n box-sizing: border-box !important;\n width: auto !important;\n height: auto !important;\n min-width: 0 !important;\n min-height: 0 !important;\n max-width: none !important;\n max-height: none !important;\n margin: 0 !important;\n border: 0 !important;\n border-radius: 0 !important;\n padding: 0 !important;\n transform: none !important;\n translate: none !important;\n rotate: none !important;\n scale: none !important;\n aspect-ratio: auto !important;\n overflow: hidden !important;\n background: #000 !important;\n color: var(--gyro-view-text) !important;\n font-family: var(--gyro-view-font) !important;\n z-index: 2147483000 !important;\n}\n\n:host(:fullscreen) {\n width: 100%;\n height: 100%;\n aspect-ratio: auto;\n}\n\n/* The size container the controls fit themselves to: the player's size, not the page's. Its\n size never depends on its content, which size containment requires. */\n.stage {\n position: absolute;\n inset: 0;\n container-type: size;\n}\n\ncanvas {\n display: block;\n width: 100%;\n height: 100%;\n touch-action: none;\n}\n\n.poster {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n}\n\n:host([data-has-frame]) .poster,\n:host(:not([poster])) .poster {\n display: none;\n}\n\n.overlay {\n position: absolute;\n inset: 0;\n display: none;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n gap: 0.5rem;\n padding: 1rem;\n text-align: center;\n background: rgb(0 0 0 / 45%);\n}\n\n:host([data-status='loading']) .overlay.loading,\n:host([data-status='buffering']) .overlay.loading,\n:host([data-status='error']) .overlay.error {\n display: flex;\n}\n\n:host([data-status='buffering']) .overlay.loading {\n background: transparent;\n pointer-events: none;\n}\n\n.spinner {\n width: 2.5rem;\n height: 2.5rem;\n border: 3px solid rgb(255 255 255 / 25%);\n border-top-color: var(--gyro-view-accent);\n border-radius: 50%;\n animation: gyro-view-spin 0.9s linear infinite;\n}\n\n@keyframes gyro-view-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n/* A viewer who asked for less motion still sees that it loads, three times slower. */\n@media (prefers-reduced-motion: reduce) {\n .spinner {\n animation-duration: 2.7s;\n }\n}\n\n.error-code {\n font-size: 0.8rem;\n opacity: 0.7;\n}\n";
3
3
  //#endregion
4
4
  export { styles_default as default };
@@ -0,0 +1,54 @@
1
+ import { hasErrorCode, messageOf } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ //#region ../../packages/player/src/player/AutomaticStarts.ts
4
+ /**
5
+ * The starts nobody pressed for: autoplay once a recording is ready, and the loop at its end. A
6
+ * refused start leaves the recording loaded and paused: a warning, never a failure.
7
+ */
8
+ var AutomaticStarts = class {
9
+ events;
10
+ play;
11
+ isLoopingValue = false;
12
+ constructor(events, play) {
13
+ this.events = events;
14
+ this.play = play;
15
+ }
16
+ get isLooping() {
17
+ return this.isLoopingValue;
18
+ }
19
+ setLooping(isLooping) {
20
+ this.isLoopingValue = isLooping;
21
+ }
22
+ async autoplay() {
23
+ try {
24
+ await this.play();
25
+ } catch (error) {
26
+ this.events.emit("warning", hasErrorCode(error, "playback-blocked") ? {
27
+ code: "autoplay-blocked",
28
+ message: "playback waits for a user gesture"
29
+ } : {
30
+ code: "playback-failed",
31
+ message: `autoplay failed: ${messageOf(error)}`
32
+ });
33
+ }
34
+ }
35
+ /**
36
+ * The recording ended: announced, and played again when looping.
37
+ */
38
+ ended() {
39
+ this.events.emit("ended", void 0);
40
+ if (this.isLoopingValue) this.replay();
41
+ }
42
+ async replay() {
43
+ try {
44
+ await this.play();
45
+ } catch (error) {
46
+ this.events.emit("warning", {
47
+ code: "playback-failed",
48
+ message: `the loop could not restart playback: ${messageOf(error)}`
49
+ });
50
+ }
51
+ }
52
+ };
53
+ //#endregion
54
+ export { AutomaticStarts };