@gjsify/video 0.1.15 → 0.3.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/lib/esm/gst-init.js +2 -5
- package/lib/esm/index.js +1 -5
- package/lib/esm/video-bridge.js +211 -62
- package/lib/types/index.d.ts +1 -0
- package/lib/types/video-bridge.d.ts +156 -134
- package/package.json +14 -13
- package/src/gst-init.ts +2 -5
- package/src/index.ts +2 -9
- package/src/video-bridge.ts +277 -113
- package/tmp/.tsbuildinfo +1 -1
package/lib/esm/gst-init.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/lib/esm/video-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
|
119
|
-
const track =
|
|
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.
|
|
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
|
-
|
|
144
|
-
if (!src) return;
|
|
257
|
+
if (!this._video.src) return;
|
|
145
258
|
try {
|
|
146
|
-
const { pipeline, paintable } = buildUriPipeline(src);
|
|
147
|
-
this.
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED