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