Files
benthicbloom/lib/liveWallpaper.js
T
Claude 5e8884d01e Allow selecting animated GIFs as live wallpapers
The playback pipeline already typefinds by content and decodes GIFs
via GStreamer's decodebin like any other video, but the preferences
file picker filtered to video/* MIME types only, hiding .gif files.
Widen the filter to include image/gif and *.gif, add an "All files"
fallback, and update the related copy/docs to mention GIF support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8
2026-08-04 00:18:43 +00:00

356 lines
12 KiB
JavaScript

import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Clutter from 'gi://Clutter';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js';
/**
* Plays a video or animated GIF file as a looping animated background.
* `playbin` typefinds the source by content rather than extension, so GIFs
* are decoded through GStreamer's own GIF element (from the "good" plugin
* set) and handled exactly like any other video stream below this point.
*
* Mutter embeds its own private copy of Clutter, so GStreamer video sinks
* that hand back a Clutter actor from a *different* Clutter instance (e.g.
* clutterglsink / ClutterGst) cannot be attached to the shell's stage. To
* stay inside gnome-shell's own Clutter, this decodes frames with a plain
* GStreamer `appsink` (raw pixels only cross that boundary) and uploads
* each frame into a `Clutter.Image` set as the content of a normal actor
* that belongs to gnome-shell itself.
*/
export class LiveWallpaperManager {
constructor(settings, logger) {
this._settings = settings;
this._logger = logger;
this._available = false;
this._active = false;
this._paused = false;
this._onBattery = false;
this._fullscreenActive = false;
this._Gst = null;
this._Cogl = null;
this._playbin = null;
this._appsink = null;
this._actor = null;
this._image = null;
this._videoWidth = 0;
this._videoHeight = 0;
this._settingsSignals = [];
this._busWatchId = 0;
this._monitorsChangedId = 0;
this._fullscreenChangedId = 0;
this._upowerProxy = null;
this._upowerSignalId = 0;
}
get isAvailable() {
return this._available;
}
get isActive() {
return this._active;
}
async enable() {
await this._loadGstreamer();
this._settingsSignals.push(
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => this._sync()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PATH}`, () => this._sync()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_MUTED}`, () => this._applyMute()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE}`, () => this._applyPlaybackRate()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY}`, () => this._updatePauseState()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN}`, () => this._updatePauseState())
);
this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._layoutActor());
this._sync();
}
disable() {
for (const id of this._settingsSignals)
this._settings.disconnect(id);
this._settingsSignals = [];
if (this._monitorsChangedId) {
Main.layoutManager.disconnect(this._monitorsChangedId);
this._monitorsChangedId = 0;
}
this._stop();
}
async _loadGstreamer() {
try {
// GstApp's binding itself is unused, but importing it registers
// GstApp.AppSink with GJS so appsink elements expose 'new-sample'
// and the 'pull-sample' action signal instead of being wrapped
// as a generic, capability-less Gst.Element.
const [{default: Gst}, , {default: Cogl}] = await Promise.all([
import('gi://Gst?version=1.0'),
import('gi://GstApp?version=1.0'),
import('gi://Cogl'),
]);
this._Gst = Gst;
this._Cogl = Cogl;
if (!Gst.is_initialized())
Gst.init(null);
this._available = true;
} catch (e) {
this._available = false;
this._logger.warn(
'Live wallpaper unavailable: GStreamer/Cogl introspection bindings could not be loaded ' +
`(${e.message ?? e}). Install GStreamer with its "good"/"base" plugin sets to enable this feature.`);
}
}
_sync() {
const shouldRun = this._available &&
this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED) &&
this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) !== '';
if (shouldRun)
this._active ? this._restart() : this._start();
else if (this._active)
this._stop();
}
_restart() {
this._stop();
this._start();
}
_start() {
const path = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH);
if (!this._available || !path)
return;
const Gst = this._Gst;
try {
this._actor = new Clutter.Actor({
content_gravity: Clutter.ContentGravity.RESIZE_ASPECT,
reactive: false,
});
Main.layoutManager._backgroundGroup.add_child(this._actor);
this._layoutActor();
const sinkBin = Gst.parse_bin_from_description(
'videoconvert ! videoscale ! video/x-raw,format=RGBA ! ' +
'appsink name=benthicbloom_sink emit-signals=true max-buffers=2 drop=true sync=true',
true);
this._appsink = sinkBin.get_by_name('benthicbloom_sink');
this._appsink.connect('new-sample', sink => this._onNewSample(sink));
this._playbin = Gst.ElementFactory.make('playbin', 'benthicbloom-live-wallpaper');
this._playbin.set_property('video-sink', sinkBin);
this._playbin.set_property('uri', GLib.filename_to_uri(path, null));
const bus = this._playbin.get_bus();
bus.add_signal_watch();
this._busWatchId = bus.connect('message', (_bus, message) => this._onBusMessage(message));
this._applyMute();
this._playbin.set_state(Gst.State.PLAYING);
this._active = true;
this._paused = false;
this._connectPowerWatches();
this._logger.debug(`Live wallpaper started: ${path}`);
} catch (e) {
this._logger.error(e, 'Failed to start live wallpaper');
this._stop();
}
}
_onNewSample(sink) {
const Gst = this._Gst;
const sample = sink.emit('pull-sample');
if (!sample)
return Gst.FlowReturn.OK;
try {
const buffer = sample.get_buffer();
const structure = sample.get_caps().get_structure(0);
const [, width] = structure.get_int('width');
const [, height] = structure.get_int('height');
const [ok, mapInfo] = buffer.map(Gst.MapFlags.READ);
if (!ok)
return Gst.FlowReturn.OK;
try {
this._updateFrame(mapInfo.data, width, height);
} finally {
buffer.unmap(mapInfo);
}
} catch (e) {
this._logger.error(e, 'Failed to process live wallpaper frame');
}
return Gst.FlowReturn.OK;
}
_updateFrame(data, width, height) {
if (!this._actor)
return;
if (!this._image || this._videoWidth !== width || this._videoHeight !== height) {
this._image = new Clutter.Image();
this._videoWidth = width;
this._videoHeight = height;
}
try {
this._image.set_data(data, this._Cogl.PixelFormat.RGBA_8888, width, height, width * 4);
this._actor.set_content(this._image);
} catch (e) {
this._logger.error(e, 'Failed to upload live wallpaper frame');
}
}
_onBusMessage(message) {
const Gst = this._Gst;
switch (message.type) {
case Gst.MessageType.EOS:
this._playbin.seek_simple(
Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0);
break;
case Gst.MessageType.ERROR: {
const [error] = message.parse_error();
this._logger.error(error, 'Live wallpaper playback error');
this._stop();
break;
}
}
}
_applyMute() {
if (this._playbin)
this._playbin.set_property('mute', this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_MUTED));
}
_applyPlaybackRate() {
if (!this._playbin || !this._active)
return;
const Gst = this._Gst;
const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE);
const [ok, position] = this._playbin.query_position(Gst.Format.TIME);
if (!ok)
return;
this._playbin.seek(
rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
Gst.SeekType.SET, position, Gst.SeekType.NONE, -1);
}
_layoutActor() {
if (!this._actor)
return;
this._actor.set_position(0, 0);
this._actor.set_size(global.stage.width, global.stage.height);
}
_connectPowerWatches() {
this._disconnectPowerWatches();
try {
this._fullscreenChangedId = global.display.connect('in-fullscreen-changed', () => this._checkFullscreen());
this._checkFullscreen();
} catch (e) {
this._logger.debug(`Fullscreen tracking unavailable, pause-when-fullscreen disabled (${e.message ?? e})`);
}
try {
this._upowerProxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null,
'org.freedesktop.UPower', '/org/freedesktop/UPower', 'org.freedesktop.UPower', null);
this._upowerSignalId = this._upowerProxy.connect(
'g-properties-changed', () => this._checkBattery());
this._checkBattery();
} catch (e) {
this._logger.debug(`UPower unavailable, pause-on-battery disabled (${e.message ?? e})`);
}
}
_disconnectPowerWatches() {
if (this._fullscreenChangedId) {
global.display.disconnect(this._fullscreenChangedId);
this._fullscreenChangedId = 0;
}
if (this._upowerProxy && this._upowerSignalId) {
this._upowerProxy.disconnect(this._upowerSignalId);
this._upowerSignalId = 0;
}
this._upowerProxy = null;
this._onBattery = false;
this._fullscreenActive = false;
}
_checkFullscreen() {
try {
const nMonitors = global.display.get_n_monitors();
this._fullscreenActive = Array.from({length: nMonitors}, (_, i) => i)
.some(i => global.display.get_monitor_in_fullscreen(i));
} catch (e) {
this._fullscreenActive = false;
}
this._updatePauseState();
}
_checkBattery() {
const value = this._upowerProxy?.get_cached_property('OnBattery');
this._onBattery = value ? value.get_boolean() : false;
this._updatePauseState();
}
_updatePauseState() {
const shouldPause =
(this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY) && this._onBattery) ||
(this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN) && this._fullscreenActive);
this._setPaused(shouldPause);
}
_setPaused(paused) {
if (!this._playbin || this._paused === paused)
return;
this._paused = paused;
this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING);
}
_stop() {
this._disconnectPowerWatches();
if (this._playbin) {
const bus = this._playbin.get_bus();
if (this._busWatchId) {
bus.disconnect(this._busWatchId);
this._busWatchId = 0;
}
bus.remove_signal_watch();
this._playbin.set_state(this._Gst.State.NULL);
this._playbin = null;
this._appsink = null;
}
this._actor?.destroy();
this._actor = null;
this._image = null;
this._videoWidth = 0;
this._videoHeight = 0;
this._active = false;
this._paused = false;
}
}