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
+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;
}