@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/src/video-bridge.ts
CHANGED
|
@@ -1,92 +1,130 @@
|
|
|
1
|
-
// VideoBridge GTK container
|
|
2
|
-
//
|
|
1
|
+
// VideoBridge — GTK container that bridges HTMLVideoElement to GStreamer video.
|
|
2
|
+
// Gtk.Box → Gtk.Overlay → (Gtk.Picture + optional control bar).
|
|
3
|
+
// Controls float over the video (valign=END), start hidden, reveal on mouse
|
|
4
|
+
// motion, and auto-hide after 2s of inactivity like browser video players.
|
|
3
5
|
//
|
|
4
|
-
// Reference: refs/showtime/showtime/play.py (gtk4paintablesink +
|
|
5
|
-
// Pattern follows packages/dom/canvas2d/src/canvas-drawing-area.ts
|
|
6
|
+
// Reference: refs/showtime/showtime/play.py (gtk4paintablesink + glsinkbin).
|
|
7
|
+
// Pattern follows packages/dom/canvas2d/src/canvas-drawing-area.ts.
|
|
6
8
|
|
|
7
9
|
import GObject from 'gi://GObject';
|
|
8
10
|
import GLib from 'gi://GLib?version=2.0';
|
|
9
11
|
import Gtk from 'gi://Gtk?version=4.0';
|
|
10
|
-
import
|
|
12
|
+
import type Gst from 'gi://Gst?version=1.0';
|
|
11
13
|
import { attachEventControllers } from '@gjsify/event-bridge';
|
|
12
14
|
import { Event } from '@gjsify/dom-events';
|
|
13
15
|
import { BridgeEnvironment } from '@gjsify/bridge-types';
|
|
14
16
|
import type { BridgeWindowHost } from '@gjsify/bridge-types';
|
|
15
17
|
|
|
18
|
+
import { HTMLVideoElement } from '@gjsify/dom-elements';
|
|
16
19
|
import { buildMediaStreamPipeline, buildUriPipeline } from './pipeline-builder.js';
|
|
17
|
-
import { Gst } from './gst-init.js';
|
|
20
|
+
import { Gst as GstRuntime } from './gst-init.js';
|
|
18
21
|
|
|
19
22
|
type VideoReadyCallback = (video: globalThis.HTMLVideoElement) => void;
|
|
20
23
|
|
|
24
|
+
type GstSourceTrack = MediaStreamTrack & {
|
|
25
|
+
_gstSource?: unknown;
|
|
26
|
+
_gstPipeline?: unknown;
|
|
27
|
+
_gstTee?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const PLAY_ICON = 'media-playback-start-symbolic';
|
|
31
|
+
const PAUSE_ICON = 'media-playback-pause-symbolic';
|
|
32
|
+
const AUTO_HIDE_SECONDS = 2;
|
|
33
|
+
const POSITION_TICK_MS = 200;
|
|
34
|
+
|
|
35
|
+
function formatTime(seconds: number): string {
|
|
36
|
+
if (!isFinite(seconds) || isNaN(seconds)) return '--:--';
|
|
37
|
+
const m = Math.floor(seconds / 60);
|
|
38
|
+
const s = Math.floor(seconds % 60);
|
|
39
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
|
-
* A `Gtk.Box` subclass that hosts a `Gtk.Picture` for video rendering
|
|
23
|
-
*
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
* - Supports `video.
|
|
27
|
-
* -
|
|
28
|
-
* - `
|
|
43
|
+
* A `Gtk.Box` subclass that hosts a `Gtk.Picture` for video rendering.
|
|
44
|
+
*
|
|
45
|
+
* - Owns an `HTMLVideoElement` whose DOM API is wired to GStreamer
|
|
46
|
+
* - Renders video via `gtk4paintablesink` → `Gdk.Paintable` → `Gtk.Picture`
|
|
47
|
+
* - Supports `video.srcObject = mediaStream` (getUserMedia / WebRTC)
|
|
48
|
+
* - Supports `video.src = 'file://…'` or HTTP URL (URI playback via playbin)
|
|
49
|
+
* - `onReady(cb)` fires with the HTMLVideoElement when the widget realizes
|
|
50
|
+
* - `showControls(true)` appends a play/pause + seek + time + volume bar
|
|
29
51
|
*
|
|
30
|
-
* Usage:
|
|
31
52
|
* ```ts
|
|
32
53
|
* const bridge = new VideoBridge();
|
|
33
|
-
* bridge.
|
|
34
|
-
* bridge.onReady((video) => {
|
|
35
|
-
* video.srcObject = mediaStream; // from getUserMedia
|
|
36
|
-
* });
|
|
54
|
+
* bridge.showControls(true);
|
|
55
|
+
* bridge.onReady((video) => { video.src = 'https://example.com/video.mp4'; });
|
|
37
56
|
* window.set_child(bridge);
|
|
38
57
|
* ```
|
|
39
58
|
*/
|
|
40
59
|
export const VideoBridge = GObject.registerClass(
|
|
41
60
|
{ GTypeName: 'GjsifyVideoBridge' },
|
|
42
61
|
class VideoBridge extends Gtk.Box {
|
|
62
|
+
// GObject.registerClass produces an anonymous class, so TS requires that fields
|
|
63
|
+
// referenced from the InstanceType alias be non-private. Prefixed with `_` as the
|
|
64
|
+
// convention for "implementation detail, not meant for external use".
|
|
65
|
+
_overlay: Gtk.Overlay;
|
|
43
66
|
_picture: Gtk.Picture;
|
|
44
67
|
_video: HTMLVideoElement;
|
|
45
|
-
_pipeline: any | null = null; // Gst.Pipeline
|
|
46
|
-
_readyCallbacks: VideoReadyCallback[] = [];
|
|
47
|
-
_resizeCallbacks: ((w: number, h: number) => void)[] = [];
|
|
48
68
|
_environment: BridgeEnvironment;
|
|
49
69
|
_timeOrigin: number = GLib.get_monotonic_time();
|
|
70
|
+
_pipeline: Gst.Pipeline | null = null;
|
|
71
|
+
// Bus associated with _pipeline; stored so `_destroyPipeline` can
|
|
72
|
+
// disconnect the handlers + `remove_signal_watch` before the pipeline
|
|
73
|
+
// is nulled. Without cleanup, changing `video.src` repeatedly
|
|
74
|
+
// accumulates handler connections on each pipeline's bus.
|
|
75
|
+
_pipelineBus: Gst.Bus | null = null;
|
|
76
|
+
_pipelineBusHandlers: number[] = [];
|
|
77
|
+
_readyCallbacks: VideoReadyCallback[] = [];
|
|
78
|
+
_resizeCallbacks: ((w: number, h: number) => void)[] = [];
|
|
50
79
|
_ready = false;
|
|
51
80
|
|
|
81
|
+
// Control bar + its per-tick change-detection state (null when
|
|
82
|
+
// showControls(false) or never called). Keeping _lastSeekValue /
|
|
83
|
+
// _lastTimeText on the same object lets them live and die with the
|
|
84
|
+
// controls; no separate reset needed.
|
|
85
|
+
_controls: {
|
|
86
|
+
bar: Gtk.Box;
|
|
87
|
+
playBtn: Gtk.Button;
|
|
88
|
+
seekAdj: Gtk.Adjustment;
|
|
89
|
+
seekScale: Gtk.Scale;
|
|
90
|
+
timeLabel: Gtk.Label;
|
|
91
|
+
volumeBtn: Gtk.VolumeButton;
|
|
92
|
+
lastSeekValue: number;
|
|
93
|
+
lastTimeText: string;
|
|
94
|
+
} | null = null;
|
|
95
|
+
_positionTimerId: number | null = null;
|
|
96
|
+
// change-value on seekScale fires on user interaction only; the guard prevents
|
|
97
|
+
// programmatic set_value from bouncing through the signal on some compositors.
|
|
98
|
+
_updatingFromTimer = false;
|
|
99
|
+
// Auto-hide: a single re-armed GLib source. Mouse motion re-starts the
|
|
100
|
+
// 2s timer by removing and re-adding, so we never pile up pending sources.
|
|
101
|
+
_hideTimerId: number | null = null;
|
|
102
|
+
|
|
52
103
|
constructor(params?: Partial<Gtk.Box.ConstructorProps>) {
|
|
53
|
-
super({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
super({ ...params, orientation: Gtk.Orientation.VERTICAL });
|
|
105
|
+
|
|
106
|
+
this._overlay = new Gtk.Overlay({ hexpand: true, vexpand: true });
|
|
107
|
+
this.append(this._overlay);
|
|
57
108
|
|
|
58
|
-
this._picture = new Gtk.Picture();
|
|
59
|
-
this.
|
|
60
|
-
this._picture.set_vexpand(true);
|
|
61
|
-
this.append(this._picture);
|
|
109
|
+
this._picture = new Gtk.Picture({ hexpand: true, vexpand: true });
|
|
110
|
+
this._overlay.set_child(this._picture);
|
|
62
111
|
|
|
63
|
-
// Create the DOM element
|
|
64
112
|
this._video = new HTMLVideoElement();
|
|
65
113
|
|
|
66
|
-
// Set up the bridge environment
|
|
67
114
|
const host: BridgeWindowHost = {
|
|
68
115
|
performanceNow: () => (GLib.get_monotonic_time() - this._timeOrigin) / 1000,
|
|
69
116
|
getWidth: () => this.get_allocated_width(),
|
|
70
117
|
getHeight: () => this.get_allocated_height(),
|
|
71
|
-
getDevicePixelRatio: () =>
|
|
72
|
-
const display = this.get_display();
|
|
73
|
-
const surface = this.get_native()?.get_surface();
|
|
74
|
-
if (surface) return surface.get_scale_factor();
|
|
75
|
-
if (display) return (display as any).get_scale?.() ?? 1;
|
|
76
|
-
return 1;
|
|
77
|
-
},
|
|
118
|
+
getDevicePixelRatio: () => this.get_native()?.get_surface()?.get_scale_factor() ?? 1,
|
|
78
119
|
};
|
|
79
120
|
this._environment = new BridgeEnvironment(host);
|
|
80
121
|
this._environment.document.body.appendChild(this._video);
|
|
81
122
|
|
|
82
|
-
// Bridge GTK events → DOM events on the video element
|
|
83
123
|
attachEventControllers(this, () => this._video);
|
|
84
124
|
|
|
85
|
-
// Listen for srcObject / src changes on the HTMLVideoElement
|
|
86
125
|
this._video.addEventListener('srcobjectchange', () => this._onSrcObjectChange());
|
|
87
126
|
this._video.addEventListener('srcchange', () => this._onSrcChange());
|
|
88
127
|
|
|
89
|
-
// Fire ready callbacks once the widget is realized
|
|
90
128
|
this.connect('realize', () => {
|
|
91
129
|
if (this._ready) return;
|
|
92
130
|
this._ready = true;
|
|
@@ -96,52 +134,32 @@ export const VideoBridge = GObject.registerClass(
|
|
|
96
134
|
this._readyCallbacks = [];
|
|
97
135
|
});
|
|
98
136
|
|
|
99
|
-
// Handle resize — Gtk.Box has no 'resize' signal (unlike DrawingArea/GLArea).
|
|
100
|
-
// Use notify on allocation width/height instead.
|
|
101
137
|
let lastWidth = 0;
|
|
102
138
|
let lastHeight = 0;
|
|
103
139
|
const checkResize = () => {
|
|
104
140
|
const width = this.get_allocated_width();
|
|
105
141
|
const height = this.get_allocated_height();
|
|
106
|
-
if (width
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
cb(width, height);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
142
|
+
if (width === lastWidth && height === lastHeight) return;
|
|
143
|
+
lastWidth = width;
|
|
144
|
+
lastHeight = height;
|
|
145
|
+
this._video.dispatchEvent(new Event('resize'));
|
|
146
|
+
for (const cb of this._resizeCallbacks) cb(width, height);
|
|
114
147
|
};
|
|
115
148
|
this.connect('notify::width-request', checkResize);
|
|
116
149
|
this.connect('notify::height-request', checkResize);
|
|
117
|
-
// Also check after the widget is mapped and sized
|
|
118
150
|
this.connect('map', checkResize);
|
|
119
151
|
|
|
120
|
-
// Cleanup on unrealize
|
|
121
152
|
this.connect('unrealize', () => {
|
|
122
153
|
this._destroyPipeline();
|
|
154
|
+
this._stopPositionTimer();
|
|
155
|
+
this._resizeCallbacks = [];
|
|
123
156
|
});
|
|
124
157
|
}
|
|
125
158
|
|
|
126
|
-
|
|
127
|
-
get
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Alias for element — matches browser naming. */
|
|
132
|
-
get videoElement(): HTMLVideoElement {
|
|
133
|
-
return this._video;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** The isolated browser environment for this bridge. */
|
|
137
|
-
get environment(): BridgeEnvironment {
|
|
138
|
-
return this._environment;
|
|
139
|
-
}
|
|
159
|
+
get element(): HTMLVideoElement { return this._video; }
|
|
160
|
+
get videoElement(): HTMLVideoElement { return this._video; }
|
|
161
|
+
get environment(): BridgeEnvironment { return this._environment; }
|
|
140
162
|
|
|
141
|
-
/**
|
|
142
|
-
* Register a callback to be invoked once the video element is ready.
|
|
143
|
-
* If already ready, the callback fires synchronously.
|
|
144
|
-
*/
|
|
145
163
|
onReady(cb: VideoReadyCallback): void {
|
|
146
164
|
if (this._ready) {
|
|
147
165
|
cb(this._video as unknown as globalThis.HTMLVideoElement);
|
|
@@ -150,34 +168,160 @@ export const VideoBridge = GObject.registerClass(
|
|
|
150
168
|
this._readyCallbacks.push(cb);
|
|
151
169
|
}
|
|
152
170
|
|
|
153
|
-
/** Register a callback invoked whenever the widget is resized. */
|
|
154
171
|
onResize(cb: (width: number, height: number) => void): void {
|
|
155
172
|
this._resizeCallbacks.push(cb);
|
|
156
173
|
}
|
|
157
174
|
|
|
158
|
-
/** Sets browser globals for video support. */
|
|
159
175
|
installGlobals(): void {
|
|
160
|
-
(globalThis as
|
|
176
|
+
(globalThis as { HTMLVideoElement?: unknown }).HTMLVideoElement = HTMLVideoElement;
|
|
161
177
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
(globalThis as
|
|
178
|
+
if (typeof (globalThis as { performance?: unknown }).performance === 'undefined') {
|
|
179
|
+
const timeOrigin = this._timeOrigin;
|
|
180
|
+
(globalThis as { performance?: unknown }).performance = {
|
|
165
181
|
now: () => (GLib.get_monotonic_time() - timeOrigin) / 1000,
|
|
166
182
|
timeOrigin: Date.now(),
|
|
167
183
|
};
|
|
168
184
|
}
|
|
169
185
|
}
|
|
170
186
|
|
|
171
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Show or hide the built-in play/pause + seek + time + volume control bar.
|
|
189
|
+
* Controls auto-hide after 2 seconds of mouse inactivity.
|
|
190
|
+
*/
|
|
191
|
+
showControls(show = true): void {
|
|
192
|
+
if (show && !this._controls) {
|
|
193
|
+
this._controls = this._buildControlBar();
|
|
194
|
+
const { bar } = this._controls;
|
|
195
|
+
bar.set_halign(Gtk.Align.FILL);
|
|
196
|
+
bar.set_valign(Gtk.Align.END);
|
|
197
|
+
bar.set_visible(false);
|
|
198
|
+
this._overlay.add_overlay(bar);
|
|
199
|
+
this._startPositionTimer();
|
|
200
|
+
this._setupAutoHideMotion(bar);
|
|
201
|
+
} else if (!show && this._controls) {
|
|
202
|
+
this._overlay.remove_overlay(this._controls.bar);
|
|
203
|
+
this._controls = null;
|
|
204
|
+
this._stopPositionTimer();
|
|
205
|
+
if (this._hideTimerId !== null) {
|
|
206
|
+
GLib.Source.remove(this._hideTimerId);
|
|
207
|
+
this._hideTimerId = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_setupAutoHideMotion(controlBar: Gtk.Box): void {
|
|
213
|
+
for (const widget of [this, controlBar] as const) {
|
|
214
|
+
const motion = new Gtk.EventControllerMotion();
|
|
215
|
+
motion.connect('motion', () => this._revealControls());
|
|
216
|
+
motion.connect('enter', () => this._revealControls());
|
|
217
|
+
widget.add_controller(motion);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_revealControls(): void {
|
|
222
|
+
if (!this._controls) return;
|
|
223
|
+
this._controls.bar.set_visible(true);
|
|
224
|
+
if (this._hideTimerId !== null) GLib.Source.remove(this._hideTimerId);
|
|
225
|
+
this._hideTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTO_HIDE_SECONDS, () => {
|
|
226
|
+
this._hideTimerId = null;
|
|
227
|
+
this._controls?.bar.set_visible(false);
|
|
228
|
+
return GLib.SOURCE_REMOVE;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_buildControlBar(): NonNullable<VideoBridge['_controls']> {
|
|
233
|
+
const bar = new Gtk.Box({
|
|
234
|
+
orientation: Gtk.Orientation.HORIZONTAL,
|
|
235
|
+
spacing: 6,
|
|
236
|
+
margin_start: 6,
|
|
237
|
+
margin_end: 6,
|
|
238
|
+
margin_top: 4,
|
|
239
|
+
margin_bottom: 4,
|
|
240
|
+
});
|
|
241
|
+
// OSD class: semi-transparent dark background for legibility over video.
|
|
242
|
+
bar.add_css_class('osd');
|
|
243
|
+
|
|
244
|
+
const playBtn = new Gtk.Button({ icon_name: PAUSE_ICON });
|
|
245
|
+
playBtn.connect('clicked', () => {
|
|
246
|
+
if (this._video.paused) {
|
|
247
|
+
this._video.play();
|
|
248
|
+
playBtn.set_icon_name(PAUSE_ICON);
|
|
249
|
+
} else {
|
|
250
|
+
this._video.pause();
|
|
251
|
+
playBtn.set_icon_name(PLAY_ICON);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
bar.append(playBtn);
|
|
255
|
+
|
|
256
|
+
const seekAdj = new Gtk.Adjustment({ lower: 0, upper: 1, step_increment: 1, page_increment: 10 });
|
|
257
|
+
const seekScale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, seekAdj);
|
|
258
|
+
seekScale.set_hexpand(true);
|
|
259
|
+
seekScale.set_draw_value(false);
|
|
260
|
+
seekScale.connect('change-value', (_scale, _scroll, value) => {
|
|
261
|
+
if (!this._updatingFromTimer && isFinite(value)) {
|
|
262
|
+
this._video.currentTime = value;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
});
|
|
266
|
+
bar.append(seekScale);
|
|
267
|
+
|
|
268
|
+
const timeLabel = new Gtk.Label({ label: '--:-- / --:--', use_markup: false });
|
|
269
|
+
bar.append(timeLabel);
|
|
270
|
+
|
|
271
|
+
const volumeBtn = new Gtk.VolumeButton({ value: 1.0 });
|
|
272
|
+
volumeBtn.connect('value-changed', (_btn, value) => { this._video.volume = value; });
|
|
273
|
+
bar.append(volumeBtn);
|
|
274
|
+
|
|
275
|
+
return { bar, playBtn, seekAdj, seekScale, timeLabel, volumeBtn, lastSeekValue: NaN, lastTimeText: '' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_startPositionTimer(): void {
|
|
279
|
+
if (this._positionTimerId !== null) return;
|
|
280
|
+
this._positionTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POSITION_TICK_MS, () => {
|
|
281
|
+
const controls = this._controls;
|
|
282
|
+
if (!controls) return GLib.SOURCE_REMOVE;
|
|
283
|
+
|
|
284
|
+
const cur = this._video.currentTime;
|
|
285
|
+
const dur = this._video.duration;
|
|
286
|
+
|
|
287
|
+
if (isFinite(dur) && dur > 0) {
|
|
288
|
+
if (controls.seekAdj.upper !== dur) controls.seekAdj.upper = dur;
|
|
289
|
+
if (cur !== controls.lastSeekValue) {
|
|
290
|
+
this._updatingFromTimer = true;
|
|
291
|
+
controls.seekAdj.set_value(cur);
|
|
292
|
+
this._updatingFromTimer = false;
|
|
293
|
+
controls.lastSeekValue = cur;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const text = `${formatTime(cur)} / ${formatTime(dur)}`;
|
|
298
|
+
if (text !== controls.lastTimeText) {
|
|
299
|
+
controls.timeLabel.set_label(text);
|
|
300
|
+
controls.lastTimeText = text;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const icon = this._video.paused ? PLAY_ICON : PAUSE_ICON;
|
|
304
|
+
if (controls.playBtn.get_icon_name() !== icon) controls.playBtn.set_icon_name(icon);
|
|
305
|
+
|
|
306
|
+
return GLib.SOURCE_CONTINUE;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
_stopPositionTimer(): void {
|
|
311
|
+
if (this._positionTimerId !== null) {
|
|
312
|
+
GLib.Source.remove(this._positionTimerId);
|
|
313
|
+
this._positionTimerId = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
172
317
|
_onSrcObjectChange(): void {
|
|
173
318
|
this._destroyPipeline();
|
|
174
319
|
|
|
175
320
|
const stream = this._video.srcObject;
|
|
176
321
|
if (!stream) return;
|
|
177
322
|
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const track = videoTracks.find((t: any) => t._gstSource != null);
|
|
323
|
+
const tracks = stream.getVideoTracks?.() as GstSourceTrack[] | undefined ?? [];
|
|
324
|
+
const track = tracks.find((t) => t._gstSource != null);
|
|
181
325
|
if (!track?._gstSource) {
|
|
182
326
|
console.warn('VideoBridge: MediaStream has no video track with GStreamer source');
|
|
183
327
|
return;
|
|
@@ -185,57 +329,77 @@ export const VideoBridge = GObject.registerClass(
|
|
|
185
329
|
|
|
186
330
|
try {
|
|
187
331
|
const { pipeline, paintable, tee } = buildMediaStreamPipeline(track._gstSource, track._gstPipeline);
|
|
188
|
-
this.
|
|
189
|
-
this._picture.set_paintable(paintable);
|
|
190
|
-
|
|
191
|
-
// Update the track's pipeline and tee references so that other
|
|
192
|
-
// consumers (e.g. RTCPeerConnection.addTrack) can find the source
|
|
193
|
-
// in this pipeline and request tee branches without cross-pipeline issues.
|
|
332
|
+
this._attachPipeline(pipeline, paintable);
|
|
194
333
|
track._gstPipeline = pipeline;
|
|
195
334
|
track._gstTee = tee;
|
|
196
|
-
|
|
197
|
-
pipeline.set_state(Gst.State.PLAYING);
|
|
198
|
-
|
|
199
|
-
this._video.readyState = 4; // HAVE_ENOUGH_DATA
|
|
200
|
-
this._video.paused = false;
|
|
201
|
-
this._video.dispatchEvent(new Event('loadedmetadata'));
|
|
202
|
-
this._video.dispatchEvent(new Event('canplay'));
|
|
203
|
-
this._video.dispatchEvent(new Event('playing'));
|
|
204
335
|
} catch (err) {
|
|
205
336
|
console.error('VideoBridge: Failed to build MediaStream pipeline:', err);
|
|
206
337
|
}
|
|
207
338
|
}
|
|
208
339
|
|
|
209
|
-
/** @internal Handle src change (URI) */
|
|
210
340
|
_onSrcChange(): void {
|
|
211
341
|
this._destroyPipeline();
|
|
212
|
-
|
|
213
|
-
const src = this._video.src;
|
|
214
|
-
if (!src) return;
|
|
342
|
+
if (!this._video.src) return;
|
|
215
343
|
|
|
216
344
|
try {
|
|
217
|
-
const { pipeline, paintable } = buildUriPipeline(src);
|
|
218
|
-
this.
|
|
219
|
-
this._picture.set_paintable(paintable);
|
|
220
|
-
pipeline.set_state(Gst.State.PLAYING);
|
|
221
|
-
|
|
222
|
-
this._video.readyState = 4;
|
|
223
|
-
this._video.paused = false;
|
|
224
|
-
this._video.dispatchEvent(new Event('loadedmetadata'));
|
|
225
|
-
this._video.dispatchEvent(new Event('canplay'));
|
|
226
|
-
this._video.dispatchEvent(new Event('playing'));
|
|
345
|
+
const { pipeline, paintable } = buildUriPipeline(this._video.src);
|
|
346
|
+
this._attachPipeline(pipeline, paintable);
|
|
227
347
|
} catch (err) {
|
|
228
348
|
console.error('VideoBridge: Failed to build URI pipeline:', err);
|
|
229
349
|
}
|
|
230
350
|
}
|
|
231
351
|
|
|
232
|
-
|
|
352
|
+
_attachPipeline(pipeline: Gst.Pipeline, paintable: Parameters<Gtk.Picture['set_paintable']>[0]): void {
|
|
353
|
+
this._pipeline = pipeline;
|
|
354
|
+
this._video._pipeline = pipeline;
|
|
355
|
+
this._picture.set_paintable(paintable);
|
|
356
|
+
|
|
357
|
+
// Bus watch surfaces pipeline errors and warnings (missing decoder,
|
|
358
|
+
// http source failure, missing plugin, etc.). Without this, playbin
|
|
359
|
+
// can fail to preroll and sit silently in READY forever. Handler
|
|
360
|
+
// ids + bus are stashed on the instance so `_destroyPipeline` can
|
|
361
|
+
// disconnect them — otherwise each pipeline swap (new video.src)
|
|
362
|
+
// leaks a set of signal handlers on the freed bus.
|
|
363
|
+
const bus = pipeline.get_bus();
|
|
364
|
+
if (bus) {
|
|
365
|
+
bus.add_signal_watch();
|
|
366
|
+
this._pipelineBus = bus;
|
|
367
|
+
this._pipelineBusHandlers = [
|
|
368
|
+
bus.connect('message::error', (_b, msg) => {
|
|
369
|
+
const [err, debug] = msg.parse_error();
|
|
370
|
+
console.error(`VideoBridge pipeline error: ${err?.message ?? 'unknown'} (${debug ?? ''})`);
|
|
371
|
+
this._video.dispatchEvent(new Event('error'));
|
|
372
|
+
}),
|
|
373
|
+
bus.connect('message::warning', (_b, msg) => {
|
|
374
|
+
const [err, debug] = msg.parse_warning();
|
|
375
|
+
console.warn(`VideoBridge pipeline warning: ${err?.message ?? 'unknown'} (${debug ?? ''})`);
|
|
376
|
+
}),
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const ret = pipeline.set_state(GstRuntime.State.PLAYING);
|
|
381
|
+
if (ret === GstRuntime.StateChangeReturn.FAILURE) {
|
|
382
|
+
console.error('VideoBridge: pipeline state change to PLAYING failed');
|
|
383
|
+
}
|
|
384
|
+
this._video.readyState = 4;
|
|
385
|
+
this._video.dispatchEvent(new Event('loadedmetadata'));
|
|
386
|
+
this._video.dispatchEvent(new Event('canplay'));
|
|
387
|
+
this._video.dispatchEvent(new Event('playing'));
|
|
388
|
+
}
|
|
389
|
+
|
|
233
390
|
_destroyPipeline(): void {
|
|
391
|
+
if (this._pipelineBus) {
|
|
392
|
+
for (const id of this._pipelineBusHandlers) {
|
|
393
|
+
try { this._pipelineBus.disconnect(id); } catch { /* ignore */ }
|
|
394
|
+
}
|
|
395
|
+
try { this._pipelineBus.remove_signal_watch(); } catch { /* ignore */ }
|
|
396
|
+
this._pipelineBus = null;
|
|
397
|
+
this._pipelineBusHandlers = [];
|
|
398
|
+
}
|
|
234
399
|
if (this._pipeline) {
|
|
235
|
-
try {
|
|
236
|
-
this._pipeline.set_state(Gst.State.NULL);
|
|
237
|
-
} catch { /* ignore */ }
|
|
400
|
+
try { this._pipeline.set_state(GstRuntime.State.NULL); } catch { /* ignore */ }
|
|
238
401
|
this._pipeline = null;
|
|
402
|
+
this._video._pipeline = null;
|
|
239
403
|
}
|
|
240
404
|
this._picture.set_paintable(null);
|
|
241
405
|
}
|