aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/HeadphonesPanel.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/island/HeadphonesPanel.qml')
-rw-r--r--desktop/island/HeadphonesPanel.qml508
1 files changed, 508 insertions, 0 deletions
diff --git a/desktop/island/HeadphonesPanel.qml b/desktop/island/HeadphonesPanel.qml
new file mode 100644
index 0000000..86e3ae4
--- /dev/null
+++ b/desktop/island/HeadphonesPanel.qml
@@ -0,0 +1,508 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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
+ // The click flips checked itself, which breaks the binding;
+ // restore it so the switch follows what the headphones report.
+ onToggled: {
+ panel.run(["voice", checked ? "on" : "off"]);
+ checked = Qt.binding(() => panel.voice);
+ }
+ }
+ }
+
+ // 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
+ }
+ }
+}