@gjsify/video 0.1.15 → 0.2.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.
@@ -1,4 +1,5 @@
1
1
  import Gst from "gi://Gst?version=1.0";
2
+ import { DOMException } from "@gjsify/dom-exception";
2
3
  let initialized = false;
3
4
  function ensureGstInit() {
4
5
  if (initialized) return;
@@ -15,11 +16,7 @@ function ensurePaintableSinkAvailable() {
15
16
  }
16
17
  }
17
18
  function throwNotSupported(message) {
18
- const DOMExceptionCtor = globalThis.DOMException;
19
- if (DOMExceptionCtor) {
20
- throw new DOMExceptionCtor(message, "NotSupportedError");
21
- }
22
- throw new Error(message);
19
+ throw new DOMException(message, "NotSupportedError");
23
20
  }
24
21
  export {
25
22
  Gst,
package/lib/esm/index.js CHANGED
@@ -1,12 +1,8 @@
1
1
  import { VideoBridge } from "./video-bridge.js";
2
2
  import { buildMediaStreamPipeline, buildUriPipeline, createPaintableSink } from "./pipeline-builder.js";
3
3
  import { HTMLVideoElement } from "@gjsify/dom-elements";
4
- Object.defineProperty(globalThis, "HTMLVideoElement", {
5
- value: HTMLVideoElement,
6
- writable: true,
7
- configurable: true
8
- });
9
4
  export {
5
+ HTMLVideoElement,
10
6
  VideoBridge,
11
7
  buildMediaStreamPipeline,
12
8
  buildUriPipeline,
@@ -1,42 +1,60 @@
1
1
  import GObject from "gi://GObject";
2
2
  import GLib from "gi://GLib?version=2.0";
3
3
  import Gtk from "gi://Gtk?version=4.0";
4
- import { HTMLVideoElement } from "@gjsify/dom-elements";
5
4
  import { attachEventControllers } from "@gjsify/event-bridge";
6
5
  import { Event } from "@gjsify/dom-events";
7
6
  import { BridgeEnvironment } from "@gjsify/bridge-types";
7
+ import { HTMLVideoElement } from "@gjsify/dom-elements";
8
8
  import { buildMediaStreamPipeline, buildUriPipeline } from "./pipeline-builder.js";
9
- import { Gst } from "./gst-init.js";
9
+ import { Gst as GstRuntime } from "./gst-init.js";
10
+ const PLAY_ICON = "media-playback-start-symbolic";
11
+ const PAUSE_ICON = "media-playback-pause-symbolic";
12
+ const AUTO_HIDE_SECONDS = 2;
13
+ const POSITION_TICK_MS = 200;
14
+ function formatTime(seconds) {
15
+ if (!isFinite(seconds) || isNaN(seconds)) return "--:--";
16
+ const m = Math.floor(seconds / 60);
17
+ const s = Math.floor(seconds % 60);
18
+ return `${m}:${s.toString().padStart(2, "0")}`;
19
+ }
10
20
  const VideoBridge = GObject.registerClass(
11
21
  { GTypeName: "GjsifyVideoBridge" },
12
22
  class VideoBridge2 extends Gtk.Box {
13
23
  constructor(params) {
14
- super({
15
- ...params,
16
- orientation: Gtk.Orientation.VERTICAL
17
- });
24
+ super({ ...params, orientation: Gtk.Orientation.VERTICAL });
25
+ this._timeOrigin = GLib.get_monotonic_time();
18
26
  this._pipeline = null;
19
- // Gst.Pipeline
27
+ // Bus associated with _pipeline; stored so `_destroyPipeline` can
28
+ // disconnect the handlers + `remove_signal_watch` before the pipeline
29
+ // is nulled. Without cleanup, changing `video.src` repeatedly
30
+ // accumulates handler connections on each pipeline's bus.
31
+ this._pipelineBus = null;
32
+ this._pipelineBusHandlers = [];
20
33
  this._readyCallbacks = [];
21
34
  this._resizeCallbacks = [];
22
- this._timeOrigin = GLib.get_monotonic_time();
23
35
  this._ready = false;
24
- this._picture = new Gtk.Picture();
25
- this._picture.set_hexpand(true);
26
- this._picture.set_vexpand(true);
27
- this.append(this._picture);
36
+ // Control bar + its per-tick change-detection state (null when
37
+ // showControls(false) or never called). Keeping _lastSeekValue /
38
+ // _lastTimeText on the same object lets them live and die with the
39
+ // controls; no separate reset needed.
40
+ this._controls = null;
41
+ this._positionTimerId = null;
42
+ // change-value on seekScale fires on user interaction only; the guard prevents
43
+ // programmatic set_value from bouncing through the signal on some compositors.
44
+ this._updatingFromTimer = false;
45
+ // Auto-hide: a single re-armed GLib source. Mouse motion re-starts the
46
+ // 2s timer by removing and re-adding, so we never pile up pending sources.
47
+ this._hideTimerId = null;
48
+ this._overlay = new Gtk.Overlay({ hexpand: true, vexpand: true });
49
+ this.append(this._overlay);
50
+ this._picture = new Gtk.Picture({ hexpand: true, vexpand: true });
51
+ this._overlay.set_child(this._picture);
28
52
  this._video = new HTMLVideoElement();
29
53
  const host = {
30
54
  performanceNow: () => (GLib.get_monotonic_time() - this._timeOrigin) / 1e3,
31
55
  getWidth: () => this.get_allocated_width(),
32
56
  getHeight: () => this.get_allocated_height(),
33
- getDevicePixelRatio: () => {
34
- const display = this.get_display();
35
- const surface = this.get_native()?.get_surface();
36
- if (surface) return surface.get_scale_factor();
37
- if (display) return display.get_scale?.() ?? 1;
38
- return 1;
39
- }
57
+ getDevicePixelRatio: () => this.get_native()?.get_surface()?.get_scale_factor() ?? 1
40
58
  };
41
59
  this._environment = new BridgeEnvironment(host);
42
60
  this._environment.document.body.appendChild(this._video);
@@ -56,38 +74,30 @@ const VideoBridge = GObject.registerClass(
56
74
  const checkResize = () => {
57
75
  const width = this.get_allocated_width();
58
76
  const height = this.get_allocated_height();
59
- if (width !== lastWidth || height !== lastHeight) {
60
- lastWidth = width;
61
- lastHeight = height;
62
- this._video.dispatchEvent(new Event("resize"));
63
- for (const cb of this._resizeCallbacks) {
64
- cb(width, height);
65
- }
66
- }
77
+ if (width === lastWidth && height === lastHeight) return;
78
+ lastWidth = width;
79
+ lastHeight = height;
80
+ this._video.dispatchEvent(new Event("resize"));
81
+ for (const cb of this._resizeCallbacks) cb(width, height);
67
82
  };
68
83
  this.connect("notify::width-request", checkResize);
69
84
  this.connect("notify::height-request", checkResize);
70
85
  this.connect("map", checkResize);
71
86
  this.connect("unrealize", () => {
72
87
  this._destroyPipeline();
88
+ this._stopPositionTimer();
89
+ this._resizeCallbacks = [];
73
90
  });
74
91
  }
75
- /** The HTMLVideoElement backing this bridge. */
76
92
  get element() {
77
93
  return this._video;
78
94
  }
79
- /** Alias for element — matches browser naming. */
80
95
  get videoElement() {
81
96
  return this._video;
82
97
  }
83
- /** The isolated browser environment for this bridge. */
84
98
  get environment() {
85
99
  return this._environment;
86
100
  }
87
- /**
88
- * Register a callback to be invoked once the video element is ready.
89
- * If already ready, the callback fires synchronously.
90
- */
91
101
  onReady(cb) {
92
102
  if (this._ready) {
93
103
  cb(this._video);
@@ -95,75 +105,214 @@ const VideoBridge = GObject.registerClass(
95
105
  }
96
106
  this._readyCallbacks.push(cb);
97
107
  }
98
- /** Register a callback invoked whenever the widget is resized. */
99
108
  onResize(cb) {
100
109
  this._resizeCallbacks.push(cb);
101
110
  }
102
- /** Sets browser globals for video support. */
103
111
  installGlobals() {
104
112
  globalThis.HTMLVideoElement = HTMLVideoElement;
105
- const timeOrigin = this._timeOrigin;
106
113
  if (typeof globalThis.performance === "undefined") {
114
+ const timeOrigin = this._timeOrigin;
107
115
  globalThis.performance = {
108
116
  now: () => (GLib.get_monotonic_time() - timeOrigin) / 1e3,
109
117
  timeOrigin: Date.now()
110
118
  };
111
119
  }
112
120
  }
113
- /** @internal Handle srcObject change (MediaStream) */
121
+ /**
122
+ * Show or hide the built-in play/pause + seek + time + volume control bar.
123
+ * Controls auto-hide after 2 seconds of mouse inactivity.
124
+ */
125
+ showControls(show = true) {
126
+ if (show && !this._controls) {
127
+ this._controls = this._buildControlBar();
128
+ const { bar } = this._controls;
129
+ bar.set_halign(Gtk.Align.FILL);
130
+ bar.set_valign(Gtk.Align.END);
131
+ bar.set_visible(false);
132
+ this._overlay.add_overlay(bar);
133
+ this._startPositionTimer();
134
+ this._setupAutoHideMotion(bar);
135
+ } else if (!show && this._controls) {
136
+ this._overlay.remove_overlay(this._controls.bar);
137
+ this._controls = null;
138
+ this._stopPositionTimer();
139
+ if (this._hideTimerId !== null) {
140
+ GLib.Source.remove(this._hideTimerId);
141
+ this._hideTimerId = null;
142
+ }
143
+ }
144
+ }
145
+ _setupAutoHideMotion(controlBar) {
146
+ for (const widget of [this, controlBar]) {
147
+ const motion = new Gtk.EventControllerMotion();
148
+ motion.connect("motion", () => this._revealControls());
149
+ motion.connect("enter", () => this._revealControls());
150
+ widget.add_controller(motion);
151
+ }
152
+ }
153
+ _revealControls() {
154
+ if (!this._controls) return;
155
+ this._controls.bar.set_visible(true);
156
+ if (this._hideTimerId !== null) GLib.Source.remove(this._hideTimerId);
157
+ this._hideTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTO_HIDE_SECONDS, () => {
158
+ this._hideTimerId = null;
159
+ this._controls?.bar.set_visible(false);
160
+ return GLib.SOURCE_REMOVE;
161
+ });
162
+ }
163
+ _buildControlBar() {
164
+ const bar = new Gtk.Box({
165
+ orientation: Gtk.Orientation.HORIZONTAL,
166
+ spacing: 6,
167
+ margin_start: 6,
168
+ margin_end: 6,
169
+ margin_top: 4,
170
+ margin_bottom: 4
171
+ });
172
+ bar.add_css_class("osd");
173
+ const playBtn = new Gtk.Button({ icon_name: PAUSE_ICON });
174
+ playBtn.connect("clicked", () => {
175
+ if (this._video.paused) {
176
+ this._video.play();
177
+ playBtn.set_icon_name(PAUSE_ICON);
178
+ } else {
179
+ this._video.pause();
180
+ playBtn.set_icon_name(PLAY_ICON);
181
+ }
182
+ });
183
+ bar.append(playBtn);
184
+ const seekAdj = new Gtk.Adjustment({ lower: 0, upper: 1, step_increment: 1, page_increment: 10 });
185
+ const seekScale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, seekAdj);
186
+ seekScale.set_hexpand(true);
187
+ seekScale.set_draw_value(false);
188
+ seekScale.connect("change-value", (_scale, _scroll, value) => {
189
+ if (!this._updatingFromTimer && isFinite(value)) {
190
+ this._video.currentTime = value;
191
+ }
192
+ return false;
193
+ });
194
+ bar.append(seekScale);
195
+ const timeLabel = new Gtk.Label({ label: "--:-- / --:--", use_markup: false });
196
+ bar.append(timeLabel);
197
+ const volumeBtn = new Gtk.VolumeButton({ value: 1 });
198
+ volumeBtn.connect("value-changed", (_btn, value) => {
199
+ this._video.volume = value;
200
+ });
201
+ bar.append(volumeBtn);
202
+ return { bar, playBtn, seekAdj, seekScale, timeLabel, volumeBtn, lastSeekValue: NaN, lastTimeText: "" };
203
+ }
204
+ _startPositionTimer() {
205
+ if (this._positionTimerId !== null) return;
206
+ this._positionTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POSITION_TICK_MS, () => {
207
+ const controls = this._controls;
208
+ if (!controls) return GLib.SOURCE_REMOVE;
209
+ const cur = this._video.currentTime;
210
+ const dur = this._video.duration;
211
+ if (isFinite(dur) && dur > 0) {
212
+ if (controls.seekAdj.upper !== dur) controls.seekAdj.upper = dur;
213
+ if (cur !== controls.lastSeekValue) {
214
+ this._updatingFromTimer = true;
215
+ controls.seekAdj.set_value(cur);
216
+ this._updatingFromTimer = false;
217
+ controls.lastSeekValue = cur;
218
+ }
219
+ }
220
+ const text = `${formatTime(cur)} / ${formatTime(dur)}`;
221
+ if (text !== controls.lastTimeText) {
222
+ controls.timeLabel.set_label(text);
223
+ controls.lastTimeText = text;
224
+ }
225
+ const icon = this._video.paused ? PLAY_ICON : PAUSE_ICON;
226
+ if (controls.playBtn.get_icon_name() !== icon) controls.playBtn.set_icon_name(icon);
227
+ return GLib.SOURCE_CONTINUE;
228
+ });
229
+ }
230
+ _stopPositionTimer() {
231
+ if (this._positionTimerId !== null) {
232
+ GLib.Source.remove(this._positionTimerId);
233
+ this._positionTimerId = null;
234
+ }
235
+ }
114
236
  _onSrcObjectChange() {
115
237
  this._destroyPipeline();
116
238
  const stream = this._video.srcObject;
117
239
  if (!stream) return;
118
- const videoTracks = stream.getVideoTracks?.() ?? [];
119
- const track = videoTracks.find((t) => t._gstSource != null);
240
+ const tracks = stream.getVideoTracks?.() ?? [];
241
+ const track = tracks.find((t) => t._gstSource != null);
120
242
  if (!track?._gstSource) {
121
243
  console.warn("VideoBridge: MediaStream has no video track with GStreamer source");
122
244
  return;
123
245
  }
124
246
  try {
125
247
  const { pipeline, paintable, tee } = buildMediaStreamPipeline(track._gstSource, track._gstPipeline);
126
- this._pipeline = pipeline;
127
- this._picture.set_paintable(paintable);
248
+ this._attachPipeline(pipeline, paintable);
128
249
  track._gstPipeline = pipeline;
129
250
  track._gstTee = tee;
130
- pipeline.set_state(Gst.State.PLAYING);
131
- this._video.readyState = 4;
132
- this._video.paused = false;
133
- this._video.dispatchEvent(new Event("loadedmetadata"));
134
- this._video.dispatchEvent(new Event("canplay"));
135
- this._video.dispatchEvent(new Event("playing"));
136
251
  } catch (err) {
137
252
  console.error("VideoBridge: Failed to build MediaStream pipeline:", err);
138
253
  }
139
254
  }
140
- /** @internal Handle src change (URI) */
141
255
  _onSrcChange() {
142
256
  this._destroyPipeline();
143
- const src = this._video.src;
144
- if (!src) return;
257
+ if (!this._video.src) return;
145
258
  try {
146
- const { pipeline, paintable } = buildUriPipeline(src);
147
- this._pipeline = pipeline;
148
- this._picture.set_paintable(paintable);
149
- pipeline.set_state(Gst.State.PLAYING);
150
- this._video.readyState = 4;
151
- this._video.paused = false;
152
- this._video.dispatchEvent(new Event("loadedmetadata"));
153
- this._video.dispatchEvent(new Event("canplay"));
154
- this._video.dispatchEvent(new Event("playing"));
259
+ const { pipeline, paintable } = buildUriPipeline(this._video.src);
260
+ this._attachPipeline(pipeline, paintable);
155
261
  } catch (err) {
156
262
  console.error("VideoBridge: Failed to build URI pipeline:", err);
157
263
  }
158
264
  }
159
- /** @internal Tear down the GStreamer pipeline */
265
+ _attachPipeline(pipeline, paintable) {
266
+ this._pipeline = pipeline;
267
+ this._video._pipeline = pipeline;
268
+ this._picture.set_paintable(paintable);
269
+ const bus = pipeline.get_bus();
270
+ if (bus) {
271
+ bus.add_signal_watch();
272
+ this._pipelineBus = bus;
273
+ this._pipelineBusHandlers = [
274
+ bus.connect("message::error", (_b, msg) => {
275
+ const [err, debug] = msg.parse_error();
276
+ console.error(`VideoBridge pipeline error: ${err?.message ?? "unknown"} (${debug ?? ""})`);
277
+ this._video.dispatchEvent(new Event("error"));
278
+ }),
279
+ bus.connect("message::warning", (_b, msg) => {
280
+ const [err, debug] = msg.parse_warning();
281
+ console.warn(`VideoBridge pipeline warning: ${err?.message ?? "unknown"} (${debug ?? ""})`);
282
+ })
283
+ ];
284
+ }
285
+ const ret = pipeline.set_state(GstRuntime.State.PLAYING);
286
+ if (ret === GstRuntime.StateChangeReturn.FAILURE) {
287
+ console.error("VideoBridge: pipeline state change to PLAYING failed");
288
+ }
289
+ this._video.readyState = 4;
290
+ this._video.dispatchEvent(new Event("loadedmetadata"));
291
+ this._video.dispatchEvent(new Event("canplay"));
292
+ this._video.dispatchEvent(new Event("playing"));
293
+ }
160
294
  _destroyPipeline() {
295
+ if (this._pipelineBus) {
296
+ for (const id of this._pipelineBusHandlers) {
297
+ try {
298
+ this._pipelineBus.disconnect(id);
299
+ } catch {
300
+ }
301
+ }
302
+ try {
303
+ this._pipelineBus.remove_signal_watch();
304
+ } catch {
305
+ }
306
+ this._pipelineBus = null;
307
+ this._pipelineBusHandlers = [];
308
+ }
161
309
  if (this._pipeline) {
162
310
  try {
163
- this._pipeline.set_state(Gst.State.NULL);
311
+ this._pipeline.set_state(GstRuntime.State.NULL);
164
312
  } catch {
165
313
  }
166
314
  this._pipeline = null;
315
+ this._video._pipeline = null;
167
316
  }
168
317
  this._picture.set_paintable(null);
169
318
  }
@@ -1,2 +1,3 @@
1
1
  export { VideoBridge } from './video-bridge.js';
2
2
  export { buildMediaStreamPipeline, buildUriPipeline, createPaintableSink } from './pipeline-builder.js';
3
+ export { HTMLVideoElement } from '@gjsify/dom-elements';