diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f92896c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +schemas/gschemas.compiled +dist/ +*.zip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..efa0595 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +UUID = benthicbloom@quinta0.github.io +EXTENSIONS_DIR = $(HOME)/.local/share/gnome-shell/extensions +INSTALL_DIR = $(EXTENSIONS_DIR)/$(UUID) +SCHEMA_DIR = schemas + +.PHONY: all schemas build install uninstall pack clean + +all: build + +schemas: + glib-compile-schemas $(SCHEMA_DIR) + +build: schemas + +install: build + mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas $(INSTALL_DIR)/icons + cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/ + cp lib/*.js $(INSTALL_DIR)/lib/ + cp icons/*.svg $(INSTALL_DIR)/icons/ + cp $(SCHEMA_DIR)/*.xml $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/ + @echo "Installed to $(INSTALL_DIR)" + @echo "Reload GNOME Shell (Alt+F2, r, Enter on X11; log out/in on Wayland), then run:" + @echo " gnome-extensions enable $(UUID)" + +uninstall: + rm -rf $(INSTALL_DIR) + +pack: schemas + gnome-extensions pack --force --extra-source=lib --extra-source=icons -o dist . + +clean: + rm -f $(SCHEMA_DIR)/gschemas.compiled + rm -rf dist diff --git a/README.md b/README.md index 799d163..7150192 100644 --- a/README.md +++ b/README.md @@ -1 +1,132 @@ -# benthicbloom \ No newline at end of file +# BenthicBloom + +A GNOME Shell 50 extension that automatically rotates your wallpaper, +supports looping video (live) wallpapers, and includes built-in +protection against OLED burn-in. + +## Features + +- **Auto rotation** — pick one or more folders and BenthicBloom cycles + through the images inside them on a timer, in sequential or shuffled + order, with an optional crossfade between changes. The chosen wallpaper + can also be applied to the lock screen. +- **Live wallpapers** — play a looping video *or animated GIF* as your + desktop background. Frames are decoded with GStreamer and rendered + directly by GNOME Shell's own Clutter stage (no external, incompatible + Clutter build involved). Playback can automatically pause on battery + power or while a window is fullscreen. Automatic rotation is + automatically suspended while a live wallpaper is actually on screen + (and resumes afterward), since changing the static wallpaper would + otherwise repaint over the video. +- **OLED burn-in protection** — three independent, individually toggled + techniques: + - *Pixel shifting*: nudges the background a few pixels on a slow drift + cycle so the same subpixels aren't lit continuously. + - *Idle dimming*: fades the background to a configurable lower + brightness after the system has been idle for a while, and restores + it the moment you're back. + - *Forced rotation*: guarantees the wallpaper changes after a maximum + static duration, even if automatic rotation is otherwise switched + off. +- A top-bar indicator for quick access to rotation, live wallpaper, and + OLED protection toggles, plus a "Next Wallpaper" action, and a full + libadwaita preferences window. + +## Requirements + +- GNOME Shell 50. +- For live wallpapers: GStreamer with its `good` and `base` plugin sets + (e.g. `gstreamer1.0-plugins-good` and `gstreamer1.0-plugins-base`, or + your distribution's equivalent) — the `good` set is also what provides + animated GIF decoding. If these aren't installed, the live wallpaper + toggle stays disabled and BenthicBloom's other features work normally. + +## Installation + +### From source + +```sh +git clone https://github.com/quinta0/benthicbloom.git +cd benthicbloom +make install +``` + +Then reload GNOME Shell — press Alt+F2, type `r`, +press Enter on X11, or log out and back in on Wayland — and +enable the extension: + +```sh +gnome-extensions enable benthicbloom@quinta0.github.io +``` + +### Packaging a zip + +```sh +make pack +``` + +produces `dist/benthicbloom@quinta0.github.io.shell-extension.zip`, +installable via `gnome-extensions install ` or the Extensions app. + +## Configuration + +Open preferences from the panel indicator's "Wallpaper Settings…" entry, +or run: + +```sh +gnome-extensions prefs benthicbloom@quinta0.github.io +``` + +- **General** — panel indicator visibility, lock screen syncing, debug + logging, and the folders scanned for wallpapers. +- **Rotation** — enable/disable, interval, shuffle vs. sequential order, + and crossfade transition settings. +- **Live Wallpaper** — enable/disable, video file, source folders to pick + a video/GIF from, mute, playback speed, and power-saving pause behavior. +- **OLED Protection** — master switch plus independent controls for + pixel shifting, idle dimming, and forced periodic rotation. + +## Architecture + +``` +extension.js Entry point: wires up the three managers + indicator +prefs.js libadwaita preferences window +lib/settingsKeys.js GSettings key name constants +lib/logger.js Small logging wrapper gated by the debug-logging setting +lib/wallpaperSource.js Async folder scanning for image files +lib/shuffleBag.js No-immediate-repeat random ordering for shuffle mode +lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay +lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor +lib/oledProtection.js Pixel shifting, idle dimming, forced rotation +lib/indicator.js Top-bar quick-access menu +icons/ Bundled symbolic icons for the preferences window +schemas/ GSettings schema +``` + +## Known limitations + +- The live wallpaper currently renders across the full stage as a single + layer, so on multi-monitor setups the video spans across all monitors + as one canvas rather than being tiled per-monitor. +- Pixel shifting and idle dimming rely on GNOME Shell's private + `Main.layoutManager._backgroundGroup` and `Meta.IdleMonitor` APIs, which + are not part of the stable extension API and could change in future + shell versions. + +## Testing + +This was developed and validated (JSON, GSettings schema compilation, +and JavaScript syntax) without a live GNOME Shell 50 session available in +the development environment. Before relying on it, test in a nested +session: + +```sh +dbus-run-session -- gnome-shell --nested --wayland +``` + +or on a real GNOME 50 desktop, and please file an issue with any problems +you hit. + +## License + +GPL-3.0-or-later — see [LICENSE](LICENSE). diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..54fa334 --- /dev/null +++ b/extension.js @@ -0,0 +1,88 @@ +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {SettingsKey} from './lib/settingsKeys.js'; +import {Logger} from './lib/logger.js'; +import {RotationManager} from './lib/rotationManager.js'; +import {LiveWallpaperManager} from './lib/liveWallpaper.js'; +import {OledProtectionManager} from './lib/oledProtection.js'; +import {BenthicBloomIndicator} from './lib/indicator.js'; + +export default class BenthicBloomExtension extends Extension { + enable() { + this._settings = this.getSettings(); + this._logger = new Logger(this._settings, this.metadata.name); + + this._rotationManager = new RotationManager(this._settings, this._logger); + this._liveWallpaperManager = new LiveWallpaperManager(this._settings, this._logger, { + // Rotation changes the static picture-uri GSettings key, which + // makes the shell repaint its own background actor on top of + // the live wallpaper's. Suspend rotation while a live + // wallpaper is actually on screen, independent of any + // user-initiated pause. + onActiveChanged: active => { + if (active) + this._rotationManager.suspend(); + else + this._rotationManager.unsuspend(); + }, + }); + this._oledProtectionManager = new OledProtectionManager(this._settings, this._logger, { + forceNextWallpaper: maxSeconds => { + if (this._rotationManager.secondsSinceLastChange >= maxSeconds) { + this._rotationManager.next({forced: true}) + .catch(e => this._logger.error(e, 'Forced OLED rotation failed')); + } + }, + }); + + this._rotationManager.enable(); + this._liveWallpaperManager.enable() + .catch(e => this._logger.error(e, 'Live wallpaper failed to initialize')); + this._oledProtectionManager.enable(); + + this._indicator = null; + this._showIndicatorSignalId = this._settings.connect( + `changed::${SettingsKey.SHOW_INDICATOR}`, () => this._syncIndicator()); + this._syncIndicator(); + } + + disable() { + if (this._showIndicatorSignalId) { + this._settings.disconnect(this._showIndicatorSignalId); + this._showIndicatorSignalId = 0; + } + + this._indicator?.destroy(); + this._indicator = null; + + this._oledProtectionManager?.disable(); + this._oledProtectionManager = null; + + this._liveWallpaperManager?.disable(); + this._liveWallpaperManager = null; + + this._rotationManager?.disable(); + this._rotationManager = null; + + this._logger = null; + this._settings = null; + } + + _syncIndicator() { + const shouldShow = this._settings.get_boolean(SettingsKey.SHOW_INDICATOR); + + if (shouldShow && !this._indicator) { + this._indicator = new BenthicBloomIndicator(this._settings, { + rotationManager: this._rotationManager, + liveWallpaperManager: this._liveWallpaperManager, + oledProtectionManager: this._oledProtectionManager, + openPreferences: () => this.openPreferences(), + }); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } else if (!shouldShow && this._indicator) { + this._indicator.destroy(); + this._indicator = null; + } + } +} diff --git a/icons/bb-about-symbolic.svg b/icons/bb-about-symbolic.svg new file mode 100644 index 0000000..b08af0c --- /dev/null +++ b/icons/bb-about-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/bb-general-symbolic.svg b/icons/bb-general-symbolic.svg new file mode 100644 index 0000000..2b157e9 --- /dev/null +++ b/icons/bb-general-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/icons/bb-live-wallpaper-symbolic.svg b/icons/bb-live-wallpaper-symbolic.svg new file mode 100644 index 0000000..3343d06 --- /dev/null +++ b/icons/bb-live-wallpaper-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/bb-oled-symbolic.svg b/icons/bb-oled-symbolic.svg new file mode 100644 index 0000000..5184bcb --- /dev/null +++ b/icons/bb-oled-symbolic.svg @@ -0,0 +1,5 @@ + + + + diff --git a/icons/bb-rotation-symbolic.svg b/icons/bb-rotation-symbolic.svg new file mode 100644 index 0000000..4897dcd --- /dev/null +++ b/icons/bb-rotation-symbolic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/gstreamerAvailability.js b/lib/gstreamerAvailability.js new file mode 100644 index 0000000..2fd86b7 --- /dev/null +++ b/lib/gstreamerAvailability.js @@ -0,0 +1,52 @@ +/** + * Loads the GObject-Introspection bindings the live wallpaper feature needs + * (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames) + * and initializes GStreamer. Used by liveWallpaper.js, which only ever runs + * inside the gnome-shell process itself. + * + * Cogl is Mutter's *private* library: its typelib is only reachable from + * gnome-shell's own process (which gets a private search path), never from + * an ordinary GTK application. Do NOT reuse this for a diagnostic check in + * prefs.js — that runs in a separate, plain GTK4 process where importing + * Cogl will *always* fail regardless of whether live wallpapers actually + * work, producing a false "not found" report. Use + * checkGstreamerBaseAvailable() there instead. + */ +export async function loadGstreamerModules() { + const [{default: Gst}, , {default: Cogl}] = await Promise.all([ + import('gi://Gst?version=1.0'), + import('gi://GstApp?version=1.0'), + import('gi://Cogl'), + ]); + + if (!Gst.is_initialized()) + Gst.init(null); + + return {Gst, Cogl}; +} + +/** + * Lighter check for prefs.js: confirms the system-wide GStreamer packages + * (Gst core + the "app" plugin providing GstApp) are installed, without + * touching Cogl. This can't fully confirm live wallpapers will work (that + * also needs Cogl, only checkable from inside gnome-shell itself), but a + * failure here is a genuine, actionable problem, unlike a Cogl probe. + */ +export async function checkGstreamerBaseAvailable() { + const [{default: Gst}] = await Promise.all([ + import('gi://Gst?version=1.0'), + import('gi://GstApp?version=1.0'), + ]); + + if (!Gst.is_initialized()) + Gst.init(null); +} + +export const GSTREAMER_INSTALL_HINT = + 'Install GStreamer’s "base" and "good" plugin sets (with their GObject-Introspection data), ' + + 'which provide playback and GIF decoding:\n' + + '• Arch: sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav\n' + + '• Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good ' + + 'gir1.2-gst-plugins-base-1.0\n' + + '• Fedora: sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gobject-introspection\n' + + 'Then restart GNOME Shell (log out and back in on Wayland).'; diff --git a/lib/indicator.js b/lib/indicator.js new file mode 100644 index 0000000..1396b82 --- /dev/null +++ b/lib/indicator.js @@ -0,0 +1,76 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import {SettingsKey} from './settingsKeys.js'; + +export const BenthicBloomIndicator = GObject.registerClass( +class BenthicBloomIndicator extends PanelMenu.Button { + _init(settings, {rotationManager, liveWallpaperManager, openPreferences}) { + super._init(0.0, 'BenthicBloom'); + + this._rotationManager = rotationManager; + + this.add_child(new St.Icon({ + icon_name: 'preferences-desktop-wallpaper-symbolic', + style_class: 'system-status-icon benthicbloom-indicator-icon', + })); + + const rotationToggle = new PopupMenu.PopupSwitchMenuItem( + 'Auto Rotation', settings.get_boolean(SettingsKey.ROTATION_ENABLED)); + rotationToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.ROTATION_ENABLED, state); + }); + this.menu.addMenuItem(rotationToggle); + + const nextItem = new PopupMenu.PopupMenuItem('Next Wallpaper'); + nextItem.connect('activate', () => { + rotationManager.next().catch(e => console.error(`[BenthicBloom] ${e.message ?? e}`)); + }); + nextItem.setSensitive(!settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); + this.menu.addMenuItem(nextItem); + + const liveToggle = new PopupMenu.PopupSwitchMenuItem( + 'Live Wallpaper', settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED)); + liveToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED, state); + }); + liveToggle.reactive = liveWallpaperManager.isAvailable; + if (!liveWallpaperManager.isAvailable) + liveToggle.label.text = 'Live Wallpaper (GStreamer not found)'; + this.menu.addMenuItem(liveToggle); + + const oledToggle = new PopupMenu.PopupSwitchMenuItem( + 'OLED Protection', settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED)); + oledToggle.connect('toggled', (_item, state) => { + settings.set_boolean(SettingsKey.OLED_PROTECTION_ENABLED, state); + }); + this.menu.addMenuItem(oledToggle); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + const settingsItem = new PopupMenu.PopupMenuItem('Wallpaper Settings…'); + settingsItem.connect('activate', () => openPreferences()); + this.menu.addMenuItem(settingsItem); + + const signalIds = [ + settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => { + rotationToggle.setToggleState(settings.get_boolean(SettingsKey.ROTATION_ENABLED)); + }), + settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => { + const enabled = settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED); + liveToggle.setToggleState(enabled); + nextItem.setSensitive(!enabled); + }), + settings.connect(`changed::${SettingsKey.OLED_PROTECTION_ENABLED}`, () => { + oledToggle.setToggleState(settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED)); + }), + ]; + + this.connect('destroy', () => { + for (const id of signalIds) + settings.disconnect(id); + }); + } +}); diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js new file mode 100644 index 0000000..342cf4d --- /dev/null +++ b/lib/liveWallpaper.js @@ -0,0 +1,531 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import Clutter from 'gi://Clutter'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {SettingsKey} from './settingsKeys.js'; +import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js'; + +/** + * Ways to get a decoded frame onto an actor, tried in order and "pinned" + * once one works. `new Clutter.Image()` failed with "is not a constructor" + * in real-world testing (Clutter.Image is apparently no longer directly + * constructible from GJS in current Mutter); St.ImageContent — the same + * mechanism gnome-shell's own code uses for uploading raw pixel buffers — + * is tried first, with the legacy Clutter.Image/set_data path kept only as + * a fallback for older shells. + * + * St.ImageContent.set_bytes() takes a Cogl.Context as its first argument + * (see js/ui/screenshot.js upstream) — omitting it is what produced + * "At least 6 arguments required, but only 5 passed". + */ +const FRAME_IMAGE_STRATEGIES = [ + { + name: 'St.ImageContent', + create: (width, height) => St.ImageContent.new_with_preferred_size(width, height), + upload: (image, Cogl, data, width, height) => + image.set_bytes( + global.stage.context.get_backend().get_cogl_context(), + GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4), + }, + { + name: 'Clutter.Image', + create: () => new Clutter.Image(), + upload: (image, Cogl, data, width, height) => + image.set_data(data, Cogl.PixelFormat.RGBA_8888, width, height, width * 4), + }, +]; + +/** + * Plays a video or animated GIF file as a looping animated background. + * `playbin` typefinds the source by content rather than extension, so GIFs + * are decoded through GStreamer's own GIF element (from the "good" plugin + * set) and handled exactly like any other video stream below this point. + * + * Mutter embeds its own private copy of Clutter, so GStreamer video sinks + * that hand back a Clutter actor from a *different* Clutter instance (e.g. + * clutterglsink / ClutterGst) cannot be attached to the shell's stage. To + * stay inside gnome-shell's own Clutter, this decodes frames with a plain + * GStreamer `appsink` (raw pixels only cross that boundary) and uploads + * each frame into a `Clutter.Image` set as the content of a normal actor + * that belongs to gnome-shell itself. + */ +export class LiveWallpaperManager { + constructor(settings, logger, {onActiveChanged} = {}) { + this._settings = settings; + this._logger = logger; + this._onActiveChanged = onActiveChanged ?? (() => {}); + + this._available = false; + this._active = false; + this._paused = false; + this._onBattery = false; + this._fullscreenActive = false; + + this._Gst = null; + this._Cogl = null; + + this._playbin = null; + this._appsink = null; + this._actor = null; + this._image = null; + this._videoWidth = 0; + this._videoHeight = 0; + + this._settingsSignals = []; + this._busWatchId = 0; + this._monitorsChangedId = 0; + this._fullscreenChangedId = 0; + this._upowerProxy = null; + this._upowerSignalId = 0; + + this._frameCount = 0; + this._pollCount = 0; + this._pollTimeoutId = 0; + this._noFrameWatchdogId = 0; + this._imageStrategyIndex = 0; + this._frameErrorCount = 0; + } + + get isAvailable() { + return this._available; + } + + get isActive() { + return this._active; + } + + _setActive(active) { + if (this._active === active) + return; + this._active = active; + this._onActiveChanged(active); + } + + async enable() { + await this._loadGstreamer(); + + this._settingsSignals.push( + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => this._sync()), + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PATH}`, () => this._sync()), + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_MUTED}`, () => this._applyMute()), + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE}`, () => this._applyPlaybackRate()), + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY}`, () => this._updatePauseState()), + this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN}`, () => this._updatePauseState()) + ); + + this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._layoutActor()); + + this._sync(); + } + + disable() { + for (const id of this._settingsSignals) + this._settings.disconnect(id); + this._settingsSignals = []; + + if (this._monitorsChangedId) { + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + } + + this._stop(); + } + + async _loadGstreamer() { + try { + const {Gst, Cogl} = await loadGstreamerModules(); + this._Gst = Gst; + this._Cogl = Cogl; + this._available = true; + } catch (e) { + this._available = false; + this._logger.warn( + `Live wallpaper unavailable: GStreamer/Cogl introspection bindings could not be loaded (${e.message ?? e}).\n${GSTREAMER_INSTALL_HINT}`); + } + } + + _sync() { + const shouldRun = this._available && + this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED) && + this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) !== ''; + + if (shouldRun) + this._active ? this._restart() : this._start(); + else if (this._active) + this._stop(); + } + + _restart() { + this._stop(); + this._start(); + } + + /** + * Builds videoconvert ! videoscale ! capsfilter(RGBA) ! appsink using + * explicit element creation, linking, and a ghost pad, rather than + * Gst.parse_bin_from_description()'s gst-launch mini-language. Both + * are meant to be equivalent, but a user's testing showed the + * string-parsed version's appsink never fired 'new-sample' even once + * inside gnome-shell's process while an equivalent standalone + * gst-launch-1.0 pipeline worked fine — this removes the string + * parsing (and its automatic ghost-pad detection) as a variable, and + * surfaces link() failures explicitly instead of failing silently. + */ + _buildSinkBin() { + const Gst = this._Gst; + + const videoconvert = Gst.ElementFactory.make('videoconvert', 'benthicbloom-convert'); + const videoscale = Gst.ElementFactory.make('videoscale', 'benthicbloom-scale'); + const capsfilter = Gst.ElementFactory.make('capsfilter', 'benthicbloom-capsfilter'); + const appsink = Gst.ElementFactory.make('appsink', 'benthicbloom-appsink'); + + if (!videoconvert || !videoscale || !capsfilter || !appsink) { + throw new Error( + 'Failed to create one or more GStreamer elements ' + + '(videoconvert/videoscale/capsfilter/appsink) — a required plugin is likely missing'); + } + + capsfilter.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA')); + // emit-signals is deliberately left off: appsink's 'new-sample' fires + // from GStreamer's streaming thread, not gnome-shell's main thread, + // and a cross-thread call into the JS engine can be silently dropped + // rather than invoked. We poll with try_pull_sample() from a main + // thread GLib timer instead, which is safe to call from any thread. + appsink.set_property('max-buffers', 2); + appsink.set_property('drop', true); + appsink.set_property('sync', true); + + const sinkBin = new Gst.Bin({name: 'benthicbloom-sinkbin'}); + sinkBin.add(videoconvert); + sinkBin.add(videoscale); + sinkBin.add(capsfilter); + sinkBin.add(appsink); + + if (!videoconvert.link(videoscale)) + throw new Error('Failed to link videoconvert -> videoscale'); + if (!videoscale.link(capsfilter)) + throw new Error('Failed to link videoscale -> capsfilter'); + if (!capsfilter.link(appsink)) + throw new Error('Failed to link capsfilter -> appsink'); + + const sinkPad = videoconvert.get_static_pad('sink'); + const ghostPad = Gst.GhostPad.new('sink', sinkPad); + if (!ghostPad) + throw new Error('Failed to create ghost pad for live wallpaper sink bin'); + ghostPad.set_active(true); + sinkBin.add_pad(ghostPad); + + this._appsink = appsink; + return sinkBin; + } + + _start() { + const path = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH); + if (!this._available || !path) + return; + + const Gst = this._Gst; + + try { + this._actor = new Clutter.Actor({ + content_gravity: Clutter.ContentGravity.RESIZE_ASPECT, + reactive: false, + }); + Main.layoutManager._backgroundGroup.add_child(this._actor); + this._layoutActor(); + + const sinkBin = this._buildSinkBin(); + + this._playbin = Gst.ElementFactory.make('playbin', 'benthicbloom-live-wallpaper'); + this._playbin.set_property('video-sink', sinkBin); + this._playbin.set_property('uri', GLib.filename_to_uri(path, null)); + + const bus = this._playbin.get_bus(); + bus.add_signal_watch(); + this._busWatchId = bus.connect('message', (_bus, message) => this._onBusMessage(message)); + + this._applyMute(); + const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING); + this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`); + + this._frameCount = 0; + this._pollCount = 0; + this._imageStrategyIndex = 0; + this._frameErrorCount = 0; + // ~30fps polling of the appsink from the main thread. See the + // comment on appsink's properties above for why this replaces + // a 'new-sample' signal handler. + this._pollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33, () => this._pollForSample()); + + this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => { + this._noFrameWatchdogId = 0; + if (this._active && this._frameCount === 0) { + this._logger.warn( + `Live wallpaper: no frames received 4s after starting playback ` + + `(polled appsink ${this._pollCount} times). If that count is 0, the poll timer ` + + 'itself never ran; if it is nonzero, try_pull_sample() keeps returning nothing ' + + '— check for a "Failed to poll live wallpaper frame" error above.'); + } + return GLib.SOURCE_REMOVE; + }); + + this._setActive(true); + this._paused = false; + this._connectPowerWatches(); + this._logger.debug(`Live wallpaper started: ${path}`); + } catch (e) { + this._logger.error(e, 'Failed to start live wallpaper'); + this._stop(); + } + } + + _pollForSample() { + if (!this._appsink) + return GLib.SOURCE_REMOVE; + + const Gst = this._Gst; + this._pollCount++; + if (this._pollCount === 1) + this._logger.debug('Live wallpaper: appsink polling started'); + + try { + const sample = this._appsink.try_pull_sample(0); + if (!sample) + return GLib.SOURCE_CONTINUE; + + const buffer = sample.get_buffer(); + const structure = sample.get_caps().get_structure(0); + const [, width] = structure.get_int('width'); + const [, height] = structure.get_int('height'); + + const [ok, mapInfo] = buffer.map(Gst.MapFlags.READ); + if (!ok) { + this._logger.debug('Live wallpaper: buffer.map() failed'); + return GLib.SOURCE_CONTINUE; + } + + try { + this._updateFrame(mapInfo.data, width, height); + this._frameCount++; + if (this._frameCount === 1) + this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`); + else if (this._frameCount % 120 === 0) + this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`); + } finally { + buffer.unmap(mapInfo); + } + } catch (e) { + // Rate-limited: this runs at ~30fps, so logging every failure + // would flood the journal (and this loop keeps retrying every + // frame, e.g. while cycling through FRAME_IMAGE_STRATEGIES). + this._frameErrorCount++; + if (this._frameErrorCount === 1 || this._frameErrorCount % 300 === 0) { + this._logger.error( + e, `Failed to poll live wallpaper frame (${this._frameErrorCount} failures so far)`); + } + } + + return GLib.SOURCE_CONTINUE; + } + + _updateFrame(data, width, height) { + if (!this._actor) + return; + + const needsNewImage = !this._image || this._videoWidth !== width || this._videoHeight !== height; + + while (this._imageStrategyIndex < FRAME_IMAGE_STRATEGIES.length) { + const strategy = FRAME_IMAGE_STRATEGIES[this._imageStrategyIndex]; + try { + if (needsNewImage) { + this._image = strategy.create(width, height); + this._videoWidth = width; + this._videoHeight = height; + } + strategy.upload(this._image, this._Cogl, data, width, height); + this._actor.set_content(this._image); + return; + } catch (e) { + this._logger.warn( + `Live wallpaper: frame image strategy "${strategy.name}" failed ` + + `(${e.message ?? e}), trying the next one`); + this._imageStrategyIndex++; + this._image = null; + } + } + + throw new Error('All live wallpaper frame image strategies failed'); + } + + _onBusMessage(message) { + const Gst = this._Gst; + switch (message.type) { + case Gst.MessageType.EOS: + this._playbin.seek_simple( + Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0); + break; + case Gst.MessageType.ERROR: { + const [error, debug] = message.parse_error(); + this._logger.error(error, `Live wallpaper playback error (${debug ?? 'no debug info'})`); + this._stop(); + break; + } + case Gst.MessageType.WARNING: { + const [warning, debug] = message.parse_warning(); + this._logger.warn(`Live wallpaper GStreamer warning: ${warning.message} (${debug ?? 'no debug info'})`); + break; + } + case Gst.MessageType.STATE_CHANGED: + if (message.src === this._playbin) { + const [, newState] = message.parse_state_changed(); + this._logger.debug(`Live wallpaper pipeline state changed to ${this._stateName(newState)}`); + } + break; + } + } + + _stateName(state) { + const Gst = this._Gst; + return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state); + } + + _applyMute() { + if (this._playbin) + this._playbin.set_property('mute', this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_MUTED)); + } + + _applyPlaybackRate() { + if (!this._playbin || !this._active) + return; + + const Gst = this._Gst; + const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE); + const [ok, position] = this._playbin.query_position(Gst.Format.TIME); + if (!ok) + return; + + this._playbin.seek( + rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, + Gst.SeekType.SET, position, Gst.SeekType.NONE, -1); + } + + _layoutActor() { + if (!this._actor) + return; + this._actor.set_position(0, 0); + this._actor.set_size(global.stage.width, global.stage.height); + } + + _connectPowerWatches() { + this._disconnectPowerWatches(); + + try { + this._fullscreenChangedId = global.display.connect('in-fullscreen-changed', () => this._checkFullscreen()); + this._checkFullscreen(); + } catch (e) { + this._logger.debug(`Fullscreen tracking unavailable, pause-when-fullscreen disabled (${e.message ?? e})`); + } + + try { + this._upowerProxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null, + 'org.freedesktop.UPower', '/org/freedesktop/UPower', 'org.freedesktop.UPower', null); + this._upowerSignalId = this._upowerProxy.connect( + 'g-properties-changed', () => this._checkBattery()); + this._checkBattery(); + } catch (e) { + this._logger.debug(`UPower unavailable, pause-on-battery disabled (${e.message ?? e})`); + } + } + + _disconnectPowerWatches() { + if (this._fullscreenChangedId) { + global.display.disconnect(this._fullscreenChangedId); + this._fullscreenChangedId = 0; + } + if (this._upowerProxy && this._upowerSignalId) { + this._upowerProxy.disconnect(this._upowerSignalId); + this._upowerSignalId = 0; + } + this._upowerProxy = null; + this._onBattery = false; + this._fullscreenActive = false; + } + + _checkFullscreen() { + try { + const nMonitors = global.display.get_n_monitors(); + this._fullscreenActive = Array.from({length: nMonitors}, (_, i) => i) + .some(i => global.display.get_monitor_in_fullscreen(i)); + } catch (e) { + this._fullscreenActive = false; + } + this._updatePauseState(); + } + + _checkBattery() { + const value = this._upowerProxy?.get_cached_property('OnBattery'); + this._onBattery = value ? value.get_boolean() : false; + this._updatePauseState(); + } + + _updatePauseState() { + const shouldPause = + (this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY) && this._onBattery) || + (this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN) && this._fullscreenActive); + this._setPaused(shouldPause); + } + + _setPaused(paused) { + if (!this._playbin || this._paused === paused) + return; + this._paused = paused; + this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING); + } + + _stop() { + this._disconnectPowerWatches(); + + if (this._noFrameWatchdogId) { + GLib.source_remove(this._noFrameWatchdogId); + this._noFrameWatchdogId = 0; + } + + if (this._pollTimeoutId) { + GLib.source_remove(this._pollTimeoutId); + this._pollTimeoutId = 0; + } + + if (this._playbin) { + const Gst = this._Gst; + const bus = this._playbin.get_bus(); + if (this._busWatchId) { + bus.disconnect(this._busWatchId); + this._busWatchId = 0; + } + bus.remove_signal_watch(); + + this._playbin.set_state(Gst.State.NULL); + // Block briefly for the (normally fast) transition to actually + // finish before dropping our reference — otherwise the element + // can get disposed mid-transition, which GStreamer logs as + // "Trying to dispose element ..., but it is in PLAYING instead + // of the NULL state". + this._playbin.get_state(200 * Gst.MSECOND); + + this._playbin = null; + this._appsink = null; + } + + this._actor?.destroy(); + this._actor = null; + this._image = null; + this._videoWidth = 0; + this._videoHeight = 0; + + this._setActive(false); + this._paused = false; + } +} diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..8a4be93 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,26 @@ +import {SettingsKey} from './settingsKeys.js'; + +export class Logger { + constructor(settings, prefix) { + this._settings = settings; + this._prefix = prefix ?? 'BenthicBloom'; + } + + debug(message) { + if (this._settings.get_boolean(SettingsKey.DEBUG_LOGGING)) + console.log(`[${this._prefix}] ${message}`); + } + + info(message) { + console.log(`[${this._prefix}] ${message}`); + } + + warn(message) { + console.warn(`[${this._prefix}] ${message}`); + } + + error(error, context) { + const detail = error?.message ?? String(error); + console.error(`[${this._prefix}] ${context ? `${context}: ${detail}` : detail}`); + } +} diff --git a/lib/oledProtection.js b/lib/oledProtection.js new file mode 100644 index 0000000..34ab4d9 --- /dev/null +++ b/lib/oledProtection.js @@ -0,0 +1,269 @@ +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {SettingsKey} from './settingsKeys.js'; + +// A slow, small drift pattern rather than a simple back-and-forth, so the +// same pixels aren't re-lit on a short, predictable cycle. +const SHIFT_PATTERN = [ + [0, 0], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], +]; + +const FORCE_ROTATION_CHECK_SECONDS = 60; + +/** + * Reduces OLED burn-in risk through three independent techniques: + * - pixel shifting: nudges the background actors a few px on a slow cycle + * - idle dimming: fades in a black overlay after prolonged inactivity + * - forced rotation: guarantees the wallpaper changes periodically even if + * automatic rotation is otherwise switched off + */ +export class OledProtectionManager { + constructor(settings, logger, {forceNextWallpaper} = {}) { + this._settings = settings; + this._logger = logger; + this._forceNextWallpaper = forceNextWallpaper ?? (() => {}); + + this._settingsSignals = []; + + this._shiftTimeoutId = 0; + this._shiftStep = 0; + + this._forceRotationTimeoutId = 0; + + this._idleMonitor = null; + this._idleWatchId = 0; + this._activeWatchId = 0; + this._dimOverlays = []; + this._dimmed = false; + } + + enable() { + this._settingsSignals.push( + this._settings.connect(`changed::${SettingsKey.OLED_PROTECTION_ENABLED}`, () => this._syncAll()), + this._settings.connect(`changed::${SettingsKey.OLED_PIXEL_SHIFT_ENABLED}`, () => this._syncPixelShift()), + this._settings.connect(`changed::${SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS}`, () => this._syncPixelShift()), + this._settings.connect(`changed::${SettingsKey.OLED_DIM_ON_IDLE_ENABLED}`, () => this._syncIdleWatch()), + this._settings.connect(`changed::${SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS}`, () => this._syncIdleWatch()), + this._settings.connect(`changed::${SettingsKey.OLED_FORCE_ROTATION_ENABLED}`, () => this._syncForceRotation()) + ); + + this._syncAll(); + } + + disable() { + for (const id of this._settingsSignals) + this._settings.disconnect(id); + this._settingsSignals = []; + + this._stopPixelShift(); + this._stopForceRotation(); + this._stopIdleWatch(); + this._clearDimOverlays(); + } + + get isEnabled() { + return this._settings.get_boolean(SettingsKey.OLED_PROTECTION_ENABLED); + } + + _syncAll() { + this._syncPixelShift(); + this._syncIdleWatch(); + this._syncForceRotation(); + } + + // --- Pixel shifting -------------------------------------------------- + + _syncPixelShift() { + this._stopPixelShift(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_PIXEL_SHIFT_ENABLED)) + this._startPixelShift(); + else + this._resetShift(); + } + + _startPixelShift() { + const interval = Math.max(5, this._settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS)); + this._shiftTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this._applyPixelShiftStep(); + return GLib.SOURCE_CONTINUE; + }); + } + + _stopPixelShift() { + if (this._shiftTimeoutId) { + GLib.source_remove(this._shiftTimeoutId); + this._shiftTimeoutId = 0; + } + } + + _applyPixelShiftStep() { + const amount = this._settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX); + this._shiftStep = (this._shiftStep + 1) % SHIFT_PATTERN.length; + const [dx, dy] = SHIFT_PATTERN[this._shiftStep]; + + for (const actor of this._backgroundActors()) { + actor.ease({ + translation_x: dx * amount, + translation_y: dy * amount, + duration: 2000, + mode: Clutter.AnimationMode.EASE_IN_OUT_SINE, + }); + } + } + + _resetShift() { + this._shiftStep = 0; + for (const actor of this._backgroundActors()) { + actor.ease({ + translation_x: 0, + translation_y: 0, + duration: 500, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _backgroundActors() { + // Private API: the group holding each monitor's background actor + // (and our own live-wallpaper/crossfade actors, which harmlessly + // shift along with it). + try { + return Main.layoutManager._backgroundGroup.get_children(); + } catch (e) { + return []; + } + } + + // --- Idle dimming ------------------------------------------------------ + + _syncIdleWatch() { + this._stopIdleWatch(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_DIM_ON_IDLE_ENABLED)) + this._startIdleWatch(); + else + this._undim(); + } + + _startIdleWatch() { + try { + this._idleMonitor = global.backend?.get_core_idle_monitor + ? global.backend.get_core_idle_monitor() + : Meta.IdleMonitor.get_core(); + } catch (e) { + this._logger.debug(`Idle monitor unavailable, idle dimming disabled (${e.message ?? e})`); + return; + } + + this._armIdleWatch(); + } + + _armIdleWatch() { + if (!this._idleMonitor) + return; + + const delayMs = Math.max(5, this._settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS)) * 1000; + this._idleWatchId = this._idleMonitor.add_idle_watch(delayMs, () => { + this._dim(); + this._activeWatchId = this._idleMonitor.add_user_active_watch(() => { + this._undim(); + this._activeWatchId = 0; + this._armIdleWatch(); + }); + }); + } + + _stopIdleWatch() { + if (this._idleMonitor) { + if (this._idleWatchId) + this._idleMonitor.remove_watch(this._idleWatchId); + if (this._activeWatchId) + this._idleMonitor.remove_watch(this._activeWatchId); + } + this._idleWatchId = 0; + this._activeWatchId = 0; + this._idleMonitor = null; + } + + _dim() { + if (this._dimmed) + return; + this._dimmed = true; + + const brightness = this._settings.get_double(SettingsKey.OLED_DIM_BRIGHTNESS); + const targetOpacity = Math.round((1 - brightness) * 255); + + this._clearDimOverlays(); + for (const monitor of Main.layoutManager.monitors) { + const overlay = new Clutter.Actor({ + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + background_color: new Clutter.Color({red: 0, green: 0, blue: 0, alpha: 255}), + opacity: 0, + reactive: false, + }); + Main.layoutManager._backgroundGroup.add_child(overlay); + this._dimOverlays.push(overlay); + overlay.ease({ + opacity: targetOpacity, + duration: 4000, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + this._logger.debug('Background dimmed for OLED protection (idle)'); + } + + _undim() { + if (!this._dimmed) + return; + this._dimmed = false; + + for (const overlay of this._dimOverlays) { + overlay.ease({ + opacity: 0, + duration: 800, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => overlay.destroy(), + }); + } + this._dimOverlays = []; + + this._logger.debug('Background dim removed'); + } + + _clearDimOverlays() { + for (const overlay of this._dimOverlays) + overlay.destroy(); + this._dimOverlays = []; + this._dimmed = false; + } + + // --- Forced rotation ----------------------------------------------- + + _syncForceRotation() { + this._stopForceRotation(); + if (this.isEnabled && this._settings.get_boolean(SettingsKey.OLED_FORCE_ROTATION_ENABLED)) + this._startForceRotation(); + } + + _startForceRotation() { + this._forceRotationTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, FORCE_ROTATION_CHECK_SECONDS, () => { + const maxSeconds = this._settings.get_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS); + this._forceNextWallpaper(maxSeconds); + return GLib.SOURCE_CONTINUE; + }); + } + + _stopForceRotation() { + if (this._forceRotationTimeoutId) { + GLib.source_remove(this._forceRotationTimeoutId); + this._forceRotationTimeoutId = 0; + } + } +} diff --git a/lib/rotationManager.js b/lib/rotationManager.js new file mode 100644 index 0000000..ec2b2e1 --- /dev/null +++ b/lib/rotationManager.js @@ -0,0 +1,245 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {SettingsKey} from './settingsKeys.js'; +import {listImagesInFolders} from './wallpaperSource.js'; +import {ShuffleBag} from './shuffleBag.js'; + +const BACKGROUND_SCHEMA = 'org.gnome.desktop.background'; +const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver'; +const MIN_INTERVAL_SECONDS = 5; + +/** + * Owns the wallpaper image list, the rotation timer, and applying the + * chosen image to both the desktop and (optionally) the lock screen via + * their standard GSettings schemas, with an optional crossfade overlay + * played on top while the change happens underneath. + */ +export class RotationManager { + constructor(settings, logger) { + this._settings = settings; + this._logger = logger; + this._backgroundSettings = new Gio.Settings({schema_id: BACKGROUND_SCHEMA}); + this._screensaverSettings = new Gio.Settings({schema_id: SCREENSAVER_SCHEMA}); + + this._images = []; + this._sequentialIndex = -1; + this._shuffleBag = new ShuffleBag(); + this._timeoutId = 0; + this._settingsSignals = []; + this._paused = false; + this._suspended = false; + this._lastChangeTime = GLib.get_monotonic_time(); + this._currentPath = null; + this._transitionOverlays = []; + } + + enable() { + this._settingsSignals.push( + this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()), + this._settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => this._restartTimer()), + this._settings.connect(`changed::${SettingsKey.ROTATION_INTERVAL_SECONDS}`, () => this._restartTimer()), + this._settings.connect(`changed::${SettingsKey.ROTATION_MODE}`, () => this._onModeChanged()) + ); + + this._reloadImages().catch(e => this._logger.error(e, 'Failed to load wallpaper folders')); + this._restartTimer(); + } + + disable() { + for (const id of this._settingsSignals) + this._settings.disconnect(id); + this._settingsSignals = []; + + this._clearTimer(); + this._clearTransitionOverlays(); + } + + get currentPath() { + return this._currentPath; + } + + get hasImages() { + return this._images.length > 0; + } + + get isPaused() { + return this._paused; + } + + get secondsSinceLastChange() { + return (GLib.get_monotonic_time() - this._lastChangeTime) / GLib.USEC_PER_SEC; + } + + async _reloadImages() { + const folders = this._settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + this._images = folders.length > 0 ? await listImagesInFolders(folders) : []; + this._shuffleBag.setItems(this._images); + this._sequentialIndex = -1; + this._logger.debug(`Loaded ${this._images.length} wallpaper(s) from ${folders.length} folder(s)`); + } + + _onModeChanged() { + this._sequentialIndex = -1; + this._shuffleBag.setItems(this._images); + } + + _restartTimer() { + this._clearTimer(); + + if (this._paused || this._suspended || !this._settings.get_boolean(SettingsKey.ROTATION_ENABLED)) + return; + + const interval = Math.max( + MIN_INTERVAL_SECONDS, this._settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS)); + + this._timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this.next().catch(e => this._logger.error(e, 'Automatic rotation failed')); + return GLib.SOURCE_CONTINUE; + }); + } + + _clearTimer() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + } + + pause() { + this._paused = true; + this._clearTimer(); + } + + resume() { + this._paused = false; + this._restartTimer(); + } + + /** + * Distinct from user-initiated pause(): called while a live wallpaper + * is actually covering the desktop, so rotation doesn't keep changing + * a static image nobody can see (which also churns the shell's own + * background actor on top of the live wallpaper's). Resuming restores + * whatever the user's own pause() state was, rather than forcing + * rotation back on. + */ + suspend() { + this._suspended = true; + this._clearTimer(); + } + + unsuspend() { + this._suspended = false; + this._restartTimer(); + } + + async next({forced = false} = {}) { + if (this._suspended) { + this._logger.debug('Rotation suspended while live wallpaper is active'); + return; + } + + if (this._images.length === 0) + await this._reloadImages(); + + if (this._images.length === 0) { + this._logger.debug('No wallpapers available to rotate to'); + return; + } + + const mode = this._settings.get_string(SettingsKey.ROTATION_MODE); + let path; + if (mode === 'sequential') { + this._sequentialIndex = (this._sequentialIndex + 1) % this._images.length; + path = this._images[this._sequentialIndex]; + } else { + path = this._shuffleBag.next(); + } + + if (path) + await this._applyWallpaper(path); + + if (forced) + this._logger.debug('Wallpaper change forced (OLED protection)'); + } + + async previous() { + if (this._suspended || this._images.length === 0) + return; + + const mode = this._settings.get_string(SettingsKey.ROTATION_MODE); + if (mode === 'sequential') { + this._sequentialIndex = (this._sequentialIndex - 1 + this._images.length) % this._images.length; + await this._applyWallpaper(this._images[this._sequentialIndex]); + } else { + await this.next(); + } + } + + async _applyWallpaper(path) { + const previousPath = this._currentPath; + const uri = GLib.filename_to_uri(path, null); + + if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath) + this._playCrossfade(previousPath); + + this._backgroundSettings.set_string('picture-uri', uri); + if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark')) + this._backgroundSettings.set_string('picture-uri-dark', uri); + + if (this._settings.get_boolean(SettingsKey.APPLY_TO_LOCK_SCREEN)) + this._screensaverSettings.set_string('picture-uri', uri); + + this._currentPath = path; + this._lastChangeTime = GLib.get_monotonic_time(); + this._logger.debug(`Wallpaper changed to ${path}`); + } + + /** + * The real background actor doesn't crossfade on its own, so we paint the + * *old* image full-screen on a throwaway overlay right as the new image + * is set underneath, then fade the overlay out to reveal it. + */ + _playCrossfade(previousPath) { + this._clearTransitionOverlays(); + + const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS); + const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22'); + + for (const monitor of Main.layoutManager.monitors) { + const overlay = new St.Widget({ + reactive: false, + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + style: `background-image: url("${uri}"); background-size: cover; background-position: center;`, + opacity: 255, + }); + Main.layoutManager._backgroundGroup.add_child(overlay); + this._transitionOverlays.push(overlay); + + overlay.ease({ + opacity: 0, + duration: durationMs, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + overlay.destroy(); + const idx = this._transitionOverlays.indexOf(overlay); + if (idx >= 0) + this._transitionOverlays.splice(idx, 1); + }, + }); + } + } + + _clearTransitionOverlays() { + for (const overlay of this._transitionOverlays) + overlay.destroy(); + this._transitionOverlays = []; + } +} diff --git a/lib/settingsKeys.js b/lib/settingsKeys.js new file mode 100644 index 0000000..ed7c823 --- /dev/null +++ b/lib/settingsKeys.js @@ -0,0 +1,39 @@ +export const SettingsKey = Object.freeze({ + SHOW_INDICATOR: 'show-indicator', + DEBUG_LOGGING: 'debug-logging', + WALLPAPER_FOLDERS: 'wallpaper-folders', + APPLY_TO_LOCK_SCREEN: 'apply-to-lock-screen', + + ROTATION_ENABLED: 'rotation-enabled', + ROTATION_INTERVAL_SECONDS: 'rotation-interval-seconds', + ROTATION_MODE: 'rotation-mode', + CURRENT_INDEX: 'current-index', + TRANSITION_ENABLED: 'transition-enabled', + TRANSITION_DURATION_MS: 'transition-duration-ms', + + LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled', + LIVE_WALLPAPER_PATH: 'live-wallpaper-path', + LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders', + LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted', + LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate', + LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery', + LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN: 'live-wallpaper-pause-when-fullscreen', + + OLED_PROTECTION_ENABLED: 'oled-protection-enabled', + OLED_PIXEL_SHIFT_ENABLED: 'oled-pixel-shift-enabled', + OLED_PIXEL_SHIFT_INTERVAL_SECONDS: 'oled-pixel-shift-interval-seconds', + OLED_PIXEL_SHIFT_AMOUNT_PX: 'oled-pixel-shift-amount-px', + OLED_DIM_ON_IDLE_ENABLED: 'oled-dim-on-idle-enabled', + OLED_DIM_IDLE_DELAY_SECONDS: 'oled-dim-idle-delay-seconds', + OLED_DIM_BRIGHTNESS: 'oled-dim-brightness', + OLED_FORCE_ROTATION_ENABLED: 'oled-force-rotation-enabled', + OLED_MAX_STATIC_DURATION_SECONDS: 'oled-max-static-duration-seconds', +}); + +export const IMAGE_EXTENSIONS = Object.freeze([ + '.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif', '.gif', +]); + +export const LIVE_WALLPAPER_EXTENSIONS = Object.freeze([ + '.gif', '.mp4', '.webm', '.mkv', '.mov', '.avi', '.m4v', '.ogv', +]); diff --git a/lib/shuffleBag.js b/lib/shuffleBag.js new file mode 100644 index 0000000..fbfc3fa --- /dev/null +++ b/lib/shuffleBag.js @@ -0,0 +1,49 @@ +/** + * Random-order iterator that guarantees every item is seen once before any + * item repeats, and never immediately repeats the previous pick across bag + * refills. Used for "shuffle" rotation mode instead of naive Math.random() + * indexing, which tends to repeat images and skip others over time. + */ +export class ShuffleBag { + constructor(items = []) { + this.setItems(items); + } + + setItems(items) { + this._items = [...items]; + this._bag = []; + this._lastItem = null; + } + + get size() { + return this._items.length; + } + + next() { + if (this._items.length === 0) + return null; + if (this._items.length === 1) + return this._items[0]; + + if (this._bag.length === 0) + this._refill(); + + const item = this._bag.pop(); + this._lastItem = item; + return item; + } + + _refill() { + this._bag = [...this._items]; + for (let i = this._bag.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this._bag[i], this._bag[j]] = [this._bag[j], this._bag[i]]; + } + + if (this._bag[this._bag.length - 1] === this._lastItem && this._bag.length > 1) { + const swapIndex = Math.floor(Math.random() * (this._bag.length - 1)); + const lastIndex = this._bag.length - 1; + [this._bag[lastIndex], this._bag[swapIndex]] = [this._bag[swapIndex], this._bag[lastIndex]]; + } + } +} diff --git a/lib/wallpaperSource.js b/lib/wallpaperSource.js new file mode 100644 index 0000000..8562405 --- /dev/null +++ b/lib/wallpaperSource.js @@ -0,0 +1,89 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import {IMAGE_EXTENSIONS, LIVE_WALLPAPER_EXTENSIONS} from './settingsKeys.js'; + +Gio._promisify(Gio.File.prototype, 'enumerate_children_async', 'enumerate_children_finish'); +Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish'); +Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish'); + +function hasExtension(name, extensions) { + const lower = name.toLowerCase(); + return extensions.some(ext => lower.endsWith(ext)); +} + +/** Non-recursively lists files with one of `extensions` directly inside a single folder. */ +async function listFilesInFolder(folderPath, extensions) { + const results = []; + const dir = folderPath.startsWith('file://') + ? Gio.File.new_for_uri(folderPath) + : Gio.File.new_for_path(folderPath); + + let enumerator; + try { + enumerator = await dir.enumerate_children_async( + 'standard::name,standard::type', + Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, + null); + } catch (e) { + return results; + } + + for (;;) { + const infos = await enumerator.next_files_async(50, GLib.PRIORITY_DEFAULT, null); + if (infos.length === 0) + break; + + for (const info of infos) { + if (info.get_file_type() !== Gio.FileType.REGULAR) + continue; + if (!hasExtension(info.get_name(), extensions)) + continue; + results.push(enumerator.get_child(info).get_path()); + } + } + + try { + await enumerator.close_async(GLib.PRIORITY_DEFAULT, null); + } catch (e) { + // Enumerator already exhausted; nothing to clean up. + } + + return results; +} + +/** Merges and de-duplicates files with one of `extensions` found across several folders. */ +async function listFilesInFolders(folderPaths, extensions) { + const lists = await Promise.all( + folderPaths.map(folder => listFilesInFolder(folder, extensions).catch(() => []))); + + const seen = new Set(); + const merged = []; + for (const list of lists) { + for (const path of list) { + if (!seen.has(path)) { + seen.add(path); + merged.push(path); + } + } + } + + merged.sort(); + return merged; +} + +/** Non-recursively lists image files directly inside a single folder. */ +export async function listImagesInFolder(folderPath) { + return listFilesInFolder(folderPath, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates images found across several folders. */ +export async function listImagesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates live-wallpaper-capable videos/GIFs found across several folders. */ +export async function listMediaFilesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, LIVE_WALLPAPER_EXTENSIONS); +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..f973e42 --- /dev/null +++ b/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "benthicbloom@quinta0.github.io", + "name": "BenthicBloom", + "description": "Automatically rotates your wallpaper from folders you choose, supports looping video live wallpapers, and includes built-in pixel-shifting and idle-dimming to protect OLED displays from burn-in.", + "shell-version": ["50"], + "url": "https://github.com/quinta0/benthicbloom", + "settings-schema": "org.gnome.shell.extensions.benthicbloom", + "gettext-domain": "benthicbloom", + "version": 1, + "version-name": "1.0.0" +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..df9576c --- /dev/null +++ b/prefs.js @@ -0,0 +1,457 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import Gdk from 'gi://Gdk'; + +import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +import {SettingsKey} from './lib/settingsKeys.js'; +import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; +import {listMediaFilesInFolders} from './lib/wallpaperSource.js'; + +export default class BenthicBloomPreferences extends ExtensionPreferences { + async fillPreferencesWindow(window) { + const settings = this.getSettings(); + const gstreamerError = await checkGstreamerBaseAvailable().then(() => null, e => e.message ?? String(e)); + + // Bundled rather than relying on the system icon theme having these + // exact names — a missing symbolic icon otherwise renders as a + // blank/broken-image placeholder in the page switcher. + Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`); + + window.set_default_size(640, 720); + window.add(this._buildGeneralPage(settings)); + window.add(this._buildRotationPage(settings)); + window.add(this._buildLiveWallpaperPage(settings, gstreamerError)); + window.add(this._buildOledPage(settings)); + window.add(this._buildAboutPage()); + } + + _switchRow(settings, key, title, subtitle) { + const row = new Adw.SwitchRow({title, subtitle}); + settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT); + return row; + } + + _spinRow({title, subtitle, lower, upper, step, page, digits = 0}, getValue, setValue) { + const row = new Adw.SpinRow({ + title, + subtitle, + digits, + adjustment: new Gtk.Adjustment({lower, upper, step_increment: step, page_increment: page ?? step}), + }); + row.value = getValue(); + row.connect('notify::value', () => setValue(row.value)); + return row; + } + + // --- General page ------------------------------------------------ + + _buildGeneralPage(settings) { + const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'}); + + const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')}); + page.add(behaviorGroup); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.SHOW_INDICATOR, + _('Show Panel Indicator'), _('Display a quick-access icon in the top bar'))); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.APPLY_TO_LOCK_SCREEN, + _('Apply to Lock Screen'), _('Also use the current wallpaper as the lock screen background'))); + behaviorGroup.add(this._switchRow( + settings, SettingsKey.DEBUG_LOGGING, + _('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)'))); + + const foldersGroup = new Adw.PreferencesGroup({ + title: _('Wallpaper Folders'), + description: _('Images found directly inside these folders are used for rotation'), + }); + page.add(foldersGroup); + + const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet')); + foldersGroup.add(folderList); + + const addButton = new Gtk.Button({ + label: _('Add Folder…'), + halign: Gtk.Align.START, + margin_top: 6, + css_classes: ['flat'], + }); + addButton.connect('clicked', () => this._pickFolderForList( + settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet'))); + foldersGroup.add(addButton); + + return page; + } + + // --- Shared folder-list widgets (used by the General and Live Wallpaper pages) -- + + _buildFolderListBox(settings, key, emptyText, onChange) { + const listBox = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']}); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + return listBox; + } + + _refreshFolderListBox(listBox, settings, key, emptyText, onChange) { + let child = listBox.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + listBox.remove(child); + child = next; + } + + const folders = settings.get_strv(key); + if (folders.length === 0) { + listBox.append(new Adw.ActionRow({title: emptyText})); + return; + } + + for (const folder of folders) { + const row = new Adw.ActionRow({title: folder}); + const removeButton = new Gtk.Button({ + icon_name: 'user-trash-symbolic', + valign: Gtk.Align.CENTER, + css_classes: ['flat'], + }); + removeButton.connect('clicked', () => { + const current = settings.get_strv(key); + settings.set_strv(key, current.filter(f => f !== folder)); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + onChange?.(); + }); + row.add_suffix(removeButton); + listBox.append(row); + } + } + + _pickFolderForList(settings, key, listBox, emptyText, onChange) { + const dialog = new Gtk.FileDialog({title: _('Select Folder')}); + dialog.select_folder(listBox.get_root(), null, (source, result) => { + try { + const folder = dialog.select_folder_finish(result); + const path = folder.get_path(); + if (!path) + return; + const current = settings.get_strv(key); + if (!current.includes(path)) { + settings.set_strv(key, [...current, path]); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + onChange?.(); + } + } catch (e) { + // Dialog was dismissed; nothing to do. + } + }); + } + + // --- Rotation page ------------------------------------------------- + + _buildRotationPage(settings) { + const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-symbolic'}); + + const group = new Adw.PreferencesGroup({title: _('Automatic Rotation')}); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.ROTATION_ENABLED, + _('Enable Rotation'), _('Automatically change the wallpaper on a timer'))); + + group.add(this._spinRow( + {title: _('Interval'), subtitle: _('Minutes between wallpaper changes'), lower: 1, upper: 1440, step: 1, page: 10}, + () => settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS) / 60, + value => settings.set_uint(SettingsKey.ROTATION_INTERVAL_SECONDS, Math.round(value) * 60))); + + const modeRow = new Adw.ComboRow({ + title: _('Order'), + model: new Gtk.StringList({strings: [_('Shuffle'), _('Sequential')]}), + }); + modeRow.selected = settings.get_string(SettingsKey.ROTATION_MODE) === 'sequential' ? 1 : 0; + modeRow.connect('notify::selected', () => { + settings.set_string(SettingsKey.ROTATION_MODE, modeRow.selected === 1 ? 'sequential' : 'shuffle'); + }); + group.add(modeRow); + + const transitionGroup = new Adw.PreferencesGroup({title: _('Transitions')}); + page.add(transitionGroup); + transitionGroup.add(this._switchRow( + settings, SettingsKey.TRANSITION_ENABLED, + _('Crossfade'), _('Smoothly fade between wallpapers instead of switching instantly'))); + transitionGroup.add(this._spinRow( + {title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500}, + () => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS), + value => settings.set_uint(SettingsKey.TRANSITION_DURATION_MS, Math.round(value)))); + + return page; + } + + // --- Live wallpaper page -------------------------------------------- + + _buildLiveWallpaperPage(settings, gstreamerError) { + const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-symbolic'}); + + if (gstreamerError) { + const warningGroup = new Adw.PreferencesGroup(); + const warningRow = new Adw.ActionRow({ + title: _('GStreamer Not Found'), + subtitle: `${_('Live wallpapers will stay disabled until this is fixed:')} ${gstreamerError}\n\n${GSTREAMER_INSTALL_HINT}`, + css_classes: ['warning'], + }); + warningRow.subtitle_lines = 0; + warningGroup.add(warningRow); + page.add(warningGroup); + } + + const group = new Adw.PreferencesGroup({ + title: _('Video Wallpaper'), + description: _( + 'Play a looping video or animated GIF as your desktop background instead of a static image. ' + + 'Requires GStreamer (with its "good" and "base" plugin sets, which provide GIF decoding) ' + + 'to be installed on your system. Note: this page can only detect GStreamer being ' + + 'completely missing — the rendering path it also needs is private to gnome-shell and can’t ' + + 'be checked from here, so the absence of a warning below isn’t a full guarantee.'), + }); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_ENABLED, + _('Enable Live Wallpaper'), _('Overrides the static wallpaper while active'))); + + const fileRow = new Adw.ActionRow({ + title: _('Video / GIF File'), + subtitle: settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) || _('None selected'), + }); + const chooseButton = new Gtk.Button({label: _('Choose…'), valign: Gtk.Align.CENTER, css_classes: ['flat']}); + chooseButton.connect('clicked', () => { + const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Video or GIF')}); + + const mediaFilter = new Gtk.FileFilter({name: _('Videos and animated GIFs')}); + mediaFilter.add_mime_type('video/*'); + mediaFilter.add_mime_type('image/gif'); + mediaFilter.add_pattern('*.gif'); + + const allFilter = new Gtk.FileFilter({name: _('All files')}); + allFilter.add_pattern('*'); + + const filterList = new Gio.ListStore({item_type: Gtk.FileFilter}); + filterList.append(mediaFilter); + filterList.append(allFilter); + dialog.filters = filterList; + dialog.default_filter = mediaFilter; + + dialog.open(fileRow.get_root(), null, (source, result) => { + try { + const file = dialog.open_finish(result); + const path = file.get_path(); + settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path); + fileRow.subtitle = path; + this._refreshLiveMediaList(settings, fileRow); + } catch (e) { + // Dialog was dismissed; nothing to do. + } + }); + }); + fileRow.add_suffix(chooseButton); + group.add(fileRow); + + const muteRow = this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_MUTED, + _('Mute Audio'), _('Play video wallpapers without sound')); + group.add(muteRow); + const rateRow = this._spinRow( + { + title: _('Playback Speed'), + subtitle: _('Multiplier, e.g. 0.5 for half speed, 2.0 for double speed'), + lower: 0.1, upper: 4.0, step: 0.1, page: 0.5, digits: 1, + }, + () => settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE), + value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value)); + group.add(rateRow); + + const foldersGroup = new Adw.PreferencesGroup({ + title: _('Wallpaper Folders'), + description: _('Videos and animated GIFs found directly inside these folders can be picked below'), + }); + page.add(foldersGroup); + + const mediaGroup = new Adw.PreferencesGroup({title: _('Available Media')}); + page.add(mediaGroup); + this._liveMediaList = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']}); + mediaGroup.add(this._liveMediaList); + this._liveMediaGeneration = 0; + + const refreshMedia = () => this._refreshLiveMediaList(settings, fileRow); + + const liveFolderList = this._buildFolderListBox( + settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia); + foldersGroup.add(liveFolderList); + + const addFolderButton = new Gtk.Button({ + label: _('Add Folder…'), + halign: Gtk.Align.START, + margin_top: 6, + css_classes: ['flat'], + }); + addFolderButton.connect('clicked', () => this._pickFolderForList( + settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia)); + foldersGroup.add(addFolderButton); + + refreshMedia(); + + const powerGroup = new Adw.PreferencesGroup({title: _('Power Saving')}); + page.add(powerGroup); + powerGroup.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY, + _('Pause on Battery'), _('Stop video playback while running on battery power'))); + powerGroup.add(this._switchRow( + settings, SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN, + _('Pause When Fullscreen'), _('Stop video playback while a window is fullscreen'))); + + // The enable switch itself stays usable so the setting can be + // prepared ahead of time, but everything that only matters once + // GStreamer is actually driving playback is greyed out. + if (gstreamerError) { + fileRow.sensitive = false; + muteRow.sensitive = false; + rateRow.sensitive = false; + foldersGroup.sensitive = false; + mediaGroup.sensitive = false; + powerGroup.sensitive = false; + } + + return page; + } + + /** Re-scans the configured live wallpaper folders and repopulates the "Available Media" list. */ + _refreshLiveMediaList(settings, fileRow) { + const listBox = this._liveMediaList; + const generation = ++this._liveMediaGeneration; + + let child = listBox.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + listBox.remove(child); + child = next; + } + + const folders = settings.get_strv(SettingsKey.LIVE_WALLPAPER_FOLDERS); + if (folders.length === 0) { + listBox.append(new Adw.ActionRow({title: _('Add a folder above to browse its videos and GIFs')})); + return; + } + + listBox.append(new Adw.ActionRow({title: _('Scanning…')})); + + listMediaFilesInFolders(folders).then(paths => { + if (generation !== this._liveMediaGeneration) + return; // A folder changed again before this scan finished; a newer one is in flight. + + let c = listBox.get_first_child(); + while (c) { + const next = c.get_next_sibling(); + listBox.remove(c); + c = next; + } + + if (paths.length === 0) { + listBox.append(new Adw.ActionRow({title: _('No videos or GIFs found in those folders')})); + return; + } + + const currentPath = settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH); + for (const path of paths) { + const row = new Adw.ActionRow({ + title: Gio.File.new_for_path(path).get_basename(), + subtitle: path, + activatable: true, + }); + if (path === currentPath) + row.add_suffix(new Gtk.Image({icon_name: 'object-select-symbolic'})); + row.connect('activated', () => { + settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path); + fileRow.subtitle = path; + this._refreshLiveMediaList(settings, fileRow); + }); + listBox.append(row); + } + }).catch(() => {}); + } + + // --- OLED protection page -------------------------------------------- + + _buildOledPage(settings) { + const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'bb-oled-symbolic'}); + + const group = new Adw.PreferencesGroup({ + title: _('Burn-in Protection'), + description: _('Reduces the risk of permanent image retention on OLED displays'), + }); + page.add(group); + group.add(this._switchRow( + settings, SettingsKey.OLED_PROTECTION_ENABLED, + _('Enable OLED Protection'), _('Master switch for all burn-in protection features'))); + + const shiftGroup = new Adw.PreferencesGroup({title: _('Pixel Shifting')}); + page.add(shiftGroup); + shiftGroup.add(this._switchRow( + settings, SettingsKey.OLED_PIXEL_SHIFT_ENABLED, + _('Enable Pixel Shifting'), _('Periodically nudge the background by a few pixels'))); + shiftGroup.add(this._spinRow( + {title: _('Shift Interval'), subtitle: _('Seconds between each shift step'), lower: 10, upper: 600, step: 5, page: 30}, + () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS), + value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value)))); + shiftGroup.add(this._spinRow( + {title: _('Shift Amount'), subtitle: _('Pixels'), lower: 1, upper: 10, step: 1, page: 1}, + () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX), + value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX, Math.round(value)))); + + const dimGroup = new Adw.PreferencesGroup({title: _('Idle Dimming')}); + page.add(dimGroup); + dimGroup.add(this._switchRow( + settings, SettingsKey.OLED_DIM_ON_IDLE_ENABLED, + _('Dim When Idle'), _('Lower brightness after a period of inactivity'))); + dimGroup.add(this._spinRow( + {title: _('Idle Delay'), subtitle: _('Seconds of inactivity before dimming'), lower: 10, upper: 3600, step: 10, page: 60}, + () => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS), + value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value)))); + dimGroup.add(this._spinRow( + { + title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'), + lower: 0.0, upper: 0.9, step: 0.05, page: 0.1, digits: 2, + }, + () => settings.get_double(SettingsKey.OLED_DIM_BRIGHTNESS), + value => settings.set_double(SettingsKey.OLED_DIM_BRIGHTNESS, value))); + + const forceGroup = new Adw.PreferencesGroup({title: _('Forced Rotation')}); + page.add(forceGroup); + forceGroup.add(this._switchRow( + settings, SettingsKey.OLED_FORCE_ROTATION_ENABLED, + _('Force Periodic Change'), + _('Change the wallpaper even if automatic rotation is off, to avoid prolonged static images'))); + forceGroup.add(this._spinRow( + {title: _('Maximum Static Duration'), subtitle: _('Hours before a change is forced'), lower: 1, upper: 48, step: 1, page: 4}, + () => settings.get_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS) / 3600, + value => settings.set_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS, Math.round(value) * 3600))); + + return page; + } + + // --- About page ----------------------------------------------------- + + _buildAboutPage() { + const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'bb-about-symbolic'}); + const group = new Adw.PreferencesGroup(); + page.add(group); + + group.add(new Adw.ActionRow({title: this.metadata.name, subtitle: this.metadata.description})); + group.add(new Adw.ActionRow({ + title: _('Version'), + subtitle: this.metadata['version-name'] ?? String(this.metadata.version ?? ''), + })); + + if (this.metadata.url) { + const linkRow = new Adw.ActionRow({title: _('Source Code'), subtitle: this.metadata.url, activatable: true}); + linkRow.connect('activated', () => Gtk.show_uri(null, this.metadata.url, Gdk.CURRENT_TIME)); + group.add(linkRow); + } + + return page; + } +} diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml new file mode 100644 index 0000000..c2f51a0 --- /dev/null +++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml @@ -0,0 +1,170 @@ + + + + + + + true + Show panel indicator + Whether to show the BenthicBloom icon in the top panel. + + + + false + Enable debug logging + Print verbose debug information to the GNOME Shell log (journalctl). + + + + [] + Wallpaper source folders + List of folder paths scanned for wallpaper images. + + + + true + Apply wallpaper to lock screen + Whether the rotated wallpaper is also applied to the lock screen background. + + + + false + Enable automatic rotation + Whether wallpapers are automatically rotated on a timer. + + + + 600 + Rotation interval + Number of seconds between automatic wallpaper changes. + + + + + + + + "shuffle" + Rotation order + Whether wallpapers are shown sequentially or in random (shuffle) order. + + + + -1 + Current wallpaper index + Internal state tracking the currently displayed wallpaper in sequential mode. + + + + true + Enable crossfade transition + Whether wallpaper changes fade smoothly instead of switching instantly. + + + + 1200 + Transition duration + Duration, in milliseconds, of the crossfade animation between wallpapers. + + + + false + Enable live wallpaper + Whether a video or animated GIF is played as an animated desktop background instead of a static image. + + + + "" + Live wallpaper media file + Path to the video or animated GIF file used as the live wallpaper. + + + + [] + Live wallpaper source folders + List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from. + + + + true + Mute live wallpaper audio + Whether audio playback is muted for the live wallpaper video. + + + + 1.0 + + Live wallpaper playback speed + Playback rate multiplier applied to the live wallpaper video. + + + + true + Pause live wallpaper on battery + Automatically pause video playback while the system is unplugged to save power. + + + + true + Pause live wallpaper when a window is fullscreen + Automatically pause video playback while a window is fullscreen, since it would be hidden anyway. + + + + false + Enable OLED burn-in protection + Master switch for all OLED burn-in protection features. + + + + true + Enable pixel shifting + Periodically nudge the rendered background by a few pixels to avoid static burn-in. + + + + 60 + Pixel shift interval + Number of seconds between each pixel-shift step. + + + + 3 + + Pixel shift amount + Maximum distance, in pixels, the background is offset during pixel shifting. + + + + true + Dim background when idle + Reduce background brightness after the system has been idle for a while. + + + + 300 + Idle delay before dimming + Number of seconds of inactivity before the background is dimmed. + + + + 0.4 + + Dimmed brightness level + Brightness multiplier applied to the background while dimmed (0 = black, 1 = full brightness). + + + + true + Force periodic wallpaper change + Force a wallpaper change after the maximum static duration even if automatic rotation is otherwise disabled. + + + + 14400 + Maximum static duration + Maximum number of seconds a single wallpaper may remain on screen before OLED protection forces a change. + + + + diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..99ce411 --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,4 @@ +/* BenthicBloom panel indicator */ +.benthicbloom-indicator-icon { + -st-icon-style: symbolic; +}