summaryrefslogtreecommitdiff
path: root/extension
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 12:38:02 +0200
committerYuval Adam <_@yuv.al>2026-07-24 12:38:02 +0200
commita0e54437fe03b36903faa8b600b6ba903cb15392 (patch)
tree049de98522cf6bac18c548edee8b4d56b7ed24c0 /extension
parent8e64e5eef0173b3f7586404650151acc684e57d7 (diff)
Add GNOME Shell status indicator
Diffstat (limited to 'extension')
-rw-r--r--extension/extension.js193
-rw-r--r--extension/metadata.json7
-rw-r--r--extension/stylesheet.css11
3 files changed, 211 insertions, 0 deletions
diff --git a/extension/extension.js b/extension/extension.js
new file mode 100644
index 0000000..00ce517
--- /dev/null
+++ b/extension/extension.js
@@ -0,0 +1,193 @@
+import Gio from 'gi://Gio';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+
+const BUS_NAME = 'org.parley.Transcription1';
+const OBJECT_PATH = '/org/parley/Transcription1';
+
+const Interface = `<node>
+ <interface name="org.parley.Transcription1">
+ <method name="StartRecording"/>
+ <method name="StopAndTranscribe"/>
+ <method name="Toggle"/>
+ <method name="Cancel"/>
+ <method name="CopyLastTranscript"/>
+ <method name="InsertLastTranscript"/>
+ <method name="OpenTranscriptFolder"/>
+ <property name="State" type="s" access="read"/>
+ <property name="LastTranscript" type="s" access="read"/>
+ <property name="LastTranscriptPath" type="s" access="read"/>
+ <property name="LastError" type="s" access="read"/>
+ <property name="InsertionMode" type="s" access="read"/>
+ <property name="AutoInsert" type="b" access="read"/>
+ <signal name="StateChanged"><arg name="state" type="s"/></signal>
+ <signal name="TranscriptReady"><arg name="text" type="s"/><arg name="path" type="s"/></signal>
+ <signal name="Error"><arg name="code" type="s"/><arg name="message" type="s"/></signal>
+ <signal name="InsertionFinished"><arg name="backend" type="s"/><arg name="success" type="b"/><arg name="message" type="s"/></signal>
+ </interface>
+</node>`;
+
+const ParleyProxy = Gio.DBusProxy.makeProxyWrapper(Interface);
+
+const PRESENTATION = {
+ idle: ['audio-input-microphone-symbolic', 'Parley is ready', 'system-status-icon'],
+ recording: ['media-record-symbolic', 'Recording', 'system-status-icon parley-recording'],
+ transcribing: ['emblem-synchronizing-symbolic', 'Transcribing locally', 'system-status-icon parley-processing'],
+ inserting: ['edit-paste-symbolic', 'Inserting transcript', 'system-status-icon parley-processing'],
+ error: ['dialog-error-symbolic', 'Parley error', 'system-status-icon parley-error'],
+};
+
+const Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.Button {
+ _init(extension) {
+ super._init(0.0, 'Parley Dictation');
+ this._extension = extension;
+ this._destroyed = false;
+ this._proxy = null;
+ this._dbusSignals = [];
+ this._gobjectSignals = [];
+
+ this._icon = new St.Icon({
+ icon_name: 'audio-input-microphone-symbolic',
+ style_class: 'system-status-icon',
+ });
+ this.add_child(this._icon);
+
+ this._statusItem = new PopupMenu.PopupMenuItem('Connecting to Parley…', {
+ reactive: false,
+ });
+ this.menu.addMenuItem(this._statusItem);
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._toggleItem = new PopupMenu.PopupMenuItem('Start recording');
+ this._toggleItem.connect('activate', () => this._call('Toggle'));
+ this.menu.addMenuItem(this._toggleItem);
+
+ this._cancelItem = new PopupMenu.PopupMenuItem('Cancel');
+ this._cancelItem.connect('activate', () => this._call('Cancel'));
+ this.menu.addMenuItem(this._cancelItem);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._copyItem = new PopupMenu.PopupMenuItem('Copy last transcript');
+ this._copyItem.connect('activate', () => this._call('CopyLastTranscript'));
+ this.menu.addMenuItem(this._copyItem);
+
+ this._openItem = new PopupMenu.PopupMenuItem('Open transcript folder');
+ this._openItem.connect('activate', () => this._call('OpenTranscriptFolder'));
+ this.menu.addMenuItem(this._openItem);
+
+ this._setUnavailable('Connecting to Parley…');
+ this._connectProxy();
+ }
+
+ _connectProxy() {
+ this._proxy = new ParleyProxy(
+ Gio.DBus.session,
+ BUS_NAME,
+ OBJECT_PATH,
+ (proxy, error) => {
+ if (this._destroyed)
+ return;
+ if (error) {
+ console.error(`Parley D-Bus connection failed: ${error.message}`);
+ this._setUnavailable('Parley service unavailable');
+ return;
+ }
+ this._dbusSignals.push(
+ proxy.connectSignal('StateChanged', () => this._refresh()),
+ proxy.connectSignal('TranscriptReady', () => this._refresh()),
+ proxy.connectSignal('Error', (_proxy, _sender, [code, message]) => {
+ console.error(`Parley ${code}: ${message}`);
+ this._refresh();
+ })
+ );
+ this._gobjectSignals.push(
+ proxy.connect('g-properties-changed', () => this._refresh()),
+ proxy.connect('notify::g-name-owner', () => this._refresh())
+ );
+ this._refresh();
+ }
+ );
+ }
+
+ _refresh() {
+ if (!this._proxy?.g_name_owner) {
+ this._setUnavailable('Parley service unavailable');
+ return;
+ }
+
+ const state = this._proxy.State ?? 'idle';
+ const [iconName, defaultStatus, styleClass] =
+ PRESENTATION[state] ?? PRESENTATION.error;
+ let status = defaultStatus;
+ if (state === 'error' && this._proxy.LastError)
+ status = `Error: ${this._proxy.LastError}`;
+
+ this._icon.icon_name = iconName;
+ this._icon.set_style_class_name(styleClass);
+ this.accessible_name = status;
+ this._statusItem.label.text = status;
+
+ this._toggleItem.label.text = state === 'recording'
+ ? 'Stop and transcribe'
+ : 'Start recording';
+ this._toggleItem.setSensitive(['idle', 'recording', 'error'].includes(state));
+ this._cancelItem.setSensitive(['recording', 'transcribing'].includes(state));
+ this._copyItem.setSensitive(Boolean(this._proxy.LastTranscriptPath));
+ this._openItem.setSensitive(true);
+ }
+
+ _setUnavailable(message) {
+ this._icon.icon_name = 'microphone-disabled-symbolic';
+ this._icon.set_style_class_name('system-status-icon parley-error');
+ this.accessible_name = message;
+ this._statusItem.label.text = message;
+ this._toggleItem?.setSensitive(false);
+ this._cancelItem?.setSensitive(false);
+ this._copyItem?.setSensitive(false);
+ this._openItem?.setSensitive(false);
+ }
+
+ _call(method) {
+ if (!this._proxy?.g_name_owner)
+ return;
+ this._proxy[`${method}Remote`]((_result, error) => {
+ if (error) {
+ console.error(`Parley ${method} failed: ${error.message}`);
+ Main.notifyError('Parley', error.message);
+ }
+ });
+ }
+
+ destroy() {
+ this._destroyed = true;
+ if (this._proxy) {
+ for (const signal of this._dbusSignals)
+ this._proxy.disconnectSignal(signal);
+ for (const signal of this._gobjectSignals)
+ this._proxy.disconnect(signal);
+ }
+ this._dbusSignals = [];
+ this._gobjectSignals = [];
+ this._proxy = null;
+ super.destroy();
+ }
+});
+
+export default class ParleyExtension extends Extension {
+ enable() {
+ this._indicator = new Indicator(this);
+ Main.panel.addToStatusArea(this.uuid, this._indicator);
+ }
+
+ disable() {
+ this._indicator?.destroy();
+ this._indicator = null;
+ }
+}
diff --git a/extension/metadata.json b/extension/metadata.json
new file mode 100644
index 0000000..520bb25
--- /dev/null
+++ b/extension/metadata.json
@@ -0,0 +1,7 @@
+{
+ "uuid": "parley@org.parley",
+ "name": "Parley Dictation",
+ "description": "Control Parley and show local dictation status in the GNOME top bar",
+ "shell-version": ["50"],
+ "version": 1
+}
diff --git a/extension/stylesheet.css b/extension/stylesheet.css
new file mode 100644
index 0000000..a0c9855
--- /dev/null
+++ b/extension/stylesheet.css
@@ -0,0 +1,11 @@
+.parley-recording {
+ color: #f66151;
+}
+
+.parley-processing {
+ color: #f9f06b;
+}
+
+.parley-error {
+ color: #ed333b;
+}