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