diff options
Diffstat (limited to 'desktop/island/HeadphonesPanel.qml')
| -rw-r--r-- | desktop/island/HeadphonesPanel.qml | 508 |
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 + } + } +} |
