Implement BenthicBloom GNOME 50 wallpaper extension
Adds a complete GNOME Shell 50 extension with folder-based wallpaper auto-rotation (shuffle/sequential, crossfade transitions, lock screen sync), GStreamer-backed live/video wallpapers rendered onto the shell's own Clutter stage, and OLED burn-in protection (pixel shifting, idle dimming, forced periodic rotation). Includes a top-bar quick-access indicator, a libadwaita preferences window, GSettings schema, and a Makefile for install/packaging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
schemas/gschemas.compiled
|
||||||
|
dist/
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/
|
||||||
|
cp lib/*.js $(INSTALL_DIR)/lib/
|
||||||
|
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 -o dist .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(SCHEMA_DIR)/gschemas.compiled
|
||||||
|
rm -rf dist
|
||||||
@@ -1 +1,128 @@
|
|||||||
# 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 file 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.
|
||||||
|
- **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). 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, 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
|
||||||
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
@@ -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]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export default class BenthicBloomPreferences extends ExtensionPreferences {
|
||||||
|
fillPreferencesWindow(window) {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
window.set_default_size(640, 720);
|
||||||
|
window.add(this._buildGeneralPage(settings));
|
||||||
|
window.add(this._buildRotationPage(settings));
|
||||||
|
window.add(this._buildLiveWallpaperPage(settings));
|
||||||
|
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: 'preferences-system-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);
|
||||||
|
|
||||||
|
this._folderList = new Gtk.ListBox({
|
||||||
|
selection_mode: Gtk.SelectionMode.NONE,
|
||||||
|
css_classes: ['boxed-list'],
|
||||||
|
});
|
||||||
|
foldersGroup.add(this._folderList);
|
||||||
|
this._refreshFolderList(settings);
|
||||||
|
|
||||||
|
const addButton = new Gtk.Button({
|
||||||
|
label: _('Add Folder…'),
|
||||||
|
halign: Gtk.Align.START,
|
||||||
|
margin_top: 6,
|
||||||
|
css_classes: ['flat'],
|
||||||
|
});
|
||||||
|
addButton.connect('clicked', () => this._pickFolder(settings));
|
||||||
|
foldersGroup.add(addButton);
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshFolderList(settings) {
|
||||||
|
let child = this._folderList.get_first_child();
|
||||||
|
while (child) {
|
||||||
|
const next = child.get_next_sibling();
|
||||||
|
this._folderList.remove(child);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS);
|
||||||
|
if (folders.length === 0) {
|
||||||
|
this._folderList.append(new Adw.ActionRow({title: _('No folders added yet')}));
|
||||||
|
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(SettingsKey.WALLPAPER_FOLDERS);
|
||||||
|
settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, current.filter(f => f !== folder));
|
||||||
|
this._refreshFolderList(settings);
|
||||||
|
});
|
||||||
|
row.add_suffix(removeButton);
|
||||||
|
this._folderList.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickFolder(settings) {
|
||||||
|
const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Folder')});
|
||||||
|
dialog.select_folder(this._folderList.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(SettingsKey.WALLPAPER_FOLDERS);
|
||||||
|
if (!current.includes(path)) {
|
||||||
|
settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, [...current, path]);
|
||||||
|
this._refreshFolderList(settings);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Dialog was dismissed; nothing to do.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rotation page -------------------------------------------------
|
||||||
|
|
||||||
|
_buildRotationPage(settings) {
|
||||||
|
const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'media-playlist-shuffle-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) {
|
||||||
|
const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'video-x-generic-symbolic'});
|
||||||
|
|
||||||
|
const group = new Adw.PreferencesGroup({
|
||||||
|
title: _('Video Wallpaper'),
|
||||||
|
description: _(
|
||||||
|
'Play a looping video as your desktop background instead of a static image. ' +
|
||||||
|
'Requires GStreamer (with its "good" and "base" plugin sets) to be installed on your system.'),
|
||||||
|
});
|
||||||
|
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 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')});
|
||||||
|
const filter = new Gtk.FileFilter();
|
||||||
|
filter.add_mime_type('video/*');
|
||||||
|
const filterList = new Gio.ListStore({item_type: Gtk.FileFilter});
|
||||||
|
filterList.append(filter);
|
||||||
|
dialog.filters = filterList;
|
||||||
|
|
||||||
|
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;
|
||||||
|
} catch (e) {
|
||||||
|
// Dialog was dismissed; nothing to do.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fileRow.add_suffix(chooseButton);
|
||||||
|
group.add(fileRow);
|
||||||
|
|
||||||
|
group.add(this._switchRow(
|
||||||
|
settings, SettingsKey.LIVE_WALLPAPER_MUTED,
|
||||||
|
_('Mute Audio'), _('Play video wallpapers without sound')));
|
||||||
|
group.add(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)));
|
||||||
|
|
||||||
|
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')));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OLED protection page --------------------------------------------
|
||||||
|
|
||||||
|
_buildOledPage(settings) {
|
||||||
|
const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'weather-clear-night-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: 'help-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,164 @@
|
|||||||
|
<?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 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 video file</summary>
|
||||||
|
<description>Path to the video file used as the live wallpaper.</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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* BenthicBloom panel indicator */
|
||||||
|
.benthicbloom-indicator-icon {
|
||||||
|
-st-icon-style: symbolic;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user