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;
+}