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.
@@ -17,16 +17,33 @@
17
17
  // A part's content is a raster the presenter paints by calling the part's
18
18
  // own `paint`, at opacity 1 and untransformed, and paints again when its
19
19
  // `version` changes; its opacity and its transform are the layer's, and so
20
- // are its animations, keyframes the bridge hands to Core Animation. A
20
+ // are its animations, keyframes the bridge hands to Core Animation. Or it is
21
+ // a **source**, the part's `contents` — a `<video>`'s frames, or its player —
22
+ // which the presenter does not paint at all: the source shows itself on the
23
+ // layer (src/cocoa/video.js, src/cocoa/player.js), between frames and on its
24
+ // own clock, and the layer is exactly the part's rect. A source this bridge
25
+ // cannot show is a part declined, drawn by its element like any other. A
21
26
  // transform is CSS's matrix, which the bridge takes from
22
27
  // `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
23
28
  // delay a part joined half way through needs, so that is the bridge sprites
24
29
  // ask for: on an older one nothing is asked and every part is drawn by its
25
30
  // element.
31
+ //
32
+ // A part may be inside another of its element's (`parent`): a spinner in a
33
+ // card that fades. Its layer is lifted inside the parent's, so it fades,
34
+ // turns and is cut with it, and the parent's raster leaves it out — the
35
+ // presenter hands the parent's `paint` the keys of its children lifted with
36
+ // it, and paints it again whenever they change, in the frame they do. The
37
+ // parent was asked about everywhere it can be, so a child is asked about
38
+ // nothing more where it stays inside the parent's raster, and is its
39
+ // element's to draw where it does not, or where its parent is not lifted.
26
40
 
27
41
  import { RASTER_PAD, RasterState } from './presenter.js';
28
- import { intersectRects } from '../nodes/rects.js';
42
+ import { CocoaPlayer, PlayerLift } from './player.js';
43
+ import { VideoLift, canLiftVideo } from './video.js';
44
+ import { intersectRects, rectContains } from '../nodes/rects.js';
29
45
  import { DEV } from '../nodes/util.js';
46
+ import { isVideoFrames } from '../videoframes.js';
30
47
 
31
48
  // Past this many device pixels on a side a part is drawn by its element: a
32
49
  // layer's contents are a bitmap, and a page-sized one is a page-sized
@@ -39,6 +56,11 @@ const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
39
56
 
40
57
  const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
41
58
 
59
+ const ORIGIN = Object.freeze({ x: 0, y: 0 });
60
+
61
+ // what a part with no part lifted inside it is handed
62
+ const NO_KIDS = Object.freeze(new Set());
63
+
42
64
  // what a part's raster holds before its first paint
43
65
  const UNPAINTED = Symbol('unpainted');
44
66
 
@@ -105,12 +127,29 @@ function isAnimation(a) {
105
127
  return true;
106
128
  }
107
129
 
108
- /** Is `sprite` a part as `Node.sprites()` documents one? */
130
+ /** Is `sprite` a part as `Node.sprites()` documents one? Either it paints
131
+ * or it has a source to show, never both. */
109
132
  function isSprite(sprite) {
110
133
  if (sprite == null || typeof sprite.key !== 'string') return false;
111
- if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
134
+ if (!isRect(sprite.rect)) return false;
135
+ if (
136
+ sprite.contents != null
137
+ ? sprite.paint !== undefined
138
+ : typeof sprite.paint !== 'function'
139
+ ) {
140
+ return false;
141
+ }
112
142
  if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
113
143
  if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
144
+ if (sprite.parent !== undefined && typeof sprite.parent !== 'string') {
145
+ return false;
146
+ }
147
+ if (
148
+ sprite.clipRadius !== undefined &&
149
+ !(finite(sprite.clipRadius) && sprite.clipRadius >= 0)
150
+ ) {
151
+ return false;
152
+ }
114
153
  if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
115
154
  if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
116
155
  return false;
@@ -204,8 +243,11 @@ function extentOf(sprite) {
204
243
  }
205
244
 
206
245
  /** The raster a part's content is painted into: its reach, out to whole
207
- * pixels and padded for antialiasing, untransformed. */
246
+ * pixels and padded for antialiasing, untransformed. A source's layer is
247
+ * the part's rect, which the element has already put on whole pixels so
248
+ * that the hole it leaves and the layer meet. */
208
249
  function rasterRectOf(sprite) {
250
+ if (sprite.contents != null) return sprite.rect;
209
251
  const reach = sprite.reach ?? sprite.rect;
210
252
  const x = Math.floor(reach.x) - RASTER_PAD;
211
253
  const y = Math.floor(reach.y) - RASTER_PAD;
@@ -236,6 +278,8 @@ export class SpriteLayers {
236
278
  this.supported = Boolean(
237
279
  this.native.transformForms?.()?.includes?.('matrix'),
238
280
  );
281
+ // and a video's frames on a layer need its video surfaces
282
+ this.videos = canLiftVideo(this.native);
239
283
  this.hosts = new Map(); // element -> Map(key -> state)
240
284
  this._warned = new WeakSet();
241
285
  }
@@ -304,31 +348,82 @@ export class SpriteLayers {
304
348
  continue;
305
349
  }
306
350
  if (now.has(sprite.key)) continue;
351
+ // inside another of the element's parts: lifted with it or not at
352
+ // all, its parent earlier in the list, and neither of them a source
353
+ let parent = null;
354
+ if (sprite.parent !== undefined) {
355
+ parent = now.get(sprite.parent) ?? null;
356
+ if (!parent || parent.contents !== null || sprite.contents != null) {
357
+ continue;
358
+ }
359
+ }
360
+ const contents = sprite.contents ?? null;
361
+ // a source this bridge cannot show: the element draws it
362
+ if (contents !== null && !this._canShow(contents)) continue;
307
363
  const raster = rasterRectOf(sprite);
308
- if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
364
+ // a raster's limit; a source's surface is its own size, whatever
365
+ // the layer is scaled to
366
+ if (
367
+ contents === null &&
368
+ (raster.width > MAX_SIDE || raster.height > MAX_SIDE)
369
+ ) {
370
+ continue;
371
+ }
309
372
  // what shows of everywhere it can be: inside its own clip, and the
310
373
  // clips of its element's ancestors, which cut its layer (`_clear`)
311
- const cut = { clip: sprite.clip ?? null, bounds: null };
374
+ const cut = {
375
+ clip: sprite.clip ?? null,
376
+ radius: sprite.clip ? (sprite.clipRadius ?? 0) : 0,
377
+ bounds: null,
378
+ };
312
379
  const extent = extentOf(sprite);
313
380
  const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
314
- if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
381
+ if (!bounds) continue;
382
+ if (parent) {
383
+ // the parent was asked about everywhere it can be, and its layer
384
+ // carries this one: nothing more to ask where it stays inside the
385
+ // parent's raster, and its element draws it where it does not
386
+ if (!rectContains(rasterRectOf(parent.sprite), bounds)) continue;
387
+ cut.bounds = bounds;
388
+ } else if (!this.promotion._clear(host, bounds, cut)) {
389
+ continue;
390
+ }
315
391
  const shown = cut.bounds;
316
392
  let state = was?.get(sprite.key);
317
393
  // a clip that comes or goes is a layer in a box or out of one: lifted
318
- // again, rather than a running layer moved between parents
319
- if (state && Boolean(state.clip) !== Boolean(cut.clip)) {
394
+ // again, rather than a running layer moved between parents — and so
395
+ // is a part that shows another source, or stops painting to show
396
+ // one, and one in another layer than it was
397
+ if (
398
+ state &&
399
+ (Boolean(state.clip) !== Boolean(cut.clip) ||
400
+ state.contents !== contents ||
401
+ state.parent !== parent)
402
+ ) {
320
403
  this._drop(state, root);
321
404
  state = null;
322
405
  }
323
406
  const lifting = !state;
324
- if (lifting) state = this._lift(host, sprite.key, cut.clip !== null);
407
+ if (lifting) {
408
+ state = this._lift(
409
+ host,
410
+ sprite.key,
411
+ cut.clip !== null,
412
+ contents,
413
+ parent,
414
+ );
415
+ }
325
416
  state.sprite = sprite;
326
417
  state.clip = cut.clip;
327
- state.order = [...place, AFTER_CHILDREN, index];
418
+ state.radius = cut.radius;
419
+ state.order = parent ? null : [...place, AFTER_CHILDREN, index];
420
+ state.kids = [];
421
+ parent?.kids.push(state);
328
422
  // the bitmap under where it shows repaints without it, from this
329
423
  // frame on; where it went since, it is the element's own claims
330
- // that move the hole
331
- if (lifting) this.promotion._claim(root, shown);
424
+ // that move the hole. A child is under its parent's layer, whose
425
+ // raster leaves it out instead (`sync`).
426
+ if (lifting && !parent) this.promotion._claim(root, shown);
332
427
  state.extent = shown;
333
428
  now.set(sprite.key, state);
334
429
  }
@@ -347,11 +442,12 @@ export class SpriteLayers {
347
442
  return changed;
348
443
  }
349
444
 
350
- /** Every lifted part, with its place in the paint order. */
445
+ /** Every lifted part above the bitmap, with its place in the paint
446
+ * order: not one inside another's layer, which goes with it (`sync`). */
351
447
  entries() {
352
448
  const out = [];
353
449
  for (const lifted of this.hosts.values()) {
354
- for (const state of lifted.values()) out.push(state);
450
+ for (const state of lifted.values()) if (!state.parent) out.push(state);
355
451
  }
356
452
  return out;
357
453
  }
@@ -370,25 +466,39 @@ export class SpriteLayers {
370
466
  }
371
467
  }
372
468
 
373
- _lift(host, key, clipped) {
469
+ /** Can this bridge show `contents` on a layer by itself? A player always
470
+ * can: it was made by this bridge, with the layer it plays into. */
471
+ _canShow(contents) {
472
+ if (contents instanceof CocoaPlayer) return !contents.released;
473
+ return isVideoFrames(contents) && this.videos;
474
+ }
475
+
476
+ _lift(host, key, clipped, contents = null, parent = null) {
374
477
  const layer = this.native.createLayer();
375
478
  // a part cut to a clip is in a box of the clip's size that masks to it,
376
- // and the box is what stands among the layers above the bitmap
479
+ // and the box is what stands among the layers above the bitmap — or
480
+ // among its parent's children, for a part inside another's layer
481
+ const above = parent ? parent.layer : this.promotion.rootVisual.layer;
377
482
  let box = null;
378
483
  if (clipped) {
379
484
  box = this.native.createLayer();
380
- this.native.addSublayer(this.promotion.rootVisual.layer, box);
485
+ this.native.addSublayer(above, box);
381
486
  this.native.addSublayer(box, layer);
382
487
  } else {
383
- this.native.addSublayer(this.promotion.rootVisual.layer, layer);
488
+ this.native.addSublayer(above, layer);
384
489
  }
385
490
  return {
386
491
  host,
387
492
  key,
388
493
  layer,
389
494
  box,
495
+ // the part whose layer this one is in, and the parts in this one's
496
+ parent,
497
+ kids: [],
498
+ paintedKids: '',
390
499
  boxProps: {},
391
500
  clip: null, // device pixels, window coordinates
501
+ radius: 0, // the clip's corners, device pixels
392
502
  raster: new RasterState(),
393
503
  painted: UNPAINTED,
394
504
  rect: null, // the raster's rect, device pixels, window coordinates
@@ -402,6 +512,14 @@ export class SpriteLayers {
402
512
  sprite: null,
403
513
  extent: null,
404
514
  order: null,
515
+ // what shows itself on the layer, for a part with a source
516
+ contents,
517
+ lift:
518
+ contents === null
519
+ ? null
520
+ : contents instanceof CocoaPlayer
521
+ ? new PlayerLift(this.native, contents, layer)
522
+ : new VideoLift(this.native, contents, layer),
405
523
  };
406
524
  }
407
525
 
@@ -409,11 +527,14 @@ export class SpriteLayers {
409
527
  * forgotten, and the bitmap under where it could be repaints with the
410
528
  * element drawing it again. */
411
529
  _drop(state, root) {
530
+ state.lift?.release();
412
531
  this.native.removeFromSuperlayer(state.box ?? state.layer);
413
532
  state.raster.release(this.native);
414
533
  for (const run of state.runs.values()) this._ends().delete(run.caId);
415
534
  state.runs.clear();
416
- if (!state.host.destroyed && state.extent) {
535
+ // a child's is under its parent's layer, which paints it again or goes
536
+ // with it
537
+ if (!state.host.destroyed && state.extent && !state.parent) {
417
538
  this.promotion._claim(root, state.extent);
418
539
  }
419
540
  }
@@ -425,39 +546,62 @@ export class SpriteLayers {
425
546
  const rect = rasterRectOf(sprite);
426
547
  const reach = sprite.reach ?? sprite.rect;
427
548
  const phase = `${reach.x - Math.floor(reach.x)},${reach.y - Math.floor(reach.y)}`;
549
+ // the parts lifted inside it, which its raster leaves out
550
+ const kids = state.kids.map((kid) => kid.key).join('\n');
428
551
  // painted again for a new version, and for a raster of another size or
429
- // with the part at another fraction of a pixel; a move by whole pixels
430
- // is the layer's alone
552
+ // with the part at another fraction of a pixel, or with other parts
553
+ // lifted inside it; a move by whole pixels is the layer's alone. A
554
+ // source paints nothing: it shows itself.
431
555
  if (
432
- state.painted === UNPAINTED ||
433
- sprite.version !== state.painted ||
434
- state.rect?.width !== rect.width ||
435
- state.rect?.height !== rect.height ||
436
- state.phase !== phase
556
+ !state.lift &&
557
+ (state.painted === UNPAINTED ||
558
+ sprite.version !== state.painted ||
559
+ state.rect?.width !== rect.width ||
560
+ state.rect?.height !== rect.height ||
561
+ state.phase !== phase ||
562
+ state.paintedKids !== kids)
437
563
  ) {
438
564
  this._paint(state, sprite, rect);
439
565
  state.phase = phase;
566
+ state.paintedKids = kids;
440
567
  }
441
568
  state.rect = rect;
442
569
  const origin = originOf(sprite);
443
570
  const m = sprite.transform ?? IDENTITY;
571
+ // where its layer's coordinates start: the window's corner, or the
572
+ // corner of its parent's raster, for a part inside another's layer
573
+ const base = state.parent?.rect ?? ORIGIN;
444
574
  // in its box, where it is cut to a clip: placed from the clip's corner,
445
575
  // and the box at the part's place among the layers
446
576
  const clip = state.clip;
447
577
  if (state.box) {
448
- const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
578
+ const frame = [
579
+ (clip.x - base.x) / s,
580
+ (clip.y - base.y) / s,
581
+ clip.width / s,
582
+ clip.height / s,
583
+ ];
584
+ // its corners rounded where the clip's are: a circle's arc, as wide
585
+ // as the box allows
586
+ const cornerRadius =
587
+ Math.min(state.radius, clip.width / 2, clip.height / 2) / s;
449
588
  const was = state.boxProps;
450
- if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
589
+ if (
590
+ !sameMatrix(was.frame, frame) ||
591
+ was.zPosition !== z ||
592
+ was.cornerRadius !== cornerRadius
593
+ ) {
451
594
  this.native.setLayerProps(state.box, {
452
595
  frame,
453
596
  masksToBounds: true,
597
+ cornerRadius,
454
598
  zPosition: z,
455
599
  });
456
- state.boxProps = { frame, zPosition: z };
600
+ state.boxProps = { frame, zPosition: z, cornerRadius };
457
601
  }
458
602
  }
459
- const ox = state.box ? clip.x : 0;
460
- const oy = state.box ? clip.y : 0;
603
+ const ox = state.box ? clip.x : base.x;
604
+ const oy = state.box ? clip.y : base.y;
461
605
  const next = {
462
606
  bounds: [0, 0, rect.width / s, rect.height / s],
463
607
  anchorPoint: [
@@ -490,8 +634,14 @@ export class SpriteLayers {
490
634
  }
491
635
  if (any) this.native.setLayerProps(state.layer, out);
492
636
  state.props = next;
637
+ // a source that fills the layer with a layer of its own
638
+ state.lift?.layout?.(next.bounds);
493
639
  // after the model went out, in the same transaction
494
640
  this._syncAnimations(state, sprite);
641
+ // the parts inside it, in the element's order, over its raster
642
+ for (let i = 0; i < state.kids.length; i++) {
643
+ this.sync(state.kids[i], i + 1);
644
+ }
495
645
  }
496
646
 
497
647
  _paint(state, sprite, rect) {
@@ -508,7 +658,10 @@ export class SpriteLayers {
508
658
  ctx.translate(-rect.x, -rect.y);
509
659
  // a part paints whole: there is no pass to cull it by
510
660
  if (root) root._paintDamage = null;
511
- sprite.paint(ctx);
661
+ sprite.paint(
662
+ ctx,
663
+ state.kids.length ? new Set(state.kids.map((kid) => kid.key)) : NO_KIDS,
664
+ );
512
665
  } catch (err) {
513
666
  this._warn(
514
667
  state.host,
@@ -595,6 +748,7 @@ export class SpriteLayers {
595
748
  destroy() {
596
749
  for (const lifted of this.hosts.values()) {
597
750
  for (const state of lifted.values()) {
751
+ state.lift?.release();
598
752
  this.native.removeFromSuperlayer(state.box ?? state.layer);
599
753
  state.raster.release(this.native);
600
754
  for (const run of state.runs.values()) this._ends().delete(run.caId);
@@ -0,0 +1,122 @@
1
+ // A video's frames on a layer of their own (docs/architecture/video.md §7):
2
+ // what the surface presenter's sprites (src/cocoa/sprites.js) do with a part
3
+ // whose `contents` is a `VideoFrames` sink instead of a `paint`.
4
+ //
5
+ // Every frame goes into a video surface — an IOSurface the render server
6
+ // scans out as it is, NV12 for a YCbCr sink and BGRA for a BGRA one
7
+ // (@windowkit/appkit's createVideoSurface) — and the layer is repointed at
8
+ // it, inside the `push` that delivered it: a layer change of its own, with
9
+ // no react-x11 frame, no paint and nothing for the frame pacer to price. At
10
+ // 1080p that is the copy in (0.07ms for NV12, 0.27ms for an I420 frame
11
+ // interleaved on the way) and a transaction.
12
+ //
13
+ // The surfaces are a ring, because a surface the render server is still
14
+ // reading — the one on screen, and in threaded mode the one a queued frame
15
+ // has not taken off the layer yet — is torn by a write. A frame goes into
16
+ // one `videoSurfaceIsInUse` says nothing reads, a new one is made while the
17
+ // ring is short, and with every one of them busy the frame waits for the
18
+ // next push or a short retry: the newest frame is the frame, and a dropped
19
+ // one is the cheapest thing to lose.
20
+ //
21
+ // The verbs are asked for by name (`canLiftVideo`), and a bridge without
22
+ // them lifts nothing: the part is declined and the element draws its frames,
23
+ // which is what it does on every other backend.
24
+
25
+ // Two would do in pump mode — one on screen, one being written — and a
26
+ // worker's queued flip holds a third.
27
+ const RING_MAX = 4;
28
+ // With the whole ring busy, how soon to try the newest frame again: about a
29
+ // display frame, since that is how often the render server lets one go.
30
+ const RETRY_MS = 8;
31
+
32
+ /** Has `native` the verbs a lifted video needs? */
33
+ export function canLiftVideo(native) {
34
+ return (
35
+ typeof native?.createVideoSurface === 'function' &&
36
+ typeof native.writeVideoSurface === 'function' &&
37
+ typeof native.videoSurfaceIsInUse === 'function' &&
38
+ typeof native.setLayerContentsIOSurface === 'function'
39
+ );
40
+ }
41
+
42
+ /**
43
+ * A sink's frames on `layer`, from the frame it holds now until `release()`.
44
+ * Placing the layer — its bounds, its place among the others, the box that
45
+ * cuts it to a clip — is the presenter's; this only ever sets what it shows.
46
+ */
47
+ export class VideoLift {
48
+ constructor(native, sink, layer) {
49
+ this.native = native;
50
+ this.sink = sink;
51
+ this.layer = layer;
52
+ this.ring = []; // { handle, id }
53
+ this.current = null;
54
+ this.shown = 0; // the version on the layer
55
+ this.released = false;
56
+ this._retry = null;
57
+ this._unsubscribe = sink._subscribe((frame) => this._show(frame));
58
+ if (sink.frame) this._show(sink.frame);
59
+ }
60
+
61
+ /** A surface nothing reads, or a new one while the ring is short. */
62
+ _free() {
63
+ const native = this.native;
64
+ for (const s of this.ring) {
65
+ if (s !== this.current && !native.videoSurfaceIsInUse(s.handle)) {
66
+ return s;
67
+ }
68
+ }
69
+ if (this.ring.length >= RING_MAX) return null;
70
+ const sink = this.sink;
71
+ const made = native.createVideoSurface(sink.width, sink.height, {
72
+ // a YCbCr frame stays YCbCr, I420 interleaved by the write; the
73
+ // render server converts it, in the colour the sink says it is
74
+ format: sink.format === 'BGRA' ? 'BGRA' : 'NV12',
75
+ colorSpace: sink.colorSpace,
76
+ range: sink.range,
77
+ });
78
+ const s = { handle: made.handle, id: made.iosurfaceId };
79
+ this.ring.push(s);
80
+ return s;
81
+ }
82
+
83
+ _show(frame) {
84
+ if (this.released || frame === null) return;
85
+ const s = this._free();
86
+ if (!s) {
87
+ this._again();
88
+ return;
89
+ }
90
+ this.native.writeVideoSurface(s.handle, this.sink.format, frame.planes, {
91
+ strides: frame.strides,
92
+ });
93
+ // its own transaction, actions off, in pump mode; a command of its own
94
+ // from a worker
95
+ this.native.setLayerContentsIOSurface(this.layer, s.id);
96
+ this.current = s;
97
+ this.shown = frame.version;
98
+ }
99
+
100
+ _again() {
101
+ if (this._retry) return;
102
+ this._retry = setTimeout(() => {
103
+ this._retry = null;
104
+ const frame = this.sink.frame;
105
+ if (frame && frame.version !== this.shown) this._show(frame);
106
+ }, RETRY_MS);
107
+ this._retry.unref?.();
108
+ }
109
+
110
+ /** Off the layer: no more frames, and the surfaces freed — the layer
111
+ * holds the one it shows until the presenter takes it away. */
112
+ release() {
113
+ if (this.released) return;
114
+ this.released = true;
115
+ this._unsubscribe();
116
+ if (this._retry) clearTimeout(this._retry);
117
+ this._retry = null;
118
+ for (const s of this.ring) this.native.releaseVideoSurface?.(s.handle);
119
+ this.ring = [];
120
+ this.current = null;
121
+ }
122
+ }
package/src/host.js CHANGED
@@ -24,6 +24,7 @@ const BUILT_IN = Object.freeze([
24
24
  'box',
25
25
  'text',
26
26
  'image',
27
+ 'video',
27
28
  'canvas',
28
29
  'textinput',
29
30
  'textarea',
package/src/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export * from './types/tray.js';
32
32
  export * from './types/permissions.js';
33
33
  export * from './types/notifications.js';
34
34
  export * from './types/desktopcalendar.js';
35
+ export * from './types/video.js';
35
36
 
36
37
  /**
37
38
  * The XID of the X11 window a ref points at, or `null` if there is not one
@@ -220,9 +221,21 @@ export function useClipboard(): Clipboard;
220
221
  * above everything the X server draws: nothing can be drawn over one there.
221
222
  * Ask it before handing a surface its HUD. A property of the backend and the
222
223
  * display, settled before the first render.
224
+ *
225
+ * `'mediaPlayback'` is whether this display plays a file or URL itself — a
226
+ * platform player, AVFoundation on macOS over a bridge with its player verbs
227
+ * — which `<video src>` needs. False on X11 and Wayland for good; ask it
228
+ * before rendering a `<video src>`, which refuses with one `onError` and
229
+ * shows its poster where it is false. `<video frames>` needs no capability.
230
+ * A property of the backend, and it never changes.
223
231
  */
224
232
  export type SupportsFeature =
225
- 'transparency' | 'shaders' | 'nativeControls' | 'embedding' | 'glOverlay';
233
+ | 'transparency'
234
+ | 'shaders'
235
+ | 'nativeControls'
236
+ | 'embedding'
237
+ | 'glOverlay'
238
+ | 'mediaPlayback';
226
239
 
227
240
  /**
228
241
  * Can this **display** do something? `'transparency'` is true when the
package/src/index.js CHANGED
@@ -75,6 +75,13 @@ export {
75
75
  export { useDesktopCalendarEvents } from './desktopcalendarhooks.js';
76
76
  export { parseUriList } from './transfer.js';
77
77
  export { useApp, useClipboard, useSupports } from './appcontext.js';
78
+ export {
79
+ createVideoFrames,
80
+ VIDEO_FORMATS,
81
+ VideoFrames,
82
+ } from './videoframes.js';
83
+ export { useVideoFrames } from './videohooks.js';
84
+ export { NoMediaPlaybackError } from './mediaplayback.js';
78
85
  export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
79
86
  export { announce } from './a11y.js';
80
87
  // the standard Undo/Cut/Copy/Paste menu, for an element that edits or
@@ -0,0 +1,37 @@
1
+ // Whether this display plays a file or URL itself — `<video src>` — and the
2
+ // error a `<video src>` reports where it does not.
3
+ //
4
+ // `<video>` asks before it opens anything, and `useSupports('mediaPlayback')`
5
+ // asks for a component deciding what to render. One function for both, so
6
+ // the two cannot disagree (#531's rule, src/embedding.js's shape). A feature
7
+ // test on the app, not a question about which backend this is: the answer is
8
+ // yes where the app can make a player — the Cocoa app over a bridge with
9
+ // AVFoundation's player verbs — and no on X11 and Wayland for good, where no
10
+ // display-server player exists and the application decodes into `frames`.
11
+ //
12
+ // A leaf module on purpose: it imports nothing of ours, so both
13
+ // `appcontext.js` and the node layer can use it.
14
+
15
+ /** Can `app` play a `<video src>` with a platform player? */
16
+ export function canPlayMedia(app) {
17
+ return typeof app?.createPlayer === 'function';
18
+ }
19
+
20
+ /**
21
+ * `<video src>` where this backend has no platform player — the error
22
+ * `onError` is handed, so an application can tell it from a file that would
23
+ * not play.
24
+ */
25
+ export class NoMediaPlaybackError extends Error {
26
+ constructor() {
27
+ super(
28
+ 'react-x11: <video src> needs a platform media player, and this ' +
29
+ 'backend has none — so the poster is shown instead. Ask ' +
30
+ "useSupports('mediaPlayback') before rendering one, and give a " +
31
+ 'backend without a player frames you decode yourself: <video ' +
32
+ 'frames={useVideoFrames(…)}> works everywhere (docs/elements.md#video).',
33
+ );
34
+ this.name = 'NoMediaPlaybackError';
35
+ this.code = 'ENOMEDIAPLAYBACK';
36
+ }
37
+ }
package/src/node.d.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  MouseEvent,
25
25
  WheelEvent,
26
26
  } from './types/events.js';
27
+ import type { VideoFrames } from './types/video.js';
27
28
 
28
29
  /** A gradient, as `createLinearGradient` answers with. */
29
30
  export interface CanvasGradientLike {
@@ -289,9 +290,31 @@ export interface Sprite {
289
290
  * every ancestor with square corners (a rounded one has to hold all of
290
291
  * it). */
291
292
  clip?: Rect;
293
+ /** The radius of `clip`'s corners, in device pixels: a circular arc at
294
+ * each of the four, as a card with a `borderRadius` cuts what it holds.
295
+ * 0 when left out. A clipping ancestor with square corners that would
296
+ * cut the clip again keeps the part with its element, since one box
297
+ * cannot take both. */
298
+ clipRadius?: number;
292
299
  /** Draws the part where it is in the window, as `paint` would, but at
293
- * opacity 1 and untransformed. */
294
- paint(ctx: Context2D): void;
300
+ * opacity 1 and untransformed — and without the parts lifted inside it
301
+ * (`parent`), whose keys the presenter hands it as `children`. A part
302
+ * with `contents` has none. */
303
+ paint?(ctx: Context2D, children?: ReadonlySet<string>): void;
304
+ /** The key of another of the element's parts this one is inside, earlier
305
+ * in the list: its layer goes in that part's layer, fading, turning and
306
+ * cut with it, and is lifted only with it, and only where everywhere it
307
+ * can be is inside that part's raster. Its `rect`, `origin` and `clip`
308
+ * are the window's, with its parent untransformed. */
309
+ parent?: string;
310
+ /**
311
+ * A source that shows itself on the part's layer instead of a paint — a
312
+ * `VideoFrames` sink, whose every push reaches the layer with no frame of
313
+ * the window's own. `rect` is the picture, on whole pixels; a presenter
314
+ * that cannot show the source declines the part, and the element draws
315
+ * the current frame itself (docs/extending.md).
316
+ */
317
+ contents?: VideoFrames;
295
318
  /** Compared with `===` from frame to frame; a change paints the part
296
319
  * again. Left out, it is painted once. */
297
320
  version?: unknown;