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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user