@hanzo/ui 8.27.3 → 8.27.4
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/dist/backdrop/Backdrop.cjs +420 -0
- package/dist/backdrop/Backdrop.d.ts +53 -0
- package/dist/backdrop/Backdrop.d.ts.map +1 -0
- package/dist/backdrop/Backdrop.js +418 -0
- package/dist/backdrop/Backdrop.js.map +1 -0
- package/dist/backdrop/index.cjs +50 -0
- package/dist/backdrop/index.d.ts +29 -0
- package/dist/backdrop/index.d.ts.map +1 -0
- package/dist/backdrop/index.js +29 -0
- package/dist/backdrop/index.js.map +1 -0
- package/dist/backdrop/scene.cjs +439 -0
- package/dist/backdrop/scene.d.ts +235 -0
- package/dist/backdrop/scene.d.ts.map +1 -0
- package/dist/backdrop/scene.js +423 -0
- package/dist/backdrop/scene.js.map +1 -0
- package/dist/chat/Chat.cjs +23 -1
- package/dist/chat/Chat.d.ts +6 -3
- package/dist/chat/Chat.d.ts.map +1 -1
- package/dist/chat/Chat.js +23 -1
- package/dist/chat/Chat.js.map +1 -1
- package/dist/chat/Inspector.cjs +51 -0
- package/dist/chat/Inspector.d.ts +49 -0
- package/dist/chat/Inspector.d.ts.map +1 -0
- package/dist/chat/Inspector.js +49 -0
- package/dist/chat/Inspector.js.map +1 -0
- package/dist/chat/Message.cjs +3 -1
- package/dist/chat/Message.d.ts +3 -1
- package/dist/chat/Message.d.ts.map +1 -1
- package/dist/chat/Message.js +3 -1
- package/dist/chat/Message.js.map +1 -1
- package/dist/chat/Parts.cjs +159 -0
- package/dist/chat/Parts.d.ts +186 -0
- package/dist/chat/Parts.d.ts.map +1 -0
- package/dist/chat/Parts.js +153 -0
- package/dist/chat/Parts.js.map +1 -0
- package/dist/chat/Persona.cjs +34 -0
- package/dist/chat/Persona.d.ts +37 -0
- package/dist/chat/Persona.d.ts.map +1 -0
- package/dist/chat/Persona.js +30 -0
- package/dist/chat/Persona.js.map +1 -0
- package/dist/chat/Preview.cjs +42 -0
- package/dist/chat/Preview.d.ts +36 -0
- package/dist/chat/Preview.d.ts.map +1 -0
- package/dist/chat/Preview.js +40 -0
- package/dist/chat/Preview.js.map +1 -0
- package/dist/chat/directive.cjs +272 -0
- package/dist/chat/directive.d.ts +167 -0
- package/dist/chat/directive.d.ts.map +1 -0
- package/dist/chat/directive.js +266 -0
- package/dist/chat/directive.js.map +1 -0
- package/dist/chat/index.cjs +59 -4
- package/dist/chat/index.d.ts +31 -3
- package/dist/chat/index.d.ts.map +1 -1
- package/dist/chat/index.js +31 -3
- package/dist/chat/index.js.map +1 -1
- package/dist/gallery.html +4 -4
- package/dist/styles.css +3 -3
- package/dist/theme.css +3 -3
- package/package.json +7 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use client';"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.Backdrop = Backdrop;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
/**
|
|
7
|
+
* Backdrop — a still image, one muted video, a list of them, or our own clips,
|
|
8
|
+
* painted behind whatever a surface renders on top of it.
|
|
9
|
+
*
|
|
10
|
+
* The layer takes no pointer and is `aria-hidden`, so nothing about the controls
|
|
11
|
+
* above it, the focus order or the screen-reader surface changes: it is scenery,
|
|
12
|
+
* not UI. That is also why nothing here is clickable — a backdrop that could
|
|
13
|
+
* take a click would be a trap sitting under the conversation.
|
|
14
|
+
*
|
|
15
|
+
* NOTHING IS REVEALED UNTIL IT IS ACTUALLY THERE. A video appears only once the
|
|
16
|
+
* player reports footage rolling, an image only once it has decoded. A video the
|
|
17
|
+
* provider refuses — taken down, region-blocked, embed throttled — and an image
|
|
18
|
+
* URL that 404s both simply stay invisible, instead of showing the provider's
|
|
19
|
+
* error card through the canvas.
|
|
20
|
+
*
|
|
21
|
+
* Provider chrome (title, watermark, suggestions) is kept out of frame two ways:
|
|
22
|
+
* players are built with every control off, and the frame is centre-cropped well
|
|
23
|
+
* past the viewport (16:9 cover, then overscan), so the strips providers reserve
|
|
24
|
+
* at the top and bottom land offscreen. The scrim keeps text legible over bright
|
|
25
|
+
* footage.
|
|
26
|
+
*
|
|
27
|
+
* WEB ONLY, and it ships at its own subpath for that reason. A YouTube embed is
|
|
28
|
+
* an `<iframe>` and a clip is a `<video>`; neither exists off the web, and the
|
|
29
|
+
* cover crop is written in `vh`/`vw`. `Grid` is off the barrel on the same rule.
|
|
30
|
+
*
|
|
31
|
+
* Configuration arrives as PROPS. There is no store here and no context: a
|
|
32
|
+
* surface holds the scene (in whatever it already uses) and passes it, which is
|
|
33
|
+
* what lets the same component serve a settings panel, a `/bg` command and a
|
|
34
|
+
* persona's emotion with one implementation and no adapter between them.
|
|
35
|
+
*/
|
|
36
|
+
const gui_1 = require("@hanzo/gui");
|
|
37
|
+
const react_1 = require("react");
|
|
38
|
+
const slot_1 = require("../backends/gui/slot.cjs");
|
|
39
|
+
const scene_1 = require("./scene.cjs");
|
|
40
|
+
const ORIGIN = 'https://www.youtube.com';
|
|
41
|
+
/**
|
|
42
|
+
* Send the origin, never the path.
|
|
43
|
+
*
|
|
44
|
+
* The address of a conversation is `/c/<id>`, and a `Referer` carrying it would
|
|
45
|
+
* hand that id to YouTube, to Twitch, and to whatever host serves a photo
|
|
46
|
+
* somebody pasted — on every load, to a party with no reason to know which
|
|
47
|
+
* thread is open. Origin alone is all these embeds need: Twitch checks the
|
|
48
|
+
* `parent` parameter and YouTube the `origin` one, both passed deliberately.
|
|
49
|
+
* Chrome's default already stops at the origin; Safari and older engines do not,
|
|
50
|
+
* which is why this is stated rather than assumed.
|
|
51
|
+
*/
|
|
52
|
+
const REFERRER = 'strict-origin-when-cross-origin';
|
|
53
|
+
/** How long the adaptive ramp is given before anything is revealed: quality
|
|
54
|
+
* cannot be forced (embeds have ignored every quality API since 2019), so the
|
|
55
|
+
* low-res opening plays out hidden. */
|
|
56
|
+
const RAMP = 4000;
|
|
57
|
+
/** The cover-crop: deliberately larger than the container, and told so twice
|
|
58
|
+
* because a page's own `img, iframe, video { max-width: 100% }` reset would
|
|
59
|
+
* otherwise clamp the formula and letterbox the footage on a narrow screen. */
|
|
60
|
+
const COVER = {
|
|
61
|
+
position: 'absolute',
|
|
62
|
+
left: '50%',
|
|
63
|
+
top: '50%',
|
|
64
|
+
width: 'max(177.78vh, 100vw)',
|
|
65
|
+
height: 'max(56.25vw, 100vh)',
|
|
66
|
+
maxWidth: 'none',
|
|
67
|
+
maxHeight: 'none',
|
|
68
|
+
border: 0,
|
|
69
|
+
};
|
|
70
|
+
/** The cover-crop at a brand's zoom, or the house 1.4. A larger scale pushes a
|
|
71
|
+
* corner watermark off-frame. */
|
|
72
|
+
const cover = (zoom) => ({
|
|
73
|
+
...COVER,
|
|
74
|
+
transform: `translate(-50%, -50%) scale(${zoom && zoom > 0 ? zoom : 1.4})`,
|
|
75
|
+
});
|
|
76
|
+
const fade = (shown) => ({
|
|
77
|
+
opacity: shown ? 1 : 0,
|
|
78
|
+
transition: 'opacity 1.2s ease',
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Ask a YouTube player to start, through its API. Muted playback started by
|
|
82
|
+
* script is always allowed, and the API path sidesteps the viewability heuristic
|
|
83
|
+
* that can leave the autoplay param unhonoured for a player sitting under the
|
|
84
|
+
* content layer. Repeated because the player ignores commands sent before its
|
|
85
|
+
* own scripts finish booting.
|
|
86
|
+
*/
|
|
87
|
+
function start(event, rate) {
|
|
88
|
+
const frame = event.currentTarget;
|
|
89
|
+
const post = (message) => frame.contentWindow?.postMessage(JSON.stringify(message), ORIGIN);
|
|
90
|
+
const kick = () => {
|
|
91
|
+
// The listening handshake makes the player report state back — the reveal
|
|
92
|
+
// below waits for a real "playing" signal.
|
|
93
|
+
post({ event: 'listening', id: 'backdrop', channel: 'widget' });
|
|
94
|
+
// Quality cannot be forced (see the note above); this only skips the low
|
|
95
|
+
// first rung. Ask for the top one so a 4K display is offered the 2160p
|
|
96
|
+
// rendition when the source and the bandwidth allow — the size-based auto
|
|
97
|
+
// picker caps at the viewport otherwise.
|
|
98
|
+
post({ event: 'command', func: 'setPlaybackQuality', args: ['hd2160'] });
|
|
99
|
+
// A brand's chosen speed. YouTube quantises to its allowed steps, so an
|
|
100
|
+
// off-grid value plays at the nearest; sent with the other kicks because a
|
|
101
|
+
// command before the player boots is dropped.
|
|
102
|
+
if (rate && rate > 0)
|
|
103
|
+
post({ event: 'command', func: 'setPlaybackRate', args: [rate] });
|
|
104
|
+
post({ event: 'command', func: 'playVideo', args: [] });
|
|
105
|
+
};
|
|
106
|
+
kick();
|
|
107
|
+
setTimeout(kick, 1500);
|
|
108
|
+
setTimeout(kick, RAMP);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The BROWSER's transport controls, disowned.
|
|
112
|
+
*
|
|
113
|
+
* A page that plays media gets a system play/pause/skip overlay — Safari draws
|
|
114
|
+
* it over the middle of the conversation — and `controls=0` never reaches it,
|
|
115
|
+
* because that overlay belongs to the user agent, not to the player.
|
|
116
|
+
* `mediaSession` is where a page says whose media this is: cleared metadata, no
|
|
117
|
+
* playback state, no handlers, nothing to present.
|
|
118
|
+
*
|
|
119
|
+
* This is scenery. It is muted, it loops, and the only controls that mean
|
|
120
|
+
* anything for it belong to the surface. System skip buttons put furniture over
|
|
121
|
+
* the page and wire it to nothing.
|
|
122
|
+
*/
|
|
123
|
+
function disown() {
|
|
124
|
+
try {
|
|
125
|
+
const ms = navigator.mediaSession;
|
|
126
|
+
if (!ms)
|
|
127
|
+
return;
|
|
128
|
+
ms.metadata = null;
|
|
129
|
+
ms.playbackState = 'none';
|
|
130
|
+
const actions = [
|
|
131
|
+
'play',
|
|
132
|
+
'pause',
|
|
133
|
+
'previoustrack',
|
|
134
|
+
'nexttrack',
|
|
135
|
+
'seekbackward',
|
|
136
|
+
'seekforward',
|
|
137
|
+
'stop',
|
|
138
|
+
];
|
|
139
|
+
for (const action of actions) {
|
|
140
|
+
try {
|
|
141
|
+
ms.setActionHandler(action, null);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* an action this agent does not know */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* no mediaSession here */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** A YouTube player. Revealed on a real `playing` report; calls `onEnd` when the
|
|
153
|
+
* player says the video finished, which is how a mixed list advances. */
|
|
154
|
+
function Video({ src, onEnd, rate, zoom, sound = false, }) {
|
|
155
|
+
const [playing, setPlaying] = (0, react_1.useState)(false);
|
|
156
|
+
const [ramped, setRamped] = (0, react_1.useState)(false);
|
|
157
|
+
const frame = (0, react_1.useRef)(null);
|
|
158
|
+
(0, react_1.useEffect)(() => {
|
|
159
|
+
const hear = (event) => {
|
|
160
|
+
if (event.origin !== ORIGIN)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
const state = JSON.parse(event.data)?.info?.playerState;
|
|
164
|
+
if (state === 1) {
|
|
165
|
+
setPlaying(true);
|
|
166
|
+
disown();
|
|
167
|
+
}
|
|
168
|
+
else if (state === 0) {
|
|
169
|
+
onEnd?.();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* not the player */
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
window.addEventListener('message', hear);
|
|
177
|
+
const ramp = setTimeout(() => setRamped(true), RAMP);
|
|
178
|
+
return () => {
|
|
179
|
+
window.removeEventListener('message', hear);
|
|
180
|
+
clearTimeout(ramp);
|
|
181
|
+
};
|
|
182
|
+
}, [onEnd]);
|
|
183
|
+
/**
|
|
184
|
+
* Sound is turned on AFTER the player is playing, never in the URL.
|
|
185
|
+
*
|
|
186
|
+
* `mute=1` is what lets the embed start at all — autoplay with sound is
|
|
187
|
+
* refused by every current browser — so unmuting is a command, and it is only
|
|
188
|
+
* worth sending once the player has said it is running: commands sent before
|
|
189
|
+
* its scripts boot are dropped, which is the same reason `start` kicks three
|
|
190
|
+
* times. Gating on `playing` rides that report instead of racing it.
|
|
191
|
+
*
|
|
192
|
+
* It follows the setting both ways while the video is on screen, so turning
|
|
193
|
+
* the sound back off silences it now rather than at the next reload — the one
|
|
194
|
+
* direction a person is likely to be in a hurry about.
|
|
195
|
+
*/
|
|
196
|
+
(0, react_1.useEffect)(() => {
|
|
197
|
+
if (!playing)
|
|
198
|
+
return;
|
|
199
|
+
const post = (message) => frame.current?.contentWindow?.postMessage(JSON.stringify(message), ORIGIN);
|
|
200
|
+
post({ event: 'command', func: sound ? 'unMute' : 'mute', args: [] });
|
|
201
|
+
// Wallpaper, not a concert. Loud enough to hear under a conversation.
|
|
202
|
+
if (sound)
|
|
203
|
+
post({ event: 'command', func: 'setVolume', args: [35] });
|
|
204
|
+
}, [playing, sound]);
|
|
205
|
+
return ((0, jsx_runtime_1.jsx)("iframe", { ref: frame, src: src, title: "", tabIndex: -1, allow: "autoplay; encrypted-media", referrerPolicy: REFERRER, onLoad: (e) => start(e, rate), style: { ...cover(zoom), ...fade(playing && ramped) } }));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A Twitch player.
|
|
209
|
+
*
|
|
210
|
+
* Twitch publishes no state channel for a bare iframe the way YouTube does —
|
|
211
|
+
* reading playback state means loading their embed script, a third party we
|
|
212
|
+
* would have to let run — so this reveals on load rather than on a playing
|
|
213
|
+
* report. The honest consequence: an offline channel shows Twitch's own idle
|
|
214
|
+
* screen rather than staying hidden. A live stream also never ends, so its turn
|
|
215
|
+
* in a list is ended by the clock instead of by the player.
|
|
216
|
+
*/
|
|
217
|
+
function Stream({ src, onEnd }) {
|
|
218
|
+
const [loaded, setLoaded] = (0, react_1.useState)(false);
|
|
219
|
+
(0, react_1.useEffect)(() => {
|
|
220
|
+
if (!onEnd)
|
|
221
|
+
return;
|
|
222
|
+
const turn = setTimeout(onEnd, scene_1.DWELL);
|
|
223
|
+
return () => clearTimeout(turn);
|
|
224
|
+
}, [onEnd]);
|
|
225
|
+
return ((0, jsx_runtime_1.jsx)("iframe", { src: src, title: "", tabIndex: -1, allow: "autoplay; encrypted-media", referrerPolicy: REFERRER, onLoad: () => setLoaded(true), style: { ...cover(), ...fade(loaded) } }));
|
|
226
|
+
}
|
|
227
|
+
/** A still image, revealed once it has decoded — a URL that 404s never fires
|
|
228
|
+
* `load`, so it stays invisible for the same reason a refused video does. */
|
|
229
|
+
function Photo({ url }) {
|
|
230
|
+
const [loaded, setLoaded] = (0, react_1.useState)(false);
|
|
231
|
+
return ((0, jsx_runtime_1.jsx)("img", { src: url, alt: "", referrerPolicy: REFERRER, onLoad: () => setLoaded(true), style: {
|
|
232
|
+
position: 'absolute',
|
|
233
|
+
inset: 0,
|
|
234
|
+
width: '100%',
|
|
235
|
+
height: '100%',
|
|
236
|
+
maxWidth: 'none',
|
|
237
|
+
maxHeight: 'none',
|
|
238
|
+
objectFit: 'cover',
|
|
239
|
+
...fade(loaded),
|
|
240
|
+
} }));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* One entry of a list, dispatched to the player its provider needs.
|
|
244
|
+
*
|
|
245
|
+
* Twitch is told the bare hostname it is being framed by — it refuses to load
|
|
246
|
+
* for a `parent` it was not given — while YouTube's API handshake is keyed to
|
|
247
|
+
* the full origin. Two different values, from the same page, for two different
|
|
248
|
+
* reasons.
|
|
249
|
+
*/
|
|
250
|
+
function Entry({ entry, onEnd, at }) {
|
|
251
|
+
if (entry.provider === 'twitch') {
|
|
252
|
+
return (0, jsx_runtime_1.jsx)(Stream, { src: (0, scene_1.twitch)(entry.url, at.host), onEnd: onEnd });
|
|
253
|
+
}
|
|
254
|
+
if (entry.provider === 'youtube') {
|
|
255
|
+
// No loop on a single entry: the list, not the video, decides what repeats.
|
|
256
|
+
return (0, jsx_runtime_1.jsx)(Video, { src: (0, scene_1.youtube)([(0, scene_1.videoId)(entry.url)], false, at.origin), onEnd: onEnd });
|
|
257
|
+
}
|
|
258
|
+
// Named, not defaulted. Callers filter by `playable` before they get here, so
|
|
259
|
+
// this branch should be unreachable — but a dispatch whose LAST case is a
|
|
260
|
+
// fall-through hands every future provider to whichever player happens to be
|
|
261
|
+
// written last, and here that would ask YouTube to embed a netflix.com link. A
|
|
262
|
+
// player has to be chosen on purpose; nothing else gets one.
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/** The list, one entry at a time: a video hands over when it ends, a stream when
|
|
266
|
+
* its dwell is up. Runs only when the list is NOT all YouTube — that case is
|
|
267
|
+
* handed to YouTube's own playlist player instead. */
|
|
268
|
+
function Rotation({ links, loop, at }) {
|
|
269
|
+
const [index, setIndex] = (0, react_1.useState)(0);
|
|
270
|
+
const [done, setDone] = (0, react_1.useState)(false);
|
|
271
|
+
// A changed list is a new rotation, not a continuation of the old one.
|
|
272
|
+
(0, react_1.useEffect)(() => {
|
|
273
|
+
setIndex(0);
|
|
274
|
+
setDone(false);
|
|
275
|
+
}, [links]);
|
|
276
|
+
const advance = (0, react_1.useCallback)(() => {
|
|
277
|
+
setIndex((current) => {
|
|
278
|
+
const next = current + 1;
|
|
279
|
+
if (next < links.length)
|
|
280
|
+
return next;
|
|
281
|
+
if (loop)
|
|
282
|
+
return 0;
|
|
283
|
+
setDone(true);
|
|
284
|
+
return current;
|
|
285
|
+
});
|
|
286
|
+
}, [links.length, loop]);
|
|
287
|
+
const entry = links[index];
|
|
288
|
+
if (done || !entry)
|
|
289
|
+
return null;
|
|
290
|
+
// Keyed by URL so a handover resets the next player's reveal state rather than
|
|
291
|
+
// inheriting the last one's.
|
|
292
|
+
return (0, jsx_runtime_1.jsx)(Entry, { entry: entry, onEnd: advance, at: at }, entry.url);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Direct video clips, ours to serve, played as a preloaded crossfade.
|
|
296
|
+
*
|
|
297
|
+
* Two stacked `<video>`s take turns: the shown one plays while the hidden one
|
|
298
|
+
* loads the next clip, and they swap only once the next has reported it is
|
|
299
|
+
* actually playing — so the canvas crosses one live frame over another and never
|
|
300
|
+
* flashes black between them. That is the same "nothing is revealed until it is
|
|
301
|
+
* real" rule the YouTube scene keeps, met with the element's own `playing` event
|
|
302
|
+
* instead of a postMessage handshake. A clip a host refuses (404, decode error)
|
|
303
|
+
* fires `error`, not `playing`, so it never appears and hands the turn on.
|
|
304
|
+
*
|
|
305
|
+
* A single clip loops itself; a list lets each clip END and advances on it — an
|
|
306
|
+
* unhurried idle cycle. `preload="auto"` is what makes the handoff seamless: the
|
|
307
|
+
* next file is already buffered when its turn comes.
|
|
308
|
+
*
|
|
309
|
+
* The FIRST clip goes into the hidden slot and is revealed by the same flip
|
|
310
|
+
* every later clip uses, so there is one reveal path and no black first frame.
|
|
311
|
+
*/
|
|
312
|
+
function Clips({ urls, loop, zoom }) {
|
|
313
|
+
const [showA, setShowA] = (0, react_1.useState)(true);
|
|
314
|
+
const [srcA, setSrcA] = (0, react_1.useState)(undefined);
|
|
315
|
+
const [srcB, setSrcB] = (0, react_1.useState)(undefined);
|
|
316
|
+
const [lit, setLit] = (0, react_1.useState)(false);
|
|
317
|
+
const shown = (0, react_1.useRef)(true);
|
|
318
|
+
shown.current = showA;
|
|
319
|
+
const at = (0, react_1.useRef)(0);
|
|
320
|
+
// Load `url` into whichever slot is hidden right now; its `playing` event then
|
|
321
|
+
// flips which slot is shown, crossing the new frame in over the old. A ref for
|
|
322
|
+
// `showA` because this is called from timers and event handlers that must read
|
|
323
|
+
// the current slot, not the one captured when they were created.
|
|
324
|
+
const cue = (0, react_1.useCallback)((url) => {
|
|
325
|
+
if (shown.current)
|
|
326
|
+
setSrcB(url);
|
|
327
|
+
else
|
|
328
|
+
setSrcA(url);
|
|
329
|
+
}, []);
|
|
330
|
+
const advance = (0, react_1.useCallback)(() => {
|
|
331
|
+
if (urls.length < 2)
|
|
332
|
+
return;
|
|
333
|
+
at.current = (at.current + 1) % urls.length;
|
|
334
|
+
cue(urls[at.current]);
|
|
335
|
+
}, [urls, cue]);
|
|
336
|
+
// Head of the list on mount and whenever the list changes (a brand's own
|
|
337
|
+
// footage arriving, or a persona changing mood). The first clip is cued into
|
|
338
|
+
// the hidden slot so the reveal below is the only path that ever shows one.
|
|
339
|
+
(0, react_1.useEffect)(() => {
|
|
340
|
+
at.current = 0;
|
|
341
|
+
setShowA(true);
|
|
342
|
+
setSrcA(undefined);
|
|
343
|
+
setSrcB(undefined);
|
|
344
|
+
setLit(false);
|
|
345
|
+
if (urls.length)
|
|
346
|
+
cue(urls[0]);
|
|
347
|
+
}, [urls, cue]);
|
|
348
|
+
if (!urls.length)
|
|
349
|
+
return null;
|
|
350
|
+
// Only the HIDDEN slot reaching `playing` is a handover — so the flip is
|
|
351
|
+
// "show whoever just started", never a toggle. The SHOWN slot fires `playing`
|
|
352
|
+
// again after any stall or seek, and toggling on that would swap a live frame
|
|
353
|
+
// for a stale one for no reason.
|
|
354
|
+
const reveal = (isA) => {
|
|
355
|
+
if (lit && isA === shown.current)
|
|
356
|
+
return;
|
|
357
|
+
setLit(true);
|
|
358
|
+
setShowA(isA);
|
|
359
|
+
};
|
|
360
|
+
const slotStyle = (isA) => ({
|
|
361
|
+
...cover(zoom),
|
|
362
|
+
objectFit: 'cover',
|
|
363
|
+
opacity: lit && isA === showA ? 1 : 0,
|
|
364
|
+
transition: 'opacity 1.2s ease',
|
|
365
|
+
});
|
|
366
|
+
const one = (isA, src) => ((0, jsx_runtime_1.jsx)("video", { src: src, autoPlay: true, muted: true, playsInline: true,
|
|
367
|
+
// A lone clip repeats itself; a member of a list must be allowed to END so
|
|
368
|
+
// the next can cross in — looping one would freeze the cycle on it.
|
|
369
|
+
loop: urls.length < 2 && loop, preload: "auto", onPlaying: () => reveal(isA), onEnded: urls.length > 1 ? advance : undefined, onError: advance, tabIndex: -1, "aria-hidden": true, style: slotStyle(isA) }, isA ? 'a' : 'b'));
|
|
370
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [one(true, srcA), one(false, srcB)] }));
|
|
371
|
+
}
|
|
372
|
+
/** What the scene asks to be painted, or null when that is nothing. */
|
|
373
|
+
function Canvas({ scene, at, zoom }) {
|
|
374
|
+
const links = (0, react_1.useMemo)(() => scene.playlist.filter(scene_1.playable), [scene.playlist]);
|
|
375
|
+
if (scene.mode === 'clips') {
|
|
376
|
+
return scene.clips.length ? ((0, jsx_runtime_1.jsx)(Clips, { urls: scene.clips, loop: scene.loop, zoom: (0, scene_1.knobs)(scene.clips[0]).zoom ?? zoom })) : null;
|
|
377
|
+
}
|
|
378
|
+
if (scene.mode === 'photo')
|
|
379
|
+
return scene.photo ? (0, jsx_runtime_1.jsx)(Photo, { url: scene.photo }) : null;
|
|
380
|
+
if (scene.mode === 'video') {
|
|
381
|
+
const id = (0, scene_1.videoId)(scene.video);
|
|
382
|
+
// start / rate / zoom ride the same URL, so a brand tunes all of them from
|
|
383
|
+
// one configuration value with no rebuild.
|
|
384
|
+
const k = (0, scene_1.knobs)(scene.video);
|
|
385
|
+
return id ? ((0, jsx_runtime_1.jsx)(Video, { src: (0, scene_1.youtube)([id], scene.loop, at.origin, k.start), rate: k.rate, zoom: k.zoom ?? zoom, sound: scene.sound })) : null;
|
|
386
|
+
}
|
|
387
|
+
if (links.length === 0)
|
|
388
|
+
return null;
|
|
389
|
+
// An all-YouTube list is handed to YouTube's own playlist player: it holds the
|
|
390
|
+
// queue in one frame, so there is no handover gap between videos and no second
|
|
391
|
+
// player to reveal.
|
|
392
|
+
if (links.every((entry) => entry.provider === 'youtube')) {
|
|
393
|
+
return ((0, jsx_runtime_1.jsx)(Video, { src: (0, scene_1.youtube)(links.map((entry) => (0, scene_1.videoId)(entry.url)), scene.loop, at.origin), sound: scene.sound }));
|
|
394
|
+
}
|
|
395
|
+
return (0, jsx_runtime_1.jsx)(Rotation, { links: links, loop: scene.loop, at: at });
|
|
396
|
+
}
|
|
397
|
+
function Backdrop({ mode = 'off', photo = '', video = '', playlist = [], clips = [], loop = true, sound = false, scrim = 0.5, zoom, hosts = scene_1.HOSTS, ...props }) {
|
|
398
|
+
const [at, setAt] = (0, react_1.useState)(null);
|
|
399
|
+
(0, react_1.useEffect)(() => {
|
|
400
|
+
setAt({ origin: window.location.origin, host: window.location.hostname });
|
|
401
|
+
}, []);
|
|
402
|
+
// The gate again, at the render boundary. `merge` guards what a surface STORES;
|
|
403
|
+
// this guards what actually reaches a `src`, so a caller that builds props by
|
|
404
|
+
// hand cannot point an element at a host the media policy would refuse.
|
|
405
|
+
const scene = (0, react_1.useMemo)(() => ({
|
|
406
|
+
mode,
|
|
407
|
+
photo: (0, scene_1.media)(photo, hosts),
|
|
408
|
+
video,
|
|
409
|
+
playlist,
|
|
410
|
+
clips: (0, scene_1.files)(clips, hosts),
|
|
411
|
+
loop,
|
|
412
|
+
sound,
|
|
413
|
+
}), [mode, photo, video, playlist, clips, loop, sound, hosts]);
|
|
414
|
+
// Off means ABSENT, not hidden: these are third-party frames that autoplay
|
|
415
|
+
// video, so leaving one mounted at opacity 0 would keep it streaming behind a
|
|
416
|
+
// setting that says it is off.
|
|
417
|
+
if (mode === 'off' || !at)
|
|
418
|
+
return null;
|
|
419
|
+
return ((0, jsx_runtime_1.jsxs)(gui_1.YStack, { ...(0, slot_1.slot)('backdrop'), "data-mode": mode, fullscreen: true, overflow: "hidden", pointerEvents: "none", "aria-hidden": true, ...props, children: [(0, jsx_runtime_1.jsx)(Canvas, { scene: scene, at: at, zoom: zoom }), (0, jsx_runtime_1.jsx)(gui_1.YStack, { fullscreen: true, bg: "$background", opacity: scrim })] }));
|
|
420
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backdrop — a still image, one muted video, a list of them, or our own clips,
|
|
3
|
+
* painted behind whatever a surface renders on top of it.
|
|
4
|
+
*
|
|
5
|
+
* The layer takes no pointer and is `aria-hidden`, so nothing about the controls
|
|
6
|
+
* above it, the focus order or the screen-reader surface changes: it is scenery,
|
|
7
|
+
* not UI. That is also why nothing here is clickable — a backdrop that could
|
|
8
|
+
* take a click would be a trap sitting under the conversation.
|
|
9
|
+
*
|
|
10
|
+
* NOTHING IS REVEALED UNTIL IT IS ACTUALLY THERE. A video appears only once the
|
|
11
|
+
* player reports footage rolling, an image only once it has decoded. A video the
|
|
12
|
+
* provider refuses — taken down, region-blocked, embed throttled — and an image
|
|
13
|
+
* URL that 404s both simply stay invisible, instead of showing the provider's
|
|
14
|
+
* error card through the canvas.
|
|
15
|
+
*
|
|
16
|
+
* Provider chrome (title, watermark, suggestions) is kept out of frame two ways:
|
|
17
|
+
* players are built with every control off, and the frame is centre-cropped well
|
|
18
|
+
* past the viewport (16:9 cover, then overscan), so the strips providers reserve
|
|
19
|
+
* at the top and bottom land offscreen. The scrim keeps text legible over bright
|
|
20
|
+
* footage.
|
|
21
|
+
*
|
|
22
|
+
* WEB ONLY, and it ships at its own subpath for that reason. A YouTube embed is
|
|
23
|
+
* an `<iframe>` and a clip is a `<video>`; neither exists off the web, and the
|
|
24
|
+
* cover crop is written in `vh`/`vw`. `Grid` is off the barrel on the same rule.
|
|
25
|
+
*
|
|
26
|
+
* Configuration arrives as PROPS. There is no store here and no context: a
|
|
27
|
+
* surface holds the scene (in whatever it already uses) and passes it, which is
|
|
28
|
+
* what lets the same component serve a settings panel, a `/bg` command and a
|
|
29
|
+
* persona's emotion with one implementation and no adapter between them.
|
|
30
|
+
*/
|
|
31
|
+
import { YStack } from '@hanzo/gui';
|
|
32
|
+
import { type ComponentProps } from 'react';
|
|
33
|
+
import { type Scene } from './scene.js';
|
|
34
|
+
export interface BackdropProps extends Partial<Scene>, Omit<ComponentProps<typeof YStack>, 'children' | keyof Scene> {
|
|
35
|
+
/**
|
|
36
|
+
* The veil over the footage, 0–1. It is `$background` at this opacity rather
|
|
37
|
+
* than a black wash, so a light theme veils with its own ground instead of
|
|
38
|
+
* dimming to something no brand chose.
|
|
39
|
+
*/
|
|
40
|
+
scrim?: number;
|
|
41
|
+
/**
|
|
42
|
+
* The cover-crop scale. The house 1.4 is OVERSCAN, and it is there to push a
|
|
43
|
+
* provider's furniture — the title strip, the corner watermark, the suggestion
|
|
44
|
+
* overlay — off the frame. Our own clips carry none of that, so a surface
|
|
45
|
+
* showing footage it serves itself can say `zoom={1}` and keep the whole
|
|
46
|
+
* frame. A `?zoom=` on the media URL is more specific and wins over this.
|
|
47
|
+
*/
|
|
48
|
+
zoom?: number;
|
|
49
|
+
/** Media hosts allowed besides this origin. Defaults to the Hanzo store. */
|
|
50
|
+
hosts?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
export declare function Backdrop({ mode, photo, video, playlist, clips, loop, sound, scrim, zoom, hosts, ...props }: BackdropProps): import("react").JSX.Element | null;
|
|
53
|
+
//# sourceMappingURL=Backdrop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Backdrop.d.ts","sourceRoot":"","sources":["../../src/backdrop/Backdrop.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AACnC,OAAO,EAML,KAAK,cAAc,EAGpB,MAAM,OAAO,CAAA;AAGd,OAAO,EAWL,KAAK,KAAK,EACX,MAAM,SAAS,CAAA;AA6chB,MAAM,WAAW,aACf,SAAQ,OAAO,CAAC,KAAK,CAAC,EACpB,IAAI,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,KAAK,CAAC;IAC/D;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1B;AAED,wBAAgB,QAAQ,CAAC,EACvB,IAAY,EACZ,KAAU,EACV,KAAU,EACV,QAAa,EACb,KAAU,EACV,IAAW,EACX,KAAa,EACb,KAAW,EACX,IAAI,EACJ,KAAa,EACb,GAAG,KAAK,EACT,EAAE,aAAa,sCAyCf"}
|