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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.32.0",
3
+ "version": "2.34.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from './nodes/scope.js';
34
34
  import { TextNode, TextChunkNode, setTextStripBelow } from './nodes/text.js';
35
35
  import { ImageNode } from './nodes/image.js';
36
+ import { VideoNode } from './nodes/video.js';
36
37
  import { CanvasNode } from './nodes/canvas.js';
37
38
  import { TextInputNode } from './nodes/textinput.js';
38
39
  import { TextAreaNode } from './nodes/textarea.js';
@@ -102,6 +103,7 @@ export const HOST_TYPES = [
102
103
  'box',
103
104
  'text',
104
105
  'image',
106
+ 'video',
105
107
  'canvas',
106
108
  'textinput',
107
109
  'textarea',
@@ -316,6 +318,9 @@ const HostConfig = {
316
318
  case 'image':
317
319
  node = new ImageNode(props, rootContainer);
318
320
  break;
321
+ case 'video':
322
+ node = new VideoNode(props, rootContainer);
323
+ break;
319
324
  case 'canvas':
320
325
  node = new CanvasNode(props, rootContainer);
321
326
  break;
package/src/a11y.js CHANGED
@@ -123,6 +123,7 @@ export const ATSPI_ROLE = Object.freeze({
123
123
  INFO_BAR: 102,
124
124
  LEVEL_BAR: 103,
125
125
  BLOCK_QUOTE: 105,
126
+ VIDEO: 107,
126
127
  ARTICLE: 109,
127
128
  LANDMARK: 110,
128
129
  LOG: 111,
@@ -307,6 +308,9 @@ const KIND_ROLES = {
307
308
  box: ATSPI_ROLE.FILLER,
308
309
  text: ATSPI_ROLE.LABEL,
309
310
  image: ATSPI_ROLE.IMAGE,
311
+ // AT-SPI's own role for one, which Chromium and Firefox give `<video>` too;
312
+ // ARIA has none, so there is no web role name to write in its place
313
+ video: ATSPI_ROLE.VIDEO,
310
314
  canvas: ATSPI_ROLE.DRAWING_AREA,
311
315
  textinput: ATSPI_ROLE.ENTRY,
312
316
  textarea: ATSPI_ROLE.ENTRY,
package/src/appcontext.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  watchCompositing,
28
28
  } from './compositing.js';
29
29
  import { canEmbed } from './embedding.js';
30
+ import { canPlayMedia } from './mediaplayback.js';
30
31
  import { hasDirectGL, watchDirectGL } from './glbackend.js';
31
32
  import { canOverlay } from './gloverlay.js';
32
33
 
@@ -59,7 +60,7 @@ export function useApp() {
59
60
  return app;
60
61
  }
61
62
 
62
- // 'nativeControls', 'embedding' and 'glOverlay' are properties of the
63
+ // 'nativeControls', 'embedding', 'glOverlay' and 'mediaPlayback' are properties of the
63
64
  // backend — and 'glOverlay' of the X server too, which `createRoot` asks
64
65
  // before the first render (src/gloverlay.js) — decided before anything
65
66
  // renders and never changing after, so their subscription has nothing to
@@ -81,6 +82,7 @@ const FEATURES = {
81
82
  },
82
83
  embedding: { watch: NEVER_CHANGES, read: canEmbed },
83
84
  glOverlay: { watch: NEVER_CHANGES, read: canOverlay },
85
+ mediaPlayback: { watch: NEVER_CHANGES, read: canPlayMedia },
84
86
  // Everything here is a property of the **display**: what the server, the
85
87
  // compositor and the drawing pipeline can do. A desktop's own surfaces --
86
88
  // a tray, a launcher icon and what hangs off it, a notification daemon --
@@ -183,6 +185,19 @@ const FEATURES = {
183
185
  *
184
186
  * A property of the backend and the display, settled before the first
185
187
  * render, and it never changes.
188
+ *
189
+ * `'mediaPlayback'` is true when this display plays a file or URL itself —
190
+ * a platform player, AVFoundation on macOS — which is what `<video src>`
191
+ * needs. It is false on X11 and Wayland for good, where no display-server
192
+ * player exists, and the question to ask before rendering a `<video src>`,
193
+ * which refuses with one `onError` and shows its poster where the answer is
194
+ * no. `<video frames>`, a sink the application decodes into, needs no
195
+ * capability: it works everywhere, at a cost that differs.
196
+ *
197
+ * ```jsx
198
+ * const playback = useSupports('mediaPlayback');
199
+ * {playback ? <video src={file} autoPlay /> : <video frames={decoded} />}
200
+ * ```
186
201
  */
187
202
  export function useSupports(feature) {
188
203
  const app = useApp();
package/src/cocoa/app.js CHANGED
@@ -39,6 +39,7 @@ import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
41
41
  import { releaseImageUpload } from '../backend/context2d.js';
42
+ import { CocoaPlayer, canPlay } from './player.js';
42
43
  import { CocoaSurface } from './surface.js';
43
44
  import { CocoaSymbols } from './symbols.js';
44
45
  import { CocoaWindow, FRAME_SLACK_MS } from './window.js';
@@ -217,6 +218,16 @@ export class CocoaApp {
217
218
  // The tray items (src/cocoa/statusitem.js), by the bridge's handle —
218
219
  // which is what a click event names them by.
219
220
  this._statusItems = new Map();
221
+ // `<video src>`'s players (src/cocoa/player.js), by the bridge's id — what
222
+ // a `player-*` event names them by. `createPlayer` is there only over a
223
+ // bridge that plays (@windowkit/appkit's player verbs): its presence is
224
+ // what `useSupports('mediaPlayback')` and `<video src>` ask
225
+ // (src/mediaplayback.js), so an app over an older bridge, or a test's
226
+ // fake without them, refuses `src` the way X11 does.
227
+ this._players = new Map();
228
+ if (canPlay(native)) {
229
+ this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
230
+ }
220
231
 
221
232
  // 'surface' (the measured default) or 'layers' — the retained CALayer
222
233
  // presenter, opt-in while docs/macos.md's measure-first gate is open.
@@ -245,7 +256,7 @@ export class CocoaApp {
245
256
  // the app's own bridge, so an app over a fake one (the tests) needs no
246
257
  // real bridge on the machine — the manager's default loads it only when
247
258
  // it is built standalone
248
- this.fonts = new CocoaFontManager(native);
259
+ this.fonts = new CocoaFontManager(native, { scale: this.scale });
249
260
  // `<image src={{ symbol }}>`'s names are SF Symbols here (src/symbols.js)
250
261
  this.symbols = new CocoaSymbols(native);
251
262
 
@@ -598,6 +609,42 @@ export class CocoaApp {
598
609
  return new CocoaSurface(this, options);
599
610
  }
600
611
 
612
+ /**
613
+ * The formats a `VideoFrames` sink is cheapest in here, first to last
614
+ * (`preferredFormats`, src/videoframes.js). Over a bridge with video
615
+ * surfaces a YCbCr frame goes onto a layer as it is and into a bitmap
616
+ * through the bridge's own conversion, so NV12 — what a hardware decoder
617
+ * emits — is first; without them every frame is converted in JavaScript on
618
+ * its way to a `putImageData`, and BGRA is the one that costs least.
619
+ */
620
+ videoFormats() {
621
+ if (typeof this._native.writeVideoSurface !== 'function') return ['BGRA'];
622
+ return ['NV12', 'I420', 'BGRA'];
623
+ }
624
+
625
+ /**
626
+ * A `<video>`'s frame into `surface` — its drawn presentation, a
627
+ * `CocoaSurface` at the frame's size — in the bridge's code: a copy for
628
+ * BGRA, and for YCbCr a conversion that keeps the colours Core Animation
629
+ * shows the same frame in on a layer (`writeVideoSurface`), so a video
630
+ * moving between the two does not change shade. False over a bridge
631
+ * without the verb, and the frame is converted in JavaScript instead
632
+ * (src/nodes/video.js).
633
+ */
634
+ writeVideoFrame(surface, sink, frame) {
635
+ const native = this._native;
636
+ const handle = surface?._surfaceHandle;
637
+ if (typeof native.writeVideoSurface !== 'function' || !handle) return false;
638
+ native.writeVideoSurface(handle, sink.format, frame.planes, {
639
+ strides: frame.strides,
640
+ width: sink.width,
641
+ height: sink.height,
642
+ colorSpace: sink.colorSpace,
643
+ range: sink.range,
644
+ });
645
+ return true;
646
+ }
647
+
601
648
  /**
602
649
  * The release seam for an `Image`'s upload (`freeImage`, src/imagesource.js).
603
650
  * `ctx.drawImage(image)` here composites from a CG bitmap made for the
@@ -1332,6 +1379,12 @@ export class CocoaApp {
1332
1379
  return this._window(ev)?._created(ev);
1333
1380
  case 'surface-released':
1334
1381
  return this._routeSurfaceReleased(ev);
1382
+ case 'player-metadata':
1383
+ case 'player-state':
1384
+ case 'player-time':
1385
+ case 'player-ended':
1386
+ case 'player-error':
1387
+ return this._players.get(ev.id)?._event(ev);
1335
1388
  default:
1336
1389
  return undefined;
1337
1390
  }
@@ -735,9 +735,25 @@ function spacedFeatures(features, spacing) {
735
735
  const KEPT_TYPESETTER_UNITS = 1 << 20;
736
736
 
737
737
  export class CocoaFontManager {
738
- /** @param native the @windowkit/appkit module; the tests hand in a fake */
739
- constructor(native = loadNative()) {
738
+ /**
739
+ * @param native the @windowkit/appkit module; the tests hand in a fake
740
+ * @param options.scale the app's backing scale. A size this engine is
741
+ * handed is in device pixels, and CoreText reads some of a face's data
742
+ * by point size — San Francisco's optical size and tracking, Apple Color
743
+ * Emoji's `trak` — so a font made at the device size is the face as it
744
+ * is set at twice the size on a 2x display: a 13px label 8% narrower
745
+ * than AppKit sets it, a 19px emoji 1em wide where it is 23pt at 19pt.
746
+ * The bridge makes a font at `size / scale` points under a matrix of
747
+ * `scale` instead (windowkit/appkit#106), and answers in device pixels
748
+ * as before. A bridge older than that ignores the scale and sets the
749
+ * text as it always did.
750
+ */
751
+ constructor(native = loadNative(), { scale = 1 } = {}) {
740
752
  this._native = native;
753
+ // the trailing argument cgFontWithSize and fontByPostScriptName take,
754
+ // and nothing at 1x, so a 1x app asks the bridge what it always asked
755
+ this._scale = scale > 0 && Number.isFinite(scale) ? scale : 1;
756
+ this._scaleArgs = this._scale === 1 ? [] : [this._scale];
741
757
  this._fonts = new Map(); // family|weight|italic|size -> handle
742
758
  // handle -> features key -> the handle with those features set
743
759
  this._featured = new WeakMap();
@@ -895,8 +911,15 @@ export class CocoaFontManager {
895
911
  const sizedKey = `${key}|${size}|${variations ? JSON.stringify(variations) : ''}`;
896
912
  let handle = this._sized.get(sizedKey);
897
913
  if (handle) return handle;
898
- if (entry.ps) handle = this._native.fontByPostScriptName(entry.ps, size);
899
- else if (entry.cg) handle = this._native.cgFontWithSize(entry.cg, size);
914
+ if (entry.ps) {
915
+ handle = this._native.fontByPostScriptName(
916
+ entry.ps,
917
+ size,
918
+ ...this._scaleArgs,
919
+ );
920
+ } else if (entry.cg) {
921
+ handle = this._native.cgFontWithSize(entry.cg, size, ...this._scaleArgs);
922
+ }
900
923
  if (!handle) return null;
901
924
  handle = this._withVariations(handle, variations);
902
925
  this._sized.set(sizedKey, handle);
@@ -1020,7 +1043,7 @@ export class CocoaFontManager {
1020
1043
  best = face;
1021
1044
  }
1022
1045
  }
1023
- handle = this._native.cgFontWithSize(best.cg, size);
1046
+ handle = this._native.cgFontWithSize(best.cg, size, ...this._scaleArgs);
1024
1047
  break;
1025
1048
  }
1026
1049
  handle ??= this._native.matchFont({
@@ -1028,6 +1051,7 @@ export class CocoaFontManager {
1028
1051
  size,
1029
1052
  weight,
1030
1053
  italic,
1054
+ ...(this._scale === 1 ? null : { scale: this._scale }),
1031
1055
  });
1032
1056
  this._fonts.set(key, handle);
1033
1057
  }
@@ -1115,7 +1139,7 @@ export class CocoaFontManager {
1115
1139
  _registeredFace(reg) {
1116
1140
  if (!reg.face) {
1117
1141
  reg.face = new CocoaFace(this, 'registered', (size) =>
1118
- this._native.cgFontWithSize(reg.cg, size),
1142
+ this._native.cgFontWithSize(reg.cg, size, ...this._scaleArgs),
1119
1143
  );
1120
1144
  reg.face.key = `registered:${reg.face.postscriptName}`;
1121
1145
  }
@@ -0,0 +1,177 @@
1
+ // `<video src>` on macOS (docs/architecture/video.md §3.2, §9): a file or URL
2
+ // played by AVFoundation through @windowkit/appkit's player verbs — the
3
+ // platform's decoder, audio, seeking and HLS — with the picture on the
4
+ // player's own AVPlayerLayer.
5
+ //
6
+ // The player is what a `<video>`'s sprite part shows as its `contents`, the
7
+ // same way a `VideoFrames` sink is: lifted (src/cocoa/sprites.js), the
8
+ // AVPlayerLayer goes inside the part's layer and fills it, and AVFoundation
9
+ // puts every frame on the screen with nothing of ours in the way; declined,
10
+ // the element draws the frame showing, which `copyFrame` converts into a
11
+ // surface of its own in the colours the layer shows it in. Everything else —
12
+ // state, time, the end, failure — comes back as the bridge's `player-*`
13
+ // events, routed here by id (`CocoaApp._route`).
14
+
15
+ /** Has `native` the verbs a player needs? */
16
+ export function canPlay(native) {
17
+ return (
18
+ typeof native?.createPlayer === 'function' &&
19
+ typeof native.playerSet === 'function' &&
20
+ typeof native.playerSeek === 'function' &&
21
+ typeof native.playerCopyFrame === 'function' &&
22
+ typeof native.releasePlayer === 'function'
23
+ );
24
+ }
25
+
26
+ /**
27
+ * One file or URL, playing or not. `width`/`height` are the picture's once
28
+ * the item is ready (0 before), `duration` its length in seconds (`Infinity`
29
+ * for a live stream, NaN before it is known).
30
+ */
31
+ export class CocoaPlayer {
32
+ constructor(app, src, options = {}) {
33
+ this.app = app;
34
+ this._native = app._native;
35
+ const made = this._native.createPlayer(String(src), {
36
+ autoPlay: Boolean(options.autoPlay),
37
+ loop: Boolean(options.loop),
38
+ muted: Boolean(options.muted),
39
+ volume: typeof options.volume === 'number' ? options.volume : 1,
40
+ rate: typeof options.playbackRate === 'number' ? options.playbackRate : 1,
41
+ });
42
+ this.id = made.id;
43
+ /** the AVPlayerLayer, a layer handle */
44
+ this.layer = made.layer;
45
+ this.width = 0;
46
+ this.height = 0;
47
+ this.duration = NaN;
48
+ this.currentTime = 0;
49
+ this.playing = false;
50
+ this.ended = false;
51
+ this.error = null;
52
+ this.released = false;
53
+ this._listeners = new Set();
54
+ app._players.set(this.id, this);
55
+ }
56
+
57
+ /** A `player-*` event from the bridge: the state it says, then whoever
58
+ * listens. */
59
+ _event(ev) {
60
+ if (this.released) return;
61
+ switch (ev.type) {
62
+ case 'player-metadata':
63
+ this.width = ev.width;
64
+ this.height = ev.height;
65
+ this.duration = ev.duration;
66
+ break;
67
+ case 'player-state':
68
+ // AVFoundation reports every move of its time control — waiting,
69
+ // playing, a loop's restart — and HTML's `play` and `pause` are the
70
+ // moves between paused and not
71
+ if (Boolean(ev.playing) === this.playing) return;
72
+ this.playing = Boolean(ev.playing);
73
+ if (this.playing) this.ended = false;
74
+ break;
75
+ case 'player-time':
76
+ this.currentTime = ev.currentTime;
77
+ break;
78
+ case 'player-ended':
79
+ this.ended = true;
80
+ this.playing = false;
81
+ break;
82
+ case 'player-error':
83
+ this.error = new Error(
84
+ `react-x11: <video src> could not be played: ${ev.message}`,
85
+ );
86
+ break;
87
+ default:
88
+ return;
89
+ }
90
+ for (const fn of [...this._listeners]) fn(ev);
91
+ }
92
+
93
+ /** `fn(event)` for every event; answers the unsubscribe. */
94
+ _subscribe(fn) {
95
+ this._listeners.add(fn);
96
+ return () => this._listeners.delete(fn);
97
+ }
98
+
99
+ /** What `paused`, `playbackRate`, `volume`, `muted` and `loop` say now;
100
+ * what is left out stays. */
101
+ set({ paused, playbackRate, volume, muted, loop } = {}) {
102
+ if (this.released) return;
103
+ const settings = {};
104
+ if (typeof paused === 'boolean') settings.paused = paused;
105
+ if (typeof playbackRate === 'number' && playbackRate > 0) {
106
+ settings.rate = playbackRate;
107
+ }
108
+ if (typeof volume === 'number') settings.volume = volume;
109
+ if (typeof muted === 'boolean') settings.muted = muted;
110
+ if (typeof loop === 'boolean') settings.loop = loop;
111
+ this._native.playerSet(this.id, settings);
112
+ }
113
+
114
+ play() {
115
+ // HTML plays an ended item from its start
116
+ if (this.ended) this.seek(0);
117
+ this.set({ paused: false });
118
+ }
119
+
120
+ pause() {
121
+ this.set({ paused: true });
122
+ }
123
+
124
+ seek(seconds) {
125
+ if (this.released || !Number.isFinite(seconds)) return;
126
+ this.currentTime = Math.max(0, seconds);
127
+ this._native.playerSeek(this.id, this.currentTime);
128
+ }
129
+
130
+ /**
131
+ * The frame showing, into `surface` (a `CocoaSurface` of the picture's
132
+ * size): `{ width, height, time, written }`, or null when nothing is newer
133
+ * than the last frame copied. `written` false means the surface is not the
134
+ * frame's size, which the caller remakes.
135
+ */
136
+ copyFrame(surface) {
137
+ if (this.released || !surface?._surfaceHandle) return null;
138
+ return this._native.playerCopyFrame(this.id, surface._surfaceHandle);
139
+ }
140
+
141
+ release() {
142
+ if (this.released) return;
143
+ this.released = true;
144
+ this._listeners.clear();
145
+ this.app._players.delete(this.id);
146
+ this._native.releasePlayer(this.id);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * A player's picture on a sprite part's layer: its AVPlayerLayer inside the
152
+ * part's, filling it. The bridge plays into it on AVFoundation's clock; this
153
+ * only places it, and takes it out again when the part comes down.
154
+ */
155
+ export class PlayerLift {
156
+ constructor(native, player, layer) {
157
+ this.native = native;
158
+ this.player = player;
159
+ this.layer = layer;
160
+ this._bounds = null;
161
+ native.addSublayer(layer, player.layer);
162
+ }
163
+
164
+ /** The part's layer is `bounds` (points) now: the picture fills it. */
165
+ layout(bounds) {
166
+ const was = this._bounds;
167
+ if (was && was[2] === bounds[2] && was[3] === bounds[3]) return;
168
+ this._bounds = bounds;
169
+ this.native.setLayerProps(this.player.layer, {
170
+ frame: [0, 0, bounds[2], bounds[3]],
171
+ });
172
+ }
173
+
174
+ release() {
175
+ this.native.removeFromSuperlayer(this.player.layer);
176
+ }
177
+ }
@@ -615,6 +615,10 @@ export class CocoaPromotion {
615
615
  !(parent.style?.borderRadius > 0)
616
616
  ) {
617
617
  const inner = insetRect(parent.abs, borderReach(parent));
618
+ // a clip with round corners cut again is no box's shape
619
+ if (cut.radius > 0 && cut.clip && !containsRect(inner, cut.clip)) {
620
+ return false;
621
+ }
618
622
  cut.clip = cut.clip ? intersectRects(cut.clip, inner) : inner;
619
623
  bounds = cut.clip && intersectRects(bounds, cut.clip);
620
624
  if (!bounds) return false;