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..efa0595
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+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 $(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:"
+ @echo " gnome-extensions enable $(UUID)"
+
+uninstall:
+ rm -rf $(INSTALL_DIR)
+
+pack: schemas
+ gnome-extensions pack --force --extra-source=lib --extra-source=icons -o dist .
+
+clean:
+ rm -f $(SCHEMA_DIR)/gschemas.compiled
+ rm -rf dist
diff --git a/README.md b/README.md
index 799d163..7150192 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,132 @@
-# 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 *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. 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
+ 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) — 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
+
+### 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, 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.
+
+## 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
+icons/ Bundled symbolic icons for the preferences window
+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..54fa334
--- /dev/null
+++ b/extension.js
@@ -0,0 +1,88 @@
+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, {
+ // 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) {
+ 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/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/gstreamerAvailability.js b/lib/gstreamerAvailability.js
new file mode 100644
index 0000000..2fd86b7
--- /dev/null
+++ b/lib/gstreamerAvailability.js
@@ -0,0 +1,52 @@
+/**
+ * Loads the GObject-Introspection bindings the live wallpaper feature needs
+ * (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames)
+ * and initializes GStreamer. Used by liveWallpaper.js, which only ever runs
+ * inside the gnome-shell process itself.
+ *
+ * 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([
+ 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};
+}
+
+/**
+ * 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' +
+ '• 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/indicator.js b/lib/indicator.js
new file mode 100644
index 0000000..1396b82
--- /dev/null
+++ b/lib/indicator.js
@@ -0,0 +1,76 @@
+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}`));
+ });
+ nextItem.setSensitive(!settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED));
+ 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}`, () => {
+ 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));
+ }),
+ ];
+
+ 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..342cf4d
--- /dev/null
+++ b/lib/liveWallpaper.js
@@ -0,0 +1,531 @@
+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.
+ *
+ * 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(
+ global.stage.context.get_backend().get_cogl_context(),
+ 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
+ * are decoded through GStreamer's own GIF element (from the "good" plugin
+ * set) and handled exactly like any other video stream below this point.
+ *
+ * Mutter embeds its own private copy of Clutter, so GStreamer video sinks
+ * that hand back a Clutter actor from a *different* Clutter instance (e.g.
+ * clutterglsink / ClutterGst) cannot be attached to the shell's stage. To
+ * stay inside gnome-shell's own Clutter, this decodes frames with a plain
+ * GStreamer `appsink` (raw pixels only cross that boundary) and uploads
+ * each frame into a `Clutter.Image` set as the content of a normal actor
+ * that belongs to gnome-shell itself.
+ */
+export class LiveWallpaperManager {
+ constructor(settings, logger, {onActiveChanged} = {}) {
+ this._settings = settings;
+ this._logger = logger;
+ this._onActiveChanged = onActiveChanged ?? (() => {});
+
+ 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;
+
+ this._frameCount = 0;
+ this._pollCount = 0;
+ this._pollTimeoutId = 0;
+ this._noFrameWatchdogId = 0;
+ this._imageStrategyIndex = 0;
+ this._frameErrorCount = 0;
+ }
+
+ get isAvailable() {
+ return this._available;
+ }
+
+ get isActive() {
+ return this._active;
+ }
+
+ _setActive(active) {
+ if (this._active === active)
+ return;
+ this._active = active;
+ this._onActiveChanged(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 {
+ const {Gst, Cogl} = await loadGstreamerModules();
+ this._Gst = Gst;
+ this._Cogl = Cogl;
+ 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}).\n${GSTREAMER_INSTALL_HINT}`);
+ }
+ }
+
+ _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();
+ }
+
+ /**
+ * 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'));
+ // 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);
+
+ 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)
+ 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 = this._buildSinkBin();
+
+ 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();
+ const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING);
+ this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`);
+
+ 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.
+ 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 ` +
+ `(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;
+ });
+
+ this._setActive(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();
+ }
+ }
+
+ _pollForSample() {
+ if (!this._appsink)
+ return GLib.SOURCE_REMOVE;
+
+ const Gst = this._Gst;
+ this._pollCount++;
+ if (this._pollCount === 1)
+ this._logger.debug('Live wallpaper: appsink polling started');
+
+ try {
+ 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);
+ const [, width] = structure.get_int('width');
+ const [, height] = structure.get_int('height');
+
+ const [ok, mapInfo] = buffer.map(Gst.MapFlags.READ);
+ if (!ok) {
+ this._logger.debug('Live wallpaper: buffer.map() failed');
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ 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);
+ }
+ } catch (e) {
+ // 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;
+ }
+
+ _updateFrame(data, width, height) {
+ if (!this._actor)
+ return;
+
+ 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;
+ }
+ }
+
+ throw new Error('All live wallpaper frame image strategies failed');
+ }
+
+ _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, 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() {
+ 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._noFrameWatchdogId) {
+ GLib.source_remove(this._noFrameWatchdogId);
+ 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();
+ if (this._busWatchId) {
+ bus.disconnect(this._busWatchId);
+ this._busWatchId = 0;
+ }
+ bus.remove_signal_watch();
+
+ 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;
+ }
+
+ this._actor?.destroy();
+ this._actor = null;
+ this._image = null;
+ this._videoWidth = 0;
+ this._videoHeight = 0;
+
+ this._setActive(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..ec2b2e1
--- /dev/null
+++ b/lib/rotationManager.js
@@ -0,0 +1,245 @@
+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._suspended = 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._suspended || !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();
+ }
+
+ /**
+ * 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();
+
+ 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._suspended || 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..ed7c823
--- /dev/null
+++ b/lib/settingsKeys.js
@@ -0,0 +1,39 @@
+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_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',
+ 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',
+]);
+
+export const LIVE_WALLPAPER_EXTENSIONS = Object.freeze([
+ '.gif', '.mp4', '.webm', '.mkv', '.mov', '.avi', '.m4v', '.ogv',
+]);
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..8562405
--- /dev/null
+++ b/lib/wallpaperSource.js
@@ -0,0 +1,89 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+
+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 hasExtension(name, extensions) {
+ const lower = name.toLowerCase();
+ return extensions.some(ext => lower.endsWith(ext));
+}
+
+/** 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)
+ : 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 (!hasExtension(info.get_name(), extensions))
+ 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 files with one of `extensions` found across several folders. */
+async function listFilesInFolders(folderPaths, extensions) {
+ const lists = await Promise.all(
+ folderPaths.map(folder => listFilesInFolder(folder, extensions).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;
+}
+
+/** 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/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..df9576c
--- /dev/null
+++ b/prefs.js
@@ -0,0 +1,457 @@
+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';
+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));
+ window.add(this._buildLiveWallpaperPage(settings, gstreamerError));
+ 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: 'bb-general-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);
+
+ const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet'));
+ foldersGroup.add(folderList);
+
+ const addButton = new Gtk.Button({
+ label: _('Add Folder…'),
+ halign: Gtk.Align.START,
+ margin_top: 6,
+ css_classes: ['flat'],
+ });
+ addButton.connect('clicked', () => this._pickFolderForList(
+ settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet')));
+ foldersGroup.add(addButton);
+
+ return page;
+ }
+
+ // --- 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();
+ listBox.remove(child);
+ child = next;
+ }
+
+ const folders = settings.get_strv(key);
+ if (folders.length === 0) {
+ listBox.append(new Adw.ActionRow({title: emptyText}));
+ 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(key);
+ settings.set_strv(key, current.filter(f => f !== folder));
+ this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
+ onChange?.();
+ });
+ row.add_suffix(removeButton);
+ listBox.append(row);
+ }
+ }
+
+ _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(key);
+ if (!current.includes(path)) {
+ settings.set_strv(key, [...current, path]);
+ this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
+ onChange?.();
+ }
+ } catch (e) {
+ // Dialog was dismissed; nothing to do.
+ }
+ });
+ }
+
+ // --- Rotation page -------------------------------------------------
+
+ _buildRotationPage(settings) {
+ const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-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, gstreamerError) {
+ const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-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: _(
+ '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. 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(
+ settings, SettingsKey.LIVE_WALLPAPER_ENABLED,
+ _('Enable Live Wallpaper'), _('Overrides the static wallpaper while active')));
+
+ const fileRow = new Adw.ActionRow({
+ 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 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(mediaFilter);
+ filterList.append(allFilter);
+ dialog.filters = filterList;
+ dialog.default_filter = mediaFilter;
+
+ 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;
+ this._refreshLiveMediaList(settings, fileRow);
+ } catch (e) {
+ // Dialog was dismissed; nothing to do.
+ }
+ });
+ });
+ fileRow.add_suffix(chooseButton);
+ group.add(fileRow);
+
+ const muteRow = this._switchRow(
+ settings, SettingsKey.LIVE_WALLPAPER_MUTED,
+ _('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));
+ 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(
+ 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')));
+
+ // 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;
+ 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: 'bb-oled-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: 'bb-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..c2f51a0
--- /dev/null
+++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ 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 or animated GIF is played as an animated desktop background instead of a static image.
+
+
+
+ ""
+ Live wallpaper media file
+ 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
+ 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;
+}