From faa3a7257f9996b10bd569fafd2de48d97963320 Mon Sep 17 00:00:00 2001
From: Quinta0 <0pietroquintavalle0@gmail.com>
Date: Thu, 6 Aug 2026 19:58:50 +0200
Subject: [PATCH] more oled features and system freeze fixes
---
icons/bb-general-symbolic.svg | 8 +-
icons/bb-live-wallpaper-symbolic.svg | 4 +-
icons/bb-rotation-symbolic.svg | 6 +-
lib/liveWallpaper.js | 178 +++++++++++++++++-
lib/oledProtection.js | 31 +++
lib/rotationManager.js | 64 +++++--
lib/settingsKeys.js | 3 +
prefs.js | 116 +++++++++---
....shell.extensions.benthicbloom.gschema.xml | 30 +++
9 files changed, 382 insertions(+), 58 deletions(-)
diff --git a/icons/bb-general-symbolic.svg b/icons/bb-general-symbolic.svg
index 2b157e9..5dd35aa 100644
--- a/icons/bb-general-symbolic.svg
+++ b/icons/bb-general-symbolic.svg
@@ -1,10 +1,10 @@
diff --git a/icons/bb-live-wallpaper-symbolic.svg b/icons/bb-live-wallpaper-symbolic.svg
index 3343d06..64e3ba6 100644
--- a/icons/bb-live-wallpaper-symbolic.svg
+++ b/icons/bb-live-wallpaper-symbolic.svg
@@ -1,4 +1,4 @@
diff --git a/icons/bb-rotation-symbolic.svg b/icons/bb-rotation-symbolic.svg
index 4897dcd..0c1b4c9 100644
--- a/icons/bb-rotation-symbolic.svg
+++ b/icons/bb-rotation-symbolic.svg
@@ -1,6 +1,4 @@
diff --git a/lib/liveWallpaper.js b/lib/liveWallpaper.js
index 36cf6e5..9362915 100644
--- a/lib/liveWallpaper.js
+++ b/lib/liveWallpaper.js
@@ -1,6 +1,7 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Clutter from 'gi://Clutter';
+import Graphene from 'gi://Graphene';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
@@ -89,6 +90,9 @@ export class LiveWallpaperManager {
this._noFrameWatchdogId = 0;
this._imageStrategyIndex = 0;
this._frameErrorCount = 0;
+
+ this._transitionOverlay = null;
+ this._fadeInPending = false;
}
get isAvailable() {
@@ -113,6 +117,7 @@ export class LiveWallpaperManager {
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_DISPLAY_MODE}`, () => this._applyDisplayMode()),
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())
@@ -134,6 +139,7 @@ export class LiveWallpaperManager {
}
this._stop();
+ this._clearTransition();
}
async _loadGstreamer() {
@@ -157,12 +163,27 @@ export class LiveWallpaperManager {
if (shouldRun)
this._active ? this._restart() : this._start();
else if (this._active)
- this._stop();
+ this._stop({animate: true});
}
+ /**
+ * Switching to a different file while already active. If transitions
+ * are on, the outgoing actor (already showing the old file's last
+ * frame) is detached before _stop() would destroy it, so it can be
+ * kept on screen and eased away by _playTransition() after _start()
+ * has the new one playing underneath — a crossfade between the two.
+ */
_restart() {
+ const transitionsOn = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
+ const outgoingActor = transitionsOn ? this._actor : null;
+ if (outgoingActor)
+ this._actor = null;
+
this._stop();
this._start();
+
+ if (outgoingActor)
+ this._playTransition(outgoingActor);
}
/**
@@ -233,11 +254,15 @@ export class LiveWallpaperManager {
try {
this._actor = new Clutter.Actor({
- content_gravity: Clutter.ContentGravity.RESIZE_ASPECT,
reactive: false,
+ pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
});
Main.layoutManager._backgroundGroup.add_child(this._actor);
- this._layoutActor();
+ this._applyDisplayMode();
+
+ this._fadeInPending = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
+ if (this._fadeInPending)
+ this._actor.opacity = 0;
const sinkBin = this._buildSinkBin();
@@ -312,9 +337,17 @@ export class LiveWallpaperManager {
try {
this._updateFrame(mapInfo.data, width, height);
this._frameCount++;
- if (this._frameCount === 1)
+ if (this._frameCount === 1) {
this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`);
- else if (this._frameCount % 120 === 0)
+ if (this._fadeInPending) {
+ this._fadeInPending = false;
+ this._actor.ease({
+ opacity: 255,
+ duration: this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS),
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ } else if (this._frameCount % 120 === 0)
this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`);
} finally {
buffer.unmap(mapInfo);
@@ -346,6 +379,7 @@ export class LiveWallpaperManager {
this._image = strategy.create(width, height);
this._videoWidth = width;
this._videoHeight = height;
+ this._layoutActor();
}
strategy.upload(this._image, this._Cogl, data, width, height);
this._actor.set_content(this._image);
@@ -414,11 +448,61 @@ export class LiveWallpaperManager {
Gst.SeekType.SET, position, Gst.SeekType.NONE, -1);
}
+ /**
+ * Clutter's ContentGravity enum covers fit/stretch/center natively, but
+ * has no "cover" (scale-and-crop) value, so "fill" mode is done by hand:
+ * the actor is overscanned to the smallest size that fully covers the
+ * stage while preserving the video's aspect ratio, centered, and then
+ * clipped back down to the stage bounds.
+ */
+ _applyDisplayMode() {
+ if (!this._actor)
+ return;
+
+ const mode = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE);
+ switch (mode) {
+ case 'stretch':
+ this._actor.content_gravity = Clutter.ContentGravity.RESIZE_FILL;
+ break;
+ case 'center':
+ this._actor.content_gravity = Clutter.ContentGravity.CENTER;
+ break;
+ case 'fill':
+ this._actor.content_gravity = Clutter.ContentGravity.RESIZE_FILL;
+ break;
+ case 'fit':
+ default:
+ this._actor.content_gravity = Clutter.ContentGravity.RESIZE_ASPECT;
+ break;
+ }
+
+ this._layoutActor();
+ }
+
_layoutActor() {
if (!this._actor)
return;
+
+ const stageWidth = global.stage.width;
+ const stageHeight = global.stage.height;
+ const mode = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE);
+
+ if (mode === 'fill' && this._videoWidth > 0 && this._videoHeight > 0) {
+ const scale = Math.max(stageWidth / this._videoWidth, stageHeight / this._videoHeight);
+ const scaledWidth = this._videoWidth * scale;
+ const scaledHeight = this._videoHeight * scale;
+ const offsetX = (stageWidth - scaledWidth) / 2;
+ const offsetY = (stageHeight - scaledHeight) / 2;
+
+ this._actor.set_position(offsetX, offsetY);
+ this._actor.set_size(scaledWidth, scaledHeight);
+ this._actor.set_clip(-offsetX, -offsetY, stageWidth, stageHeight);
+ return;
+ }
+
+ this._actor.remove_clip();
this._actor.set_position(0, 0);
- this._actor.set_size(global.stage.width, global.stage.height);
+ this._actor.set_size(stageWidth, stageHeight);
}
_connectPowerWatches() {
@@ -502,7 +586,15 @@ export class LiveWallpaperManager {
this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING);
}
- _stop() {
+ /**
+ * `animate: true` is for a user-visible stop (toggling the setting off,
+ * or the path being cleared) — the last frame stays on screen and eases
+ * away per the shared Transitions setting, revealing the static
+ * wallpaper beneath. Left off for restarts (which hand the actor to
+ * _playTransition() themselves, see _restart()), errors, and
+ * disable(), where an immediate teardown is what's wanted.
+ */
+ _stop({animate = false} = {}) {
this._disconnectPowerWatches();
if (this._noFrameWatchdogId) {
@@ -536,13 +628,81 @@ export class LiveWallpaperManager {
this._appsink = null;
}
- this._actor?.destroy();
- this._actor = null;
+ if (animate && this._actor && this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED)) {
+ this._playTransition(this._actor);
+ this._actor = null;
+ } else {
+ this._actor?.destroy();
+ this._actor = null;
+ }
this._image = null;
this._videoWidth = 0;
this._videoHeight = 0;
+ this._fadeInPending = false;
this._setActive(false);
this._paused = false;
}
+
+ /**
+ * Eases `overlay` — an actor that was just detached from active duty,
+ * still showing its last frame — off screen per the Transitions
+ * setting (fade/slide/zoom/random, same styles and duration as static
+ * rotation), then destroys it. Raising it above everything else first
+ * means this works whether it's revealing a freshly started live
+ * wallpaper actor underneath (switch) or the static desktop background
+ * (stop) — both are already in place by the time this runs.
+ */
+ _playTransition(overlay) {
+ this._transitionOverlay?.destroy();
+ this._transitionOverlay = overlay;
+
+ Main.layoutManager._backgroundGroup.set_child_above_sibling(overlay, null);
+
+ const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS);
+ let style = this._settings.get_string(SettingsKey.TRANSITION_STYLE);
+ if (style === 'random')
+ style = ['fade', 'slide', 'zoom'][Math.floor(Math.random() * 3)];
+
+ const onComplete = () => {
+ if (this._transitionOverlay === overlay)
+ this._transitionOverlay = null;
+ overlay.destroy();
+ };
+
+ switch (style) {
+ case 'slide':
+ overlay.ease({
+ x: overlay.x - overlay.width,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ onComplete,
+ });
+ break;
+ case 'zoom':
+ overlay.ease({
+ scale_x: 1.15,
+ scale_y: 1.15,
+ opacity: 0,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ onComplete,
+ });
+ break;
+ case 'fade':
+ default:
+ overlay.ease({
+ opacity: 0,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete,
+ });
+ break;
+ }
+ }
+
+ _clearTransition() {
+ this._transitionOverlay?.destroy();
+ this._transitionOverlay = null;
+ }
}
diff --git a/lib/oledProtection.js b/lib/oledProtection.js
index 34ab4d9..00bd33e 100644
--- a/lib/oledProtection.js
+++ b/lib/oledProtection.js
@@ -1,6 +1,7 @@
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import Clutter from 'gi://Clutter';
+import Graphene from 'gi://Graphene';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js';
@@ -13,6 +14,11 @@ const SHIFT_PATTERN = [
const FORCE_ROTATION_CHECK_SECONDS = 60;
+// Extra margin, in pixels, added on top of the configured shift amount when
+// overscanning background actors — rounding/timing slack so a step never
+// exposes a sliver of empty stage at the trailing edge.
+const OVERSCAN_SAFETY_PX = 2;
+
/**
* Reduces OLED burn-in risk through three independent techniques:
* - pixel shifting: nudges the background actors a few px on a slow cycle
@@ -105,6 +111,7 @@ export class OledProtectionManager {
const [dx, dy] = SHIFT_PATTERN[this._shiftStep];
for (const actor of this._backgroundActors()) {
+ this._ensureOverscan(actor, amount);
actor.ease({
translation_x: dx * amount,
translation_y: dy * amount,
@@ -114,12 +121,36 @@ export class OledProtectionManager {
}
}
+ /**
+ * Background actors (the real desktop background, and our own live
+ * wallpaper actor in most display modes) are sized to exactly cover
+ * the stage with no slack, so nudging one by a few pixels for burn-in
+ * protection would drag its edge inward and expose bare stage on the
+ * side it moved away from. Scaling the actor up slightly around its
+ * center — by just enough margin to cover the configured shift amount
+ * — keeps every edge overscanned past the stage bounds no matter which
+ * step of the shift pattern is active.
+ */
+ _ensureOverscan(actor, amount) {
+ const width = actor.width;
+ const height = actor.height;
+ if (width <= 0 || height <= 0)
+ return;
+
+ const margin = amount + OVERSCAN_SAFETY_PX;
+ actor.pivot_point = new Graphene.Point({x: 0.5, y: 0.5});
+ actor.scale_x = 1 + (2 * margin) / width;
+ actor.scale_y = 1 + (2 * margin) / height;
+ }
+
_resetShift() {
this._shiftStep = 0;
for (const actor of this._backgroundActors()) {
actor.ease({
translation_x: 0,
translation_y: 0,
+ scale_x: 1,
+ scale_y: 1,
duration: 500,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
diff --git a/lib/rotationManager.js b/lib/rotationManager.js
index ec2b2e1..09da5cb 100644
--- a/lib/rotationManager.js
+++ b/lib/rotationManager.js
@@ -2,6 +2,7 @@ import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
+import Graphene from 'gi://Graphene';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js';
@@ -40,6 +41,7 @@ export class RotationManager {
enable() {
this._settingsSignals.push(
this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()),
+ this._settings.connect(`changed::${SettingsKey.WALLPAPER_RESCAN_REQUEST}`, () => 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())
@@ -185,7 +187,7 @@ export class RotationManager {
const uri = GLib.filename_to_uri(path, null);
if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath)
- this._playCrossfade(previousPath);
+ this._playTransition(previousPath);
this._backgroundSettings.set_string('picture-uri', uri);
if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark'))
@@ -200,15 +202,19 @@ export class RotationManager {
}
/**
- * 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.
+ * The real background actor doesn't animate changes on its own, so we
+ * paint the *old* image full-screen on a throwaway overlay right as the
+ * new image is set underneath, then animate the overlay away to reveal
+ * it. Which animation runs is picked per monitor by style.
*/
- _playCrossfade(previousPath) {
+ _playTransition(previousPath) {
this._clearTransitionOverlays();
const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS);
const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22');
+ let style = this._settings.get_string(SettingsKey.TRANSITION_STYLE);
+ if (style === 'random')
+ style = ['fade', 'slide', 'zoom'][Math.floor(Math.random() * 3)];
for (const monitor of Main.layoutManager.monitors) {
const overlay = new St.Widget({
@@ -217,23 +223,49 @@ export class RotationManager {
y: monitor.y,
width: monitor.width,
height: monitor.height,
+ pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
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);
- },
- });
+ const onComplete = () => {
+ overlay.destroy();
+ const idx = this._transitionOverlays.indexOf(overlay);
+ if (idx >= 0)
+ this._transitionOverlays.splice(idx, 1);
+ };
+
+ switch (style) {
+ case 'slide':
+ overlay.ease({
+ x: monitor.x - monitor.width,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ onComplete,
+ });
+ break;
+ case 'zoom':
+ overlay.ease({
+ scale_x: 1.15,
+ scale_y: 1.15,
+ opacity: 0,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ onComplete,
+ });
+ break;
+ case 'fade':
+ default:
+ overlay.ease({
+ opacity: 0,
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete,
+ });
+ break;
+ }
}
}
diff --git a/lib/settingsKeys.js b/lib/settingsKeys.js
index ed7c823..9e567e0 100644
--- a/lib/settingsKeys.js
+++ b/lib/settingsKeys.js
@@ -2,6 +2,7 @@ export const SettingsKey = Object.freeze({
SHOW_INDICATOR: 'show-indicator',
DEBUG_LOGGING: 'debug-logging',
WALLPAPER_FOLDERS: 'wallpaper-folders',
+ WALLPAPER_RESCAN_REQUEST: 'wallpaper-rescan-request',
APPLY_TO_LOCK_SCREEN: 'apply-to-lock-screen',
ROTATION_ENABLED: 'rotation-enabled',
@@ -10,10 +11,12 @@ export const SettingsKey = Object.freeze({
CURRENT_INDEX: 'current-index',
TRANSITION_ENABLED: 'transition-enabled',
TRANSITION_DURATION_MS: 'transition-duration-ms',
+ TRANSITION_STYLE: 'transition-style',
LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled',
LIVE_WALLPAPER_PATH: 'live-wallpaper-path',
LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders',
+ LIVE_WALLPAPER_DISPLAY_MODE: 'live-wallpaper-display-mode',
LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted',
LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate',
LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery',
diff --git a/prefs.js b/prefs.js
index df9576c..0091a91 100644
--- a/prefs.js
+++ b/prefs.js
@@ -9,6 +9,25 @@ import {SettingsKey} from './lib/settingsKeys.js';
import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js';
import {listMediaFilesInFolders} from './lib/wallpaperSource.js';
+const BACKGROUND_SCHEMA = 'org.gnome.desktop.background';
+
+// GNOME's own picture-options values, exposed here so wallpaper fill mode
+// can be controlled without inventing a parallel setting of our own.
+// Built lazily (not at module top level) because gettext can only be
+// called once the extension is registered, which hasn't happened yet
+// while this module is still being evaluated on import.
+function pictureOptions() {
+ return [
+ {value: 'zoom', label: _('Fill (Crop to Zoom)')},
+ {value: 'scaled', label: _('Fit (No Cropping)')},
+ {value: 'stretched', label: _('Stretch')},
+ {value: 'centered', label: _('Centered')},
+ {value: 'wallpaper', label: _('Tiled')},
+ {value: 'spanned', label: _('Spanned Across Monitors')},
+ {value: 'none', label: _('None (Background Color Only)')},
+ ];
+}
+
export default class BenthicBloomPreferences extends ExtensionPreferences {
async fillPreferencesWindow(window) {
const settings = this.getSettings();
@@ -19,8 +38,10 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// blank/broken-image placeholder in the page switcher.
Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`);
+ const backgroundSettings = new Gio.Settings({schema_id: BACKGROUND_SCHEMA});
+
window.set_default_size(640, 720);
- window.add(this._buildGeneralPage(settings));
+ window.add(this._buildGeneralPage(settings, backgroundSettings));
window.add(this._buildRotationPage(settings));
window.add(this._buildLiveWallpaperPage(settings, gstreamerError));
window.add(this._buildOledPage(settings));
@@ -47,7 +68,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// --- General page ------------------------------------------------
- _buildGeneralPage(settings) {
+ _buildGeneralPage(settings, backgroundSettings) {
const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'});
const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')});
@@ -62,6 +83,23 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
settings, SettingsKey.DEBUG_LOGGING,
_('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)')));
+ const displayGroup = new Adw.PreferencesGroup({
+ title: _('Display'),
+ description: _('How the wallpaper image is fitted to your screen, e.g. cropped to fill it'),
+ });
+ page.add(displayGroup);
+ const pictureOptionsList = pictureOptions();
+ const fillModeRow = new Adw.ComboRow({
+ title: _('Fill Mode'),
+ model: new Gtk.StringList({strings: pictureOptionsList.map(o => o.label)}),
+ });
+ const currentOption = backgroundSettings.get_string('picture-options');
+ fillModeRow.selected = Math.max(0, pictureOptionsList.findIndex(o => o.value === currentOption));
+ fillModeRow.connect('notify::selected', () => {
+ backgroundSettings.set_string('picture-options', pictureOptionsList[fillModeRow.selected].value);
+ });
+ displayGroup.add(fillModeRow);
+
const foldersGroup = new Adw.PreferencesGroup({
title: _('Wallpaper Folders'),
description: _('Images found directly inside these folders are used for rotation'),
@@ -71,15 +109,18 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
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'],
- });
+ const buttonRow = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL, spacing: 6, margin_top: 6});
+ foldersGroup.add(buttonRow);
+
+ const addButton = new Gtk.Button({label: _('Add Folder…'), css_classes: ['flat']});
addButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet')));
- foldersGroup.add(addButton);
+ buttonRow.append(addButton);
+
+ const refreshButton = new Gtk.Button({label: _('Refresh'), css_classes: ['flat']});
+ refreshButton.connect('clicked', () => settings.set_uint(
+ SettingsKey.WALLPAPER_RESCAN_REQUEST, settings.get_uint(SettingsKey.WALLPAPER_RESCAN_REQUEST) + 1));
+ buttonRow.append(refreshButton);
return page;
}
@@ -174,7 +215,17 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
page.add(transitionGroup);
transitionGroup.add(this._switchRow(
settings, SettingsKey.TRANSITION_ENABLED,
- _('Crossfade'), _('Smoothly fade between wallpapers instead of switching instantly')));
+ _('Enable Transition'), _('Play an animation between wallpapers instead of switching instantly')));
+
+ const styles = ['fade', 'slide', 'zoom', 'random'];
+ const styleLabels = [_('Fade'), _('Slide'), _('Zoom'), _('Random')];
+ const styleRow = new Adw.ComboRow({title: _('Style'), model: new Gtk.StringList({strings: styleLabels})});
+ styleRow.selected = Math.max(0, styles.indexOf(settings.get_string(SettingsKey.TRANSITION_STYLE)));
+ styleRow.connect('notify::selected', () => {
+ settings.set_string(SettingsKey.TRANSITION_STYLE, styles[styleRow.selected]);
+ });
+ transitionGroup.add(styleRow);
+
transitionGroup.add(this._spinRow(
{title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500},
() => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS),
@@ -251,6 +302,19 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
fileRow.add_suffix(chooseButton);
group.add(fileRow);
+ const liveDisplayModes = ['fit', 'fill', 'stretch', 'center'];
+ const liveDisplayLabels = [_('Fit (Letterboxed)'), _('Fill (Crop to Cover)'), _('Stretch'), _('Center')];
+ const displayModeRow = new Adw.ComboRow({
+ title: _('Display Mode'),
+ model: new Gtk.StringList({strings: liveDisplayLabels}),
+ });
+ displayModeRow.selected = Math.max(
+ 0, liveDisplayModes.indexOf(settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE)));
+ displayModeRow.connect('notify::selected', () => {
+ settings.set_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE, liveDisplayModes[displayModeRow.selected]);
+ });
+ group.add(displayModeRow);
+
const muteRow = this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_MUTED,
_('Mute Audio'), _('Play video wallpapers without sound'));
@@ -283,15 +347,17 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
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'],
- });
+ const liveButtonRow = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL, spacing: 6, margin_top: 6});
+ foldersGroup.add(liveButtonRow);
+
+ const addFolderButton = new Gtk.Button({label: _('Add Folder…'), css_classes: ['flat']});
addFolderButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia));
- foldersGroup.add(addFolderButton);
+ liveButtonRow.append(addFolderButton);
+
+ const refreshMediaButton = new Gtk.Button({label: _('Refresh'), css_classes: ['flat']});
+ refreshMediaButton.connect('clicked', refreshMedia);
+ liveButtonRow.append(refreshMediaButton);
refreshMedia();
@@ -309,6 +375,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// GStreamer is actually driving playback is greyed out.
if (gstreamerError) {
fileRow.sensitive = false;
+ displayModeRow.sensitive = false;
muteRow.sensitive = false;
rateRow.sensitive = false;
foldersGroup.sensitive = false;
@@ -394,9 +461,12 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
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))));
+ {
+ title: _('Shift Interval'), subtitle: _('Minutes between each shift step'),
+ lower: 0.5, upper: 10, step: 0.5, page: 1, digits: 1,
+ },
+ () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS) / 60,
+ value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value * 60))));
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),
@@ -408,9 +478,9 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
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))));
+ {title: _('Idle Delay'), subtitle: _('Minutes of inactivity before dimming'), lower: 1, upper: 60, step: 1, page: 5},
+ () => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS) / 60,
+ value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value * 60))));
dimGroup.add(this._spinRow(
{
title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'),
diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml
index c2f51a0..b68d119 100644
--- a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml
@@ -21,6 +21,12 @@
List of folder paths scanned for wallpaper images.
+
+ 0
+ Wallpaper folder rescan request
+ Internal counter bumped by the preferences UI to ask the running extension to rescan wallpaper folders for new files.
+
+
true
Apply wallpaper to lock screen
@@ -67,6 +73,18 @@
Duration, in milliseconds, of the crossfade animation between wallpapers.
+
+
+
+
+
+
+
+ "fade"
+ Transition style
+ Visual effect used when the wallpaper changes.
+
+
false
Enable live wallpaper
@@ -85,6 +103,18 @@
List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from.
+
+
+
+
+
+
+
+ "fit"
+ Live wallpaper display mode
+ How the video/GIF is fitted to the screen: fit (letterboxed), fill (cropped to cover), stretch (distorted to fill) or center (original size).
+
+
true
Mute live wallpaper audio