diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 12:38:02 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 12:38:02 +0200 |
| commit | a0e54437fe03b36903faa8b600b6ba903cb15392 (patch) | |
| tree | 049de98522cf6bac18c548edee8b4d56b7ed24c0 /extension | |
| parent | 8e64e5eef0173b3f7586404650151acc684e57d7 (diff) | |
Add GNOME Shell status indicator
Diffstat (limited to 'extension')
| -rw-r--r-- | extension/extension.js | 193 | ||||
| -rw-r--r-- | extension/metadata.json | 7 | ||||
| -rw-r--r-- | extension/stylesheet.css | 11 |
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; +} |
