react-x11 2.32.0 → 2.34.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/package.json +1 -1
- package/src/Reconciler.js +5 -0
- package/src/a11y.js +4 -0
- package/src/appcontext.js +16 -1
- package/src/cocoa/app.js +54 -1
- package/src/cocoa/fonts.js +30 -6
- package/src/cocoa/player.js +177 -0
- package/src/cocoa/promotion.js +4 -0
- package/src/cocoa/sprites.js +188 -34
- package/src/cocoa/video.js +122 -0
- package/src/host.js +1 -0
- package/src/index.d.ts +14 -1
- package/src/index.js +7 -0
- package/src/mediaplayback.js +37 -0
- package/src/node.d.ts +25 -2
- package/src/nodes/fit.js +46 -0
- package/src/nodes/image.js +37 -6
- package/src/nodes/kinds.js +1 -0
- package/src/nodes/layouthost.js +1 -0
- package/src/nodes/video.js +641 -0
- package/src/styles.js +19 -4
- package/src/types/elements.d.ts +54 -0
- package/src/types/nodes.d.ts +19 -0
- package/src/types/style.d.ts +11 -0
- package/src/types/video.d.ts +123 -0
- package/src/videoframes.js +375 -0
- package/src/videohooks.js +29 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// <video>: frames an application decodes (`frames`, a VideoFrames sink), or a
|
|
2
|
+
// file or URL the platform plays (`src`, where there is a player), fitted
|
|
3
|
+
// into the node's box (docs/architecture/video.md §6–7).
|
|
4
|
+
//
|
|
5
|
+
// A drawn node like `<image>`: it joins yoga, it is in the paint order and it
|
|
6
|
+
// is hit like anything else — which is the whole point. A video in a form
|
|
7
|
+
// sits under the form's popups and tooltips and inside its scroll panes, and
|
|
8
|
+
// a child window or a `<glarea>`'s layer can do neither.
|
|
9
|
+
//
|
|
10
|
+
// It is presented two ways, decided per frame:
|
|
11
|
+
//
|
|
12
|
+
// - **lifted** — it offers its picture as its one sprite part (`sprites()`),
|
|
13
|
+
// with the sink as the part's `contents`; a presenter that lifts sprites
|
|
14
|
+
// (the Cocoa surface presenter, over a bridge with video surfaces) puts
|
|
15
|
+
// the frames on a layer of their own, written by `push` itself, and this
|
|
16
|
+
// node leaves a hole where the picture is. No react-x11 frame per video
|
|
17
|
+
// frame.
|
|
18
|
+
// - **drawn** — everywhere else, and in every frame the presenter declines
|
|
19
|
+
// the part (something is painted over it, it fades, a rounded card does
|
|
20
|
+
// not hold it): the newest frame is copied into a surface of the node's
|
|
21
|
+
// own at paint, natively where the backend can (`app.writeVideoFrame`) and
|
|
22
|
+
// through `frameToRGBA` where it cannot, and composited at the picture's
|
|
23
|
+
// rect like an `<image>` whose pixels changed. A push claims the picture.
|
|
24
|
+
//
|
|
25
|
+
// `src` needs a platform player, which this backend has when
|
|
26
|
+
// `useSupports('mediaPlayback')` says so — the app's `createPlayer`, on macOS
|
|
27
|
+
// AVFoundation's (src/cocoa/player.js). The player is the part's `contents`
|
|
28
|
+
// the way a sink is: lifted, its own layer shows the picture; drawn, the
|
|
29
|
+
// frame showing is copied into the node's surface (`copyFrame`) for as long
|
|
30
|
+
// as it plays. Where there is no player the element says so once, through
|
|
31
|
+
// `onError`, and shows its poster — never a black box that looks like a
|
|
32
|
+
// video that has not started (#531's rule for `<foreign>`).
|
|
33
|
+
|
|
34
|
+
import { loadImageFile } from '../imagedecode.js';
|
|
35
|
+
import {
|
|
36
|
+
decodeImageSource,
|
|
37
|
+
freeImage,
|
|
38
|
+
isDirectImageSource,
|
|
39
|
+
isPathImageSource,
|
|
40
|
+
toLoadablePath,
|
|
41
|
+
} from '../imagesource.js';
|
|
42
|
+
import { canPlayMedia, NoMediaPlaybackError } from '../mediaplayback.js';
|
|
43
|
+
import { Surface } from '../offscreen.js';
|
|
44
|
+
import { frameToRGBA, isVideoFrames } from '../videoframes.js';
|
|
45
|
+
import { fitRect, objectFitOf } from './fit.js';
|
|
46
|
+
import { intrinsicSize } from './layout.js';
|
|
47
|
+
import { Node } from './node.js';
|
|
48
|
+
import { DEV } from './util.js';
|
|
49
|
+
|
|
50
|
+
// HTML's size for a video that has nothing to say about its own
|
|
51
|
+
const DEFAULT_WIDTH = 300;
|
|
52
|
+
const DEFAULT_HEIGHT = 150;
|
|
53
|
+
|
|
54
|
+
// How often a drawn player's frame is asked for while it plays: a display
|
|
55
|
+
// frame, since the copy answers null for one that has not changed and the
|
|
56
|
+
// claim follows only a new one.
|
|
57
|
+
const PULL_MS = 16;
|
|
58
|
+
|
|
59
|
+
// The player's settings among a `<video>`'s props, as the player takes them.
|
|
60
|
+
const PLAYER_PROPS = ['paused', 'muted', 'volume', 'loop', 'playbackRate'];
|
|
61
|
+
|
|
62
|
+
// Apps already told, by a <video src> with no `onError`, that this backend
|
|
63
|
+
// plays nothing: the answer is the backend's, so the second element to ask
|
|
64
|
+
// carries no news.
|
|
65
|
+
const warnedNoPlayback = new WeakSet();
|
|
66
|
+
|
|
67
|
+
/** Throws for a combination no `<video>` can be. */
|
|
68
|
+
export function validateVideoProps(props) {
|
|
69
|
+
if (props.frames != null && props.src != null) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'react-x11: <video> takes frames or src, not both — frames are ' +
|
|
72
|
+
'decoded by the application, src by the platform',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (props.frames != null && !isVideoFrames(props.frames)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'react-x11: <video frames> must be a VideoFrames sink — from ' +
|
|
78
|
+
'useVideoFrames({ width, height, format }) or createVideoFrames(app, …)',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class VideoNode extends Node {
|
|
84
|
+
constructor(props, app) {
|
|
85
|
+
super('video', props, app);
|
|
86
|
+
validateVideoProps(props);
|
|
87
|
+
this.focusableByDefault = false;
|
|
88
|
+
/** the sink being shown, subscribed to from the first layout on */
|
|
89
|
+
this._sink = null;
|
|
90
|
+
this._unsubscribe = null;
|
|
91
|
+
/** whether the presenter has the picture on a layer (`spritesLifted`) */
|
|
92
|
+
this._lifted = false;
|
|
93
|
+
/** the drawn presentation: a surface at the frame's size, and which
|
|
94
|
+
* frame it holds */
|
|
95
|
+
this._surface = null;
|
|
96
|
+
this._drawn = -1;
|
|
97
|
+
this._rgba = null;
|
|
98
|
+
/** the poster, once loaded or decoded */
|
|
99
|
+
this._poster = null;
|
|
100
|
+
this._ownedPoster = null;
|
|
101
|
+
this._posterToken = 0;
|
|
102
|
+
this._refused = false;
|
|
103
|
+
/** `<video src>`'s player, from the first layout on, and what the drawn
|
|
104
|
+
* presentation has of it: whether a frame is in the surface, and the
|
|
105
|
+
* timer that asks for the next one while it plays */
|
|
106
|
+
this._player = null;
|
|
107
|
+
this._playerOff = null;
|
|
108
|
+
this._playerFrame = false;
|
|
109
|
+
this._pull = null;
|
|
110
|
+
// Like `<image>`, nothing is resolved in the constructor: it runs in the
|
|
111
|
+
// render phase, which React may discard, and a subscription made there
|
|
112
|
+
// would never be undone.
|
|
113
|
+
this._dirty = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- the source -------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** First layout or paint after a mount or a change: both after commit. */
|
|
119
|
+
_ensure() {
|
|
120
|
+
if (!this._dirty || this.destroyed) return;
|
|
121
|
+
this._dirty = false;
|
|
122
|
+
this._attach();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_attach() {
|
|
126
|
+
const { frames, src, poster } = this.props;
|
|
127
|
+
if (frames != null) {
|
|
128
|
+
this._sink = frames;
|
|
129
|
+
this._unsubscribe = frames._subscribe((frame) => this._onFrame(frame));
|
|
130
|
+
this._metadata();
|
|
131
|
+
} else if (src != null) {
|
|
132
|
+
if (canPlayMedia(this.app)) this._play(src);
|
|
133
|
+
else this._refuse();
|
|
134
|
+
}
|
|
135
|
+
if (poster != null) this._loadPoster(poster);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_detach() {
|
|
139
|
+
this._unsubscribe?.();
|
|
140
|
+
this._unsubscribe = null;
|
|
141
|
+
this._sink = null;
|
|
142
|
+
this._playerOff?.();
|
|
143
|
+
this._playerOff = null;
|
|
144
|
+
this._player?.release();
|
|
145
|
+
this._player = null;
|
|
146
|
+
this._playerFrame = false;
|
|
147
|
+
this._stopPull();
|
|
148
|
+
this._posterToken++;
|
|
149
|
+
if (this._ownedPoster) {
|
|
150
|
+
freeImage(this.app, this._ownedPoster);
|
|
151
|
+
this._ownedPoster = null;
|
|
152
|
+
}
|
|
153
|
+
this._poster = null;
|
|
154
|
+
this._refused = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A push, or a close. Lifted, the layer shows the frame and the window
|
|
159
|
+
* paints nothing; otherwise the picture is claimed, and the frame that
|
|
160
|
+
* claim brings asks for the parts again — which is how the first frame
|
|
161
|
+
* goes up onto a layer and a close takes the picture back down.
|
|
162
|
+
*/
|
|
163
|
+
_onFrame(frame) {
|
|
164
|
+
if (this.destroyed) return;
|
|
165
|
+
if (frame === null || !this._lifted) {
|
|
166
|
+
this.root?.invalidate(false, this, 'content');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_metadata() {
|
|
171
|
+
const sink = this._sink;
|
|
172
|
+
const handler = this.props.onLoadedMetadata;
|
|
173
|
+
if (!handler || !sink) return;
|
|
174
|
+
// after the commit that mounted it: the ordinary answer to the news is
|
|
175
|
+
// to set state, which React refuses in the middle of a commit
|
|
176
|
+
queueMicrotask(() => {
|
|
177
|
+
if (this.destroyed || this._sink !== sink) return;
|
|
178
|
+
handler({
|
|
179
|
+
width: sink.width,
|
|
180
|
+
height: sink.height,
|
|
181
|
+
// a sink is a live source: HTML's duration for one
|
|
182
|
+
duration: Infinity,
|
|
183
|
+
node: this,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `src` on the platform's player, playing from the start if the props
|
|
189
|
+
* say so: a controlled `paused={false}`, or `autoPlay`. */
|
|
190
|
+
_play(src) {
|
|
191
|
+
const p = this.props;
|
|
192
|
+
const player = this.app.createPlayer(src, {
|
|
193
|
+
autoPlay:
|
|
194
|
+
p.paused !== undefined ? p.paused === false : Boolean(p.autoPlay),
|
|
195
|
+
loop: p.loop,
|
|
196
|
+
muted: p.muted,
|
|
197
|
+
volume: p.volume,
|
|
198
|
+
playbackRate: p.playbackRate,
|
|
199
|
+
});
|
|
200
|
+
this._player = player;
|
|
201
|
+
this._playerOff = player._subscribe((ev) => this._onPlayer(ev));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The player's news, as HTML's media events — after the frame they
|
|
205
|
+
* change has been asked for. */
|
|
206
|
+
_onPlayer(ev) {
|
|
207
|
+
if (this.destroyed) return;
|
|
208
|
+
const player = this._player;
|
|
209
|
+
const props = this.props;
|
|
210
|
+
switch (ev.type) {
|
|
211
|
+
case 'player-metadata':
|
|
212
|
+
// a size at last, or a new one: laid out again, and a part to offer
|
|
213
|
+
this.invalidateMeasure('content');
|
|
214
|
+
this.spritesChanged();
|
|
215
|
+
props.onLoadedMetadata?.({
|
|
216
|
+
width: player.width,
|
|
217
|
+
height: player.height,
|
|
218
|
+
duration: player.duration,
|
|
219
|
+
node: this,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
case 'player-state':
|
|
223
|
+
this._syncPull();
|
|
224
|
+
if (player.playing) props.onPlay?.();
|
|
225
|
+
else props.onPause?.();
|
|
226
|
+
return;
|
|
227
|
+
case 'player-time':
|
|
228
|
+
// a seek while paused shows another frame, which nothing else pulls
|
|
229
|
+
if (!player.playing) this._pullFrame();
|
|
230
|
+
props.onTimeUpdate?.({ currentTime: player.currentTime, node: this });
|
|
231
|
+
return;
|
|
232
|
+
case 'player-ended':
|
|
233
|
+
this._syncPull();
|
|
234
|
+
props.onEnded?.();
|
|
235
|
+
return;
|
|
236
|
+
case 'player-error':
|
|
237
|
+
this.error = player.error;
|
|
238
|
+
if (props.onError) props.onError(player.error);
|
|
239
|
+
else console.warn(player.error.message);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** While the picture is drawn and playing, the frame showing is asked for
|
|
245
|
+
* every display frame; lifted or paused, nothing is. */
|
|
246
|
+
_syncPull() {
|
|
247
|
+
const want =
|
|
248
|
+
this._player !== null &&
|
|
249
|
+
!this._lifted &&
|
|
250
|
+
this._player.playing &&
|
|
251
|
+
!this.destroyed;
|
|
252
|
+
if (want && this._pull === null) {
|
|
253
|
+
this._pull = setInterval(() => this._pullFrame(), PULL_MS);
|
|
254
|
+
this._pull.unref?.();
|
|
255
|
+
} else if (!want) {
|
|
256
|
+
this._stopPull();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_stopPull() {
|
|
261
|
+
if (this._pull !== null) clearInterval(this._pull);
|
|
262
|
+
this._pull = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The frame showing into the surface, and the picture claimed if it was
|
|
266
|
+
* a new one. */
|
|
267
|
+
_pullFrame() {
|
|
268
|
+
if (this._lifted || this.destroyed) return;
|
|
269
|
+
if (this._copyPlayerFrame()) this.root?.invalidate(false, this, 'content');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** True when a new frame went into the surface. */
|
|
273
|
+
_copyPlayerFrame() {
|
|
274
|
+
const player = this._player;
|
|
275
|
+
if (!player || !(player.width > 0)) return false;
|
|
276
|
+
const surface = this._surfaceFor(player.width, player.height);
|
|
277
|
+
if (!surface) return false;
|
|
278
|
+
const copied = player.copyFrame(surface);
|
|
279
|
+
// another size than the surface: the metadata that says so remakes it
|
|
280
|
+
if (!copied?.written) return false;
|
|
281
|
+
this._playerFrame = true;
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_refuse() {
|
|
286
|
+
if (canPlayMedia(this.app)) return;
|
|
287
|
+
this._refused = true;
|
|
288
|
+
const err = new NoMediaPlaybackError();
|
|
289
|
+
this.error = err;
|
|
290
|
+
queueMicrotask(() => {
|
|
291
|
+
if (this.destroyed || !this._refused) return;
|
|
292
|
+
if (this.props.onError) this.props.onError(err);
|
|
293
|
+
else if (this.app && !warnedNoPlayback.has(this.app)) {
|
|
294
|
+
warnedNoPlayback.add(this.app);
|
|
295
|
+
console.warn(err.message);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The poster through `<image>`'s own ladder: a file read, bytes decoded
|
|
301
|
+
* now or — WebP, or anything under Bun — later, raw RGBA or an Image as
|
|
302
|
+
* it is. A failure is a content failure: said once, and no poster. */
|
|
303
|
+
_loadPoster(src) {
|
|
304
|
+
if (isDirectImageSource(src)) {
|
|
305
|
+
this._poster = src;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let decoded;
|
|
309
|
+
try {
|
|
310
|
+
decoded = isPathImageSource(src)
|
|
311
|
+
? loadImageFile(toLoadablePath(src))
|
|
312
|
+
: decodeImageSource(src);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
console.error('react-x11: <video poster> did not decode:', err.message);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (typeof decoded?.then !== 'function') {
|
|
318
|
+
this._ownedPoster = decoded;
|
|
319
|
+
this._poster = decoded;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const token = ++this._posterToken;
|
|
323
|
+
decoded.then(
|
|
324
|
+
(image) => {
|
|
325
|
+
if (!image || token !== this._posterToken || this.destroyed) return;
|
|
326
|
+
this._ownedPoster = image;
|
|
327
|
+
this._poster = image;
|
|
328
|
+
if (!this._sink) this.invalidateMeasure('content');
|
|
329
|
+
else this.root?.invalidate(false, this, 'content');
|
|
330
|
+
},
|
|
331
|
+
(err) => {
|
|
332
|
+
if (token !== this._posterToken || this.destroyed) return;
|
|
333
|
+
console.error(
|
|
334
|
+
`react-x11: <video poster> ${isPathImageSource(src) ? src : 'bytes'} did not load:`,
|
|
335
|
+
err.message,
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- layout -------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/** The picture's size in source pixels, which are logical pixels — the
|
|
344
|
+
* browser's rule, and `<image>`'s: a 1280x720 stream is 1280 logical px
|
|
345
|
+
* wide at any scale. The stream's size once it is known, then the
|
|
346
|
+
* poster's, then HTML's 300x150. */
|
|
347
|
+
_natural() {
|
|
348
|
+
const sink = this._sink;
|
|
349
|
+
if (sink) return { width: sink.width, height: sink.height };
|
|
350
|
+
const player = this._player;
|
|
351
|
+
if (player?.width > 0 && player?.height > 0) {
|
|
352
|
+
return { width: player.width, height: player.height };
|
|
353
|
+
}
|
|
354
|
+
const poster = this._poster;
|
|
355
|
+
if (poster?.width > 0 && poster?.height > 0) {
|
|
356
|
+
return { width: poster.width, height: poster.height };
|
|
357
|
+
}
|
|
358
|
+
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* HTML's video sizing: a style that names one axis gets the other from
|
|
363
|
+
* the picture's ratio — larger than the stream as well as smaller, which
|
|
364
|
+
* is where a video parts from `<image>` — and a style that names neither
|
|
365
|
+
* gets the stream's size, shrunk to fit what is on offer. Whole pixels:
|
|
366
|
+
* a fractional answer is an input yoga's line arithmetic cannot take.
|
|
367
|
+
*/
|
|
368
|
+
measureContent(constraints) {
|
|
369
|
+
this._ensure();
|
|
370
|
+
const s = this.scale;
|
|
371
|
+
const n = this._natural();
|
|
372
|
+
const natural = { width: n.width * s, height: n.height * s };
|
|
373
|
+
const { width, height, widthMode, heightMode } = constraints;
|
|
374
|
+
if (widthMode === 'exactly' && heightMode !== 'exactly') {
|
|
375
|
+
return { width, height: Math.round((width * n.height) / n.width) };
|
|
376
|
+
}
|
|
377
|
+
if (heightMode === 'exactly' && widthMode !== 'exactly') {
|
|
378
|
+
return { width: Math.round((height * n.width) / n.height), height };
|
|
379
|
+
}
|
|
380
|
+
const size = intrinsicSize(natural, constraints);
|
|
381
|
+
return { width: Math.round(size.width), height: Math.round(size.height) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Where the picture goes in the content box, device pixels, snapped to
|
|
385
|
+
* whole ones so the hole a lifted picture leaves and the layer that
|
|
386
|
+
* fills it meet without a seam. */
|
|
387
|
+
_pictureRect(natural) {
|
|
388
|
+
const s = this.scale;
|
|
389
|
+
return fitRect(
|
|
390
|
+
this.contentBox(),
|
|
391
|
+
natural.width * s,
|
|
392
|
+
natural.height * s,
|
|
393
|
+
objectFitOf(this.style, 'contain'),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- props --------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
applyProps(newProps, oldProps) {
|
|
400
|
+
const before = oldProps ?? this.props;
|
|
401
|
+
const changed =
|
|
402
|
+
newProps.frames !== before.frames ||
|
|
403
|
+
newProps.src !== before.src ||
|
|
404
|
+
newProps.poster !== before.poster;
|
|
405
|
+
if (changed) validateVideoProps(newProps);
|
|
406
|
+
super.applyProps(newProps, oldProps);
|
|
407
|
+
if (!changed) {
|
|
408
|
+
// the same player, told what changed of how it plays
|
|
409
|
+
if (
|
|
410
|
+
this._player &&
|
|
411
|
+
PLAYER_PROPS.some((name) => newProps[name] !== before[name])
|
|
412
|
+
) {
|
|
413
|
+
const settings = {};
|
|
414
|
+
for (const name of PLAYER_PROPS) {
|
|
415
|
+
if (newProps[name] !== before[name]) settings[name] = newProps[name];
|
|
416
|
+
}
|
|
417
|
+
this._player.set(settings);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const was = this._natural();
|
|
422
|
+
this._detach();
|
|
423
|
+
this._dirty = false;
|
|
424
|
+
this._attach();
|
|
425
|
+
this._releaseSurface();
|
|
426
|
+
this.spritesChanged();
|
|
427
|
+
const now = this._natural();
|
|
428
|
+
if (was.width !== now.width || was.height !== now.height) {
|
|
429
|
+
this.invalidateMeasure('content');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
destroySubtree() {
|
|
434
|
+
this.destroyed = true;
|
|
435
|
+
this._detach();
|
|
436
|
+
this._releaseSurface();
|
|
437
|
+
super.destroySubtree();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- sprites --------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The picture, as one part whose content is the sink: offered once there
|
|
444
|
+
* is a frame to show, and cut to the content box where `objectFit:
|
|
445
|
+
* 'cover'` makes it larger.
|
|
446
|
+
*/
|
|
447
|
+
sprites() {
|
|
448
|
+
if (this.hidden) return null;
|
|
449
|
+
const sink = this._sink;
|
|
450
|
+
const player = this._player;
|
|
451
|
+
// a sink with a frame, or a player that knows its size and works
|
|
452
|
+
const contents = sink?.frame
|
|
453
|
+
? sink
|
|
454
|
+
: player?.width > 0 && !player.error
|
|
455
|
+
? player
|
|
456
|
+
: null;
|
|
457
|
+
if (contents === null) return null;
|
|
458
|
+
const rect = this._pictureRect(this._natural());
|
|
459
|
+
if (!(rect.width > 0 && rect.height > 0)) return null;
|
|
460
|
+
const part = { key: 'video', rect, contents };
|
|
461
|
+
const box = this.contentBox();
|
|
462
|
+
if (
|
|
463
|
+
rect.x < box.x ||
|
|
464
|
+
rect.y < box.y ||
|
|
465
|
+
rect.x + rect.width > box.x + box.width ||
|
|
466
|
+
rect.y + rect.height > box.y + box.height
|
|
467
|
+
) {
|
|
468
|
+
part.clip = box;
|
|
469
|
+
}
|
|
470
|
+
return [part];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
spritesLifted(keys) {
|
|
474
|
+
this._lifted = keys.has('video');
|
|
475
|
+
if (this._player) {
|
|
476
|
+
this._syncPull();
|
|
477
|
+
// given back: the frame showing, for the paint this frame owes it
|
|
478
|
+
if (!this._lifted) this._copyPlayerFrame();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- the imperative half: what a `ref` can do, as HTMLMediaElement can ----------
|
|
483
|
+
|
|
484
|
+
/** Play `src` — from its start if it has ended. */
|
|
485
|
+
play() {
|
|
486
|
+
this._ensure();
|
|
487
|
+
this._player?.play();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
pause() {
|
|
491
|
+
this._ensure();
|
|
492
|
+
this._player?.pause();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** To `seconds` into `src`, exactly. */
|
|
496
|
+
seek(seconds) {
|
|
497
|
+
this._ensure();
|
|
498
|
+
this._player?.seek(seconds);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Seconds into `src`; a sink's newest frame's time, or 0. */
|
|
502
|
+
get currentTime() {
|
|
503
|
+
if (this._player) return this._player.currentTime;
|
|
504
|
+
return this._sink?.frame?.time ?? 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Seconds; `Infinity` for a live stream and for a sink, NaN before it is
|
|
508
|
+
* known. */
|
|
509
|
+
get duration() {
|
|
510
|
+
if (this._player) return this._player.duration;
|
|
511
|
+
return this._sink ? Infinity : NaN;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
get paused() {
|
|
515
|
+
return this._player ? !this._player.playing : true;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// --- paint ----------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
paintContent(ctx) {
|
|
521
|
+
this._ensure();
|
|
522
|
+
const sink = this._sink;
|
|
523
|
+
if (sink?.frame) {
|
|
524
|
+
// the layer is showing it, and the bitmap under it is a hole
|
|
525
|
+
if (this._lifted) return;
|
|
526
|
+
if (this._drawFrame(ctx, sink)) return;
|
|
527
|
+
}
|
|
528
|
+
const player = this._player;
|
|
529
|
+
if (player?.width > 0 && !player.error) {
|
|
530
|
+
if (this._lifted) return;
|
|
531
|
+
if (this._playerFrame || this._copyPlayerFrame()) {
|
|
532
|
+
this._drawFitted(ctx, this._surface, {
|
|
533
|
+
width: player.width,
|
|
534
|
+
height: player.height,
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const poster = this._poster;
|
|
540
|
+
if (poster?.width > 0 && poster?.height > 0) {
|
|
541
|
+
this._drawFitted(ctx, poster, {
|
|
542
|
+
width: poster.width,
|
|
543
|
+
height: poster.height,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
_drawFitted(ctx, image, natural) {
|
|
549
|
+
const rect = this._pictureRect(natural);
|
|
550
|
+
if (!(rect.width > 0 && rect.height > 0)) return;
|
|
551
|
+
const box = this.contentBox();
|
|
552
|
+
const overflows =
|
|
553
|
+
rect.x < box.x ||
|
|
554
|
+
rect.y < box.y ||
|
|
555
|
+
rect.x + rect.width > box.x + box.width ||
|
|
556
|
+
rect.y + rect.height > box.y + box.height;
|
|
557
|
+
if (overflows) {
|
|
558
|
+
ctx.save();
|
|
559
|
+
ctx.beginPath();
|
|
560
|
+
ctx.rect(box.x, box.y, box.width, box.height);
|
|
561
|
+
ctx.clip();
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
ctx.drawImage(image, rect.x, rect.y, rect.width, rect.height);
|
|
565
|
+
} finally {
|
|
566
|
+
if (overflows) ctx.restore();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The newest frame into the node's surface, if it is not there yet, and
|
|
571
|
+
* the surface composited. False where there is no surface to be had —
|
|
572
|
+
* the headless mock — and the poster stands in. */
|
|
573
|
+
_drawFrame(ctx, sink) {
|
|
574
|
+
const frame = sink.frame;
|
|
575
|
+
const surface = this._surfaceFor(sink.width, sink.height);
|
|
576
|
+
if (!surface) return false;
|
|
577
|
+
if (this._drawn !== frame.version) {
|
|
578
|
+
try {
|
|
579
|
+
this._upload(surface, sink, frame);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (DEV) {
|
|
582
|
+
console.warn(
|
|
583
|
+
`react-x11: <video> could not draw a frame: ${err?.message ?? err}`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
this._drawn = frame.version;
|
|
589
|
+
}
|
|
590
|
+
this._drawFitted(ctx, surface, { width: sink.width, height: sink.height });
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
_upload(surface, sink, frame) {
|
|
595
|
+
// natively where the backend converts in its own code (the Cocoa
|
|
596
|
+
// bridge's writeVideoSurface: a copy for BGRA, VideoToolbox for YCbCr,
|
|
597
|
+
// colour-matched to what a layer shows)
|
|
598
|
+
if (this.app?.writeVideoFrame?.(surface, sink, frame)) return;
|
|
599
|
+
const w = sink.width;
|
|
600
|
+
const h = sink.height;
|
|
601
|
+
if (this._rgba?.width !== w || this._rgba?.height !== h) {
|
|
602
|
+
this._rgba = {
|
|
603
|
+
width: w,
|
|
604
|
+
height: h,
|
|
605
|
+
data: new Uint8ClampedArray(w * h * 4),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
frameToRGBA(sink, frame, this._rgba.data);
|
|
609
|
+
surface.render((sctx) => sctx.putImageData(this._rgba, 0, 0));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
_surfaceFor(width, height) {
|
|
613
|
+
const kept = this._surface;
|
|
614
|
+
if (kept && kept.width === width && kept.height === height) return kept;
|
|
615
|
+
this._releaseSurface();
|
|
616
|
+
this._playerFrame = false;
|
|
617
|
+
const app = this.app;
|
|
618
|
+
if (!app?.display?.Render && typeof app?.createSurface !== 'function') {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
this._surface = new Surface(app, { width, height });
|
|
623
|
+
} catch {
|
|
624
|
+
this._surface = null;
|
|
625
|
+
}
|
|
626
|
+
this._drawn = -1;
|
|
627
|
+
return this._surface;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
_releaseSurface() {
|
|
631
|
+
const surface = this._surface;
|
|
632
|
+
this._surface = null;
|
|
633
|
+
this._rgba = null;
|
|
634
|
+
this._drawn = -1;
|
|
635
|
+
try {
|
|
636
|
+
surface?.destroy();
|
|
637
|
+
} catch {
|
|
638
|
+
// gone with its connection
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
package/src/styles.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { cssColorStraight } from 'ntk/color';
|
|
7
7
|
|
|
8
8
|
import { parseBoxShadow, parseLinearGradient } from './decorations.js';
|
|
9
|
+
import { OBJECT_FITS } from './nodes/fit.js';
|
|
9
10
|
import {
|
|
10
11
|
validateGridValue,
|
|
11
12
|
GRID_CONTAINER_PROPS,
|
|
@@ -575,6 +576,9 @@ const STYLE_PROPS = new Set([
|
|
|
575
576
|
...TEXT_PAINT_PROPS,
|
|
576
577
|
'color',
|
|
577
578
|
'borderStyle',
|
|
579
|
+
// Where an `<image>`'s or a `<video>`'s picture goes in its box — CSS's
|
|
580
|
+
// `object-fit`. Paint: the box is laid out the same whichever it is.
|
|
581
|
+
'objectFit',
|
|
578
582
|
'transition',
|
|
579
583
|
// a loop rather than a change with an end — see `animationsOf`
|
|
580
584
|
'animation',
|
|
@@ -938,6 +942,13 @@ function validateValue(key, value, where) {
|
|
|
938
942
|
}
|
|
939
943
|
return;
|
|
940
944
|
}
|
|
945
|
+
if (key === 'objectFit') {
|
|
946
|
+
if (value == null || OBJECT_FITS.includes(value) || isToken(value)) return;
|
|
947
|
+
throw new Error(
|
|
948
|
+
`react-x11: invalid objectFit ${JSON.stringify(value)} in ${where} ` +
|
|
949
|
+
`(expected ${OBJECT_FITS.map((v) => `'${v}'`).join(', ')})`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
941
952
|
if (key === 'opacity') {
|
|
942
953
|
// unset, the way a conditional writes it: `opacity: dim ? 0.5 : undefined`
|
|
943
954
|
if (value == null) return;
|
|
@@ -1189,6 +1200,8 @@ const NOT_ANIMATABLE = new Set([
|
|
|
1189
1200
|
'cursor',
|
|
1190
1201
|
'pointerEvents',
|
|
1191
1202
|
'borderStyle',
|
|
1203
|
+
// a keyword: there is no halfway between filling a box and covering it
|
|
1204
|
+
'objectFit',
|
|
1192
1205
|
'fontFamily',
|
|
1193
1206
|
'fontWeight',
|
|
1194
1207
|
'fontStyle',
|
|
@@ -2269,11 +2282,13 @@ export function paintPropsChanged(props, oldProps = {}) {
|
|
|
2269
2282
|
for (const key of PAINT_PROPS) {
|
|
2270
2283
|
if (props[key] !== oldProps[key]) return true;
|
|
2271
2284
|
}
|
|
2272
|
-
// `color` and `
|
|
2273
|
-
// that set also decides what a state block is allowed to set,
|
|
2274
|
-
// it would change validation rather than just this comparison
|
|
2285
|
+
// `color`, `borderStyle` and `objectFit` paint but are deliberately not in
|
|
2286
|
+
// PAINT_PROPS: that set also decides what a state block is allowed to set,
|
|
2287
|
+
// and widening it would change validation rather than just this comparison
|
|
2275
2288
|
return (
|
|
2276
|
-
props.color !== oldProps.color ||
|
|
2289
|
+
props.color !== oldProps.color ||
|
|
2290
|
+
props.borderStyle !== oldProps.borderStyle ||
|
|
2291
|
+
props.objectFit !== oldProps.objectFit
|
|
2277
2292
|
);
|
|
2278
2293
|
}
|
|
2279
2294
|
|