Merge pull request #1 from Quinta0/claude/gnome-wallpaper-auto-rotation-r1ailq

Claude/gnome wallpaper auto rotation r1ailq
This commit was merged in pull request #1.
This commit is contained in:
Quinta
2026-08-04 20:53:28 +02:00
committed by GitHub
22 changed files with 2304 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
schemas/gschemas.compiled
dist/
*.zip
+33
View File
@@ -0,0 +1,33 @@
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 $(INSTALL_DIR)/icons
cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/
cp lib/*.js $(INSTALL_DIR)/lib/
cp icons/*.svg $(INSTALL_DIR)/icons/
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 --extra-source=icons -o dist .
clean:
rm -f $(SCHEMA_DIR)/gschemas.compiled
rm -rf dist
+132 -1
View File
@@ -1 +1,132 @@
# 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 *or animated GIF* 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. Automatic rotation is
automatically suspended while a live wallpaper is actually on screen
(and resumes afterward), since changing the static wallpaper would
otherwise repaint over the video.
- **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) — the `good` set is also what provides
animated GIF decoding. 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, source folders to pick
a video/GIF from, 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
icons/ Bundled symbolic icons for the preferences window
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).
+88
View File
@@ -0,0 +1,88 @@
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, {
// Rotation changes the static picture-uri GSettings key, which
// makes the shell repaint its own background actor on top of
// the live wallpaper's. Suspend rotation while a live
// wallpaper is actually on screen, independent of any
// user-initiated pause.
onActiveChanged: active => {
if (active)
this._rotationManager.suspend();
else
this._rotationManager.unsuspend();
},
});
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;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="6.2" fill="none" stroke="#2e3436" stroke-width="1.3"/>
<circle cx="8" cy="4.9" r="0.95" fill="#2e3436"/>
<path d="M 8 7 L 8 11.4" fill="none" stroke="#2e3436" stroke-width="1.4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" stroke="#2e3436" stroke-width="1.3" stroke-linecap="round">
<line x1="3.5" y1="2" x2="3.5" y2="14"/>
<line x1="8" y1="2" x2="8" y2="14"/>
<line x1="12.5" y1="2" x2="12.5" y2="14"/>
</g>
<circle cx="3.5" cy="5.5" r="1.7" fill="#2e3436"/>
<circle cx="8" cy="10.5" r="1.7" fill="#2e3436"/>
<circle cx="12.5" cy="7" r="1.7" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 465 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect x="1.5" y="3" width="13" height="10" rx="1.8" fill="none" stroke="#2e3436" stroke-width="1.3"/>
<path d="M 6.6 5.8 L 6.6 10.2 L 10.4 8 Z" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 256 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M 8 1.3 L 13.5 3.4 V 7.6 C 13.5 11.2 11.2 13.6 8 14.7 C 4.8 13.6 2.5 11.2 2.5 7.6 V 3.4 Z"
fill="none" stroke="#2e3436" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M 5.6 8.1 L 7.3 9.8 L 10.6 6.2" fill="none" stroke="#2e3436" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M 3.11 6.22 A 5.2 5.2 0 0 1 13.12 7.10" fill="none" stroke="#2e3436" stroke-width="1.4" stroke-linecap="round"/>
<path d="M 12.89 9.78 A 5.2 5.2 0 0 1 2.88 8.90" fill="none" stroke="#2e3436" stroke-width="1.4" stroke-linecap="round"/>
<path d="M 13.52,9.36 11.38,7.00 14.73,6.41 Z" fill="#2e3436"/>
<path d="M 2.48,6.64 4.62,9.00 1.27,9.59 Z" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

+52
View File
@@ -0,0 +1,52 @@
/**
* Loads the GObject-Introspection bindings the live wallpaper feature needs
* (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames)
* and initializes GStreamer. Used by liveWallpaper.js, which only ever runs
* inside the gnome-shell process itself.
*
* Cogl is Mutter's *private* library: its typelib is only reachable from
* gnome-shell's own process (which gets a private search path), never from
* an ordinary GTK application. Do NOT reuse this for a diagnostic check in
* prefs.js — that runs in a separate, plain GTK4 process where importing
* Cogl will *always* fail regardless of whether live wallpapers actually
* work, producing a false "not found" report. Use
* checkGstreamerBaseAvailable() there instead.
*/
export async function loadGstreamerModules() {
const [{default: Gst}, , {default: Cogl}] = await Promise.all([
import('gi://Gst?version=1.0'),
import('gi://GstApp?version=1.0'),
import('gi://Cogl'),
]);
if (!Gst.is_initialized())
Gst.init(null);
return {Gst, Cogl};
}
/**
* Lighter check for prefs.js: confirms the system-wide GStreamer packages
* (Gst core + the "app" plugin providing GstApp) are installed, without
* touching Cogl. This can't fully confirm live wallpapers will work (that
* also needs Cogl, only checkable from inside gnome-shell itself), but a
* failure here is a genuine, actionable problem, unlike a Cogl probe.
*/
export async function checkGstreamerBaseAvailable() {
const [{default: Gst}] = await Promise.all([
import('gi://Gst?version=1.0'),
import('gi://GstApp?version=1.0'),
]);
if (!Gst.is_initialized())
Gst.init(null);
}
export const GSTREAMER_INSTALL_HINT =
'Install GStreamer’s "base" and "good" plugin sets (with their GObject-Introspection data), ' +
'which provide playback and GIF decoding:\n' +
'• Arch: sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav\n' +
'• Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good ' +
'gir1.2-gst-plugins-base-1.0\n' +
'• Fedora: sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gobject-introspection\n' +
'Then restart GNOME Shell (log out and back in on Wayland).';
+76
View File
@@ -0,0 +1,76 @@
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}`));
});
nextItem.setSensitive(!settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED));
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}`, () => {
const enabled = settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED);
liveToggle.setToggleState(enabled);
nextItem.setSensitive(!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);
});
}
});
+531
View File
@@ -0,0 +1,531 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Clutter from 'gi://Clutter';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js';
import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js';
/**
* Ways to get a decoded frame onto an actor, tried in order and "pinned"
* once one works. `new Clutter.Image()` failed with "is not a constructor"
* in real-world testing (Clutter.Image is apparently no longer directly
* constructible from GJS in current Mutter); St.ImageContent — the same
* mechanism gnome-shell's own code uses for uploading raw pixel buffers —
* is tried first, with the legacy Clutter.Image/set_data path kept only as
* a fallback for older shells.
*
* St.ImageContent.set_bytes() takes a Cogl.Context as its first argument
* (see js/ui/screenshot.js upstream) — omitting it is what produced
* "At least 6 arguments required, but only 5 passed".
*/
const FRAME_IMAGE_STRATEGIES = [
{
name: 'St.ImageContent',
create: (width, height) => St.ImageContent.new_with_preferred_size(width, height),
upload: (image, Cogl, data, width, height) =>
image.set_bytes(
global.stage.context.get_backend().get_cogl_context(),
GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4),
},
{
name: 'Clutter.Image',
create: () => new Clutter.Image(),
upload: (image, Cogl, data, width, height) =>
image.set_data(data, Cogl.PixelFormat.RGBA_8888, width, height, width * 4),
},
];
/**
* Plays a video or animated GIF file as a looping animated background.
* `playbin` typefinds the source by content rather than extension, so GIFs
* are decoded through GStreamer's own GIF element (from the "good" plugin
* set) and handled exactly like any other video stream below this point.
*
* 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, {onActiveChanged} = {}) {
this._settings = settings;
this._logger = logger;
this._onActiveChanged = onActiveChanged ?? (() => {});
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;
this._frameCount = 0;
this._pollCount = 0;
this._pollTimeoutId = 0;
this._noFrameWatchdogId = 0;
this._imageStrategyIndex = 0;
this._frameErrorCount = 0;
}
get isAvailable() {
return this._available;
}
get isActive() {
return this._active;
}
_setActive(active) {
if (this._active === active)
return;
this._active = active;
this._onActiveChanged(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 {
const {Gst, Cogl} = await loadGstreamerModules();
this._Gst = Gst;
this._Cogl = Cogl;
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}).\n${GSTREAMER_INSTALL_HINT}`);
}
}
_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();
}
/**
* Builds videoconvert ! videoscale ! capsfilter(RGBA) ! appsink using
* explicit element creation, linking, and a ghost pad, rather than
* Gst.parse_bin_from_description()'s gst-launch mini-language. Both
* are meant to be equivalent, but a user's testing showed the
* string-parsed version's appsink never fired 'new-sample' even once
* inside gnome-shell's process while an equivalent standalone
* gst-launch-1.0 pipeline worked fine — this removes the string
* parsing (and its automatic ghost-pad detection) as a variable, and
* surfaces link() failures explicitly instead of failing silently.
*/
_buildSinkBin() {
const Gst = this._Gst;
const videoconvert = Gst.ElementFactory.make('videoconvert', 'benthicbloom-convert');
const videoscale = Gst.ElementFactory.make('videoscale', 'benthicbloom-scale');
const capsfilter = Gst.ElementFactory.make('capsfilter', 'benthicbloom-capsfilter');
const appsink = Gst.ElementFactory.make('appsink', 'benthicbloom-appsink');
if (!videoconvert || !videoscale || !capsfilter || !appsink) {
throw new Error(
'Failed to create one or more GStreamer elements ' +
'(videoconvert/videoscale/capsfilter/appsink) — a required plugin is likely missing');
}
capsfilter.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA'));
// emit-signals is deliberately left off: appsink's 'new-sample' fires
// from GStreamer's streaming thread, not gnome-shell's main thread,
// and a cross-thread call into the JS engine can be silently dropped
// rather than invoked. We poll with try_pull_sample() from a main
// thread GLib timer instead, which is safe to call from any thread.
appsink.set_property('max-buffers', 2);
appsink.set_property('drop', true);
appsink.set_property('sync', true);
const sinkBin = new Gst.Bin({name: 'benthicbloom-sinkbin'});
sinkBin.add(videoconvert);
sinkBin.add(videoscale);
sinkBin.add(capsfilter);
sinkBin.add(appsink);
if (!videoconvert.link(videoscale))
throw new Error('Failed to link videoconvert -> videoscale');
if (!videoscale.link(capsfilter))
throw new Error('Failed to link videoscale -> capsfilter');
if (!capsfilter.link(appsink))
throw new Error('Failed to link capsfilter -> appsink');
const sinkPad = videoconvert.get_static_pad('sink');
const ghostPad = Gst.GhostPad.new('sink', sinkPad);
if (!ghostPad)
throw new Error('Failed to create ghost pad for live wallpaper sink bin');
ghostPad.set_active(true);
sinkBin.add_pad(ghostPad);
this._appsink = appsink;
return sinkBin;
}
_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 = this._buildSinkBin();
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();
const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING);
this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`);
this._frameCount = 0;
this._pollCount = 0;
this._imageStrategyIndex = 0;
this._frameErrorCount = 0;
// ~30fps polling of the appsink from the main thread. See the
// comment on appsink's properties above for why this replaces
// a 'new-sample' signal handler.
this._pollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33, () => this._pollForSample());
this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => {
this._noFrameWatchdogId = 0;
if (this._active && this._frameCount === 0) {
this._logger.warn(
`Live wallpaper: no frames received 4s after starting playback ` +
`(polled appsink ${this._pollCount} times). If that count is 0, the poll timer ` +
'itself never ran; if it is nonzero, try_pull_sample() keeps returning nothing ' +
'— check for a "Failed to poll live wallpaper frame" error above.');
}
return GLib.SOURCE_REMOVE;
});
this._setActive(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();
}
}
_pollForSample() {
if (!this._appsink)
return GLib.SOURCE_REMOVE;
const Gst = this._Gst;
this._pollCount++;
if (this._pollCount === 1)
this._logger.debug('Live wallpaper: appsink polling started');
try {
const sample = this._appsink.try_pull_sample(0);
if (!sample)
return GLib.SOURCE_CONTINUE;
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) {
this._logger.debug('Live wallpaper: buffer.map() failed');
return GLib.SOURCE_CONTINUE;
}
try {
this._updateFrame(mapInfo.data, width, height);
this._frameCount++;
if (this._frameCount === 1)
this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`);
else if (this._frameCount % 120 === 0)
this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`);
} finally {
buffer.unmap(mapInfo);
}
} catch (e) {
// Rate-limited: this runs at ~30fps, so logging every failure
// would flood the journal (and this loop keeps retrying every
// frame, e.g. while cycling through FRAME_IMAGE_STRATEGIES).
this._frameErrorCount++;
if (this._frameErrorCount === 1 || this._frameErrorCount % 300 === 0) {
this._logger.error(
e, `Failed to poll live wallpaper frame (${this._frameErrorCount} failures so far)`);
}
}
return GLib.SOURCE_CONTINUE;
}
_updateFrame(data, width, height) {
if (!this._actor)
return;
const needsNewImage = !this._image || this._videoWidth !== width || this._videoHeight !== height;
while (this._imageStrategyIndex < FRAME_IMAGE_STRATEGIES.length) {
const strategy = FRAME_IMAGE_STRATEGIES[this._imageStrategyIndex];
try {
if (needsNewImage) {
this._image = strategy.create(width, height);
this._videoWidth = width;
this._videoHeight = height;
}
strategy.upload(this._image, this._Cogl, data, width, height);
this._actor.set_content(this._image);
return;
} catch (e) {
this._logger.warn(
`Live wallpaper: frame image strategy "${strategy.name}" failed ` +
`(${e.message ?? e}), trying the next one`);
this._imageStrategyIndex++;
this._image = null;
}
}
throw new Error('All live wallpaper frame image strategies failed');
}
_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, debug] = message.parse_error();
this._logger.error(error, `Live wallpaper playback error (${debug ?? 'no debug info'})`);
this._stop();
break;
}
case Gst.MessageType.WARNING: {
const [warning, debug] = message.parse_warning();
this._logger.warn(`Live wallpaper GStreamer warning: ${warning.message} (${debug ?? 'no debug info'})`);
break;
}
case Gst.MessageType.STATE_CHANGED:
if (message.src === this._playbin) {
const [, newState] = message.parse_state_changed();
this._logger.debug(`Live wallpaper pipeline state changed to ${this._stateName(newState)}`);
}
break;
}
}
_stateName(state) {
const Gst = this._Gst;
return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state);
}
_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._noFrameWatchdogId) {
GLib.source_remove(this._noFrameWatchdogId);
this._noFrameWatchdogId = 0;
}
if (this._pollTimeoutId) {
GLib.source_remove(this._pollTimeoutId);
this._pollTimeoutId = 0;
}
if (this._playbin) {
const Gst = this._Gst;
const bus = this._playbin.get_bus();
if (this._busWatchId) {
bus.disconnect(this._busWatchId);
this._busWatchId = 0;
}
bus.remove_signal_watch();
this._playbin.set_state(Gst.State.NULL);
// Block briefly for the (normally fast) transition to actually
// finish before dropping our reference — otherwise the element
// can get disposed mid-transition, which GStreamer logs as
// "Trying to dispose element ..., but it is in PLAYING instead
// of the NULL state".
this._playbin.get_state(200 * Gst.MSECOND);
this._playbin = null;
this._appsink = null;
}
this._actor?.destroy();
this._actor = null;
this._image = null;
this._videoWidth = 0;
this._videoHeight = 0;
this._setActive(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;
}
}
}
+245
View File
@@ -0,0 +1,245 @@
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._suspended = 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._suspended || !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();
}
/**
* Distinct from user-initiated pause(): called while a live wallpaper
* is actually covering the desktop, so rotation doesn't keep changing
* a static image nobody can see (which also churns the shell's own
* background actor on top of the live wallpaper's). Resuming restores
* whatever the user's own pause() state was, rather than forcing
* rotation back on.
*/
suspend() {
this._suspended = true;
this._clearTimer();
}
unsuspend() {
this._suspended = false;
this._restartTimer();
}
async next({forced = false} = {}) {
if (this._suspended) {
this._logger.debug('Rotation suspended while live wallpaper is active');
return;
}
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._suspended || 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 = [];
}
}
+39
View File
@@ -0,0 +1,39 @@
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_FOLDERS: 'live-wallpaper-folders',
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',
]);
export const LIVE_WALLPAPER_EXTENSIONS = Object.freeze([
'.gif', '.mp4', '.webm', '.mkv', '.mov', '.avi', '.m4v', '.ogv',
]);
+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]];
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import {IMAGE_EXTENSIONS, LIVE_WALLPAPER_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 hasExtension(name, extensions) {
const lower = name.toLowerCase();
return extensions.some(ext => lower.endsWith(ext));
}
/** Non-recursively lists files with one of `extensions` directly inside a single folder. */
async function listFilesInFolder(folderPath, extensions) {
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 (!hasExtension(info.get_name(), extensions))
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 files with one of `extensions` found across several folders. */
async function listFilesInFolders(folderPaths, extensions) {
const lists = await Promise.all(
folderPaths.map(folder => listFilesInFolder(folder, extensions).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;
}
/** Non-recursively lists image files directly inside a single folder. */
export async function listImagesInFolder(folderPath) {
return listFilesInFolder(folderPath, IMAGE_EXTENSIONS);
}
/** Merges and de-duplicates images found across several folders. */
export async function listImagesInFolders(folderPaths) {
return listFilesInFolders(folderPaths, IMAGE_EXTENSIONS);
}
/** Merges and de-duplicates live-wallpaper-capable videos/GIFs found across several folders. */
export async function listMediaFilesInFolders(folderPaths) {
return listFilesInFolders(folderPaths, LIVE_WALLPAPER_EXTENSIONS);
}
+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"
}
+457
View File
@@ -0,0 +1,457 @@
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';
import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js';
import {listMediaFilesInFolders} from './lib/wallpaperSource.js';
export default class BenthicBloomPreferences extends ExtensionPreferences {
async fillPreferencesWindow(window) {
const settings = this.getSettings();
const gstreamerError = await checkGstreamerBaseAvailable().then(() => null, e => e.message ?? String(e));
// Bundled rather than relying on the system icon theme having these
// exact names — a missing symbolic icon otherwise renders as a
// blank/broken-image placeholder in the page switcher.
Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`);
window.set_default_size(640, 720);
window.add(this._buildGeneralPage(settings));
window.add(this._buildRotationPage(settings));
window.add(this._buildLiveWallpaperPage(settings, gstreamerError));
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: 'bb-general-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);
const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet'));
foldersGroup.add(folderList);
const addButton = new Gtk.Button({
label: _('Add Folder…'),
halign: Gtk.Align.START,
margin_top: 6,
css_classes: ['flat'],
});
addButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet')));
foldersGroup.add(addButton);
return page;
}
// --- Shared folder-list widgets (used by the General and Live Wallpaper pages) --
_buildFolderListBox(settings, key, emptyText, onChange) {
const listBox = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']});
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
return listBox;
}
_refreshFolderListBox(listBox, settings, key, emptyText, onChange) {
let child = listBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
listBox.remove(child);
child = next;
}
const folders = settings.get_strv(key);
if (folders.length === 0) {
listBox.append(new Adw.ActionRow({title: emptyText}));
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(key);
settings.set_strv(key, current.filter(f => f !== folder));
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
onChange?.();
});
row.add_suffix(removeButton);
listBox.append(row);
}
}
_pickFolderForList(settings, key, listBox, emptyText, onChange) {
const dialog = new Gtk.FileDialog({title: _('Select Folder')});
dialog.select_folder(listBox.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(key);
if (!current.includes(path)) {
settings.set_strv(key, [...current, path]);
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
onChange?.();
}
} catch (e) {
// Dialog was dismissed; nothing to do.
}
});
}
// --- Rotation page -------------------------------------------------
_buildRotationPage(settings) {
const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-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, gstreamerError) {
const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-symbolic'});
if (gstreamerError) {
const warningGroup = new Adw.PreferencesGroup();
const warningRow = new Adw.ActionRow({
title: _('GStreamer Not Found'),
subtitle: `${_('Live wallpapers will stay disabled until this is fixed:')} ${gstreamerError}\n\n${GSTREAMER_INSTALL_HINT}`,
css_classes: ['warning'],
});
warningRow.subtitle_lines = 0;
warningGroup.add(warningRow);
page.add(warningGroup);
}
const group = new Adw.PreferencesGroup({
title: _('Video Wallpaper'),
description: _(
'Play a looping video or animated GIF as your desktop background instead of a static image. ' +
'Requires GStreamer (with its "good" and "base" plugin sets, which provide GIF decoding) ' +
'to be installed on your system. Note: this page can only detect GStreamer being ' +
'completely missing — the rendering path it also needs is private to gnome-shell and can’t ' +
'be checked from here, so the absence of a warning below isn’t a full guarantee.'),
});
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 / GIF 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 or GIF')});
const mediaFilter = new Gtk.FileFilter({name: _('Videos and animated GIFs')});
mediaFilter.add_mime_type('video/*');
mediaFilter.add_mime_type('image/gif');
mediaFilter.add_pattern('*.gif');
const allFilter = new Gtk.FileFilter({name: _('All files')});
allFilter.add_pattern('*');
const filterList = new Gio.ListStore({item_type: Gtk.FileFilter});
filterList.append(mediaFilter);
filterList.append(allFilter);
dialog.filters = filterList;
dialog.default_filter = mediaFilter;
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;
this._refreshLiveMediaList(settings, fileRow);
} catch (e) {
// Dialog was dismissed; nothing to do.
}
});
});
fileRow.add_suffix(chooseButton);
group.add(fileRow);
const muteRow = this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_MUTED,
_('Mute Audio'), _('Play video wallpapers without sound'));
group.add(muteRow);
const rateRow = 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));
group.add(rateRow);
const foldersGroup = new Adw.PreferencesGroup({
title: _('Wallpaper Folders'),
description: _('Videos and animated GIFs found directly inside these folders can be picked below'),
});
page.add(foldersGroup);
const mediaGroup = new Adw.PreferencesGroup({title: _('Available Media')});
page.add(mediaGroup);
this._liveMediaList = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']});
mediaGroup.add(this._liveMediaList);
this._liveMediaGeneration = 0;
const refreshMedia = () => this._refreshLiveMediaList(settings, fileRow);
const liveFolderList = this._buildFolderListBox(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia);
foldersGroup.add(liveFolderList);
const addFolderButton = new Gtk.Button({
label: _('Add Folder…'),
halign: Gtk.Align.START,
margin_top: 6,
css_classes: ['flat'],
});
addFolderButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia));
foldersGroup.add(addFolderButton);
refreshMedia();
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')));
// The enable switch itself stays usable so the setting can be
// prepared ahead of time, but everything that only matters once
// GStreamer is actually driving playback is greyed out.
if (gstreamerError) {
fileRow.sensitive = false;
muteRow.sensitive = false;
rateRow.sensitive = false;
foldersGroup.sensitive = false;
mediaGroup.sensitive = false;
powerGroup.sensitive = false;
}
return page;
}
/** Re-scans the configured live wallpaper folders and repopulates the "Available Media" list. */
_refreshLiveMediaList(settings, fileRow) {
const listBox = this._liveMediaList;
const generation = ++this._liveMediaGeneration;
let child = listBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
listBox.remove(child);
child = next;
}
const folders = settings.get_strv(SettingsKey.LIVE_WALLPAPER_FOLDERS);
if (folders.length === 0) {
listBox.append(new Adw.ActionRow({title: _('Add a folder above to browse its videos and GIFs')}));
return;
}
listBox.append(new Adw.ActionRow({title: _('Scanning…')}));
listMediaFilesInFolders(folders).then(paths => {
if (generation !== this._liveMediaGeneration)
return; // A folder changed again before this scan finished; a newer one is in flight.
let c = listBox.get_first_child();
while (c) {
const next = c.get_next_sibling();
listBox.remove(c);
c = next;
}
if (paths.length === 0) {
listBox.append(new Adw.ActionRow({title: _('No videos or GIFs found in those folders')}));
return;
}
const currentPath = settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH);
for (const path of paths) {
const row = new Adw.ActionRow({
title: Gio.File.new_for_path(path).get_basename(),
subtitle: path,
activatable: true,
});
if (path === currentPath)
row.add_suffix(new Gtk.Image({icon_name: 'object-select-symbolic'}));
row.connect('activated', () => {
settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path);
fileRow.subtitle = path;
this._refreshLiveMediaList(settings, fileRow);
});
listBox.append(row);
}
}).catch(() => {});
}
// --- OLED protection page --------------------------------------------
_buildOledPage(settings) {
const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'bb-oled-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: 'bb-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,170 @@
<?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 or animated GIF 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 media file</summary>
<description>Path to the video or animated GIF file used as the live wallpaper.</description>
</key>
<key name="live-wallpaper-folders" type="as">
<default>[]</default>
<summary>Live wallpaper source folders</summary>
<description>List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from.</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;
}