@bubo-squared/gyroview 0.5.0 → 0.6.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.
- package/CHANGELOG.md +22 -0
- package/README.md +2 -2
- package/dist/index.d.ts +56 -15
- package/dist/packages/core/src/domain/view/Framing.js +8 -2
- package/dist/packages/core/src/domain/view/ViewState.js +13 -8
- package/dist/packages/core/src/domain/view/lensTilesView.js +2 -1
- package/dist/packages/core/src/domain/view/motionLookView.js +103 -0
- package/dist/packages/core/src/domain/view/normalView.js +12 -5
- package/dist/packages/core/src/domain/view/panoramaView.js +2 -1
- package/dist/packages/core/src/domain/view/screenLook.js +74 -0
- package/dist/packages/core/src/domain/view/viewModes.js +18 -1
- package/dist/packages/core/src/index.js +5 -3
- package/dist/packages/core/src/shared/math/Matrix3.js +12 -1
- package/dist/packages/player/src/browserPlayer.js +6 -4
- package/dist/packages/player/src/composition/BrowserAttitudeSensor.js +192 -0
- package/dist/packages/player/src/composition/attitudeSensor.js +18 -0
- package/dist/packages/player/src/controls/MotionLookButton.js +35 -0
- package/dist/packages/player/src/controls/controlParts.js +1 -0
- package/dist/packages/player/src/controls/controls.js +1 -1
- package/dist/packages/player/src/controls/controlsBar.js +5 -3
- package/dist/packages/player/src/controls/controlsMarkup.js +1 -0
- package/dist/packages/player/src/controls/icons.js +1 -0
- package/dist/packages/player/src/controls/messages.js +1 -0
- package/dist/packages/player/src/element/GyroViewElement.js +21 -0
- package/dist/packages/player/src/player/AutomaticStarts.js +54 -0
- package/dist/packages/player/src/player/MotionLook.js +150 -0
- package/dist/packages/player/src/player/Player.js +46 -39
- package/dist/packages/player/src/player/PlayerEvents.js +1 -0
- package/dist/packages/player/src/player/PlayerView.js +44 -13
- package/dist/packages/player/src/player/ensureFinite.js +2 -1
- package/dist/packages/player/src/player/viewAngles.js +17 -0
- package/dist/standalone.js +115 -62
- package/package.json +1 -1
|
@@ -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,
|
|
11
|
-
* state. They live as long as
|
|
12
|
-
*
|
|
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
|
};
|
|
@@ -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.
|
|
@@ -231,6 +238,20 @@ var GyroViewElement = class extends TypedEventElement {
|
|
|
231
238
|
this.player.zoom(steps, focus);
|
|
232
239
|
}
|
|
233
240
|
/**
|
|
241
|
+
* Lets the device turn the normal view, as a window into the recording. Call it from a tap's
|
|
242
|
+
* handler: iOS asks the viewer for access there and refuses it anywhere else. Resolves with the
|
|
243
|
+
* state it ends in (`motionLook`); a refusal is a `warning`, never a rejection.
|
|
244
|
+
*/
|
|
245
|
+
startMotionLook() {
|
|
246
|
+
return this.player.startMotionLook();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Gives the view back to the pointer, level, looking where it looked.
|
|
250
|
+
*/
|
|
251
|
+
stopMotionLook() {
|
|
252
|
+
this.player.stopMotionLook();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
234
255
|
* Stabilizes the picture in `mode` (`off`, `lock`, `horizon`, `follow`); any other value,
|
|
235
256
|
* an unset one included, is refused.
|
|
236
257
|
*/
|
|
@@ -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 };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//#region ../../packages/player/src/player/MotionLook.ts
|
|
2
|
+
/**
|
|
3
|
+
* The motion look setting, kept across loads like the others (ADR 0016), and what of the device
|
|
4
|
+
* it hears: its availability while a recording is drawn, and its readings while motion look is
|
|
5
|
+
* also on and the view mode follows the device. Each time the readings stop, the view is let go,
|
|
6
|
+
* so it is level again everywhere else. Nothing of the page holds the player between recordings:
|
|
7
|
+
* an element taken out of the page unloads, and can be collected.
|
|
8
|
+
*/
|
|
9
|
+
var MotionLook = class {
|
|
10
|
+
parts;
|
|
11
|
+
stateValue;
|
|
12
|
+
request;
|
|
13
|
+
/**
|
|
14
|
+
* Counts the requests `stop`, `dispose` and a change of availability overtook: an answer that
|
|
15
|
+
* comes after them changes nothing.
|
|
16
|
+
*/
|
|
17
|
+
generation = 0;
|
|
18
|
+
stopHearing;
|
|
19
|
+
stopFollowingAvailability;
|
|
20
|
+
/**
|
|
21
|
+
* The viewer refused access: the device's availability changes nothing after that.
|
|
22
|
+
*/
|
|
23
|
+
wasRefused = false;
|
|
24
|
+
constructor(parts) {
|
|
25
|
+
this.parts = parts;
|
|
26
|
+
this.stateValue = parts.sensor.availability === "available" ? "off" : "unavailable";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* As last known: the device's availability is followed while a recording is drawn.
|
|
30
|
+
*/
|
|
31
|
+
get state() {
|
|
32
|
+
return this.stateValue;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Turns motion look on, asking for access first; resolves with the state it ends in and never
|
|
36
|
+
* rejects. A second call while the first asks waits for the same answer.
|
|
37
|
+
*/
|
|
38
|
+
start() {
|
|
39
|
+
this.followAvailability();
|
|
40
|
+
if (this.stateValue !== "off") return Promise.resolve(this.stateValue);
|
|
41
|
+
this.request ??= this.ask();
|
|
42
|
+
return this.request;
|
|
43
|
+
}
|
|
44
|
+
stop() {
|
|
45
|
+
this.overtakeRequest();
|
|
46
|
+
if (this.stateValue === "on") this.change("off");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The player calls it whenever a recording or the view mode may have changed.
|
|
50
|
+
*/
|
|
51
|
+
reconsider() {
|
|
52
|
+
this.reconsiderAvailability();
|
|
53
|
+
this.reconsiderHearing();
|
|
54
|
+
}
|
|
55
|
+
dispose() {
|
|
56
|
+
this.overtakeRequest();
|
|
57
|
+
this.stopFollowingAvailability?.();
|
|
58
|
+
this.stopFollowingAvailability = void 0;
|
|
59
|
+
this.stopHearing?.();
|
|
60
|
+
this.stopHearing = void 0;
|
|
61
|
+
this.parts.sensor.dispose();
|
|
62
|
+
}
|
|
63
|
+
reconsiderAvailability() {
|
|
64
|
+
const isDrawn = this.parts.hasRecording();
|
|
65
|
+
if (isDrawn && !this.stopFollowingAvailability) {
|
|
66
|
+
this.stopFollowingAvailability = this.parts.sensor.onAvailabilityChange(() => {
|
|
67
|
+
this.followAvailability();
|
|
68
|
+
});
|
|
69
|
+
this.followAvailability();
|
|
70
|
+
} else if (!isDrawn && this.stopFollowingAvailability) {
|
|
71
|
+
this.stopFollowingAvailability();
|
|
72
|
+
this.stopFollowingAvailability = void 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Hears the device exactly while motion look is on, a recording is drawn and the view mode
|
|
77
|
+
* follows the device; lets the view go whenever it stops.
|
|
78
|
+
*/
|
|
79
|
+
reconsiderHearing() {
|
|
80
|
+
const { sensor, view, hasRecording } = this.parts;
|
|
81
|
+
const shouldHear = this.stateValue === "on" && hasRecording() && view.followsDevice;
|
|
82
|
+
if (shouldHear && !this.stopHearing) this.stopHearing = sensor.listen((reading) => {
|
|
83
|
+
view.followDevice(reading);
|
|
84
|
+
});
|
|
85
|
+
else if (!shouldHear && this.stopHearing) {
|
|
86
|
+
this.stopHearing();
|
|
87
|
+
this.stopHearing = void 0;
|
|
88
|
+
view.letGo();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A device found to report its attitude after all may be turned on; one found not to, which
|
|
93
|
+
* may happen after it was turned on, can no longer be.
|
|
94
|
+
*/
|
|
95
|
+
followAvailability() {
|
|
96
|
+
const isAvailable = this.parts.sensor.availability === "available";
|
|
97
|
+
if (this.wasRefused || isAvailable === (this.stateValue !== "unavailable")) return;
|
|
98
|
+
this.overtakeRequest();
|
|
99
|
+
this.change(isAvailable ? "off" : "unavailable");
|
|
100
|
+
}
|
|
101
|
+
async ask() {
|
|
102
|
+
const generation = this.generation;
|
|
103
|
+
const access = await this.accessOrGesture();
|
|
104
|
+
if (generation !== this.generation) return this.stateValue;
|
|
105
|
+
this.request = void 0;
|
|
106
|
+
this.answer(access);
|
|
107
|
+
return this.stateValue;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A sensor's request never rejects; one that did is taken as wanting a gesture, so motion look
|
|
111
|
+
* may still be started from the next tap.
|
|
112
|
+
*/
|
|
113
|
+
async accessOrGesture() {
|
|
114
|
+
try {
|
|
115
|
+
return await this.parts.sensor.requestAccess();
|
|
116
|
+
} catch {
|
|
117
|
+
return "needs-gesture";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
answer(access) {
|
|
121
|
+
if (access === "granted") this.change("on");
|
|
122
|
+
else if (access === "denied") {
|
|
123
|
+
this.wasRefused = true;
|
|
124
|
+
this.change("unavailable");
|
|
125
|
+
this.warn("motion-look-refused", REFUSED);
|
|
126
|
+
} else this.warn("motion-look-needs-gesture", NEEDS_GESTURE);
|
|
127
|
+
}
|
|
128
|
+
overtakeRequest() {
|
|
129
|
+
this.generation += 1;
|
|
130
|
+
this.request = void 0;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Announced once the change is whole (ADR 0021): the device heard, or let go, first.
|
|
134
|
+
*/
|
|
135
|
+
change(state) {
|
|
136
|
+
this.stateValue = state;
|
|
137
|
+
this.reconsiderHearing();
|
|
138
|
+
this.parts.events.emit("motionlookchange", state);
|
|
139
|
+
}
|
|
140
|
+
warn(code, message) {
|
|
141
|
+
this.parts.events.emit("warning", {
|
|
142
|
+
code,
|
|
143
|
+
message
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var REFUSED = "motion look was refused: by the viewer, or by an iframe whose `allow` leaves out accelerometer, gyroscope and magnetometer";
|
|
148
|
+
var NEEDS_GESTURE = "motion look asks for access, which this browser grants only from a user gesture such as a tap";
|
|
149
|
+
//#endregion
|
|
150
|
+
export { MotionLook };
|