Claude/gnome wallpaper auto rotation r1ailq #1

Merged
Quinta0 merged 12 commits from claude/gnome-wallpaper-auto-rotation-r1ailq into main 2026-08-04 20:53:28 +02:00
16 changed files with 1834 additions and 1 deletions
Showing only changes of commit c0ec4a59d5 - Show all commits
+3
View File
@@ -0,0 +1,3 @@
schemas/gschemas.compiled
dist/
*.zip
+32
View File
@@ -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
+128 -1
View File
@@ -1 +1,128 @@
# benthicbloom
# 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 <kbd>Alt</kbd>+<kbd>F2</kbd>, type `r`,
press <kbd>Enter</kbd> 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 <file>` 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).
+76
View File
@@ -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;
}
}
}
+73
View File
@@ -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);
});
}
});
+352
View File
@@ -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;
}
}
+26
View File
@@ -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}`);
}
}
+269
View File
@@ -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;
}
}
}
+221
View File
@@ -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 = [];
}
}
+34
View File
@@ -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',
]);
+49
View File
@@ -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]];
}
}
}
+74
View File
@@ -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;
}
+11
View File
@@ -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"
}
+318
View File
@@ -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;
}
}
@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="benthicbloom">
<schema id="org.gnome.shell.extensions.benthicbloom" path="/org/gnome/shell/extensions/benthicbloom/">
<key name="show-indicator" type="b">
<default>true</default>
<summary>Show panel indicator</summary>
<description>Whether to show the BenthicBloom icon in the top panel.</description>
</key>
<key name="debug-logging" type="b">
<default>false</default>
<summary>Enable debug logging</summary>
<description>Print verbose debug information to the GNOME Shell log (journalctl).</description>
</key>
<key name="wallpaper-folders" type="as">
<default>[]</default>
<summary>Wallpaper source folders</summary>
<description>List of folder paths scanned for wallpaper images.</description>
</key>
<key name="apply-to-lock-screen" type="b">
<default>true</default>
<summary>Apply wallpaper to lock screen</summary>
<description>Whether the rotated wallpaper is also applied to the lock screen background.</description>
</key>
<key name="rotation-enabled" type="b">
<default>false</default>
<summary>Enable automatic rotation</summary>
<description>Whether wallpapers are automatically rotated on a timer.</description>
</key>
<key name="rotation-interval-seconds" type="u">
<default>600</default>
<summary>Rotation interval</summary>
<description>Number of seconds between automatic wallpaper changes.</description>
</key>
<key name="rotation-mode" type="s">
<choices>
<choice value="sequential"/>
<choice value="shuffle"/>
</choices>
<default>"shuffle"</default>
<summary>Rotation order</summary>
<description>Whether wallpapers are shown sequentially or in random (shuffle) order.</description>
</key>
<key name="current-index" type="i">
<default>-1</default>
<summary>Current wallpaper index</summary>
<description>Internal state tracking the currently displayed wallpaper in sequential mode.</description>
</key>
<key name="transition-enabled" type="b">
<default>true</default>
<summary>Enable crossfade transition</summary>
<description>Whether wallpaper changes fade smoothly instead of switching instantly.</description>
</key>
<key name="transition-duration-ms" type="u">
<default>1200</default>
<summary>Transition duration</summary>
<description>Duration, in milliseconds, of the crossfade animation between wallpapers.</description>
</key>
<key name="live-wallpaper-enabled" type="b">
<default>false</default>
<summary>Enable live wallpaper</summary>
<description>Whether a video is played as an animated desktop background instead of a static image.</description>
</key>
<key name="live-wallpaper-path" type="s">
<default>""</default>
<summary>Live wallpaper video file</summary>
<description>Path to the video file used as the live wallpaper.</description>
</key>
<key name="live-wallpaper-muted" type="b">
<default>true</default>
<summary>Mute live wallpaper audio</summary>
<description>Whether audio playback is muted for the live wallpaper video.</description>
</key>
<key name="live-wallpaper-playback-rate" type="d">
<default>1.0</default>
<range min="0.1" max="4.0"/>
<summary>Live wallpaper playback speed</summary>
<description>Playback rate multiplier applied to the live wallpaper video.</description>
</key>
<key name="live-wallpaper-pause-on-battery" type="b">
<default>true</default>
<summary>Pause live wallpaper on battery</summary>
<description>Automatically pause video playback while the system is unplugged to save power.</description>
</key>
<key name="live-wallpaper-pause-when-fullscreen" type="b">
<default>true</default>
<summary>Pause live wallpaper when a window is fullscreen</summary>
<description>Automatically pause video playback while a window is fullscreen, since it would be hidden anyway.</description>
</key>
<key name="oled-protection-enabled" type="b">
<default>false</default>
<summary>Enable OLED burn-in protection</summary>
<description>Master switch for all OLED burn-in protection features.</description>
</key>
<key name="oled-pixel-shift-enabled" type="b">
<default>true</default>
<summary>Enable pixel shifting</summary>
<description>Periodically nudge the rendered background by a few pixels to avoid static burn-in.</description>
</key>
<key name="oled-pixel-shift-interval-seconds" type="u">
<default>60</default>
<summary>Pixel shift interval</summary>
<description>Number of seconds between each pixel-shift step.</description>
</key>
<key name="oled-pixel-shift-amount-px" type="u">
<default>3</default>
<range min="1" max="10"/>
<summary>Pixel shift amount</summary>
<description>Maximum distance, in pixels, the background is offset during pixel shifting.</description>
</key>
<key name="oled-dim-on-idle-enabled" type="b">
<default>true</default>
<summary>Dim background when idle</summary>
<description>Reduce background brightness after the system has been idle for a while.</description>
</key>
<key name="oled-dim-idle-delay-seconds" type="u">
<default>300</default>
<summary>Idle delay before dimming</summary>
<description>Number of seconds of inactivity before the background is dimmed.</description>
</key>
<key name="oled-dim-brightness" type="d">
<default>0.4</default>
<range min="0.0" max="0.9"/>
<summary>Dimmed brightness level</summary>
<description>Brightness multiplier applied to the background while dimmed (0 = black, 1 = full brightness).</description>
</key>
<key name="oled-force-rotation-enabled" type="b">
<default>true</default>
<summary>Force periodic wallpaper change</summary>
<description>Force a wallpaper change after the maximum static duration even if automatic rotation is otherwise disabled.</description>
</key>
<key name="oled-max-static-duration-seconds" type="u">
<default>14400</default>
<summary>Maximum static duration</summary>
<description>Maximum number of seconds a single wallpaper may remain on screen before OLED protection forces a change.</description>
</key>
</schema>
</schemalist>
+4
View File
@@ -0,0 +1,4 @@
/* BenthicBloom panel indicator */
.benthicbloom-indicator-icon {
-st-icon-style: symbolic;
}