// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Io import Quickshell.Widgets import Quickshell.Services.Pipewire import QtQuick import ".." import "Logic.js" as Logic // The Sony headphones: battery, noise control tiles and ambient level // through sonyctl, the headphones' own volume, power off and refresh. // Battery is BlueZ's and volume PipeWire's; only the mode needs the Sony // protocol. Glyphs are Material Design codepoints checked against the // Inconsolata Nerd Font cmap (AGENTS.md). Column { id: panel required property var device // the connected BluetoothDevice property bool open: false signal close() readonly property string sonyctl: Qt.resolvedUrl("sonyctl").toString().replace(/^file:\/\//, "") property string mode: "" // "nc", "ambient", "off"; "" until read property int level: 10 property bool voice: false property bool busy: false property string error: "" // The headphones' sink rather than the default one, so the slider is // theirs even while another output is selected. readonly property PwNode sink: { const prefix = "bluez_output." + (device?.address ?? "").replace(/:/g, "_"); return Pipewire.nodes.values.find(n => n.name.startsWith(prefix)) ?? null; } readonly property var audio: sink?.audio ?? null PwObjectTracker { objects: panel.sink ? [panel.sink] : [] } readonly property real battery: device?.batteryAvailable ? device.battery : -1 function glyph(cp) { return String.fromCodePoint(cp); } // Read on every open: the headphones' own button changes the mode too, // and nothing pushes it here. onOpenChanged: if (open) run(["status"]) // One run at a time: each opens its own RFCOMM link, and a second one // while the first is up is refused by the headphones. A fresh Process per // run (a running one ignores another start, AGENTS.md). function run(args) { if (busy || !device) return; busy = true; op.createObject(panel, { command: [sonyctl, device.address].concat(args) }).running = true; } Component { id: op Process { id: proc stdout: StdioCollector { onStreamFinished: { let r; try { r = JSON.parse(text); } catch (e) { r = { ok: false, error: "sonyctl: " + (text.trim() || "no output") }; } panel.error = r.ok ? "" : r.error; if (r.ok && r.mode) { panel.mode = r.mode; panel.level = r.level; panel.voice = r.voice; } panel.busy = false; proc.destroy(); } } } } // The ambient level is sent once the slider or wheel settles: each set // is a ~2s round trip, and a send per wheel notch would queue up. property int pendingLevel: -1 readonly property int shownLevel: pendingLevel > 0 ? pendingLevel : level Timer { id: levelSend interval: 500 onTriggered: { const v = panel.pendingLevel; if (panel.busy) { restart(); return; } panel.pendingLevel = -1; if (v > 0 && v !== panel.level) { panel.level = v; panel.run(["ambient", String(v)]); } } } spacing: 14 // --- header ---------------------------------------------------------- Item { width: panel.width height: 26 Text { id: headGlyph anchors.verticalCenter: parent.verticalCenter text: panel.glyph(0xF02CB) // md-headphones font { family: Theme.iconFamily; pixelSize: 22 } color: Theme.accent } Rectangle { id: headSep anchors { left: headGlyph.right; leftMargin: 10; verticalCenter: parent.verticalCenter } width: 1 height: 18 color: Qt.alpha(Theme.text, 0.15) } Text { anchors { left: headSep.right; leftMargin: 10; right: closeGlyph.left; rightMargin: 8 verticalCenter: parent.verticalCenter } elide: Text.ElideRight text: (panel.device?.deviceName || panel.device?.name || "Headphones").toUpperCase() font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; letterSpacing: 2 } color: Theme.text } Text { id: closeGlyph anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: panel.glyph(0xF0156) // md-close font { family: Theme.iconFamily; pixelSize: 18 } color: closeHit.containsMouse ? Theme.text : Theme.subtext MouseArea { id: closeHit anchors { fill: parent; margins: -4 } hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: panel.close() } } } // --- battery --------------------------------------------------------- Row { visible: panel.battery >= 0 width: panel.width spacing: 10 Text { id: batGlyph anchors.verticalCenter: parent.verticalCenter // md-battery_10 .. md-battery_90 are consecutive, md-battery is full. text: panel.glyph(panel.battery >= 0.95 ? 0xF0079 : 0xF007A + Math.max(0, Math.min(8, Math.round(panel.battery * 10) - 1))) font { family: Theme.iconFamily; pixelSize: 20 } color: batBar.tint } Rectangle { id: batBar readonly property color tint: panel.battery < 0.2 ? Theme.red : Theme.accent anchors.verticalCenter: parent.verticalCenter width: parent.width - batGlyph.width - batPct.width - parent.spacing * 2 height: 8 radius: 4 color: Theme.surface Rectangle { height: parent.height radius: parent.radius width: parent.width * Math.max(0, panel.battery) color: batBar.tint } } Text { id: batPct anchors.verticalCenter: parent.verticalCenter width: 44 horizontalAlignment: Text.AlignRight text: Math.round(panel.battery * 100) + "%" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } color: batBar.tint } } // --- noise control --------------------------------------------------- Text { text: "NOISE CONTROL" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5; bold: true; letterSpacing: 1.5 } color: Theme.accent } Row { id: tiles spacing: 8 Repeater { model: [ { id: "nc", label: "ANC", cp: 0xF147D }, // md-waveform { id: "ambient", label: "Ambient", cp: 0xF05CB }, // md-account_voice { id: "off", label: "Off", cp: 0xF073A } // md-cancel ] delegate: Rectangle { id: tile required property var modelData readonly property bool current: panel.mode === modelData.id width: (panel.width - tiles.spacing * 2) / 3 height: 84 radius: 12 color: current ? Qt.alpha(Theme.accent, 0.14) : tileHit.containsMouse ? Qt.alpha(Theme.surface, 0.8) : Qt.alpha(Theme.surface, 0.4) border.width: current ? 1.5 : 1 border.color: current ? Theme.accent : Qt.alpha(Theme.text, 0.1) opacity: panel.busy && !current ? 0.6 : 1 Column { anchors.centerIn: parent spacing: 6 Text { anchors.horizontalCenter: parent.horizontalCenter text: panel.glyph(tile.modelData.cp) font { family: Theme.iconFamily; pixelSize: 26 } color: tile.current ? Theme.accent : Theme.text } Text { anchors.horizontalCenter: parent.horizontalCenter text: tile.modelData.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: tile.current ? Theme.text : Theme.subtext } Rectangle { anchors.horizontalCenter: parent.horizontalCenter width: 6 height: 6 radius: 3 color: tile.current ? Theme.accent : Theme.overlay } } MouseArea { id: tileHit anchors.fill: parent hoverEnabled: true enabled: !panel.busy && !tile.current cursorShape: Qt.PointingHandCursor onClicked: panel.run([tile.modelData.id]) } } } } // --- sliders --------------------------------------------------------- // Ambient level 1-20, while in ambient mode. Wheel or drag, sent once it // settles (levelSend above). Slider { visible: panel.mode === "ambient" cp: 0xF07C5 // md-ear_hearing value: panel.shownLevel / 20 label: String(panel.shownLevel) onMoved: v => { panel.pendingLevel = Math.max(1, Math.min(20, Math.round(v * 20))); levelSend.restart(); } onStepped: d => { panel.pendingLevel = Math.max(1, Math.min(20, panel.shownLevel + d)); levelSend.restart(); } } // Focus on voice, an ambient-only flag: the headphones keep it in the // other modes but it does nothing there. Item { visible: panel.mode === "ambient" width: panel.width height: voiceSwitch.height Text { id: voiceGlyph anchors.verticalCenter: parent.verticalCenter text: panel.glyph(0xF1308) // md-account_tie_voice font { family: Theme.iconFamily; pixelSize: 20 } color: Theme.subtext } Text { anchors { left: voiceGlyph.right; leftMargin: 10; verticalCenter: parent.verticalCenter } text: "Focus on voice" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Switch { id: voiceSwitch anchors { right: parent.right; verticalCenter: parent.verticalCenter } enabled: !panel.busy checked: panel.voice onToggled: on => panel.run(["voice", on ? "on" : "off"]) } } // The headphones' volume; the icon mutes, the wheel steps 5%. Slider { enabled: panel.audio !== null icon: Logic.volumeIcon(panel.audio?.volume ?? 0, panel.audio?.muted ?? false) value: panel.audio?.volume ?? 0 muted: panel.audio?.muted ?? false label: !panel.audio ? "—" : panel.audio.muted ? "muted" : Math.round(panel.audio.volume * 100) + "%" onIconClicked: panel.audio.muted = !panel.audio.muted onMoved: v => panel.audio.volume = v onStepped: d => panel.audio.volume = Math.max(0, Math.min(1, panel.audio.volume + d * 0.05)) } Text { visible: panel.error !== "" width: panel.width wrapMode: Text.Wrap text: panel.error font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.red } // --- footer ---------------------------------------------------------- Rectangle { width: panel.width height: 1 color: Qt.alpha(Theme.text, 0.1) } Row { id: footer width: panel.width Repeater { model: [ { label: "Power off", cp: 0xF0425, tint: "red", args: ["poweroff"] }, // md-power { label: "Refresh", cp: 0xF0450, tint: "text", args: ["status"] } // md-refresh ] delegate: Item { id: action required property var modelData required property int index width: footer.width / 2 height: 36 Rectangle { id: actionBox anchors { left: parent.left; leftMargin: action.index ? 12 : 0; verticalCenter: parent.verticalCenter } width: 36 height: 36 radius: 10 color: actionHit.containsMouse ? Qt.alpha(Theme[action.modelData.tint], 0.18) : Qt.alpha(Theme.surface, 0.6) border.width: 1 border.color: Qt.alpha(Theme[action.modelData.tint], 0.25) Text { id: actionGlyph anchors.centerIn: parent text: panel.glyph(action.modelData.cp) font { family: Theme.iconFamily; pixelSize: 20 } color: Theme[action.modelData.tint] // The refresh glyph turns while sonyctl runs. RotationAnimation on rotation { running: panel.busy && action.index === 1 from: 0; to: 360; duration: 900; loops: Animation.Infinite onStopped: actionGlyph.rotation = 0 } } } Text { anchors { left: actionBox.right; leftMargin: 10; verticalCenter: parent.verticalCenter } text: action.modelData.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Rectangle { visible: action.index === 1 anchors { left: parent.left; verticalCenter: parent.verticalCenter } width: 1 height: 22 color: Qt.alpha(Theme.text, 0.15) } MouseArea { id: actionHit anchors.fill: parent hoverEnabled: true enabled: !panel.busy cursorShape: Qt.PointingHandCursor onClicked: panel.run(action.modelData.args) } } } } // One slider row: a glyph (cp) or a named icon (icon), a track, a label. // moved(v) gives 0..1 from a click or drag; stepped(+1|-1) one wheel notch. component Slider: Row { id: row property int cp: 0 property string icon: "" property real value: 0 property bool muted: false property string label: "" signal moved(real v) signal stepped(int d) signal iconClicked() width: panel.width spacing: 10 Item { anchors.verticalCenter: parent.verticalCenter width: 20 height: 20 Text { visible: row.icon === "" anchors.centerIn: parent text: panel.glyph(row.cp) font { family: Theme.iconFamily; pixelSize: 20 } color: Theme.subtext } IconImage { visible: row.icon !== "" anchors.centerIn: parent implicitSize: 18 source: row.icon !== "" ? Quickshell.iconPath(row.icon, true) : "" } MouseArea { anchors.fill: parent enabled: row.enabled cursorShape: Qt.PointingHandCursor onClicked: row.iconClicked() } } Rectangle { anchors.verticalCenter: parent.verticalCenter width: row.width - 20 - 44 - row.spacing * 2 height: 8 radius: 4 color: Theme.surface Rectangle { height: parent.height radius: parent.radius width: parent.width * Math.max(0, Math.min(1, row.value)) color: row.muted ? Theme.red : Theme.accent opacity: row.muted ? 0.5 : 1 } // Taller than the track so it is easy to hit. A MouseArea for the // wheel, not a WheelHandler (AGENTS.md); one step per notch (120), // since a touchpad sends many small deltas. MouseArea { anchors { fill: parent; topMargin: -8; bottomMargin: -8 } enabled: row.enabled preventStealing: true cursorShape: Qt.PointingHandCursor property real acc: 0 function at(x) { return Math.max(0, Math.min(1, x / width)); } onPressed: mouse => row.moved(at(mouse.x)) onPositionChanged: mouse => { if (pressed) row.moved(at(mouse.x)); } onWheel: wheel => { acc += wheel.angleDelta.y; while (Math.abs(acc) >= 120) { row.stepped(acc > 0 ? 1 : -1); acc -= acc > 0 ? 120 : -120; } wheel.accepted = true; } } } Text { anchors.verticalCenter: parent.verticalCenter width: 44 horizontalAlignment: Text.AlignRight text: row.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: row.muted ? Theme.red : Theme.subtext } } }