diff --git a/data/gnome-shell-icons.gresource.xml b/data/gnome-shell-icons.gresource.xml
index f28a37d2b09d90dffd923f49de7a90d37f256f8a..e25908c4bd84f17bb9c064a9da37292633cd43ed 100644
--- a/data/gnome-shell-icons.gresource.xml
+++ b/data/gnome-shell-icons.gresource.xml
@@ -6,6 +6,7 @@
scalable/actions/carousel-arrow-previous-symbolic.svg
scalable/actions/cog-wheel-symbolic.svg
scalable/actions/dark-mode-symbolic.svg
+ scalable/actions/flashlight-symbolic.svg
scalable/actions/group-collapse-symbolic.svg
scalable/actions/notification-expand-symbolic.svg
scalable/actions/ornament-check-symbolic.svg
diff --git a/data/icons/scalable/actions/flashlight-symbolic.svg b/data/icons/scalable/actions/flashlight-symbolic.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2976cf107266c147d65d492f1c6e009eaec72c1c
--- /dev/null
+++ b/data/icons/scalable/actions/flashlight-symbolic.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml
index 3c096d7bc912887b3029010d4d56f8b088358b1b..8a4b0da63cd89357b13aefd89b161ca4f9c52346 100644
--- a/js/js-resources.gresource.xml
+++ b/js/js-resources.gresource.xml
@@ -148,6 +148,7 @@
ui/status/darkMode.js
ui/status/doNotDisturb.js
ui/status/dwellClick.js
+ ui/status/flashlight.js
ui/status/keyboard.js
ui/status/location.js
ui/status/network.js
diff --git a/js/ui/panel.js b/js/ui/panel.js
index 6e9f12211f69e38ae9addad6ba87c76414f457a5..5e4cd2596b61f7b0407ca157ced599a2e2c31274 100644
--- a/js/ui/panel.js
+++ b/js/ui/panel.js
@@ -34,6 +34,7 @@ import * as ThunderboltStatus from './status/thunderbolt.js';
import * as AutoRotateStatus from './status/autoRotate.js';
import * as BackgroundAppsStatus from './status/backgroundApps.js';
import * as DoNotDisturbStatus from './status/doNotDisturb.js';
+import * as FlashlightStatus from './status/flashlight.js';
import {DateMenuButton} from './dateMenu.js';
import {ATIndicator} from './status/accessibility.js';
@@ -548,6 +549,7 @@ class QuickSettings extends PanelMenu.Button {
this._doNotDisturb = new DoNotDisturbStatus.Indicator();
this._unsafeMode = new UnsafeModeIndicator();
this._backgroundApps = new BackgroundAppsStatus.Indicator();
+ this._flashlight = new FlashlightStatus.Indicator();
// add privacy-related indicators before any external indicators
let pos = 0;
@@ -600,6 +602,7 @@ class QuickSettings extends PanelMenu.Button {
this._addItemsBefore(this._rfkill.quickSettingsItems, sibling);
this._addItemsBefore(this._autoRotate.quickSettingsItems, sibling);
this._addItemsBefore(this._doNotDisturb.quickSettingsItems, sibling);
+ this._addItemsBefore(this._flashlight.quickSettingsItems, sibling);
this._addItemsBefore(this._unsafeMode.quickSettingsItems, sibling);
// append background apps
diff --git a/js/ui/status/flashlight.js b/js/ui/status/flashlight.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d1972b7eaa23c0756fc63130e8a4e0ce069bf54
--- /dev/null
+++ b/js/ui/status/flashlight.js
@@ -0,0 +1,166 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+import GLib from 'gi://GLib';
+import GObject from 'gi://GObject';
+import GUdev from 'gi://GUdev';
+
+import * as PopupMenu from '../popupMenu.js';
+import {Slider} from '../slider.js';
+import {QuickMenuToggle, SystemIndicator} from '../quickSettings.js';
+import * as LoginManager from '../../misc/loginManager.js';
+
+const _AUTO_TURN_OFF_FLASHLIGHT_TIMEOUT = 5 * 60;
+
+const SliderItem = GObject.registerClass({
+ Properties: {
+ 'value': GObject.ParamSpec.double(
+ 'value', '', '',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 0),
+ },
+}, class SliderItem extends PopupMenu.PopupBaseMenuItem {
+ constructor() {
+ super({
+ activate: false,
+ });
+
+ this._slider = new Slider(0);
+
+ this._sliderChangedId = this._slider.connect('notify::value',
+ () => this.notify('value'));
+ this._slider.accessible_name = _('Flashlight Brightness');
+
+ this.add_child(this._slider);
+ }
+
+ get value() {
+ return this._slider.value;
+ }
+
+ set value(value) {
+ value = Math.max(Math.min(value, 1), 0);
+
+ if (this.value === value)
+ return;
+
+ this._slider.block_signal_handler(this._sliderChangedId);
+ this._slider.value = value;
+ this._slider.unblock_signal_handler(this._sliderChangedId);
+
+ this.notify('value');
+ }
+});
+
+const FlashlightToggle = GObject.registerClass(
+class FlashlightToggle extends QuickMenuToggle {
+ _init() {
+ super._init({
+ title: _('Flashlight'),
+ iconName: 'flashlight-symbolic',
+ });
+
+ this._autoFlashlightOffTimeoutId = 0;
+
+ this._torches = [];
+ this._currentTorch = null;
+
+ this._loginSession = null;
+ this._getLoginSession().catch(logError);
+
+ this.connect('clicked', () => {
+ this._brightnessSlider.value = this.checked ? 0.0 : 1.0;
+ });
+
+ this._brightnessSlider = new SliderItem();
+
+ this._brightnessSlider.connect('notify::value', () => this._syncTorchState().catch(logError));
+
+ this.menu.addMenuItem(this._brightnessSlider);
+
+ this._udevClient = GUdev.Client.new(['leds']);
+
+ this._udevClient.connect('uevent', () => this._syncTorchList());
+ this._syncTorchList();
+ }
+
+ async _getLoginSession() {
+ this._loginSession = await LoginManager.getLoginManager().getCurrentSessionProxy();
+ }
+
+ _syncTorchList() {
+ this._torches = [];
+ this._currentTorch = null;
+
+ try {
+ const udevEnumerator = GUdev.Enumerator.new(this._udevClient);
+
+ udevEnumerator.add_match_subsystem('leds');
+ udevEnumerator.add_match_name('*:torch');
+ udevEnumerator.add_match_name('*:flash');
+
+ this._torches = udevEnumerator.execute();
+ if (this._torches.length > 0)
+ this._currentTorch = this._torches[0];
+ } catch (error) {
+ logError(error);
+ }
+
+ let anyFlashlightOn = this._torches.some(torch => {
+ return torch.get_sysfs_attr_as_int('brightness') > 0;
+ });
+
+ if (this._currentTorch !== null) {
+ const maxBrightness = this._currentTorch.get_sysfs_attr_as_int('max_brightness');
+ const brightness = this._currentTorch.get_sysfs_attr_as_int('brightness');
+ if (Math.floor(this._brightnessSlider.value * maxBrightness) !== brightness)
+ this._brightnessSlider.value = brightness / maxBrightness;
+ }
+
+ this._updateFlashlightTimeout(anyFlashlightOn);
+
+ this.checked = anyFlashlightOn;
+ this.menuEnabled = this._torches.length > 0;
+ this.visible = this._torches.length > 0;
+ }
+
+ async _syncTorchState() {
+ await Promise.all(this._torches.map(torch => {
+ const deviceName = torch.get_name();
+ const maxBrightness = torch.get_sysfs_attr_as_int('max_brightness');
+ let newBrightness = 0;
+
+ if (torch === this._currentTorch)
+ newBrightness = Math.floor(this._brightnessSlider.value * maxBrightness);
+
+ return this._loginSession?.SetBrightnessAsync('leds', deviceName, newBrightness);
+ }));
+ this._syncTorchList();
+ }
+
+ _updateFlashlightTimeout(anyFlashlightOn) {
+ if (this._autoFlashlightOffTimeoutId !== 0) {
+ GLib.source_remove(this._autoFlashlightOffTimeoutId);
+ this._autoFlashlightOffTimeoutId = 0;
+ }
+
+ if (!anyFlashlightOn)
+ return;
+
+ this._autoFlashlightOffTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, _AUTO_TURN_OFF_FLASHLIGHT_TIMEOUT, () => {
+ this._brightnessSlider.value = 0;
+
+ this._autoFlashlightOffTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+});
+
+export const Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new FlashlightToggle());
+ }
+});
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 7df17181f4e3ce4bf13a26532c24d47abcf05a20..6ea72605a9d28cf4303ecc96b8a6cb7de4cb5bb5 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -69,6 +69,7 @@ js/ui/status/bluetooth.js
js/ui/status/brightness.js
js/ui/status/darkMode.js
js/ui/status/dwellClick.js
+js/ui/status/flashlight.js
js/ui/status/keyboard.js
js/ui/status/location.js
js/ui/status/network.js