I’ve created a custom class that wraps a GStreamer pipeline in GJS (GNOME Shell extension)
using playbin with video output into appsink and audio into autoaudiosink:
const videoBin = new Gst.Bin({ name: 'video-bin' });
const videoConvert = Gst.ElementFactory.make('videoconvert', 'videoconvert');
const videoSink = Gst.ElementFactory.make('appsink', 'video-sink');
if (!videoConvert || !videoSink) {
throw new Error('Failed to create video elements');
}
videoSink.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA'));
videoSink.set_property('max-buffers', 1);
videoSink.set_property('drop', true);
videoSink.set_property('sync', true);
videoBin.add(videoConvert);
videoBin.add(videoSink);
videoConvert.link(videoSink);
const videoGhostPad = Gst.GhostPad.new('sink', videoConvert.get_static_pad('sink'));
videoBin.add_pad(videoGhostPad);
let pipeline = Gst.ElementFactory.make('playbin', 'pipeline');
if (!pipeline) {
throw new Error('Failed to create playbin element')
}
pipeline.set_property('uri', GLib.filename_to_uri(this._videoPath, null));
pipeline.set_property('video-sink', videoBin);
if (this._volume > 0) {
const audioBin = new Gst.Bin({ name: 'audio-bin' });
const audioQueue = Gst.ElementFactory.make('queue2', 'audio-queue');
const audioConvert = Gst.ElementFactory.make('audioconvert', 'audioconvert');
const audioResample = Gst.ElementFactory.make('audioresample', 'audioresample');
const audioSink = Gst.ElementFactory.make('autoaudiosink', 'audio-sink');
if (!audioConvert || !audioResample || !audioSink) {
throw new Error('Failed to create audio elements');
}
audioQueue.set_property('max-size-buffers', 0); // unlimited
audioQueue.set_property('max-size-time', 2 * Gst.SECOND); // 2 second buffer
audioQueue.set_property('max-size-bytes', 0); // unlimited
audioSink.set_property('sync', true);
audioBin.add(audioConvert);
audioBin.add(audioResample);
audioBin.add(audioQueue);
audioBin.add(audioSink);
audioConvert.link(audioResample);
audioResample.link(audioQueue);
audioQueue.link(audioSink);
const audioGhostPad = Gst.GhostPad.new('sink', audioConvert.get_static_pad('sink'));
audioBin.add_pad(audioGhostPad);
pipeline.set_property('audio-sink', audioBin);
pipeline.set_property('volume', this._volume);
} else {
const fakeSink = Gst.ElementFactory.make('fakesink', 'audio-fake');
pipeline.set_property('audio-sink', fakeSink);
}
this._pipeline = pipeline;
this._bus = pipeline.get_bus();
this._videoSink = videoSink;
this._initBusWatch();
const interval = 1000 / this._framerate;
this._timeoutId = this._startFetchTimer(interval)
if (!this._timeoutId) {
throw new Error('Failed to create the fetch timer')
}
this._initialized = true;
return true;
It works well except for two issues:
1. Green frame at video start
Occasionally the first pulled sample from appsink contains a fully green frame before
actual video data appears. I currently skip the first frame as a workaround but this
feels hacky and unreliable. Is there a proper way to ensure the first valid frame is
ready before pulling samples?
2. Click/pop sound on state change and volume change
When transitioning between PLAYING and PAUSED states, or when changing the volume
property on playbin, there is a short clicking sound. I’ve tried:
- autoaudiosink
- pulsesink
- pipewiresink
- Adding queue2 with larger buffers before the sink
- Short volume fade before state change
None of these fully resolve the issue. Is this a known problem with playbin state
transitions in GJS? Is there a recommended way to handle clean pause/resume without
audio artifacts?
GStreamer version: 1.26.10