Implement BenthicBloom GNOME 50 wallpaper extension

Adds a complete GNOME Shell 50 extension with folder-based wallpaper
auto-rotation (shuffle/sequential, crossfade transitions, lock screen
sync), GStreamer-backed live/video wallpapers rendered onto the shell's
own Clutter stage, and OLED burn-in protection (pixel shifting, idle
dimming, forced periodic rotation). Includes a top-bar quick-access
indicator, a libadwaita preferences window, GSettings schema, and a
Makefile for install/packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8
This commit is contained in:
Claude
2026-08-04 00:05:58 +00:00
parent 068c00c0ac
commit c0ec4a59d5
16 changed files with 1834 additions and 1 deletions
+318
View File
@@ -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;
}
}