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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user