import GLib from 'gi://GLib'; import Meta from 'gi://Meta'; import Clutter from 'gi://Clutter'; import Graphene from 'gi://Graphene'; 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; // Extra margin, in pixels, added on top of the configured shift amount when // overscanning background actors — rounding/timing slack so a step never // exposes a sliver of empty stage at the trailing edge. const OVERSCAN_SAFETY_PX = 2; /** * 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()) { this._ensureOverscan(actor, amount); actor.ease({ translation_x: dx * amount, translation_y: dy * amount, duration: 2000, mode: Clutter.AnimationMode.EASE_IN_OUT_SINE, }); } } /** * Background actors (the real desktop background, and our own live * wallpaper actor in most display modes) are sized to exactly cover * the stage with no slack, so nudging one by a few pixels for burn-in * protection would drag its edge inward and expose bare stage on the * side it moved away from. Scaling the actor up slightly around its * center — by just enough margin to cover the configured shift amount * — keeps every edge overscanned past the stage bounds no matter which * step of the shift pattern is active. */ _ensureOverscan(actor, amount) { const width = actor.width; const height = actor.height; if (width <= 0 || height <= 0) return; const margin = amount + OVERSCAN_SAFETY_PX; actor.pivot_point = new Graphene.Point({x: 0.5, y: 0.5}); actor.scale_x = 1 + (2 * margin) / width; actor.scale_y = 1 + (2 * margin) / height; } _resetShift() { this._shiftStep = 0; for (const actor of this._backgroundActors()) { actor.ease({ translation_x: 0, translation_y: 0, scale_x: 1, scale_y: 1, 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; } } }