Implement BenthicBloom GNOME 50 wallpaper extension

Adds a complete GNOME Shell 50 extension with folder-based wallpaper
auto-rotation (shuffle/sequential, crossfade transitions, lock screen
sync), GStreamer-backed live/video wallpapers rendered onto the shell's
own Clutter stage, and OLED burn-in protection (pixel shifting, idle
dimming, forced periodic rotation). Includes a top-bar quick-access
indicator, a libadwaita preferences window, GSettings schema, and a
Makefile for install/packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8
This commit is contained in:
Claude
2026-08-04 00:05:58 +00:00
parent 068c00c0ac
commit c0ec4a59d5
16 changed files with 1834 additions and 1 deletions
+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;
}
}
}