react-x11 2.40.0 → 2.41.1

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.40.0",
3
+ "version": "2.41.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -104,14 +104,14 @@
104
104
  "ical.js": "^2.2.1",
105
105
  "image-in-browser": "^3.6.0",
106
106
  "linebreak": "^1.1.0",
107
- "ntk": "^8.17.8",
107
+ "ntk": "^8.23.0",
108
108
  "react-reconciler": "^0.34.0",
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
- "@windowkit/appkit": "^0.24.0",
112
+ "@windowkit/appkit": "^0.25.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
- "@windowkit/win32": "^0.0.7",
114
+ "@windowkit/win32": "^0.0.9",
115
115
  "dbus-native": "^0.16.0",
116
116
  "x11-dri": "^0.9.0"
117
117
  },
package/src/cocoa/app.js CHANGED
@@ -290,7 +290,9 @@ export class CocoaApp {
290
290
  // the text controls' Undo/Cut/Copy/Paste/Select All answer ⌘ rather
291
291
  // than ⌃, and the edit menu prints them the way a Mac menu does. The
292
292
  // menu bar already read a chord's `Control` as ⌘ (cocoa/globalmenu.js);
293
- // this is the same rule for everything else that reads one.
293
+ // this is the same rule for everything else that reads one. Text is
294
+ // edited the Mac way with it too: ⌥ moves by word, ⌘ to the ends of a
295
+ // line and the document, ⌃ is Emacs's (src/nodes/textkeys.js).
294
296
  this.primaryModifier = 'Super';
295
297
 
296
298
  // AppKit-rendered control bezels. Its *presence* is the capability:
@@ -934,8 +936,9 @@ export class CocoaApp {
934
936
 
935
937
  /**
936
938
  * The pane's end of the frame channel (childmain hands it over,
937
- * feature-detected so the X11 pane path never notices): geometry and
938
- * input come in, pane-present and pane-cursor go out. `players` is the
939
+ * feature-detected so the X11 pane path never notices): geometry, input
940
+ * and the host's word that it has looked a present up come in,
941
+ * pane-present and pane-cursor go out. `players` is the
939
942
  * host's word that it plays the pane's `<video src>`s, which makes
940
943
  * `createPlayer` here — before the pane's first render, which asks.
941
944
  */
@@ -963,7 +966,12 @@ export class CocoaApp {
963
966
  }
964
967
  const pane = this._paneWindow ?? [...this._windows.values()][0];
965
968
  if (!pane) return;
966
- if (msg?.type === 'pane-rect') {
969
+ if (msg?.type === 'pane-shown') {
970
+ // the host has looked up this window's presents to `seq`, so the
971
+ // buffers they named may go (`CocoaPaneWindow._releaseRing`)
972
+ const wnd = msg.window != null ? this._windows.get(msg.window) : pane;
973
+ wnd?._shown?.(msg.seq);
974
+ } else if (msg?.type === 'pane-rect') {
967
975
  pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
968
976
  this._flushPaneOutbox();
969
977
  this._afterInput();
@@ -1210,11 +1218,12 @@ export class CocoaApp {
1210
1218
  const surfaces = [];
1211
1219
  for (const entry of queue) {
1212
1220
  // A window whose last flip has not reached its layer yet (threaded
1213
- // mode's fence, `CocoaWindow.frameInFlight`) waits before
1214
- // its clock is asked, or the clock would count a frame that did not
1215
- // run. The release is an event, and the batch it arrives in ticks
1221
+ // mode's fence, `CocoaWindow.frameHeld`), or a pane with no buffer the
1222
+ // host is done with (`CocoaPaneWindow.frameHeld`), waits before its
1223
+ // clock is asked, or the clock would count a frame that did not run.
1224
+ // The release is an event — a batch, a `pane-shown` — and it ticks
1216
1225
  // again.
1217
- if (entry.wnd?.frameInFlight?.()) {
1226
+ if (entry.wnd?.frameHeld?.()) {
1218
1227
  this._rafQueue.push(entry);
1219
1228
  continue;
1220
1229
  }
@@ -1560,6 +1569,8 @@ export class CocoaApp {
1560
1569
  keysym: decoded.keysym,
1561
1570
  baseKeysym: decoded.baseKeysym,
1562
1571
  codepoint: decoded.codepoint,
1572
+ // more than one character, which no code point carries (keymap.js)
1573
+ text: decoded.text,
1563
1574
  buttons: modifierMask(ev),
1564
1575
  group: 0,
1565
1576
  time: ev.time,
@@ -5,8 +5,39 @@
5
5
  // have fixed keysyms looked up here by kVK_* virtual key code. NSEvent's
6
6
  // three character views map onto the three keysym roles events.js reads:
7
7
  //
8
- // charsBase (modifiers stripped) -> baseKeysym — what chords match
9
- // charsShifted (only Shift applied) -> keysym / codepoint — what was typed
8
+ // charsBase (modifiers stripped) -> baseKeysym — what chords match
9
+ // chars (every modifier applied) -> keysym / codepoint — what was
10
+ // typed, unless ⌘ or ⌃ is held
11
+ // charsShifted (only Shift applied) -> keysym / codepoint of a ⌘ or
12
+ // ⌃ chord, which types nothing
13
+ //
14
+ // **⌥ types on a Mac.** It is the layout's third and fourth level: ⌥S is ß on
15
+ // a US keyboard, and on a German one ⌥L is @, ⌥5 [ and ⌥8 { — the only way
16
+ // to type them. `charsShifted` is `charactersIgnoringModifiers`, which drops
17
+ // ⌥ along with everything but Shift, so reading the typed character there
18
+ // made ⌥S type `s` and left a German keyboard no @ at all. `chars` is
19
+ // `characters`, the layout's own answer with ⌥, Shift and Caps Lock applied —
20
+ // Caps Lock is the other thing `charsShifted` leaves out, so a field typed
21
+ // lower case with it on. It is also exactly the DOM's `key` on macOS: ⌥S is
22
+ // `ß`, ⌘S is `s`.
23
+ //
24
+ // There is no input method behind this. The bridge's view swallows
25
+ // `keyDown:` without `interpretKeyEvents:` and is no `NSTextInputClient`
26
+ // (docs/macos.md, "IME"), so these fields are all the text there is. Two
27
+ // consequences, both handled below:
28
+ //
29
+ // - **A dead key types nothing.** ⌥E on a US layout leaves `chars` empty, and
30
+ // the key it is waiting for decides what the accent becomes. Typing the
31
+ // key's own letter, which is what reading `charsShifted` did, put an `e` in
32
+ // the field. Whether the next key's `chars` carries the composed `é` is the
33
+ // window server's business — an NSEvent built in-process does not carry the
34
+ // state, and a real keyboard was not measured — so nothing here depends on
35
+ // it: a composed character is taken as typed, and a plain one is the
36
+ // accent lost rather than a letter too many.
37
+ // - **A key can type more than one character** — the accent and a letter it
38
+ // does not combine with, `´s`, where the layout composes. No code point
39
+ // carries two, so the whole string rides `text` and events.js commits it
40
+ // the way it commits an input method's text.
10
41
  //
11
42
  // Function and editing keys type Unicode private-use characters (U+F700…),
12
43
  // which must never leak out as code points; the kVK table wins over the
@@ -48,30 +79,56 @@ const VK_KEYSYMS = new Map([
48
79
  [111, 0xffc9], // F12
49
80
  ]);
50
81
 
51
- const PRIVATE_USE = (cp) => cp >= 0xf700 && cp <= 0xf8ff;
82
+ // The block NSEvent's function-key constants live in (NSUpArrowFunctionKey is
83
+ // U+F700). Not the whole U+F700–U+F8FF range Apple reserves: U+F8FF is the
84
+ // Apple logo, which ⌥⇧K types and the system font draws.
85
+ const FUNCTION_KEY = (cp) => cp >= 0xf700 && cp <= 0xf7ff;
86
+
87
+ const printable = (cp) => cp != null && cp >= 0x20 && cp !== 0x7f;
52
88
 
53
89
  function keysymFromChars(chars) {
54
90
  if (!chars) return 0;
55
91
  const cp = chars.codePointAt(0);
56
- if (cp == null || cp < 0x20 || PRIVATE_USE(cp)) return 0;
92
+ if (!printable(cp) || FUNCTION_KEY(cp)) return 0;
57
93
  return keysymOf(String.fromCodePoint(cp));
58
94
  }
59
95
 
60
96
  /**
61
- * The three key facts events.js reads off a native key event, from the raw
62
- * @windowkit/appkit payload.
97
+ * The key facts events.js reads off a native key event, from the raw
98
+ * @windowkit/appkit payload: `keysym`, `baseKeysym` and `codepoint`, and
99
+ * `text` when the key typed more than one character.
63
100
  */
64
101
  export function decodeKey(ev) {
65
102
  const fixed = VK_KEYSYMS.get(ev.keyCode);
66
103
  if (fixed) {
67
104
  return { keysym: fixed, baseKeysym: fixed, codepoint: undefined };
68
105
  }
69
- const keysym = keysymFromChars(ev.charsShifted);
70
- const baseKeysym = keysymFromChars(ev.charsBase) || keysym;
71
- const cp = ev.charsShifted?.codePointAt(0);
72
- const codepoint =
73
- cp != null && cp >= 0x20 && !PRIVATE_USE(cp) ? cp : undefined;
74
- return { keysym: keysym || baseKeysym, baseKeysym, codepoint };
106
+ const baseKeysym =
107
+ keysymFromChars(ev.charsBase) || keysymFromChars(ev.charsShifted);
108
+ // A ⌘ or ⌃ chord types nothing, and is named by its key: ⌘⌥S is a chord
109
+ // on S, not on ß. (`chars` for ⌃S is a control character besides.)
110
+ // `chars` is absent from a bridge older than the field, and from a test's
111
+ // event that only says which key it was.
112
+ const chord = ev.command || ev.control;
113
+ const typed = chord ? ev.charsShifted : (ev.chars ?? ev.charsShifted);
114
+ const points = typed ? Array.from(typed) : [];
115
+ if (points.length > 1) {
116
+ return {
117
+ keysym: keysymFromChars(ev.charsShifted) || baseKeysym,
118
+ baseKeysym,
119
+ codepoint: undefined,
120
+ text: typed,
121
+ };
122
+ }
123
+ const keysym = keysymFromChars(typed);
124
+ const cp = typed?.codePointAt(0);
125
+ const codepoint = printable(cp) && !FUNCTION_KEY(cp) ? cp : undefined;
126
+ return {
127
+ // a dead key typed nothing; the key it sits on is what a chord names
128
+ keysym: keysym || keysymFromChars(ev.charsShifted) || baseKeysym,
129
+ baseKeysym,
130
+ codepoint,
131
+ };
75
132
  }
76
133
 
77
134
  /** AppKit modifier booleans -> the X-style state mask events carry. */
@@ -31,6 +31,12 @@ const ASKED_KEPT = 16;
31
31
  * drag itself. */
32
32
  const GESTURE_GAP_MS = 500;
33
33
 
34
+ /** How far inside the budget a frame of a pane that fell behind has to
35
+ * come for its frames to be anchored again (`_answered`): a pane whose
36
+ * frames come near the budget keeps one look rather than taking the
37
+ * other at every frame. */
38
+ const KEPT_UP = 0.75;
39
+
34
40
  /** One shared surface on glass: a sublayer of a window's root layer, its
35
41
  * contents the IOSurface the pane last presented into it. */
36
42
  class PaneLayer {
@@ -54,6 +60,10 @@ class PaneLayer {
54
60
  this._asked = [];
55
61
  this._askedAt = null;
56
62
  this._behind = false;
63
+ // when a frame last answered a size, and how many frames in a row came
64
+ // later than the budget (`_answered`)
65
+ this._answeredAt = -Infinity;
66
+ this._late = 0;
57
67
  }
58
68
 
59
69
  /**
@@ -73,13 +83,28 @@ class PaneLayer {
73
83
  * size may take before the old one is shown in its place: 0 stretches
74
84
  * always, as before. A bridge that predates `contentsGravity` ignores it,
75
85
  * and stretches.
86
+ *
87
+ * Behind is a pane whose frames come later than the budget, not one with
88
+ * a size waiting that long. `<Frame>` sends a pane the newest size once
89
+ * it has painted the last, so the sizes a drag passes through while a
90
+ * frame is being painted are never sent, and never answered: a size
91
+ * waits until a frame of a later one lands, two pane frames at most. A
92
+ * page that painted in 27 ms answered every size it was sent in 31, and
93
+ * still had one 52 ms old at every step of the drag — so its layer was
94
+ * stretched at every step and anchored again at every frame, the page
95
+ * scaling and coming back thirty times a second. Here a size waiting
96
+ * longer than the budget counts only when no frame has answered any
97
+ * size in that time, which is a pane that stopped, and a frame that
98
+ * lands decides the rest (`_answered`).
76
99
  */
77
100
  _gravityNow() {
78
101
  const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
102
  if (!(budget > 0)) return 'resize';
80
- // a size asked longer ago than the budget, and no frame of it yet
103
+ // a size asked longer ago than the budget, and no frame of any since
81
104
  const oldest = this._asked[0];
82
- if (oldest && now() - oldest.at > budget) this._behind = true;
105
+ if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
106
+ this._behind = true;
107
+ }
83
108
  return this._behind ? 'resize' : 'topLeft';
84
109
  }
85
110
 
@@ -106,20 +131,35 @@ class PaneLayer {
106
131
  return { contentsScale: scale };
107
132
  }
108
133
 
109
- /** A frame of `width` by `height` device px landed: what it answers of
110
- * the sizes asked, and how long it took. */
134
+ /**
135
+ * A frame of `width` by `height` device px landed: what it answers of
136
+ * the sizes asked, and how long it took. Two frames in a row later than
137
+ * the budget put the pane behind, and one well inside it — `KEPT_UP` of
138
+ * it — brings it back: a frame the collector held up stretches nothing,
139
+ * and a pane whose frames come near the budget is not stretched and
140
+ * anchored by turns.
141
+ */
111
142
  _answered(width, height) {
112
143
  const asked = this._asked;
113
144
  // the pane rounds the logical size it was sent back to device pixels
114
145
  const slack = Math.max(2, this.wnd.scale ?? 1);
115
- const at = asked.findIndex(
146
+ // the last time it was asked: a drag back and forth asks a size twice
147
+ const at = asked.findLastIndex(
116
148
  (a) =>
117
149
  Math.abs(a.width - width) <= slack &&
118
150
  Math.abs(a.height - height) <= slack,
119
151
  );
120
152
  if (at < 0) return;
121
- this._behind =
122
- now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
153
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
154
+ const took = now() - asked[at].at;
155
+ this._answeredAt = now();
156
+ if (took > budget) {
157
+ this._late += 1;
158
+ if (this._late >= 2) this._behind = true;
159
+ } else {
160
+ this._late = 0;
161
+ if (took <= budget * KEPT_UP) this._behind = false;
162
+ }
123
163
  asked.splice(0, at + 1);
124
164
  }
125
165
 
@@ -144,6 +184,7 @@ class PaneLayer {
144
184
  if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
185
  this._asked = [];
146
186
  this._behind = false;
187
+ this._late = 0;
147
188
  }
148
189
  this._askedAt = at;
149
190
  if (this._asked.length === ASKED_KEPT) this._asked.shift();
@@ -172,16 +213,20 @@ class PaneLayer {
172
213
  * A pane-present landed: scan out of the named shared surface.
173
214
  *
174
215
  * A present names a buffer the pane may since have retired. The channel
175
- * is a queue and nothing acknowledges a present, so a pane-rect that
176
- * changes the pane's size (or its scale — the host window moved to another
177
- * display during startup, which is how this was first hit) can cross a
178
- * present already in flight: the pane rebuilds its ring on that rect and
179
- * releases the old one (`CocoaPaneWindow._ensureSurface`), and by the
180
- * time the host looks the id up the surface is gone. That present is
181
- * stale by construction — the pane's full frame on the fresh ring is
182
- * queued behind it — so it is dropped, and the layer keeps the frame it
183
- * already holds a reference to. Nothing else here throws; any other
184
- * error is the bug it says it is.
216
+ * is a queue, so a pane-rect that changes the pane's size (or its scale —
217
+ * the host window moved to another display during startup, which is how
218
+ * this was first hit) can cross a present already in flight: the pane
219
+ * rebuilds its ring on that rect (`CocoaPaneWindow._ensureSurface`)
220
+ * before the host has looked the id up. The id is all the present
221
+ * carries, and the system gives a freed id to the next surface made, so
222
+ * the pane keeps such a buffer until the host says it has looked it up
223
+ * (`pane-shown`, which `CocoaPaneHost` sends once it has) — the lookup finds
224
+ * the frame the present named, a size behind, and the full frame on the
225
+ * fresh ring is queued behind it. Where the buffer is gone anyway — a
226
+ * pane that freed it for want of an answer, or one that died with
227
+ * presents in the channel — the present is dropped, and the layer keeps
228
+ * the frame it already holds a reference to. Nothing else here throws;
229
+ * any other error is the bug it says it is.
185
230
  */
186
231
  present(iosurfaceId, size = null) {
187
232
  if (this.destroyed) return false;
@@ -260,6 +305,28 @@ export class CocoaPaneHost extends PaneLayer {
260
305
  this._players = new Map();
261
306
  }
262
307
 
308
+ /**
309
+ * The pane's own present, scanned out (`PaneLayer.present`), and the pane
310
+ * told: whatever the buffer it named holds is the layer's now, or was
311
+ * dropped, so the pane may free it (`CocoaPaneWindow._releaseRing`).
312
+ */
313
+ present(iosurfaceId, size = null) {
314
+ const shown = super.present(iosurfaceId, size);
315
+ this._acknowledge(size?.seq, null);
316
+ return shown;
317
+ }
318
+
319
+ /** `pane-shown`: the host has looked up the presents to `seq` of the
320
+ * pane's own window, or of the one `window` names. */
321
+ _acknowledge(seq, window) {
322
+ if (this.destroyed || seq == null) return;
323
+ this._send?.({
324
+ type: 'pane-shown',
325
+ seq,
326
+ ...(window != null && { window }),
327
+ });
328
+ }
329
+
263
330
  /**
264
331
  * A `pane-player` message: one of the pane's `<video src>`s made, steered
265
332
  * or let go of its player. A player that cannot be made says so the way
@@ -375,9 +442,15 @@ export class CocoaPaneHost extends PaneLayer {
375
442
  /** A pane-present naming one of the pane's other windows. */
376
443
  presentWindow(msg) {
377
444
  const entry = this._windows.get(msg.window);
378
- if (!entry || this.destroyed) return;
445
+ if (!entry || this.destroyed) {
446
+ this._acknowledge(msg.seq, msg.window);
447
+ return;
448
+ }
379
449
  entry.layer.setRect({ x: 0, y: 0, width: msg.width, height: msg.height });
380
- if (!entry.layer.present(msg.id)) return;
450
+ const shown = entry.layer.present(msg.id);
451
+ // its ring is the pane's own ring, retired the same way
452
+ this._acknowledge(msg.seq, msg.window);
453
+ if (!shown) return;
381
454
  entry.presented = true;
382
455
  // a transparent window's shadow is the shape of what it shows
383
456
  entry.wnd._shadowAfterFlip?.();
@@ -18,6 +18,15 @@
18
18
  // other: the host writes the buffer after the one it last named, so the one
19
19
  // the pane is told about is not written again for two frames — some 66ms at
20
20
  // 30fps, against a pane that copies within a display frame of hearing.
21
+ //
22
+ // A buffer is named by its IOSurface id, and the system gives a freed id to
23
+ // the next surface anyone makes, in either process. So the pane answers
24
+ // each frame once it has looked the buffer up (`seen`), the host keeps a
25
+ // buffer of a ring it retired until the frame naming it is answered
26
+ // (`HostedPlayer._freeRing`), and the pane keeps nothing a lookup found at
27
+ // another size than the frame says (`PanePlayer._lookUp`) — the pane's own
28
+ // presents, the other way round, are kept the same way
29
+ // (`CocoaPaneWindow._releaseRing`).
21
30
 
22
31
  import { PlayerState } from './player.js';
23
32
 
@@ -113,27 +122,13 @@ export class PanePlayer extends PlayerState {
113
122
  }
114
123
 
115
124
  /** The host named a buffer with a new frame in it. */
116
- _frame({ surface, width, height, time }) {
125
+ _frame({ surface, width, height, time, seq }) {
117
126
  if (this.released) return;
118
- // a ring of another size is a new ring: the old one's buffers are the
119
- // host's to free, and these handles only keep them alive
120
- if (
121
- this._bufferSize?.width !== width ||
122
- this._bufferSize?.height !== height
123
- ) {
124
- this._dropBuffers();
125
- this._bufferSize = { width, height };
126
- }
127
- let handle = this._buffers.get(surface);
128
- if (!handle) {
129
- try {
130
- handle = this._native.surfaceFromIOSurfaceID(surface, 1).handle;
131
- } catch {
132
- // freed before it got here: the next frame is in another buffer
133
- return;
134
- }
135
- this._buffers.set(surface, handle);
136
- }
127
+ const handle = this._lookUp(surface, width, height);
128
+ // this frame's lookup is done, whatever it found: the host may free the
129
+ // buffer, which it kept for it (`HostedPlayer._freeRing`)
130
+ if (seq != null) this._post({ op: 'seen', seq });
131
+ if (!handle) return;
137
132
  this._shown = { handle, width, height, time };
138
133
  this._fresh = true;
139
134
  // a playing `<video>` asks every display frame; a paused one asks when
@@ -148,6 +143,40 @@ export class PanePlayer extends PlayerState {
148
143
  }
149
144
  }
150
145
 
146
+ /**
147
+ * The buffer the host named, looked up the first time it is named; null
148
+ * where the lookup found none, or found a surface of another size than
149
+ * the frame, which is not the frame's buffer but whatever the id has gone
150
+ * to since (`HostedPlayer._freeRing` says how) — never kept, and never
151
+ * copied out of.
152
+ */
153
+ _lookUp(surface, width, height) {
154
+ // a ring of another size is a new ring: the old one's buffers are the
155
+ // host's to free, and these handles only keep them alive
156
+ if (
157
+ this._bufferSize?.width !== width ||
158
+ this._bufferSize?.height !== height
159
+ ) {
160
+ this._dropBuffers();
161
+ this._bufferSize = { width, height };
162
+ }
163
+ const known = this._buffers.get(surface);
164
+ if (known) return known;
165
+ let found;
166
+ try {
167
+ found = this._native.surfaceFromIOSurfaceID(surface, 1);
168
+ } catch {
169
+ // freed before it got here: the next frame is in another buffer
170
+ return null;
171
+ }
172
+ if (found.width !== width || found.height !== height) {
173
+ this._native.releaseSurface?.(found.handle);
174
+ return null;
175
+ }
176
+ this._buffers.set(surface, found.handle);
177
+ return found.handle;
178
+ }
179
+
151
180
  /** As `CocoaPlayer.copyFrame`: the newest frame into `surface`, or null
152
181
  * when the host has sent nothing since the last copy. */
153
182
  copyFrame(surface) {
@@ -212,6 +241,12 @@ export class HostedPlayer {
212
241
  this._next = 0;
213
242
  this._want = 0;
214
243
  this._pull = null;
244
+ // Each frame's number, the newest the pane has looked up (`seen`), and
245
+ // the buffers of retired rings a frame it has not looked up yet names:
246
+ // freed as it does (`_freeRing`).
247
+ this._seq = 0;
248
+ this._seenSeq = 0;
249
+ this._retired = [];
215
250
  this._off = this.player._subscribe((ev) => this._event(ev));
216
251
  }
217
252
 
@@ -221,6 +256,7 @@ export class HostedPlayer {
221
256
  if (player.released) return;
222
257
  if (msg.op === 'set') player.set(msg.settings);
223
258
  else if (msg.op === 'seek') player.seek(msg.seconds);
259
+ else if (msg.op === 'seen') this._seen(msg.seq);
224
260
  else if (msg.op === 'release') this.release();
225
261
  }
226
262
 
@@ -274,6 +310,9 @@ export class HostedPlayer {
274
310
  );
275
311
  if (got?.written) {
276
312
  this._next = (this._next + 1) % RING;
313
+ // the buffer is named in the channel until the pane says otherwise
314
+ // (`_freeRing`)
315
+ buffer.namedSeq = ++this._seq;
277
316
  this._send({
278
317
  type: 'pane-player-frame',
279
318
  player: this.id,
@@ -281,6 +320,7 @@ export class HostedPlayer {
281
320
  width: got.width,
282
321
  height: got.height,
283
322
  time: got.time,
323
+ seq: this._seq,
284
324
  });
285
325
  if (!player.playing) this._want = 0;
286
326
  } else if (got?.width > 0 && got?.height > 0) {
@@ -316,13 +356,46 @@ export class HostedPlayer {
316
356
  return this._ring[this._next];
317
357
  }
318
358
 
359
+ /**
360
+ * Let the ring go — all but a buffer a frame names that the pane has not
361
+ * looked up yet. The pane finds a buffer by its IOSurface id, and the
362
+ * system hands a freed id to the next surface anyone makes: the ring of
363
+ * the new size this same call is about to make, the pane's own ring,
364
+ * another process's surface. A frame still in the channel named the id,
365
+ * not the surface, so the pane copied out of whatever had it by the time
366
+ * it looked — the next ring's buffer, unwritten, shown until the next
367
+ * frame came, which for a paused player is the next seek. So that buffer
368
+ * is kept (`_retired`) until the pane says it has looked the frame up
369
+ * (`seen`, `_seen`). One, as a rule, since the pane answers as it reads,
370
+ * and never more than a ring's worth: a pane that stops answering has the
371
+ * oldest freed anyway, the one it is likeliest to have looked up already.
372
+ */
319
373
  _freeRing() {
374
+ const ring = this._ring;
375
+ this._ring = [];
320
376
  const release = this._native.releaseSurface;
321
- if (typeof release === 'function') {
322
- for (const buffer of this._ring)
323
- release.call(this._native, buffer.handle);
377
+ if (typeof release !== 'function') return;
378
+ for (const buffer of ring) {
379
+ if (buffer.namedSeq > this._seenSeq) this._retired.push(buffer);
380
+ else release.call(this._native, buffer.handle);
324
381
  }
325
- this._ring = [];
382
+ while (this._retired.length > RING) {
383
+ release.call(this._native, this._retired.shift().handle);
384
+ }
385
+ }
386
+
387
+ /** The pane has looked up every frame up to `seq`: the retired buffers
388
+ * they named go. */
389
+ _seen(seq) {
390
+ if (!(seq > this._seenSeq)) return;
391
+ this._seenSeq = seq;
392
+ if (this._retired.length === 0) return;
393
+ const release = this._native.releaseSurface;
394
+ this._retired = this._retired.filter((buffer) => {
395
+ if (buffer.namedSeq > seq) return true;
396
+ release.call(this._native, buffer.handle);
397
+ return false;
398
+ });
326
399
  }
327
400
 
328
401
  release() {
@@ -332,5 +405,9 @@ export class HostedPlayer {
332
405
  this._want = 0;
333
406
  this._syncPull();
334
407
  this._freeRing();
408
+ // and what it kept: the pane's player is gone, or the pane is
409
+ for (const buffer of this._retired.splice(0)) {
410
+ this._native.releaseSurface(buffer.handle);
411
+ }
335
412
  }
336
413
  }