From c0ec4a59d501ca8ca87f00915927bcfbf50a694a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:05:58 +0000 Subject: [PATCH 01/12] Implement BenthicBloom GNOME 50 wallpaper extension Adds a complete GNOME Shell 50 extension with folder-based wallpaper auto-rotation (shuffle/sequential, crossfade transitions, lock screen sync), GStreamer-backed live/video wallpapers rendered onto the shell's own Clutter stage, and OLED burn-in protection (pixel shifting, idle dimming, forced periodic rotation). Includes a top-bar quick-access indicator, a libadwaita preferences window, GSettings schema, and a Makefile for install/packaging. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- .gitignore | 3 + Makefile | 32 ++ README.md | 129 ++++++- extension.js | 76 ++++ lib/indicator.js | 73 ++++ lib/liveWallpaper.js | 352 ++++++++++++++++++ lib/logger.js | 26 ++ lib/oledProtection.js | 269 +++++++++++++ lib/rotationManager.js | 221 +++++++++++ lib/settingsKeys.js | 34 ++ lib/shuffleBag.js | 49 +++ lib/wallpaperSource.js | 74 ++++ metadata.json | 11 + prefs.js | 318 ++++++++++++++++ ....shell.extensions.benthicbloom.gschema.xml | 164 ++++++++ stylesheet.css | 4 + 16 files changed, 1834 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 extension.js create mode 100644 lib/indicator.js create mode 100644 lib/liveWallpaper.js create mode 100644 lib/logger.js create mode 100644 lib/oledProtection.js create mode 100644 lib/rotationManager.js create mode 100644 lib/settingsKeys.js create mode 100644 lib/shuffleBag.js create mode 100644 lib/wallpaperSource.js create mode 100644 metadata.json create mode 100644 prefs.js create mode 100644 schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml create mode 100644 stylesheet.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f92896c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +schemas/gschemas.compiled +dist/ +*.zip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..827c2ed --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +UUID = benthicbloom@quinta0.github.io +EXTENSIONS_DIR = $(HOME)/.local/share/gnome-shell/extensions +INSTALL_DIR = $(EXTENSIONS_DIR)/$(UUID) +SCHEMA_DIR = schemas + +.PHONY: all schemas build install uninstall pack clean + +all: build + +schemas: + glib-compile-schemas $(SCHEMA_DIR) + +build: schemas + +install: build + mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas + cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/ + cp lib/*.js $(INSTALL_DIR)/lib/ + cp $(SCHEMA_DIR)/*.xml $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/ + @echo "Installed to $(INSTALL_DIR)" + @echo "Reload GNOME Shell (Alt+F2, r, Enter on X11; log out/in on Wayland), then run:" + @echo " gnome-extensions enable $(UUID)" + +uninstall: + rm -rf $(INSTALL_DIR) + +pack: schemas + gnome-extensions pack --force --extra-source=lib -o dist . + +clean: + rm -f $(SCHEMA_DIR)/gschemas.compiled + rm -rf dist diff --git a/README.md b/README.md index 799d163..426773e 100644 --- a/README.md +++ b/README.md @@ -1 +1,128 @@ -# benthicbloom \ No newline at end of file +# BenthicBloom + +A GNOME Shell 50 extension that automatically rotates your wallpaper, +supports looping video (live) wallpapers, and includes built-in +protection against OLED burn-in. + +## Features + +- **Auto rotation** — pick one or more folders and BenthicBloom cycles + through the images inside them on a timer, in sequential or shuffled + order, with an optional crossfade between changes. The chosen wallpaper + can also be applied to the lock screen. +- **Live wallpapers** — play a looping video file as your desktop + background. Frames are decoded with GStreamer and rendered directly by + GNOME Shell's own Clutter stage (no external, incompatible Clutter + build involved). Playback can automatically pause on battery power or + while a window is fullscreen. +- **OLED burn-in protection** — three independent, individually toggled + techniques: + - *Pixel shifting*: nudges the background a few pixels on a slow drift + cycle so the same subpixels aren't lit continuously. + - *Idle dimming*: fades the background to a configurable lower + brightness after the system has been idle for a while, and restores + it the moment you're back. + - *Forced rotation*: guarantees the wallpaper changes after a maximum + static duration, even if automatic rotation is otherwise switched + off. +- A top-bar indicator for quick access to rotation, live wallpaper, and + OLED protection toggles, plus a "Next Wallpaper" action, and a full + libadwaita preferences window. + +## Requirements + +- GNOME Shell 50. +- For live wallpapers: GStreamer with its `good` and `base` plugin sets + (e.g. `gstreamer1.0-plugins-good` and `gstreamer1.0-plugins-base`, or + your distribution's equivalent). If these aren't installed, the live + wallpaper toggle stays disabled and BenthicBloom's other features work + normally. + +## Installation + +### From source + +```sh +git clone https://github.com/quinta0/benthicbloom.git +cd benthicbloom +make install +``` + +Then reload GNOME Shell — press Alt+F2, type `r`, +press Enter on X11, or log out and back in on Wayland — and +enable the extension: + +```sh +gnome-extensions enable benthicbloom@quinta0.github.io +``` + +### Packaging a zip + +```sh +make pack +``` + +produces `dist/benthicbloom@quinta0.github.io.shell-extension.zip`, +installable via `gnome-extensions install ` or the Extensions app. + +## Configuration + +Open preferences from the panel indicator's "Wallpaper Settings…" entry, +or run: + +```sh +gnome-extensions prefs benthicbloom@quinta0.github.io +``` + +- **General** — panel indicator visibility, lock screen syncing, debug + logging, and the folders scanned for wallpapers. +- **Rotation** — enable/disable, interval, shuffle vs. sequential order, + and crossfade transition settings. +- **Live Wallpaper** — enable/disable, video file, mute, playback speed, + and power-saving pause behavior. +- **OLED Protection** — master switch plus independent controls for + pixel shifting, idle dimming, and forced periodic rotation. + +## Architecture + +``` +extension.js Entry point: wires up the three managers + indicator +prefs.js libadwaita preferences window +lib/settingsKeys.js GSettings key name constants +lib/logger.js Small logging wrapper gated by the debug-logging setting +lib/wallpaperSource.js Async folder scanning for image files +lib/shuffleBag.js No-immediate-repeat random ordering for shuffle mode +lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay +lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor +lib/oledProtection.js Pixel shifting, idle dimming, forced rotation +lib/indicator.js Top-bar quick-access menu +schemas/ GSettings schema +``` + +## Known limitations + +- The live wallpaper currently renders across the full stage as a single + layer, so on multi-monitor setups the video spans across all monitors + as one canvas rather than being tiled per-monitor. +- Pixel shifting and idle dimming rely on GNOME Shell's private + `Main.layoutManager._backgroundGroup` and `Meta.IdleMonitor` APIs, which + are not part of the stable extension API and could change in future + shell versions. + +## Testing + +This was developed and validated (JSON, GSettings schema compilation, +and JavaScript syntax) without a live GNOME Shell 50 session available in +the development environment. Before relying on it, test in a nested +session: + +```sh +dbus-run-session -- gnome-shell --nested --wayland +``` + +or on a real GNOME 50 desktop, and please file an issue with any problems +you hit. + +## License + +GPL-3.0-or-later — see [LICENSE](LICENSE). diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..c1edf0e --- /dev/null +++ b/extension.js @@ -0,0 +1,76 @@ +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {SettingsKey} from './lib/settingsKeys.js'; +import {Logger} from './lib/logger.js'; +import {RotationManager} from './lib/rotationManager.js'; +import {LiveWallpaperManager} from './lib/liveWallpaper.js'; +import {OledProtectionManager} from './lib/oledProtection.js'; +import {BenthicBloomIndicator} from './lib/indicator.js'; + +export default class BenthicBloomExtension extends Extension { + enable() { + this._settings = this.getSettings(); + this._logger = new Logger(this._settings, this.metadata.name); + + this._rotationManager = new RotationManager(this._settings, this._logger); + this._liveWallpaperManager = new LiveWallpaperManager(this._settings, this._logger); + this._oledProtectionManager = new OledProtectionManager(this._settings, this._logger, { + forceNextWallpaper: maxSeconds => { + if (this._rotationManager.secondsSinceLastChange >= maxSeconds) { + this._rotationManager.next({forced: true}) + .catch(e => this._logger.error(e, 'Forced OLED rotation failed')); + } + }, + }); + + this._rotationManager.enable(); + this._liveWallpaperManager.enable() + .catch(e => this._logger.error(e, 'Live wallpaper failed to initialize')); + this._oledProtectionManager.enable(); + + this._indicator = null; + this._showIndicatorSignalId = this._settings.connect( + `changed::${SettingsKey.SHOW_INDICATOR}`, () => this._syncIndicator()); + this._syncIndicator(); + } + + disable() { + if (this._showIndicatorSignalId) { + this._settings.disconnect(this._showIndicatorSignalId); + this._showIndicatorSignalId = 0; + } + + this._indicator?.destroy(); + this._indicator = null; + + this._oledProtectionManager?.disable(); + this._oledProtectionManager = null; + + this._liveWallpaperManager?.disable(); + this._liveWallpaperManager = null; + + this._rotationManager?.disable(); + this._rotationManager = null; + + this._logger = null; + this._settings = null; + } + + _syncIndicator() { + const shouldShow = this._settings.get_boolean(SettingsKey.SHOW_INDICATOR); + + if (shouldShow && !this._indicator) { + this._indicator = new BenthicBloomIndicator(this._settings, { + rotationManager: this._rotationManager, + liveWallpaperManager: this._liveWallpaperManager, + oledProtectionManager: this._oledProtectionManager, + openPreferences: () => this.openPreferences(), + }); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } else if (!shouldShow && this._indicator) { + this._indicator.destroy(); + this._indicator = null; + } + } +} diff --git a/lib/indicator.js b/lib/indicator.js new file mode 100644 index 0000000..de3fadb --- /dev/null +++ b/lib/indicator.js @@ -0,0 +1,73 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import {SettingsKey} from './settingsKeys.js'; + +export const BenthicBloomIndicator = GObject.registerClass( +class BenthicBloomIndicator extends PanelMenu.Button { + _init(settings, {rotationManager, liveWallpaperManager, openPreferences}) { + super._init(0.0, 'BenthicBloom'); + + this._rotationManager = rotationManager; + + this.add_child(new St.Icon({ + icon_name: 'preferences-desktop-wallpaper-symbolic', + style_class: 'system-status-icon benthicbloom-indicator-icon', + })); + + const rotationToggle = new PopupMenu.PopupSwitchMenuItem( + 'Auto Rotation', settings.get_boolean(SettingsKey.ROTATION_ENABLED)); + rotationToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.ROTATION_ENABLED, state); + }); + this.menu.addMenuItem(rotationToggle); + + const nextItem = new PopupMenu.PopupMenuItem('Next Wallpaper'); + nextItem.connect('activate', () => { + rotationManager.next().catch(e => console.error(`[BenthicBloom] ${e.message ?? e}`)); + }); + this.menu.addMenuItem(nextItem); + + const liveToggle = new PopupMenu.PopupSwitchMenuItem( + 'Live Wallpaper', settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); + liveToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED, state); + }); + liveToggle.reactive = liveWallpaperManager.isAvailable; + if (!liveWallpaperManager.isAvailable) + liveToggle.label.text = 'Live Wallpaper (GStreamer not found)'; + this.menu.addMenuItem(liveToggle); + + const oledToggle = new PopupMenu.PopupSwitchMenuItem( + 'OLED Protection', settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED)); + oledToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.OLED_PROTECTION_ENABLED, state); + }); + this.menu.addMenuItem(oledToggle); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + const settingsItem = new PopupMenu.PopupMenuItem('Wallpaper Settings…'); + settingsItem.connect('activate', () => openPreferences()); + this.menu.addMenuItem(settingsItem); + + const signalIds = [ + settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => { + rotationToggle.setToggleState(settings.get_boolean(SettingsKey.ROTATION_ENABLED)); + }), + settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => { + liveToggle.setToggleState(settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); + }), + settings.connect(`changed::${SettingsKey.OLED_PROTECTION_ENABLED}`, () => { + oledToggle.setToggleState(settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED)); + }), + ]; + + this.connect('destroy', () => { + for (const id of signalIds) + settings.disconnect(id); + }); + } +}); diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js new file mode 100644 index 0000000..3f504c8 --- /dev/null +++ b/lib/liveWallpaper.js @@ -0,0 +1,352 @@ +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 file as a looping animated background. + * + * 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; + } +} diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..8a4be93 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,26 @@ +import {SettingsKey} from './settingsKeys.js'; + +export class Logger { + constructor(settings, prefix) { + this._settings = settings; + this._prefix = prefix ?? 'BenthicBloom'; + } + + debug(message) { + if (this._settings.get_boolean(SettingsKey.DEBUG_LOGGING)) + console.log(`[${this._prefix}] ${message}`); + } + + info(message) { + console.log(`[${this._prefix}] ${message}`); + } + + warn(message) { + console.warn(`[${this._prefix}] ${message}`); + } + + error(error, context) { + const detail = error?.message ?? String(error); + console.error(`[${this._prefix}] ${context ? `${context}: ${detail}` : detail}`); + } +} diff --git a/lib/oledProtection.js b/lib/oledProtection.js new file mode 100644 index 0000000..34ab4d9 --- /dev/null +++ b/lib/oledProtection.js @@ -0,0 +1,269 @@ +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {SettingsKey} from './settingsKeys.js'; + +// A slow, small drift pattern rather than a simple back-and-forth, so the +// same pixels aren't re-lit on a short, predictable cycle. +const SHIFT_PATTERN = [ + [0, 0], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], +]; + +const FORCE_ROTATION_CHECK_SECONDS = 60; + +/** + * Reduces OLED burn-in risk through three independent techniques: + * - pixel shifting: nudges the background actors a few px on a slow cycle + * - idle dimming: fades in a black overlay after prolonged inactivity + * - forced rotation: guarantees the wallpaper changes periodically even if + * automatic rotation is otherwise switched off + */ +export class OledProtectionManager { + constructor(settings, logger, {forceNextWallpaper} = {}) { + this._settings = settings; + this._logger = logger; + this._forceNextWallpaper = forceNextWallpaper ?? (() => {}); + + this._settingsSignals = []; + + this._shiftTimeoutId = 0; + this._shiftStep = 0; + + this._forceRotationTimeoutId = 0; + + this._idleMonitor = null; + this._idleWatchId = 0; + this._activeWatchId = 0; + this._dimOverlays = []; + this._dimmed = false; + } + + enable() { + this._settingsSignals.push( + this._settings.connect(`changed::${SettingsKey.OLED_PROTECTION_ENABLED}`, () => this._syncAll()), + this._settings.connect(`changed::${SettingsKey.OLED_PIXEL_SHIFT_ENABLED}`, () => this._syncPixelShift()), + this._settings.connect(`changed::${SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS}`, () => this._syncPixelShift()), + this._settings.connect(`changed::${SettingsKey.OLED_DIM_ON_IDLE_ENABLED}`, () => this._syncIdleWatch()), + this._settings.connect(`changed::${SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS}`, () => this._syncIdleWatch()), + this._settings.connect(`changed::${SettingsKey.OLED_FORCE_ROTATION_ENABLED}`, () => this._syncForceRotation()) + ); + + this._syncAll(); + } + + disable() { + for (const id of this._settingsSignals) + this._settings.disconnect(id); + this._settingsSignals = []; + + this._stopPixelShift(); + this._stopForceRotation(); + this._stopIdleWatch(); + this._clearDimOverlays(); + } + + get isEnabled() { + return this._settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED); + } + + _syncAll() { + this._syncPixelShift(); + this._syncIdleWatch(); + this._syncForceRotation(); + } + + // --- Pixel shifting -------------------------------------------------- + + _syncPixelShift() { + this._stopPixelShift(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_PIXEL_SHIFT_ENABLED)) + this._startPixelShift(); + else + this._resetShift(); + } + + _startPixelShift() { + const interval = Math.max(5, this._settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS)); + this._shiftTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this._applyPixelShiftStep(); + return GLib.SOURCE_CONTINUE; + }); + } + + _stopPixelShift() { + if (this._shiftTimeoutId) { + GLib.source_remove(this._shiftTimeoutId); + this._shiftTimeoutId = 0; + } + } + + _applyPixelShiftStep() { + const amount = this._settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX); + this._shiftStep = (this._shiftStep + 1) % SHIFT_PATTERN.length; + const [dx, dy] = SHIFT_PATTERN[this._shiftStep]; + + for (const actor of this._backgroundActors()) { + actor.ease({ + translation_x: dx * amount, + translation_y: dy * amount, + duration: 2000, + mode: Clutter.AnimationMode.EASE_IN_OUT_SINE, + }); + } + } + + _resetShift() { + this._shiftStep = 0; + for (const actor of this._backgroundActors()) { + actor.ease({ + translation_x: 0, + translation_y: 0, + duration: 500, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _backgroundActors() { + // Private API: the group holding each monitor's background actor + // (and our own live-wallpaper/crossfade actors, which harmlessly + // shift along with it). + try { + return Main.layoutManager._backgroundGroup.get_children(); + } catch (e) { + return []; + } + } + + // --- Idle dimming ------------------------------------------------------ + + _syncIdleWatch() { + this._stopIdleWatch(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_DIM_ON_IDLE_ENABLED)) + this._startIdleWatch(); + else + this._undim(); + } + + _startIdleWatch() { + try { + this._idleMonitor = global.backend?.get_core_idle_monitor + ? global.backend.get_core_idle_monitor() + : Meta.IdleMonitor.get_core(); + } catch (e) { + this._logger.debug(`Idle monitor unavailable, idle dimming disabled (${e.message ?? e})`); + return; + } + + this._armIdleWatch(); + } + + _armIdleWatch() { + if (!this._idleMonitor) + return; + + const delayMs = Math.max(5, this._settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS)) * 1000; + this._idleWatchId = this._idleMonitor.add_idle_watch(delayMs, () => { + this._dim(); + this._activeWatchId = this._idleMonitor.add_user_active_watch(() => { + this._undim(); + this._activeWatchId = 0; + this._armIdleWatch(); + }); + }); + } + + _stopIdleWatch() { + if (this._idleMonitor) { + if (this._idleWatchId) + this._idleMonitor.remove_watch(this._idleWatchId); + if (this._activeWatchId) + this._idleMonitor.remove_watch(this._activeWatchId); + } + this._idleWatchId = 0; + this._activeWatchId = 0; + this._idleMonitor = null; + } + + _dim() { + if (this._dimmed) + return; + this._dimmed = true; + + const brightness = this._settings.get_double(SettingsKey.OLED_DIM_BRIGHTNESS); + const targetOpacity = Math.round((1 - brightness) * 255); + + this._clearDimOverlays(); + for (const monitor of Main.layoutManager.monitors) { + const overlay = new Clutter.Actor({ + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + background_color: new Clutter.Color({red: 0, green: 0, blue: 0, alpha: 255}), + opacity: 0, + reactive: false, + }); + Main.layoutManager._backgroundGroup.add_child(overlay); + this._dimOverlays.push(overlay); + overlay.ease({ + opacity: targetOpacity, + duration: 4000, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + this._logger.debug('Background dimmed for OLED protection (idle)'); + } + + _undim() { + if (!this._dimmed) + return; + this._dimmed = false; + + for (const overlay of this._dimOverlays) { + overlay.ease({ + opacity: 0, + duration: 800, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => overlay.destroy(), + }); + } + this._dimOverlays = []; + + this._logger.debug('Background dim removed'); + } + + _clearDimOverlays() { + for (const overlay of this._dimOverlays) + overlay.destroy(); + this._dimOverlays = []; + this._dimmed = false; + } + + // --- Forced rotation ----------------------------------------------- + + _syncForceRotation() { + this._stopForceRotation(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_FORCE_ROTATION_ENABLED)) + this._startForceRotation(); + } + + _startForceRotation() { + this._forceRotationTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, FORCE_ROTATION_CHECK_SECONDS, () => { + const maxSeconds = this._settings.get_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS); + this._forceNextWallpaper(maxSeconds); + return GLib.SOURCE_CONTINUE; + }); + } + + _stopForceRotation() { + if (this._forceRotationTimeoutId) { + GLib.source_remove(this._forceRotationTimeoutId); + this._forceRotationTimeoutId = 0; + } + } +} diff --git a/lib/rotationManager.js b/lib/rotationManager.js new file mode 100644 index 0000000..b61ed7f --- /dev/null +++ b/lib/rotationManager.js @@ -0,0 +1,221 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {SettingsKey} from './settingsKeys.js'; +import {listImagesInFolders} from './wallpaperSource.js'; +import {ShuffleBag} from './shuffleBag.js'; + +const BACKGROUND_SCHEMA = 'org.gnome.desktop.background'; +const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver'; +const MIN_INTERVAL_SECONDS = 5; + +/** + * Owns the wallpaper image list, the rotation timer, and applying the + * chosen image to both the desktop and (optionally) the lock screen via + * their standard GSettings schemas, with an optional crossfade overlay + * played on top while the change happens underneath. + */ +export class RotationManager { + constructor(settings, logger) { + this._settings = settings; + this._logger = logger; + this._backgroundSettings = new Gio.Settings({schema_id: BACKGROUND_SCHEMA}); + this._screensaverSettings = new Gio.Settings({schema_id: SCREENSAVER_SCHEMA}); + + this._images = []; + this._sequentialIndex = -1; + this._shuffleBag = new ShuffleBag(); + this._timeoutId = 0; + this._settingsSignals = []; + this._paused = false; + this._lastChangeTime = GLib.get_monotonic_time(); + this._currentPath = null; + this._transitionOverlays = []; + } + + enable() { + this._settingsSignals.push( + this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()), + this._settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => this._restartTimer()), + this._settings.connect(`changed::${SettingsKey.ROTATION_INTERVAL_SECONDS}`, () => this._restartTimer()), + this._settings.connect(`changed::${SettingsKey.ROTATION_MODE}`, () => this._onModeChanged()) + ); + + this._reloadImages().catch(e => this._logger.error(e, 'Failed to load wallpaper folders')); + this._restartTimer(); + } + + disable() { + for (const id of this._settingsSignals) + this._settings.disconnect(id); + this._settingsSignals = []; + + this._clearTimer(); + this._clearTransitionOverlays(); + } + + get currentPath() { + return this._currentPath; + } + + get hasImages() { + return this._images.length > 0; + } + + get isPaused() { + return this._paused; + } + + get secondsSinceLastChange() { + return (GLib.get_monotonic_time() - this._lastChangeTime) / GLib.USEC_PER_SEC; + } + + async _reloadImages() { + const folders = this._settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + this._images = folders.length > 0 ? await listImagesInFolders(folders) : []; + this._shuffleBag.setItems(this._images); + this._sequentialIndex = -1; + this._logger.debug(`Loaded ${this._images.length} wallpaper(s) from ${folders.length} folder(s)`); + } + + _onModeChanged() { + this._sequentialIndex = -1; + this._shuffleBag.setItems(this._images); + } + + _restartTimer() { + this._clearTimer(); + + if (this._paused || !this._settings.get_boolean(SettingsKey.ROTATION_ENABLED)) + return; + + const interval = Math.max( + MIN_INTERVAL_SECONDS, this._settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS)); + + this._timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this.next().catch(e => this._logger.error(e, 'Automatic rotation failed')); + return GLib.SOURCE_CONTINUE; + }); + } + + _clearTimer() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + } + + pause() { + this._paused = true; + this._clearTimer(); + } + + resume() { + this._paused = false; + this._restartTimer(); + } + + async next({forced = false} = {}) { + if (this._images.length === 0) + await this._reloadImages(); + + if (this._images.length === 0) { + this._logger.debug('No wallpapers available to rotate to'); + return; + } + + const mode = this._settings.get_string(SettingsKey.ROTATION_MODE); + let path; + if (mode === 'sequential') { + this._sequentialIndex = (this._sequentialIndex + 1) % this._images.length; + path = this._images[this._sequentialIndex]; + } else { + path = this._shuffleBag.next(); + } + + if (path) + await this._applyWallpaper(path); + + if (forced) + this._logger.debug('Wallpaper change forced (OLED protection)'); + } + + async previous() { + if (this._images.length === 0) + return; + + const mode = this._settings.get_string(SettingsKey.ROTATION_MODE); + if (mode === 'sequential') { + this._sequentialIndex = (this._sequentialIndex - 1 + this._images.length) % this._images.length; + await this._applyWallpaper(this._images[this._sequentialIndex]); + } else { + await this.next(); + } + } + + async _applyWallpaper(path) { + const previousPath = this._currentPath; + const uri = GLib.filename_to_uri(path, null); + + if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath) + this._playCrossfade(previousPath); + + this._backgroundSettings.set_string('picture-uri', uri); + if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark')) + this._backgroundSettings.set_string('picture-uri-dark', uri); + + if (this._settings.get_boolean(SettingsKey.APPLY_TO_LOCK_SCREEN)) + this._screensaverSettings.set_string('picture-uri', uri); + + this._currentPath = path; + this._lastChangeTime = GLib.get_monotonic_time(); + this._logger.debug(`Wallpaper changed to ${path}`); + } + + /** + * The real background actor doesn't crossfade on its own, so we paint the + * *old* image full-screen on a throwaway overlay right as the new image + * is set underneath, then fade the overlay out to reveal it. + */ + _playCrossfade(previousPath) { + this._clearTransitionOverlays(); + + const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS); + const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22'); + + for (const monitor of Main.layoutManager.monitors) { + const overlay = new St.Widget({ + reactive: false, + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + style: `background-image: url("${uri}"); background-size: cover; background-position: center;`, + opacity: 255, + }); + Main.layoutManager._backgroundGroup.add_child(overlay); + this._transitionOverlays.push(overlay); + + overlay.ease({ + opacity: 0, + duration: durationMs, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + overlay.destroy(); + const idx = this._transitionOverlays.indexOf(overlay); + if (idx >= 0) + this._transitionOverlays.splice(idx, 1); + }, + }); + } + } + + _clearTransitionOverlays() { + for (const overlay of this._transitionOverlays) + overlay.destroy(); + this._transitionOverlays = []; + } +} diff --git a/lib/settingsKeys.js b/lib/settingsKeys.js new file mode 100644 index 0000000..d6eb665 --- /dev/null +++ b/lib/settingsKeys.js @@ -0,0 +1,34 @@ +export const SettingsKey = Object.freeze({ + SHOW_INDICATOR: 'show-indicator', + DEBUG_LOGGING: 'debug-logging', + WALLPAPER_FOLDERS: 'wallpaper-folders', + APPLY_TO_LOCK_SCREEN: 'apply-to-lock-screen', + + ROTATION_ENABLED: 'rotation-enabled', + ROTATION_INTERVAL_SECONDS: 'rotation-interval-seconds', + ROTATION_MODE: 'rotation-mode', + CURRENT_INDEX: 'current-index', + TRANSITION_ENABLED: 'transition-enabled', + TRANSITION_DURATION_MS: 'transition-duration-ms', + + LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled', + LIVE_WALLPAPER_PATH: 'live-wallpaper-path', + LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted', + LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate', + LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery', + LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN: 'live-wallpaper-pause-when-fullscreen', + + OLED_PROTECTION_ENABLED: 'oled-protection-enabled', + OLED_PIXEL_SHIFT_ENABLED: 'oled-pixel-shift-enabled', + OLED_PIXEL_SHIFT_INTERVAL_SECONDS: 'oled-pixel-shift-interval-seconds', + OLED_PIXEL_SHIFT_AMOUNT_PX: 'oled-pixel-shift-amount-px', + OLED_DIM_ON_IDLE_ENABLED: 'oled-dim-on-idle-enabled', + OLED_DIM_IDLE_DELAY_SECONDS: 'oled-dim-idle-delay-seconds', + OLED_DIM_BRIGHTNESS: 'oled-dim-brightness', + OLED_FORCE_ROTATION_ENABLED: 'oled-force-rotation-enabled', + OLED_MAX_STATIC_DURATION_SECONDS: 'oled-max-static-duration-seconds', +}); + +export const IMAGE_EXTENSIONS = Object.freeze([ + '.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif', '.gif', +]); diff --git a/lib/shuffleBag.js b/lib/shuffleBag.js new file mode 100644 index 0000000..fbfc3fa --- /dev/null +++ b/lib/shuffleBag.js @@ -0,0 +1,49 @@ +/** + * Random-order iterator that guarantees every item is seen once before any + * item repeats, and never immediately repeats the previous pick across bag + * refills. Used for "shuffle" rotation mode instead of naive Math.random() + * indexing, which tends to repeat images and skip others over time. + */ +export class ShuffleBag { + constructor(items = []) { + this.setItems(items); + } + + setItems(items) { + this._items = [...items]; + this._bag = []; + this._lastItem = null; + } + + get size() { + return this._items.length; + } + + next() { + if (this._items.length === 0) + return null; + if (this._items.length === 1) + return this._items[0]; + + if (this._bag.length === 0) + this._refill(); + + const item = this._bag.pop(); + this._lastItem = item; + return item; + } + + _refill() { + this._bag = [...this._items]; + for (let i = this._bag.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this._bag[i], this._bag[j]] = [this._bag[j], this._bag[i]]; + } + + if (this._bag[this._bag.length - 1] === this._lastItem && this._bag.length > 1) { + const swapIndex = Math.floor(Math.random() * (this._bag.length - 1)); + const lastIndex = this._bag.length - 1; + [this._bag[lastIndex], this._bag[swapIndex]] = [this._bag[swapIndex], this._bag[lastIndex]]; + } + } +} diff --git a/lib/wallpaperSource.js b/lib/wallpaperSource.js new file mode 100644 index 0000000..7695685 --- /dev/null +++ b/lib/wallpaperSource.js @@ -0,0 +1,74 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import {IMAGE_EXTENSIONS} from './settingsKeys.js'; + +Gio._promisify(Gio.File.prototype, 'enumerate_children_async', 'enumerate_children_finish'); +Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish'); +Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish'); + +function hasImageExtension(name) { + const lower = name.toLowerCase(); + return IMAGE_EXTENSIONS.some(ext => lower.endsWith(ext)); +} + +/** Non-recursively lists image files directly inside a single folder. */ +export async function listImagesInFolder(folderPath) { + const results = []; + const dir = folderPath.startsWith('file://') + ? Gio.File.new_for_uri(folderPath) + : Gio.File.new_for_path(folderPath); + + let enumerator; + try { + enumerator = await dir.enumerate_children_async( + 'standard::name,standard::type', + Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, + null); + } catch (e) { + return results; + } + + for (;;) { + const infos = await enumerator.next_files_async(50, GLib.PRIORITY_DEFAULT, null); + if (infos.length === 0) + break; + + for (const info of infos) { + if (info.get_file_type() !== Gio.FileType.REGULAR) + continue; + if (!hasImageExtension(info.get_name())) + continue; + results.push(enumerator.get_child(info).get_path()); + } + } + + try { + await enumerator.close_async(GLib.PRIORITY_DEFAULT, null); + } catch (e) { + // Enumerator already exhausted; nothing to clean up. + } + + return results; +} + +/** Merges and de-duplicates images found across several folders. */ +export async function listImagesInFolders(folderPaths) { + const lists = await Promise.all( + folderPaths.map(folder => listImagesInFolder(folder).catch(() => []))); + + const seen = new Set(); + const merged = []; + for (const list of lists) { + for (const path of list) { + if (!seen.has(path)) { + seen.add(path); + merged.push(path); + } + } + } + + merged.sort(); + return merged; +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..f973e42 --- /dev/null +++ b/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "benthicbloom@quinta0.github.io", + "name": "BenthicBloom", + "description": "Automatically rotates your wallpaper from folders you choose, supports looping video live wallpapers, and includes built-in pixel-shifting and idle-dimming to protect OLED displays from burn-in.", + "shell-version": ["50"], + "url": "https://github.com/quinta0/benthicbloom", + "settings-schema": "org.gnome.shell.extensions.benthicbloom", + "gettext-domain": "benthicbloom", + "version": 1, + "version-name": "1.0.0" +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..da5945f --- /dev/null +++ b/prefs.js @@ -0,0 +1,318 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import Gdk from 'gi://Gdk'; + +import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +import {SettingsKey} from './lib/settingsKeys.js'; + +export default class BenthicBloomPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings(); + + window.set_default_size(640, 720); + window.add(this._buildGeneralPage(settings)); + window.add(this._buildRotationPage(settings)); + window.add(this._buildLiveWallpaperPage(settings)); + window.add(this._buildOledPage(settings)); + window.add(this._buildAboutPage()); + } + + _switchRow(settings, key, title, subtitle) { + const row = new Adw.SwitchRow({title, subtitle}); + settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT); + return row; + } + + _spinRow({title, subtitle, lower, upper, step, page, digits = 0}, getValue, setValue) { + const row = new Adw.SpinRow({ + title, + subtitle, + digits, + adjustment: new Gtk.Adjustment({lower, upper, step_increment: step, page_increment: page ?? step}), + }); + row.value = getValue(); + row.connect('notify::value', () => setValue(row.value)); + return row; + } + + // --- General page ------------------------------------------------ + + _buildGeneralPage(settings) { + const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'preferences-system-symbolic'}); + + const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')}); + page.add(behaviorGroup); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.SHOW_INDICATOR, + _('Show Panel Indicator'), _('Display a quick-access icon in the top bar'))); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.APPLY_TO_LOCK_SCREEN, + _('Apply to Lock Screen'), _('Also use the current wallpaper as the lock screen background'))); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.DEBUG_LOGGING, + _('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)'))); + + const foldersGroup = new Adw.PreferencesGroup({ + title: _('Wallpaper Folders'), + description: _('Images found directly inside these folders are used for rotation'), + }); + page.add(foldersGroup); + + this._folderList = new Gtk.ListBox({ + selection_mode: Gtk.SelectionMode.NONE, + css_classes: ['boxed-list'], + }); + foldersGroup.add(this._folderList); + this._refreshFolderList(settings); + + const addButton = new Gtk.Button({ + label: _('Add Folder…'), + halign: Gtk.Align.START, + margin_top: 6, + css_classes: ['flat'], + }); + addButton.connect('clicked', () => this._pickFolder(settings)); + foldersGroup.add(addButton); + + return page; + } + + _refreshFolderList(settings) { + let child = this._folderList.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + this._folderList.remove(child); + child = next; + } + + const folders = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + if (folders.length === 0) { + this._folderList.append(new Adw.ActionRow({title: _('No folders added yet')})); + return; + } + + for (const folder of folders) { + const row = new Adw.ActionRow({title: folder}); + const removeButton = new Gtk.Button({ + icon_name: 'user-trash-symbolic', + valign: Gtk.Align.CENTER, + css_classes: ['flat'], + }); + removeButton.connect('clicked', () => { + const current = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, current.filter(f => f !== folder)); + this._refreshFolderList(settings); + }); + row.add_suffix(removeButton); + this._folderList.append(row); + } + } + + _pickFolder(settings) { + const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Folder')}); + dialog.select_folder(this._folderList.get_root(), null, (source, result) => { + try { + const folder = dialog.select_folder_finish(result); + const path = folder.get_path(); + if (!path) + return; + const current = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + if (!current.includes(path)) { + settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, [...current, path]); + this._refreshFolderList(settings); + } + } catch (e) { + // Dialog was dismissed; nothing to do. + } + }); + } + + // --- Rotation page ------------------------------------------------- + + _buildRotationPage(settings) { + const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'media-playlist-shuffle-symbolic'}); + + const group = new Adw.PreferencesGroup({title: _('Automatic Rotation')}); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.ROTATION_ENABLED, + _('Enable Rotation'), _('Automatically change the wallpaper on a timer'))); + + group.add(this._spinRow( + {title: _('Interval'), subtitle: _('Minutes between wallpaper changes'), lower: 1, upper: 1440, step: 1, page: 10}, + () => settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS) / 60, + value => settings.set_uint(SettingsKey.ROTATION_INTERVAL_SECONDS, Math.round(value) * 60))); + + const modeRow = new Adw.ComboRow({ + title: _('Order'), + model: new Gtk.StringList({strings: [_('Shuffle'), _('Sequential')]}), + }); + modeRow.selected = settings.get_string(SettingsKey.ROTATION_MODE) === 'sequential' ? 1 : 0; + modeRow.connect('notify::selected', () => { + settings.set_string(SettingsKey.ROTATION_MODE, modeRow.selected === 1 ? 'sequential' : 'shuffle'); + }); + group.add(modeRow); + + const transitionGroup = new Adw.PreferencesGroup({title: _('Transitions')}); + page.add(transitionGroup); + transitionGroup.add(this._switchRow( + settings, SettingsKey.TRANSITION_ENABLED, + _('Crossfade'), _('Smoothly fade between wallpapers instead of switching instantly'))); + transitionGroup.add(this._spinRow( + {title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500}, + () => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS), + value => settings.set_uint(SettingsKey.TRANSITION_DURATION_MS, Math.round(value)))); + + return page; + } + + // --- Live wallpaper page -------------------------------------------- + + _buildLiveWallpaperPage(settings) { + const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'video-x-generic-symbolic'}); + + const group = new Adw.PreferencesGroup({ + title: _('Video Wallpaper'), + description: _( + 'Play a looping video as your desktop background instead of a static image. ' + + 'Requires GStreamer (with its "good" and "base" plugin sets) to be installed on your system.'), + }); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_ENABLED, + _('Enable Live Wallpaper'), _('Overrides the static wallpaper while active'))); + + const fileRow = new Adw.ActionRow({ + title: _('Video File'), + subtitle: settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) || _('None selected'), + }); + const chooseButton = new Gtk.Button({label: _('Choose…'), valign: Gtk.Align.CENTER, css_classes: ['flat']}); + chooseButton.connect('clicked', () => { + const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Video')}); + const filter = new Gtk.FileFilter(); + filter.add_mime_type('video/*'); + const filterList = new Gio.ListStore({item_type: Gtk.FileFilter}); + filterList.append(filter); + dialog.filters = filterList; + + dialog.open(fileRow.get_root(), null, (source, result) => { + try { + const file = dialog.open_finish(result); + const path = file.get_path(); + settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path); + fileRow.subtitle = path; + } catch (e) { + // Dialog was dismissed; nothing to do. + } + }); + }); + fileRow.add_suffix(chooseButton); + group.add(fileRow); + + group.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_MUTED, + _('Mute Audio'), _('Play video wallpapers without sound'))); + group.add(this._spinRow( + { + title: _('Playback Speed'), + subtitle: _('Multiplier, e.g. 0.5 for half speed, 2.0 for double speed'), + lower: 0.1, upper: 4.0, step: 0.1, page: 0.5, digits: 1, + }, + () => settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE), + value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value))); + + const powerGroup = new Adw.PreferencesGroup({title: _('Power Saving')}); + page.add(powerGroup); + powerGroup.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY, + _('Pause on Battery'), _('Stop video playback while running on battery power'))); + powerGroup.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN, + _('Pause When Fullscreen'), _('Stop video playback while a window is fullscreen'))); + + return page; + } + + // --- OLED protection page -------------------------------------------- + + _buildOledPage(settings) { + const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'weather-clear-night-symbolic'}); + + const group = new Adw.PreferencesGroup({ + title: _('Burn-in Protection'), + description: _('Reduces the risk of permanent image retention on OLED displays'), + }); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.OLED_PROTECTION_ENABLED, + _('Enable OLED Protection'), _('Master switch for all burn-in protection features'))); + + const shiftGroup = new Adw.PreferencesGroup({title: _('Pixel Shifting')}); + page.add(shiftGroup); + shiftGroup.add(this._switchRow( + settings, SettingsKey.OLED_PIXEL_SHIFT_ENABLED, + _('Enable Pixel Shifting'), _('Periodically nudge the background by a few pixels'))); + shiftGroup.add(this._spinRow( + {title: _('Shift Interval'), subtitle: _('Seconds between each shift step'), lower: 10, upper: 600, step: 5, page: 30}, + () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS), + value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value)))); + shiftGroup.add(this._spinRow( + {title: _('Shift Amount'), subtitle: _('Pixels'), lower: 1, upper: 10, step: 1, page: 1}, + () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX), + value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX, Math.round(value)))); + + const dimGroup = new Adw.PreferencesGroup({title: _('Idle Dimming')}); + page.add(dimGroup); + dimGroup.add(this._switchRow( + settings, SettingsKey.OLED_DIM_ON_IDLE_ENABLED, + _('Dim When Idle'), _('Lower brightness after a period of inactivity'))); + dimGroup.add(this._spinRow( + {title: _('Idle Delay'), subtitle: _('Seconds of inactivity before dimming'), lower: 10, upper: 3600, step: 10, page: 60}, + () => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS), + value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value)))); + dimGroup.add(this._spinRow( + { + title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'), + lower: 0.0, upper: 0.9, step: 0.05, page: 0.1, digits: 2, + }, + () => settings.get_double(SettingsKey.OLED_DIM_BRIGHTNESS), + value => settings.set_double(SettingsKey.OLED_DIM_BRIGHTNESS, value))); + + const forceGroup = new Adw.PreferencesGroup({title: _('Forced Rotation')}); + page.add(forceGroup); + forceGroup.add(this._switchRow( + settings, SettingsKey.OLED_FORCE_ROTATION_ENABLED, + _('Force Periodic Change'), + _('Change the wallpaper even if automatic rotation is off, to avoid prolonged static images'))); + forceGroup.add(this._spinRow( + {title: _('Maximum Static Duration'), subtitle: _('Hours before a change is forced'), lower: 1, upper: 48, step: 1, page: 4}, + () => settings.get_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS) / 3600, + value => settings.set_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS, Math.round(value) * 3600))); + + return page; + } + + // --- About page ----------------------------------------------------- + + _buildAboutPage() { + const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'help-about-symbolic'}); + const group = new Adw.PreferencesGroup(); + page.add(group); + + group.add(new Adw.ActionRow({title: this.metadata.name, subtitle: this.metadata.description})); + group.add(new Adw.ActionRow({ + title: _('Version'), + subtitle: this.metadata['version-name'] ?? String(this.metadata.version ?? ''), + })); + + if (this.metadata.url) { + const linkRow = new Adw.ActionRow({title: _('Source Code'), subtitle: this.metadata.url, activatable: true}); + linkRow.connect('activated', () => Gtk.show_uri(null, this.metadata.url, Gdk.CURRENT_TIME)); + group.add(linkRow); + } + + return page; + } +} diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml new file mode 100644 index 0000000..0cc5fab --- /dev/null +++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml @@ -0,0 +1,164 @@ + + + + + + + true + Show panel indicator + Whether to show the BenthicBloom icon in the top panel. + + + + false + Enable debug logging + Print verbose debug information to the GNOME Shell log (journalctl). + + + + [] + Wallpaper source folders + List of folder paths scanned for wallpaper images. + + + + true + Apply wallpaper to lock screen + Whether the rotated wallpaper is also applied to the lock screen background. + + + + false + Enable automatic rotation + Whether wallpapers are automatically rotated on a timer. + + + + 600 + Rotation interval + Number of seconds between automatic wallpaper changes. + + + + + + + + "shuffle" + Rotation order + Whether wallpapers are shown sequentially or in random (shuffle) order. + + + + -1 + Current wallpaper index + Internal state tracking the currently displayed wallpaper in sequential mode. + + + + true + Enable crossfade transition + Whether wallpaper changes fade smoothly instead of switching instantly. + + + + 1200 + Transition duration + Duration, in milliseconds, of the crossfade animation between wallpapers. + + + + false + Enable live wallpaper + Whether a video is played as an animated desktop background instead of a static image. + + + + "" + Live wallpaper video file + Path to the video file used as the live wallpaper. + + + + true + Mute live wallpaper audio + Whether audio playback is muted for the live wallpaper video. + + + + 1.0 + + Live wallpaper playback speed + Playback rate multiplier applied to the live wallpaper video. + + + + true + Pause live wallpaper on battery + Automatically pause video playback while the system is unplugged to save power. + + + + true + Pause live wallpaper when a window is fullscreen + Automatically pause video playback while a window is fullscreen, since it would be hidden anyway. + + + + false + Enable OLED burn-in protection + Master switch for all OLED burn-in protection features. + + + + true + Enable pixel shifting + Periodically nudge the rendered background by a few pixels to avoid static burn-in. + + + + 60 + Pixel shift interval + Number of seconds between each pixel-shift step. + + + + 3 + + Pixel shift amount + Maximum distance, in pixels, the background is offset during pixel shifting. + + + + true + Dim background when idle + Reduce background brightness after the system has been idle for a while. + + + + 300 + Idle delay before dimming + Number of seconds of inactivity before the background is dimmed. + + + + 0.4 + + Dimmed brightness level + Brightness multiplier applied to the background while dimmed (0 = black, 1 = full brightness). + + + + true + Force periodic wallpaper change + Force a wallpaper change after the maximum static duration even if automatic rotation is otherwise disabled. + + + + 14400 + Maximum static duration + Maximum number of seconds a single wallpaper may remain on screen before OLED protection forces a change. + + + + diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..99ce411 --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,4 @@ +/* BenthicBloom panel indicator */ +.benthicbloom-indicator-icon { + -st-icon-style: symbolic; +} From 5e8884d01ef43fa8340afaa4ebd5d7cb463547ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:18:43 +0000 Subject: [PATCH 02/12] 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 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- README.md | 16 ++++++------- lib/liveWallpaper.js | 5 +++- prefs.js | 24 +++++++++++++------ ....shell.extensions.benthicbloom.gschema.xml | 6 ++--- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 426773e..c21639d 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ protection against OLED burn-in. through the images inside them on a timer, in sequential or shuffled order, with an optional crossfade between changes. The chosen wallpaper can also be applied to the lock screen. -- **Live wallpapers** — play a looping video file as your desktop - background. Frames are decoded with GStreamer and rendered directly by - GNOME Shell's own Clutter stage (no external, incompatible Clutter - build involved). Playback can automatically pause on battery power or - while a window is fullscreen. +- **Live wallpapers** — play a looping video *or animated GIF* as your + desktop background. Frames are decoded with GStreamer and rendered + directly by GNOME Shell's own Clutter stage (no external, incompatible + Clutter build involved). Playback can automatically pause on battery + power or while a window is fullscreen. - **OLED burn-in protection** — three independent, individually toggled techniques: - *Pixel shifting*: nudges the background a few pixels on a slow drift @@ -34,9 +34,9 @@ protection against OLED burn-in. - GNOME Shell 50. - For live wallpapers: GStreamer with its `good` and `base` plugin sets (e.g. `gstreamer1.0-plugins-good` and `gstreamer1.0-plugins-base`, or - your distribution's equivalent). If these aren't installed, the live - wallpaper toggle stays disabled and BenthicBloom's other features work - normally. + your distribution's equivalent) — the `good` set is also what provides + animated GIF decoding. If these aren't installed, the live wallpaper + toggle stays disabled and BenthicBloom's other features work normally. ## Installation diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 3f504c8..58af89e 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -6,7 +6,10 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import {SettingsKey} from './settingsKeys.js'; /** - * Plays a video file as a looping animated background. + * 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. diff --git a/prefs.js b/prefs.js index da5945f..27e5030 100644 --- a/prefs.js +++ b/prefs.js @@ -176,8 +176,9 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { const group = new Adw.PreferencesGroup({ title: _('Video Wallpaper'), description: _( - 'Play a looping video as your desktop background instead of a static image. ' + - 'Requires GStreamer (with its "good" and "base" plugin sets) to be installed on your system.'), + 'Play a looping video or animated GIF as your desktop background instead of a static image. ' + + 'Requires GStreamer (with its "good" and "base" plugin sets, which provide GIF decoding) ' + + 'to be installed on your system.'), }); page.add(group); group.add(this._switchRow( @@ -185,17 +186,26 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { _('Enable Live Wallpaper'), _('Overrides the static wallpaper while active'))); const fileRow = new Adw.ActionRow({ - title: _('Video File'), + title: _('Video / GIF File'), subtitle: settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) || _('None selected'), }); const chooseButton = new Gtk.Button({label: _('Choose…'), valign: Gtk.Align.CENTER, css_classes: ['flat']}); chooseButton.connect('clicked', () => { - const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Video')}); - const filter = new Gtk.FileFilter(); - filter.add_mime_type('video/*'); + const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Video or GIF')}); + + const mediaFilter = new Gtk.FileFilter({name: _('Videos and animated GIFs')}); + mediaFilter.add_mime_type('video/*'); + mediaFilter.add_mime_type('image/gif'); + mediaFilter.add_pattern('*.gif'); + + const allFilter = new Gtk.FileFilter({name: _('All files')}); + allFilter.add_pattern('*'); + const filterList = new Gio.ListStore({item_type: Gtk.FileFilter}); - filterList.append(filter); + filterList.append(mediaFilter); + filterList.append(allFilter); dialog.filters = filterList; + dialog.default_filter = mediaFilter; dialog.open(fileRow.get_root(), null, (source, result) => { try { diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml index 0cc5fab..8291cf2 100644 --- a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml +++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml @@ -70,13 +70,13 @@ false Enable live wallpaper - Whether a video is played as an animated desktop background instead of a static image. + Whether a video or animated GIF is played as an animated desktop background instead of a static image. "" - Live wallpaper video file - Path to the video file used as the live wallpaper. + Live wallpaper media file + Path to the video or animated GIF file used as the live wallpaper. From 4a5ef4fe864e0f962dd51837acdef85865953dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:26:53 +0000 Subject: [PATCH 03/12] Surface missing GStreamer deps in Preferences instead of only logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if the Gst/GstApp/Cogl GI typelibs couldn't be loaded (e.g. gst-plugins-base/good not installed, only the bare gstreamer package), the only sign was a warning buried in the shell's log — the live wallpaper page looked fully functional and silently did nothing. Preferences now probes for the same bindings the running extension needs and shows a warning row with the exact import error plus per-distro install commands, and greys out the controls that only matter once GStreamer is actually available. Extracted the shared probing logic into lib/gstreamerAvailability.js so extension.js and prefs.js (separate processes) don't duplicate it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/gstreamerAvailability.js | 33 +++++++++++++++++++++++++++++ lib/liveWallpaper.js | 19 +++-------------- prefs.js | 40 +++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 lib/gstreamerAvailability.js diff --git a/lib/gstreamerAvailability.js b/lib/gstreamerAvailability.js new file mode 100644 index 0000000..cf8ee59 --- /dev/null +++ b/lib/gstreamerAvailability.js @@ -0,0 +1,33 @@ +/** + * Loads the GObject-Introspection bindings the live wallpaper feature needs + * (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames) + * and initializes GStreamer. Shared by extension.js (to actually run the + * pipeline) and prefs.js (to tell the user up front why the feature is + * greyed out), since either process may be missing the system packages + * that provide these typelibs. + * + * Throws with a descriptive message on failure; never caches a *failure*, + * since the user may install the missing packages and reopen preferences + * without restarting the shell. + */ +export async function loadGstreamerModules() { + const [{default: Gst}, , {default: Cogl}] = await Promise.all([ + import('gi://Gst?version=1.0'), + import('gi://GstApp?version=1.0'), + import('gi://Cogl'), + ]); + + if (!Gst.is_initialized()) + Gst.init(null); + + return {Gst, Cogl}; +} + +export const GSTREAMER_INSTALL_HINT = + 'Install GStreamer’s "base" and "good" plugin sets (with their GObject-Introspection data), ' + + 'which provide playback and GIF decoding:\n' + + '• Arch: sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav\n' + + '• Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good ' + + 'gir1.2-gst-plugins-base-1.0\n' + + '• Fedora: sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gobject-introspection\n' + + 'Then restart GNOME Shell (log out and back in on Wayland).'; diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 58af89e..96cf267 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -4,6 +4,7 @@ import Clutter from 'gi://Clutter'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import {SettingsKey} from './settingsKeys.js'; +import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js'; /** * Plays a video or animated GIF file as a looping animated background. @@ -88,28 +89,14 @@ export class LiveWallpaperManager { 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'), - ]); - + const {Gst, Cogl} = await loadGstreamerModules(); 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.`); + `Live wallpaper unavailable: GStreamer/Cogl introspection bindings could not be loaded (${e.message ?? e}).\n${GSTREAMER_INSTALL_HINT}`); } } diff --git a/prefs.js b/prefs.js index 27e5030..2c94ffb 100644 --- a/prefs.js +++ b/prefs.js @@ -6,15 +6,17 @@ import Gdk from 'gi://Gdk'; import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; import {SettingsKey} from './lib/settingsKeys.js'; +import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; export default class BenthicBloomPreferences extends ExtensionPreferences { - fillPreferencesWindow(window) { + async fillPreferencesWindow(window) { const settings = this.getSettings(); + const gstreamerError = await loadGstreamerModules().then(() => null, e => e.message ?? String(e)); window.set_default_size(640, 720); window.add(this._buildGeneralPage(settings)); window.add(this._buildRotationPage(settings)); - window.add(this._buildLiveWallpaperPage(settings)); + window.add(this._buildLiveWallpaperPage(settings, gstreamerError)); window.add(this._buildOledPage(settings)); window.add(this._buildAboutPage()); } @@ -170,9 +172,21 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- Live wallpaper page -------------------------------------------- - _buildLiveWallpaperPage(settings) { + _buildLiveWallpaperPage(settings, gstreamerError) { const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'video-x-generic-symbolic'}); + if (gstreamerError) { + const warningGroup = new Adw.PreferencesGroup(); + const warningRow = new Adw.ActionRow({ + title: _('GStreamer Not Found'), + subtitle: `${_('Live wallpapers will stay disabled until this is fixed:')} ${gstreamerError}\n\n${GSTREAMER_INSTALL_HINT}`, + css_classes: ['warning'], + }); + warningRow.subtitle_lines = 0; + warningGroup.add(warningRow); + page.add(warningGroup); + } + const group = new Adw.PreferencesGroup({ title: _('Video Wallpaper'), description: _( @@ -221,17 +235,19 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { fileRow.add_suffix(chooseButton); group.add(fileRow); - group.add(this._switchRow( + const muteRow = this._switchRow( settings, SettingsKey.LIVE_WALLPAPER_MUTED, - _('Mute Audio'), _('Play video wallpapers without sound'))); - group.add(this._spinRow( + _('Mute Audio'), _('Play video wallpapers without sound')); + group.add(muteRow); + const rateRow = this._spinRow( { title: _('Playback Speed'), subtitle: _('Multiplier, e.g. 0.5 for half speed, 2.0 for double speed'), lower: 0.1, upper: 4.0, step: 0.1, page: 0.5, digits: 1, }, () => settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE), - value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value))); + value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value)); + group.add(rateRow); const powerGroup = new Adw.PreferencesGroup({title: _('Power Saving')}); page.add(powerGroup); @@ -242,6 +258,16 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { settings, SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN, _('Pause When Fullscreen'), _('Stop video playback while a window is fullscreen'))); + // The enable switch itself stays usable so the setting can be + // prepared ahead of time, but everything that only matters once + // GStreamer is actually driving playback is greyed out. + if (gstreamerError) { + fileRow.sensitive = false; + muteRow.sensitive = false; + rateRow.sensitive = false; + powerGroup.sensitive = false; + } + return page; } From d180c90434a599dbc4e4db244435438c86c51cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:33:01 +0000 Subject: [PATCH 04/12] Suspend rotation while a live wallpaper is on screen The extension's own log showed rotation kept firing ("Wallpaper changed to ...") after live wallpaper playback had already started. Each rotation change writes org.gnome.desktop.background's picture-uri, which makes GNOME Shell repaint its own background actor into the same _backgroundGroup our live wallpaper actor lives in, landing on top of it and hiding the video/GIF entirely. LiveWallpaperManager now takes an onActiveChanged callback, invoked only on actual start/stop transitions, which extension.js uses to suspend/unsuspend RotationManager. This is tracked separately from user-initiated pause()/resume() so turning live wallpaper off restores whatever rotation state the user actually had, and next()/previous() now no-op while suspended so manual/forced-OLED wallpaper changes can't sneak one in either. The indicator's "Next Wallpaper" item is greyed out to match. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- README.md | 5 ++++- extension.js | 14 +++++++++++++- lib/indicator.js | 5 ++++- lib/liveWallpaper.js | 14 +++++++++++--- lib/rotationManager.js | 28 ++++++++++++++++++++++++++-- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c21639d..e956ac1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,10 @@ protection against OLED burn-in. desktop background. Frames are decoded with GStreamer and rendered directly by GNOME Shell's own Clutter stage (no external, incompatible Clutter build involved). Playback can automatically pause on battery - power or while a window is fullscreen. + power or while a window is fullscreen. Automatic rotation is + automatically suspended while a live wallpaper is actually on screen + (and resumes afterward), since changing the static wallpaper would + otherwise repaint over the video. - **OLED burn-in protection** — three independent, individually toggled techniques: - *Pixel shifting*: nudges the background a few pixels on a slow drift diff --git a/extension.js b/extension.js index c1edf0e..54fa334 100644 --- a/extension.js +++ b/extension.js @@ -14,7 +14,19 @@ export default class BenthicBloomExtension extends Extension { this._logger = new Logger(this._settings, this.metadata.name); this._rotationManager = new RotationManager(this._settings, this._logger); - this._liveWallpaperManager = new LiveWallpaperManager(this._settings, this._logger); + this._liveWallpaperManager = new LiveWallpaperManager(this._settings, this._logger, { + // Rotation changes the static picture-uri GSettings key, which + // makes the shell repaint its own background actor on top of + // the live wallpaper's. Suspend rotation while a live + // wallpaper is actually on screen, independent of any + // user-initiated pause. + onActiveChanged: active => { + if (active) + this._rotationManager.suspend(); + else + this._rotationManager.unsuspend(); + }, + }); this._oledProtectionManager = new OledProtectionManager(this._settings, this._logger, { forceNextWallpaper: maxSeconds => { if (this._rotationManager.secondsSinceLastChange >= maxSeconds) { diff --git a/lib/indicator.js b/lib/indicator.js index de3fadb..1396b82 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -28,6 +28,7 @@ class BenthicBloomIndicator extends PanelMenu.Button { nextItem.connect('activate', () => { rotationManager.next().catch(e => console.error(`[BenthicBloom] ${e.message ?? e}`)); }); + nextItem.setSensitive(!settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); this.menu.addMenuItem(nextItem); const liveToggle = new PopupMenu.PopupSwitchMenuItem( @@ -58,7 +59,9 @@ class BenthicBloomIndicator extends PanelMenu.Button { rotationToggle.setToggleState(settings.get_boolean(SettingsKey.ROTATION_ENABLED)); }), settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => { - liveToggle.setToggleState(settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); + const enabled = settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED); + liveToggle.setToggleState(enabled); + nextItem.setSensitive(!enabled); }), settings.connect(`changed::${SettingsKey.OLED_PROTECTION_ENABLED}`, () => { oledToggle.setToggleState(settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED)); diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 96cf267..335c67c 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -21,9 +21,10 @@ import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailabil * that belongs to gnome-shell itself. */ export class LiveWallpaperManager { - constructor(settings, logger) { + constructor(settings, logger, {onActiveChanged} = {}) { this._settings = settings; this._logger = logger; + this._onActiveChanged = onActiveChanged ?? (() => {}); this._available = false; this._active = false; @@ -57,6 +58,13 @@ export class LiveWallpaperManager { return this._active; } + _setActive(active) { + if (this._active === active) + return; + this._active = active; + this._onActiveChanged(active); + } + async enable() { await this._loadGstreamer(); @@ -149,7 +157,7 @@ export class LiveWallpaperManager { this._applyMute(); this._playbin.set_state(Gst.State.PLAYING); - this._active = true; + this._setActive(true); this._paused = false; this._connectPowerWatches(); this._logger.debug(`Live wallpaper started: ${path}`); @@ -336,7 +344,7 @@ export class LiveWallpaperManager { this._videoWidth = 0; this._videoHeight = 0; - this._active = false; + this._setActive(false); this._paused = false; } } diff --git a/lib/rotationManager.js b/lib/rotationManager.js index b61ed7f..ec2b2e1 100644 --- a/lib/rotationManager.js +++ b/lib/rotationManager.js @@ -31,6 +31,7 @@ export class RotationManager { this._timeoutId = 0; this._settingsSignals = []; this._paused = false; + this._suspended = false; this._lastChangeTime = GLib.get_monotonic_time(); this._currentPath = null; this._transitionOverlays = []; @@ -89,7 +90,7 @@ export class RotationManager { _restartTimer() { this._clearTimer(); - if (this._paused || !this._settings.get_boolean(SettingsKey.ROTATION_ENABLED)) + if (this._paused || this._suspended || !this._settings.get_boolean(SettingsKey.ROTATION_ENABLED)) return; const interval = Math.max( @@ -118,7 +119,30 @@ export class RotationManager { this._restartTimer(); } + /** + * Distinct from user-initiated pause(): called while a live wallpaper + * is actually covering the desktop, so rotation doesn't keep changing + * a static image nobody can see (which also churns the shell's own + * background actor on top of the live wallpaper's). Resuming restores + * whatever the user's own pause() state was, rather than forcing + * rotation back on. + */ + suspend() { + this._suspended = true; + this._clearTimer(); + } + + unsuspend() { + this._suspended = false; + this._restartTimer(); + } + async next({forced = false} = {}) { + if (this._suspended) { + this._logger.debug('Rotation suspended while live wallpaper is active'); + return; + } + if (this._images.length === 0) await this._reloadImages(); @@ -144,7 +168,7 @@ export class RotationManager { } async previous() { - if (this._images.length === 0) + if (this._suspended || this._images.length === 0) return; const mode = this._settings.get_string(SettingsKey.ROTATION_MODE); From f7c05c2c7628a9ba825ffb005ae64590f2af944a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:36:48 +0000 Subject: [PATCH 05/12] Fix false-positive "GStreamer Not Found" warning in Preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Preferences window's availability probe (added to surface missing GStreamer deps in-UI) reused loadGstreamerModules(), which also checks for Cogl. Cogl is Mutter's private library — its typelib is only reachable from inside the actual gnome-shell process (which gets a private GI search path), never from the separate, plain-GTK4 Preferences process. So the check always failed there with "Requiring Cogl ... not found" even when live wallpapers were working correctly in the real shell process, as confirmed by a user report where the extension's own log showed live wallpaper playback starting cleanly while Preferences simultaneously claimed GStreamer was missing. Split into loadGstreamerModules() (Gst+GstApp+Cogl, used only by liveWallpaper.js in the shell process) and a new lighter checkGstreamerBaseAvailable() (Gst+GstApp only) for prefs.js, with a note in the UI that a clean check there isn't a full guarantee since the private half can't be verified from that process. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/gstreamerAvailability.js | 33 ++++++++++++++++++++++++++------- prefs.js | 8 +++++--- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/lib/gstreamerAvailability.js b/lib/gstreamerAvailability.js index cf8ee59..2fd86b7 100644 --- a/lib/gstreamerAvailability.js +++ b/lib/gstreamerAvailability.js @@ -1,14 +1,16 @@ /** * Loads the GObject-Introspection bindings the live wallpaper feature needs * (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames) - * and initializes GStreamer. Shared by extension.js (to actually run the - * pipeline) and prefs.js (to tell the user up front why the feature is - * greyed out), since either process may be missing the system packages - * that provide these typelibs. + * and initializes GStreamer. Used by liveWallpaper.js, which only ever runs + * inside the gnome-shell process itself. * - * Throws with a descriptive message on failure; never caches a *failure*, - * since the user may install the missing packages and reopen preferences - * without restarting the shell. + * Cogl is Mutter's *private* library: its typelib is only reachable from + * gnome-shell's own process (which gets a private search path), never from + * an ordinary GTK application. Do NOT reuse this for a diagnostic check in + * prefs.js — that runs in a separate, plain GTK4 process where importing + * Cogl will *always* fail regardless of whether live wallpapers actually + * work, producing a false "not found" report. Use + * checkGstreamerBaseAvailable() there instead. */ export async function loadGstreamerModules() { const [{default: Gst}, , {default: Cogl}] = await Promise.all([ @@ -23,6 +25,23 @@ export async function loadGstreamerModules() { return {Gst, Cogl}; } +/** + * Lighter check for prefs.js: confirms the system-wide GStreamer packages + * (Gst core + the "app" plugin providing GstApp) are installed, without + * touching Cogl. This can't fully confirm live wallpapers will work (that + * also needs Cogl, only checkable from inside gnome-shell itself), but a + * failure here is a genuine, actionable problem, unlike a Cogl probe. + */ +export async function checkGstreamerBaseAvailable() { + const [{default: Gst}] = await Promise.all([ + import('gi://Gst?version=1.0'), + import('gi://GstApp?version=1.0'), + ]); + + if (!Gst.is_initialized()) + Gst.init(null); +} + export const GSTREAMER_INSTALL_HINT = 'Install GStreamer’s "base" and "good" plugin sets (with their GObject-Introspection data), ' + 'which provide playback and GIF decoding:\n' + diff --git a/prefs.js b/prefs.js index 2c94ffb..1051370 100644 --- a/prefs.js +++ b/prefs.js @@ -6,12 +6,12 @@ import Gdk from 'gi://Gdk'; import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; import {SettingsKey} from './lib/settingsKeys.js'; -import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; +import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; export default class BenthicBloomPreferences extends ExtensionPreferences { async fillPreferencesWindow(window) { const settings = this.getSettings(); - const gstreamerError = await loadGstreamerModules().then(() => null, e => e.message ?? String(e)); + const gstreamerError = await checkGstreamerBaseAvailable().then(() => null, e => e.message ?? String(e)); window.set_default_size(640, 720); window.add(this._buildGeneralPage(settings)); @@ -192,7 +192,9 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { description: _( 'Play a looping video or animated GIF as your desktop background instead of a static image. ' + 'Requires GStreamer (with its "good" and "base" plugin sets, which provide GIF decoding) ' + - 'to be installed on your system.'), + 'to be installed on your system. Note: this page can only detect GStreamer being ' + + 'completely missing — the rendering path it also needs is private to gnome-shell and can’t ' + + 'be checked from here, so the absence of a warning below isn’t a full guarantee.'), }); page.add(group); group.add(this._switchRow( From 2688568fac1b43e14cf7067f28e0ddf6177545dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:50:24 +0000 Subject: [PATCH 06/12] Add live wallpaper pipeline diagnostics A user's live wallpaper reached "started" with zero errors (Cogl and GStreamer both loading fine, actor added to _backgroundGroup) but never actually appeared on screen, and no code path here could tell us why: the bus handler only looked at EOS/ERROR, silently discarding WARNING and STATE_CHANGED messages where a stalled negotiation or preroll would actually show up. Adds: WARNING message logging, PLAYING/PAUSED/etc. state-changed logging for the playbin itself, a frame counter with first-frame and periodic debug logs, and a one-shot watchdog that logs an explicit warning if zero frames arrive within 4s of set_state(PLAYING). Also switched appsink's pull-sample from an emitted action signal to the plain pull_sample() method, which is the more directly supported GstApp.AppSink API and one less variable while debugging. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/liveWallpaper.js | 50 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 335c67c..566192f 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -48,6 +48,9 @@ export class LiveWallpaperManager { this._fullscreenChangedId = 0; this._upowerProxy = null; this._upowerSignalId = 0; + + this._frameCount = 0; + this._noFrameWatchdogId = 0; } get isAvailable() { @@ -155,7 +158,20 @@ export class LiveWallpaperManager { this._busWatchId = bus.connect('message', (_bus, message) => this._onBusMessage(message)); this._applyMute(); - this._playbin.set_state(Gst.State.PLAYING); + const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING); + this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`); + + this._frameCount = 0; + this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => { + this._noFrameWatchdogId = 0; + if (this._active && this._frameCount === 0) { + this._logger.warn( + 'Live wallpaper: no frames received 4s after starting playback. ' + + 'The pipeline likely stalled during caps negotiation or preroll without ' + + 'raising an error — check for GStreamer WARNING messages logged above.'); + } + return GLib.SOURCE_REMOVE; + }); this._setActive(true); this._paused = false; @@ -169,7 +185,7 @@ export class LiveWallpaperManager { _onNewSample(sink) { const Gst = this._Gst; - const sample = sink.emit('pull-sample'); + const sample = sink.pull_sample(); if (!sample) return Gst.FlowReturn.OK; @@ -185,6 +201,11 @@ export class LiveWallpaperManager { try { this._updateFrame(mapInfo.data, width, height); + this._frameCount++; + if (this._frameCount === 1) + this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`); + else if (this._frameCount % 120 === 0) + this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`); } finally { buffer.unmap(mapInfo); } @@ -221,12 +242,28 @@ export class LiveWallpaperManager { 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'); + const [error, debug] = message.parse_error(); + this._logger.error(error, `Live wallpaper playback error (${debug ?? 'no debug info'})`); this._stop(); break; } + case Gst.MessageType.WARNING: { + const [warning, debug] = message.parse_warning(); + this._logger.warn(`Live wallpaper GStreamer warning: ${warning.message} (${debug ?? 'no debug info'})`); + break; } + case Gst.MessageType.STATE_CHANGED: + if (message.src === this._playbin) { + const [, newState] = message.parse_state_changed(); + this._logger.debug(`Live wallpaper pipeline state changed to ${this._stateName(newState)}`); + } + break; + } + } + + _stateName(state) { + const Gst = this._Gst; + return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state); } _applyMute() { @@ -326,6 +363,11 @@ export class LiveWallpaperManager { _stop() { this._disconnectPowerWatches(); + if (this._noFrameWatchdogId) { + GLib.source_remove(this._noFrameWatchdogId); + this._noFrameWatchdogId = 0; + } + if (this._playbin) { const bus = this._playbin.get_bus(); if (this._busWatchId) { From 14078f0466a0255e7bb10148657af91130d5ee30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:04:07 +0000 Subject: [PATCH 07/12] Close try/catch gap around appsink pull_sample() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone gst-launch testing confirmed appsink-as-playbin-video-sink works fine outside the extension (reaches PLAYING, negotiates caps, runs to completion), narrowing the "no frames received" symptom down to something inside our own new-sample handling in the shell process. _onNewSample()'s try/catch didn't cover the sink.pull_sample() call itself — if that specific call throws when invoked as a GJS signal callback, the exception bypassed our logging entirely, falling back to GJS's generic uncaught-exception path (which, like the openPreferences case, may not mention "benthicbloom" and gets missed by a filtered grep). Now the whole handler is covered, logs when the signal fires for the first time, logs a null pull_sample() or failed buffer.map() explicitly, and the watchdog reports the new-sample fire count so we can tell "signal never fired" from "signal fired but processing failed" on the next test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/liveWallpaper.js | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 566192f..fe69289 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -50,6 +50,7 @@ export class LiveWallpaperManager { this._upowerSignalId = 0; this._frameCount = 0; + this._newSampleCallCount = 0; this._noFrameWatchdogId = 0; } @@ -162,13 +163,16 @@ export class LiveWallpaperManager { this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`); this._frameCount = 0; + this._newSampleCallCount = 0; this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => { this._noFrameWatchdogId = 0; if (this._active && this._frameCount === 0) { this._logger.warn( - 'Live wallpaper: no frames received 4s after starting playback. ' + - 'The pipeline likely stalled during caps negotiation or preroll without ' + - 'raising an error — check for GStreamer WARNING messages logged above.'); + `Live wallpaper: no frames received 4s after starting playback ` + + `(new-sample signal fired ${this._newSampleCallCount} times). ` + + 'If that count is 0, the signal itself never fired (check for a stalled ' + + 'pipeline above); if it is nonzero, pull_sample()/buffer processing is failing ' + + '— check for a "Failed to process live wallpaper frame" error above.'); } return GLib.SOURCE_REMOVE; }); @@ -185,19 +189,27 @@ export class LiveWallpaperManager { _onNewSample(sink) { const Gst = this._Gst; - const sample = sink.pull_sample(); - if (!sample) - return Gst.FlowReturn.OK; + this._newSampleCallCount = (this._newSampleCallCount ?? 0) + 1; + if (this._newSampleCallCount === 1) + this._logger.debug('Live wallpaper: new-sample signal fired for the first time'); try { + const sample = sink.pull_sample(); + if (!sample) { + this._logger.debug('Live wallpaper: pull_sample() returned no sample'); + return Gst.FlowReturn.OK; + } + 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) + if (!ok) { + this._logger.debug('Live wallpaper: buffer.map() failed'); return Gst.FlowReturn.OK; + } try { this._updateFrame(mapInfo.data, width, height); @@ -210,7 +222,7 @@ export class LiveWallpaperManager { buffer.unmap(mapInfo); } } catch (e) { - this._logger.error(e, 'Failed to process live wallpaper frame'); + this._logger.error(e, 'Failed to process live wallpaper frame (new-sample handler)'); } return Gst.FlowReturn.OK; From 5157f4a1c91cd8531f22866958128aabc85a93d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:11:29 +0000 Subject: [PATCH 08/12] Build the live wallpaper sink bin explicitly instead of via string parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User testing pinned the bug precisely: our try/catch is airtight now, and the diagnostics showed "new-sample signal fired 0 times" — the appsink genuinely never receives a single sample inside gnome-shell's process, while an equivalent pipeline (videoconvert ! videoscale ! RGBA caps ! appsink) played correctly standalone via gst-launch-1.0. Replaces Gst.parse_bin_from_description()'s gst-launch mini-language parsing (with its automatic unlinked-pad ghosting) with explicit ElementFactory.make() + Bin.add() + element.link() + a manually created GhostPad. This removes the string-parsing path as a variable entirely and, importantly, surfaces any link() failure as a thrown error instead of failing silently, which the old code path had no way to report even if that patch were the whole cachet of the problem. Also fixes a related warning ("Trying to dispose element ..., but it is in PLAYING instead of the NULL state") from _stop() dropping the playbin reference immediately after requesting the NULL state, before the (async) transition actually completed — now waits briefly via get_state() for it to settle first. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/liveWallpaper.js | 72 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index fe69289..6317ee9 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -128,6 +128,61 @@ export class LiveWallpaperManager { this._start(); } + /** + * Builds videoconvert ! videoscale ! capsfilter(RGBA) ! appsink using + * explicit element creation, linking, and a ghost pad, rather than + * Gst.parse_bin_from_description()'s gst-launch mini-language. Both + * are meant to be equivalent, but a user's testing showed the + * string-parsed version's appsink never fired 'new-sample' even once + * inside gnome-shell's process while an equivalent standalone + * gst-launch-1.0 pipeline worked fine — this removes the string + * parsing (and its automatic ghost-pad detection) as a variable, and + * surfaces link() failures explicitly instead of failing silently. + */ + _buildSinkBin() { + const Gst = this._Gst; + + const videoconvert = Gst.ElementFactory.make('videoconvert', 'benthicbloom-convert'); + const videoscale = Gst.ElementFactory.make('videoscale', 'benthicbloom-scale'); + const capsfilter = Gst.ElementFactory.make('capsfilter', 'benthicbloom-capsfilter'); + const appsink = Gst.ElementFactory.make('appsink', 'benthicbloom-appsink'); + + if (!videoconvert || !videoscale || !capsfilter || !appsink) { + throw new Error( + 'Failed to create one or more GStreamer elements ' + + '(videoconvert/videoscale/capsfilter/appsink) — a required plugin is likely missing'); + } + + capsfilter.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA')); + appsink.set_property('emit-signals', true); + appsink.set_property('max-buffers', 2); + appsink.set_property('drop', true); + appsink.set_property('sync', true); + + const sinkBin = new Gst.Bin({name: 'benthicbloom-sinkbin'}); + sinkBin.add(videoconvert); + sinkBin.add(videoscale); + sinkBin.add(capsfilter); + sinkBin.add(appsink); + + if (!videoconvert.link(videoscale)) + throw new Error('Failed to link videoconvert -> videoscale'); + if (!videoscale.link(capsfilter)) + throw new Error('Failed to link videoscale -> capsfilter'); + if (!capsfilter.link(appsink)) + throw new Error('Failed to link capsfilter -> appsink'); + + const sinkPad = videoconvert.get_static_pad('sink'); + const ghostPad = Gst.GhostPad.new('sink', sinkPad); + if (!ghostPad) + throw new Error('Failed to create ghost pad for live wallpaper sink bin'); + ghostPad.set_active(true); + sinkBin.add_pad(ghostPad); + + this._appsink = appsink; + return sinkBin; + } + _start() { const path = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH); if (!this._available || !path) @@ -143,11 +198,7 @@ export class LiveWallpaperManager { 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'); + const sinkBin = this._buildSinkBin(); this._appsink.connect('new-sample', sink => this._onNewSample(sink)); this._playbin = Gst.ElementFactory.make('playbin', 'benthicbloom-live-wallpaper'); @@ -381,13 +432,22 @@ export class LiveWallpaperManager { } if (this._playbin) { + const Gst = this._Gst; 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.set_state(Gst.State.NULL); + // Block briefly for the (normally fast) transition to actually + // finish before dropping our reference — otherwise the element + // can get disposed mid-transition, which GStreamer logs as + // "Trying to dispose element ..., but it is in PLAYING instead + // of the NULL state". + this._playbin.get_state(200 * Gst.MSECOND); + this._playbin = null; this._appsink = null; } From d5f6d3ecad9d92596bf524fe74080a55ded18a1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:17:07 +0000 Subject: [PATCH 09/12] Poll appsink instead of relying on the new-sample signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last round of testing ruled out the pipeline construction itself: even with the sink bin rebuilt from explicit, individually-checked link() calls (no thrown errors, playbin reaches PLAYING), the 'new-sample' signal still never fired inside gnome-shell — while an equivalent pipeline worked fine standalone via gst-launch-1.0. The remaining, gnome-shell-specific difference: appsink emits 'new-sample' from GStreamer's own streaming thread, not the main thread. GJS's JS engine isn't safe to call into from an arbitrary background thread, and a cross-thread signal emission can be silently dropped rather than invoked or crashed on — which looks exactly like "the signal never fires" from here, even though the pipeline is actually running. Replaces the signal entirely with polling: a ~30fps GLib.timeout_add() on the main thread calls appsink.try_pull_sample(0), which is explicitly documented as safe to call from any thread since we are now the ones calling into GStreamer rather than the reverse. Also updates the no-frames watchdog to report the poll count instead of a signal fire count. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/liveWallpaper.js | 54 ++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 6317ee9..8b67347 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -50,7 +50,8 @@ export class LiveWallpaperManager { this._upowerSignalId = 0; this._frameCount = 0; - this._newSampleCallCount = 0; + this._pollCount = 0; + this._pollTimeoutId = 0; this._noFrameWatchdogId = 0; } @@ -154,7 +155,11 @@ export class LiveWallpaperManager { } capsfilter.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA')); - appsink.set_property('emit-signals', true); + // emit-signals is deliberately left off: appsink's 'new-sample' fires + // from GStreamer's streaming thread, not gnome-shell's main thread, + // and a cross-thread call into the JS engine can be silently dropped + // rather than invoked. We poll with try_pull_sample() from a main + // thread GLib timer instead, which is safe to call from any thread. appsink.set_property('max-buffers', 2); appsink.set_property('drop', true); appsink.set_property('sync', true); @@ -199,7 +204,6 @@ export class LiveWallpaperManager { this._layoutActor(); const sinkBin = this._buildSinkBin(); - 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); @@ -214,16 +218,20 @@ export class LiveWallpaperManager { this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`); this._frameCount = 0; - this._newSampleCallCount = 0; + this._pollCount = 0; + // ~30fps polling of the appsink from the main thread. See the + // comment on appsink's properties above for why this replaces + // a 'new-sample' signal handler. + this._pollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33, () => this._pollForSample()); + this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => { this._noFrameWatchdogId = 0; if (this._active && this._frameCount === 0) { this._logger.warn( `Live wallpaper: no frames received 4s after starting playback ` + - `(new-sample signal fired ${this._newSampleCallCount} times). ` + - 'If that count is 0, the signal itself never fired (check for a stalled ' + - 'pipeline above); if it is nonzero, pull_sample()/buffer processing is failing ' + - '— check for a "Failed to process live wallpaper frame" error above.'); + `(polled appsink ${this._pollCount} times). If that count is 0, the poll timer ` + + 'itself never ran; if it is nonzero, try_pull_sample() keeps returning nothing ' + + '— check for a "Failed to poll live wallpaper frame" error above.'); } return GLib.SOURCE_REMOVE; }); @@ -238,18 +246,19 @@ export class LiveWallpaperManager { } } - _onNewSample(sink) { + _pollForSample() { + if (!this._appsink) + return GLib.SOURCE_REMOVE; + const Gst = this._Gst; - this._newSampleCallCount = (this._newSampleCallCount ?? 0) + 1; - if (this._newSampleCallCount === 1) - this._logger.debug('Live wallpaper: new-sample signal fired for the first time'); + this._pollCount++; + if (this._pollCount === 1) + this._logger.debug('Live wallpaper: appsink polling started'); try { - const sample = sink.pull_sample(); - if (!sample) { - this._logger.debug('Live wallpaper: pull_sample() returned no sample'); - return Gst.FlowReturn.OK; - } + const sample = this._appsink.try_pull_sample(0); + if (!sample) + return GLib.SOURCE_CONTINUE; const buffer = sample.get_buffer(); const structure = sample.get_caps().get_structure(0); @@ -259,7 +268,7 @@ export class LiveWallpaperManager { const [ok, mapInfo] = buffer.map(Gst.MapFlags.READ); if (!ok) { this._logger.debug('Live wallpaper: buffer.map() failed'); - return Gst.FlowReturn.OK; + return GLib.SOURCE_CONTINUE; } try { @@ -273,10 +282,10 @@ export class LiveWallpaperManager { buffer.unmap(mapInfo); } } catch (e) { - this._logger.error(e, 'Failed to process live wallpaper frame (new-sample handler)'); + this._logger.error(e, 'Failed to poll live wallpaper frame'); } - return Gst.FlowReturn.OK; + return GLib.SOURCE_CONTINUE; } _updateFrame(data, width, height) { @@ -431,6 +440,11 @@ export class LiveWallpaperManager { this._noFrameWatchdogId = 0; } + if (this._pollTimeoutId) { + GLib.source_remove(this._pollTimeoutId); + this._pollTimeoutId = 0; + } + if (this._playbin) { const Gst = this._Gst; const bus = this._playbin.get_bus(); From 2365d303f3ee4305dde5631266f5dff3e7c8072c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:26:38 +0000 Subject: [PATCH 10/12] Fix frame upload: Clutter.Image isn't constructible from GJS anymore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polling fix worked and pinpointed the real, final bug: appsink was delivering frames correctly (confirmed by the polling infrastructure itself running fine), but every single upload attempt threw "(intermediate value).Image is not a constructor" from `new Clutter.Image()`. In current Mutter, Clutter.Image apparently isn't directly constructible via `new` from GJS anymore, and the resulting exception was also flooding the log at ~30/sec with no rate limiting. Adds St.ImageContent.new_with_preferred_size() + set_bytes(GLib.Bytes) as the primary path — the same mechanism gnome-shell's own code uses for uploading raw pixel buffers onto actors — with the old Clutter.Image/set_data() path kept only as a fallback for older shells. The two are tried in order per frame size change and "pinned" once one works, avoiding a live per-frame strategy search. Also rate-limits the poll error log to the 1st and every 300th failure instead of every single one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8 --- lib/liveWallpaper.js | 69 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 8b67347..9cafde0 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -1,11 +1,36 @@ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import Clutter from 'gi://Clutter'; +import St from 'gi://St'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import {SettingsKey} from './settingsKeys.js'; import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js'; +/** + * Ways to get a decoded frame onto an actor, tried in order and "pinned" + * once one works. `new Clutter.Image()` failed with "is not a constructor" + * in real-world testing (Clutter.Image is apparently no longer directly + * constructible from GJS in current Mutter); St.ImageContent — the same + * mechanism gnome-shell's own code uses for uploading raw pixel buffers — + * is tried first, with the legacy Clutter.Image/set_data path kept only as + * a fallback for older shells. + */ +const FRAME_IMAGE_STRATEGIES = [ + { + name: 'St.ImageContent', + create: (width, height) => St.ImageContent.new_with_preferred_size(width, height), + upload: (image, Cogl, data, width, height) => + image.set_bytes(GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4), + }, + { + name: 'Clutter.Image', + create: () => new Clutter.Image(), + upload: (image, Cogl, data, width, height) => + image.set_data(data, Cogl.PixelFormat.RGBA_8888, width, height, width * 4), + }, +]; + /** * Plays a video or animated GIF file as a looping animated background. * `playbin` typefinds the source by content rather than extension, so GIFs @@ -53,6 +78,8 @@ export class LiveWallpaperManager { this._pollCount = 0; this._pollTimeoutId = 0; this._noFrameWatchdogId = 0; + this._imageStrategyIndex = 0; + this._frameErrorCount = 0; } get isAvailable() { @@ -219,6 +246,8 @@ export class LiveWallpaperManager { this._frameCount = 0; this._pollCount = 0; + this._imageStrategyIndex = 0; + this._frameErrorCount = 0; // ~30fps polling of the appsink from the main thread. See the // comment on appsink's properties above for why this replaces // a 'new-sample' signal handler. @@ -282,7 +311,14 @@ export class LiveWallpaperManager { buffer.unmap(mapInfo); } } catch (e) { - this._logger.error(e, 'Failed to poll live wallpaper frame'); + // Rate-limited: this runs at ~30fps, so logging every failure + // would flood the journal (and this loop keeps retrying every + // frame, e.g. while cycling through FRAME_IMAGE_STRATEGIES). + this._frameErrorCount++; + if (this._frameErrorCount === 1 || this._frameErrorCount % 300 === 0) { + this._logger.error( + e, `Failed to poll live wallpaper frame (${this._frameErrorCount} failures so far)`); + } } return GLib.SOURCE_CONTINUE; @@ -292,18 +328,29 @@ export class LiveWallpaperManager { if (!this._actor) return; - if (!this._image || this._videoWidth !== width || this._videoHeight !== height) { - this._image = new Clutter.Image(); - this._videoWidth = width; - this._videoHeight = height; + const needsNewImage = !this._image || this._videoWidth !== width || this._videoHeight !== height; + + while (this._imageStrategyIndex < FRAME_IMAGE_STRATEGIES.length) { + const strategy = FRAME_IMAGE_STRATEGIES[this._imageStrategyIndex]; + try { + if (needsNewImage) { + this._image = strategy.create(width, height); + this._videoWidth = width; + this._videoHeight = height; + } + strategy.upload(this._image, this._Cogl, data, width, height); + this._actor.set_content(this._image); + return; + } catch (e) { + this._logger.warn( + `Live wallpaper: frame image strategy "${strategy.name}" failed ` + + `(${e.message ?? e}), trying the next one`); + this._imageStrategyIndex++; + this._image = null; + } } - 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'); - } + throw new Error('All live wallpaper frame image strategies failed'); } _onBusMessage(message) { From bbd21c9e5a5ad3dff78334bbf701d8b902e9f5ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:34:30 +0000 Subject: [PATCH 11/12] Fix St.ImageContent.set_bytes missing Cogl.Context argument set_bytes() takes a Cogl.Context as its first parameter (added upstream in GNOME Shell 48, see js/ui/screenshot.js); omitting it produced "At least 6 arguments required, but only 5 passed" for every frame, which fell through to the equally non-functional Clutter.Image fallback and left the live wallpaper blank. --- lib/liveWallpaper.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js index 9cafde0..342cf4d 100644 --- a/lib/liveWallpaper.js +++ b/lib/liveWallpaper.js @@ -15,13 +15,19 @@ import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailabil * mechanism gnome-shell's own code uses for uploading raw pixel buffers — * is tried first, with the legacy Clutter.Image/set_data path kept only as * a fallback for older shells. + * + * St.ImageContent.set_bytes() takes a Cogl.Context as its first argument + * (see js/ui/screenshot.js upstream) — omitting it is what produced + * "At least 6 arguments required, but only 5 passed". */ const FRAME_IMAGE_STRATEGIES = [ { name: 'St.ImageContent', create: (width, height) => St.ImageContent.new_with_preferred_size(width, height), upload: (image, Cogl, data, width, height) => - image.set_bytes(GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4), + image.set_bytes( + global.stage.context.get_backend().get_cogl_context(), + GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4), }, { name: 'Clutter.Image', From f246d6a4fe71ffcc67b88e23e8bfe72efd8ca65a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:08:14 +0000 Subject: [PATCH 12/12] Add live wallpaper source folders and bundle preferences page icons Live Wallpaper page gains a "Wallpaper Folders" list (mirroring the static rotation one) plus an "Available Media" list of the videos/GIFs found inside them to pick from, backed by a new live-wallpaper-folders GSettings key and a generalized wallpaperSource.js extension scan. The preferences page-switcher icons (General, Rotation, Live Wallpaper, OLED Protection, About) were showing as broken-image placeholders since they depended on exact symbolic icon names being present in the user's system icon theme. Bundle a small set of custom SVG icons under icons/ and register them via Gtk.IconTheme.add_search_path so they always resolve regardless of the system theme. --- Makefile | 5 +- README.md | 5 +- icons/bb-about-symbolic.svg | 5 + icons/bb-general-symbolic.svg | 10 ++ icons/bb-live-wallpaper-symbolic.svg | 4 + icons/bb-oled-symbolic.svg | 5 + icons/bb-rotation-symbolic.svg | 6 + lib/settingsKeys.js | 5 + lib/wallpaperSource.js | 33 +++- prefs.js | 155 +++++++++++++++--- ....shell.extensions.benthicbloom.gschema.xml | 6 + 11 files changed, 199 insertions(+), 40 deletions(-) create mode 100644 icons/bb-about-symbolic.svg create mode 100644 icons/bb-general-symbolic.svg create mode 100644 icons/bb-live-wallpaper-symbolic.svg create mode 100644 icons/bb-oled-symbolic.svg create mode 100644 icons/bb-rotation-symbolic.svg diff --git a/Makefile b/Makefile index 827c2ed..efa0595 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,10 @@ schemas: build: schemas install: build - mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas + mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas $(INSTALL_DIR)/icons cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/ cp lib/*.js $(INSTALL_DIR)/lib/ + cp icons/*.svg $(INSTALL_DIR)/icons/ cp $(SCHEMA_DIR)/*.xml $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/ @echo "Installed to $(INSTALL_DIR)" @echo "Reload GNOME Shell (Alt+F2, r, Enter on X11; log out/in on Wayland), then run:" @@ -25,7 +26,7 @@ uninstall: rm -rf $(INSTALL_DIR) pack: schemas - gnome-extensions pack --force --extra-source=lib -o dist . + gnome-extensions pack --force --extra-source=lib --extra-source=icons -o dist . clean: rm -f $(SCHEMA_DIR)/gschemas.compiled diff --git a/README.md b/README.md index e956ac1..7150192 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ gnome-extensions prefs benthicbloom@quinta0.github.io logging, and the folders scanned for wallpapers. - **Rotation** — enable/disable, interval, shuffle vs. sequential order, and crossfade transition settings. -- **Live Wallpaper** — enable/disable, video file, mute, playback speed, - and power-saving pause behavior. +- **Live Wallpaper** — enable/disable, video file, source folders to pick + a video/GIF from, mute, playback speed, and power-saving pause behavior. - **OLED Protection** — master switch plus independent controls for pixel shifting, idle dimming, and forced periodic rotation. @@ -99,6 +99,7 @@ lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor lib/oledProtection.js Pixel shifting, idle dimming, forced rotation lib/indicator.js Top-bar quick-access menu +icons/ Bundled symbolic icons for the preferences window schemas/ GSettings schema ``` diff --git a/icons/bb-about-symbolic.svg b/icons/bb-about-symbolic.svg new file mode 100644 index 0000000..b08af0c --- /dev/null +++ b/icons/bb-about-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/bb-general-symbolic.svg b/icons/bb-general-symbolic.svg new file mode 100644 index 0000000..2b157e9 --- /dev/null +++ b/icons/bb-general-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/icons/bb-live-wallpaper-symbolic.svg b/icons/bb-live-wallpaper-symbolic.svg new file mode 100644 index 0000000..3343d06 --- /dev/null +++ b/icons/bb-live-wallpaper-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/bb-oled-symbolic.svg b/icons/bb-oled-symbolic.svg new file mode 100644 index 0000000..5184bcb --- /dev/null +++ b/icons/bb-oled-symbolic.svg @@ -0,0 +1,5 @@ + + + + diff --git a/icons/bb-rotation-symbolic.svg b/icons/bb-rotation-symbolic.svg new file mode 100644 index 0000000..4897dcd --- /dev/null +++ b/icons/bb-rotation-symbolic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/settingsKeys.js b/lib/settingsKeys.js index d6eb665..ed7c823 100644 --- a/lib/settingsKeys.js +++ b/lib/settingsKeys.js @@ -13,6 +13,7 @@ export const SettingsKey = Object.freeze({ LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled', LIVE_WALLPAPER_PATH: 'live-wallpaper-path', + LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders', LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted', LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate', LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery', @@ -32,3 +33,7 @@ export const SettingsKey = Object.freeze({ export const IMAGE_EXTENSIONS = Object.freeze([ '.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif', '.gif', ]); + +export const LIVE_WALLPAPER_EXTENSIONS = Object.freeze([ + '.gif', '.mp4', '.webm', '.mkv', '.mov', '.avi', '.m4v', '.ogv', +]); diff --git a/lib/wallpaperSource.js b/lib/wallpaperSource.js index 7695685..8562405 100644 --- a/lib/wallpaperSource.js +++ b/lib/wallpaperSource.js @@ -1,19 +1,19 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; -import {IMAGE_EXTENSIONS} from './settingsKeys.js'; +import {IMAGE_EXTENSIONS, LIVE_WALLPAPER_EXTENSIONS} from './settingsKeys.js'; Gio._promisify(Gio.File.prototype, 'enumerate_children_async', 'enumerate_children_finish'); Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish'); Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish'); -function hasImageExtension(name) { +function hasExtension(name, extensions) { const lower = name.toLowerCase(); - return IMAGE_EXTENSIONS.some(ext => lower.endsWith(ext)); + return extensions.some(ext => lower.endsWith(ext)); } -/** Non-recursively lists image files directly inside a single folder. */ -export async function listImagesInFolder(folderPath) { +/** Non-recursively lists files with one of `extensions` directly inside a single folder. */ +async function listFilesInFolder(folderPath, extensions) { const results = []; const dir = folderPath.startsWith('file://') ? Gio.File.new_for_uri(folderPath) @@ -38,7 +38,7 @@ export async function listImagesInFolder(folderPath) { for (const info of infos) { if (info.get_file_type() !== Gio.FileType.REGULAR) continue; - if (!hasImageExtension(info.get_name())) + if (!hasExtension(info.get_name(), extensions)) continue; results.push(enumerator.get_child(info).get_path()); } @@ -53,10 +53,10 @@ export async function listImagesInFolder(folderPath) { return results; } -/** Merges and de-duplicates images found across several folders. */ -export async function listImagesInFolders(folderPaths) { +/** Merges and de-duplicates files with one of `extensions` found across several folders. */ +async function listFilesInFolders(folderPaths, extensions) { const lists = await Promise.all( - folderPaths.map(folder => listImagesInFolder(folder).catch(() => []))); + folderPaths.map(folder => listFilesInFolder(folder, extensions).catch(() => []))); const seen = new Set(); const merged = []; @@ -72,3 +72,18 @@ export async function listImagesInFolders(folderPaths) { merged.sort(); return merged; } + +/** Non-recursively lists image files directly inside a single folder. */ +export async function listImagesInFolder(folderPath) { + return listFilesInFolder(folderPath, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates images found across several folders. */ +export async function listImagesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates live-wallpaper-capable videos/GIFs found across several folders. */ +export async function listMediaFilesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, LIVE_WALLPAPER_EXTENSIONS); +} diff --git a/prefs.js b/prefs.js index 1051370..df9576c 100644 --- a/prefs.js +++ b/prefs.js @@ -7,12 +7,18 @@ import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Ex import {SettingsKey} from './lib/settingsKeys.js'; import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; +import {listMediaFilesInFolders} from './lib/wallpaperSource.js'; export default class BenthicBloomPreferences extends ExtensionPreferences { async fillPreferencesWindow(window) { const settings = this.getSettings(); const gstreamerError = await checkGstreamerBaseAvailable().then(() => null, e => e.message ?? String(e)); + // Bundled rather than relying on the system icon theme having these + // exact names — a missing symbolic icon otherwise renders as a + // blank/broken-image placeholder in the page switcher. + Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`); + window.set_default_size(640, 720); window.add(this._buildGeneralPage(settings)); window.add(this._buildRotationPage(settings)); @@ -42,7 +48,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- General page ------------------------------------------------ _buildGeneralPage(settings) { - const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'preferences-system-symbolic'}); + const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'}); const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')}); page.add(behaviorGroup); @@ -62,12 +68,8 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { }); page.add(foldersGroup); - this._folderList = new Gtk.ListBox({ - selection_mode: Gtk.SelectionMode.NONE, - css_classes: ['boxed-list'], - }); - foldersGroup.add(this._folderList); - this._refreshFolderList(settings); + const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet')); + foldersGroup.add(folderList); const addButton = new Gtk.Button({ label: _('Add Folder…'), @@ -75,23 +77,32 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { margin_top: 6, css_classes: ['flat'], }); - addButton.connect('clicked', () => this._pickFolder(settings)); + addButton.connect('clicked', () => this._pickFolderForList( + settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet'))); foldersGroup.add(addButton); return page; } - _refreshFolderList(settings) { - let child = this._folderList.get_first_child(); + // --- Shared folder-list widgets (used by the General and Live Wallpaper pages) -- + + _buildFolderListBox(settings, key, emptyText, onChange) { + const listBox = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']}); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + return listBox; + } + + _refreshFolderListBox(listBox, settings, key, emptyText, onChange) { + let child = listBox.get_first_child(); while (child) { const next = child.get_next_sibling(); - this._folderList.remove(child); + listBox.remove(child); child = next; } - const folders = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + const folders = settings.get_strv(key); if (folders.length === 0) { - this._folderList.append(new Adw.ActionRow({title: _('No folders added yet')})); + listBox.append(new Adw.ActionRow({title: emptyText})); return; } @@ -103,27 +114,29 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { css_classes: ['flat'], }); removeButton.connect('clicked', () => { - const current = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); - settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, current.filter(f => f !== folder)); - this._refreshFolderList(settings); + const current = settings.get_strv(key); + settings.set_strv(key, current.filter(f => f !== folder)); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + onChange?.(); }); row.add_suffix(removeButton); - this._folderList.append(row); + listBox.append(row); } } - _pickFolder(settings) { - const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Folder')}); - dialog.select_folder(this._folderList.get_root(), null, (source, result) => { + _pickFolderForList(settings, key, listBox, emptyText, onChange) { + const dialog = new Gtk.FileDialog({title: _('Select Folder')}); + dialog.select_folder(listBox.get_root(), null, (source, result) => { try { const folder = dialog.select_folder_finish(result); const path = folder.get_path(); if (!path) return; - const current = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + const current = settings.get_strv(key); if (!current.includes(path)) { - settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, [...current, path]); - this._refreshFolderList(settings); + settings.set_strv(key, [...current, path]); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + onChange?.(); } } catch (e) { // Dialog was dismissed; nothing to do. @@ -134,7 +147,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- Rotation page ------------------------------------------------- _buildRotationPage(settings) { - const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'media-playlist-shuffle-symbolic'}); + const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-symbolic'}); const group = new Adw.PreferencesGroup({title: _('Automatic Rotation')}); page.add(group); @@ -173,7 +186,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- Live wallpaper page -------------------------------------------- _buildLiveWallpaperPage(settings, gstreamerError) { - const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'video-x-generic-symbolic'}); + const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-symbolic'}); if (gstreamerError) { const warningGroup = new Adw.PreferencesGroup(); @@ -229,6 +242,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { const path = file.get_path(); settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path); fileRow.subtitle = path; + this._refreshLiveMediaList(settings, fileRow); } catch (e) { // Dialog was dismissed; nothing to do. } @@ -251,6 +265,36 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value)); group.add(rateRow); + const foldersGroup = new Adw.PreferencesGroup({ + title: _('Wallpaper Folders'), + description: _('Videos and animated GIFs found directly inside these folders can be picked below'), + }); + page.add(foldersGroup); + + const mediaGroup = new Adw.PreferencesGroup({title: _('Available Media')}); + page.add(mediaGroup); + this._liveMediaList = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']}); + mediaGroup.add(this._liveMediaList); + this._liveMediaGeneration = 0; + + const refreshMedia = () => this._refreshLiveMediaList(settings, fileRow); + + const liveFolderList = this._buildFolderListBox( + settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia); + foldersGroup.add(liveFolderList); + + const addFolderButton = new Gtk.Button({ + label: _('Add Folder…'), + halign: Gtk.Align.START, + margin_top: 6, + css_classes: ['flat'], + }); + addFolderButton.connect('clicked', () => this._pickFolderForList( + settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia)); + foldersGroup.add(addFolderButton); + + refreshMedia(); + const powerGroup = new Adw.PreferencesGroup({title: _('Power Saving')}); page.add(powerGroup); powerGroup.add(this._switchRow( @@ -267,16 +311,73 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { fileRow.sensitive = false; muteRow.sensitive = false; rateRow.sensitive = false; + foldersGroup.sensitive = false; + mediaGroup.sensitive = false; powerGroup.sensitive = false; } return page; } + /** Re-scans the configured live wallpaper folders and repopulates the "Available Media" list. */ + _refreshLiveMediaList(settings, fileRow) { + const listBox = this._liveMediaList; + const generation = ++this._liveMediaGeneration; + + let child = listBox.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + listBox.remove(child); + child = next; + } + + const folders = settings.get_strv(SettingsKey.LIVE_WALLPAPER_FOLDERS); + if (folders.length === 0) { + listBox.append(new Adw.ActionRow({title: _('Add a folder above to browse its videos and GIFs')})); + return; + } + + listBox.append(new Adw.ActionRow({title: _('Scanning…')})); + + listMediaFilesInFolders(folders).then(paths => { + if (generation !== this._liveMediaGeneration) + return; // A folder changed again before this scan finished; a newer one is in flight. + + let c = listBox.get_first_child(); + while (c) { + const next = c.get_next_sibling(); + listBox.remove(c); + c = next; + } + + if (paths.length === 0) { + listBox.append(new Adw.ActionRow({title: _('No videos or GIFs found in those folders')})); + return; + } + + const currentPath = settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH); + for (const path of paths) { + const row = new Adw.ActionRow({ + title: Gio.File.new_for_path(path).get_basename(), + subtitle: path, + activatable: true, + }); + if (path === currentPath) + row.add_suffix(new Gtk.Image({icon_name: 'object-select-symbolic'})); + row.connect('activated', () => { + settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path); + fileRow.subtitle = path; + this._refreshLiveMediaList(settings, fileRow); + }); + listBox.append(row); + } + }).catch(() => {}); + } + // --- OLED protection page -------------------------------------------- _buildOledPage(settings) { - const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'weather-clear-night-symbolic'}); + const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'bb-oled-symbolic'}); const group = new Adw.PreferencesGroup({ title: _('Burn-in Protection'), @@ -335,7 +436,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- About page ----------------------------------------------------- _buildAboutPage() { - const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'help-about-symbolic'}); + const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'bb-about-symbolic'}); const group = new Adw.PreferencesGroup(); page.add(group); diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml index 8291cf2..c2f51a0 100644 --- a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml +++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml @@ -79,6 +79,12 @@ Path to the video or animated GIF file used as the live wallpaper. + + [] + Live wallpaper source folders + List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from. + + true Mute live wallpaper audio