import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Io import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import qs.Ui import qs.Commons import "Model.js" as Model Panel { id: root moduleName: "omarchy.audio" ipcTarget: "omarchy.audio" readonly property var sink: Pipewire.defaultAudioSink readonly property var source: Pipewire.defaultAudioSource readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] readonly property var mprisPlayers: Mpris.players ? Mpris.players.values : [] readonly property var mediaService: bar && bar.shell ? bar.shell.firstPartyServiceFor("omarchy.media") : null readonly property var activeMediaPlayer: mediaService ? mediaService.activePlayer : null readonly property var candidateSinks: { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] if (n && n.isSink && !n.isStream) list.push(n) } return list } readonly property var candidateSources: { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] if (n && !n.isSink && !n.isStream && isAudioSource(n)) { var name = n.name || "" if (name === "quickshell") continue list.push(n) } } return list } readonly property var candidateStreams: { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] if (n && n.isStream && isPlaybackStream(n)) list.push(n) } return list } property var sinkAvailability: ({}) property bool sinkAvailabilityLoaded: false // Identify true playback streams without reading node.properties here: // PwNode.properties is invalid until the node is bound, and reading it while // capture streams are appearing (for example, when Voxtype starts recording) // can destabilize Quickshell's Pipewire service. Quickshell versions differ // in how `type` is exposed (media.class, enum name, or numeric enum), but // playback streams consistently accept audio input from clients and publish // `isSink: true`; capture streams publish as stream sources. function isPlaybackStream(node) { return Model.isPlaybackStream(node) } function isAudioSource(node) { return Model.isAudioSource(node) } property var cachedAudioSinks: [] property var cachedAudioSources: [] readonly property var rawAudioSinks: { var list = [] for (var i = 0; i < candidateSinks.length; i++) if (sinkAvailable(candidateSinks[i])) list.push(candidateSinks[i]) if (sink && list.indexOf(sink) < 0) list.unshift(sink) return list } readonly property var rawAudioSources: { var list = candidateSources.slice() if (source && list.indexOf(source) < 0) list.unshift(source) return list } readonly property var audioSinks: rawAudioSinks.length > 0 ? rawAudioSinks : cachedAudioSinks readonly property var audioSources: rawAudioSources.length > 0 ? rawAudioSources : cachedAudioSources readonly property var audioStreams: { var list = [] for (var i = 0; i < candidateStreams.length; i++) if (candidateStreams[i].audio) list.push(candidateStreams[i]) return list } // Feed Repeaters with panel-local snapshots instead of the live PipeWire // model. PipeWire can remove nodes while Quickshell is dispatching the // removal signal; rebuilding a Repeater from that signal path has crashed // in Quickshell's PipeWire service. The snapshot timer lets that mutation // settle first, and closed panels keep their repeaters detached entirely. property var displayAudioSinks: [] property var displayAudioSources: [] property var displayAudioStreams: [] readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0 readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 readonly property bool inputMuted: source && source.audio ? source.audio.muted : false onRawAudioSinksChanged: if (rawAudioSinks.length > 0) cachedAudioSinks = rawAudioSinks onRawAudioSourcesChanged: if (rawAudioSources.length > 0) cachedAudioSources = rawAudioSources // Single cursor model shared by keyboard and mouse. Sections: // "output" — output slider + sink device list // "input" — input slider + source device list // "streams" — per-app playback streams // selectedIndex semantics within a section: // -1 → on the slider row (h/l adjusts volume, m/Enter mute) // 0..N-1 → on the Nth device/stream row // Visuals derive from hasCursor/current via CursorSurface, never // from containsMouse — that's what keeps the highlight unique across // keyboard + mouse like wifi does. property string focusSection: "output" property int selectedIndex: -1 property bool cursorActive: false readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" function sectionCount(section) { if (section === "output") return displayAudioSinks.length if (section === "input") return displayAudioSources.length if (section === "streams") return displayAudioStreams.length return 0 } function sectionVisible(section) { if (section === "output") return true if (section === "input") return displayAudioSources.length > 0 || !!source if (section === "streams") return displayAudioStreams.length > 0 return false } function sectionHasSlider(section) { if (section === "output") return true if (section === "input") return !!source return false // stream rows carry their own sliders inline; not a section-level slider } // Order of visible sections, recomputed reactively so dropping a section // (e.g. no input devices) doesn't leave the cursor pointing at it. readonly property var visibleSections: { var list = [] if (sectionVisible("output")) list.push("output") if (sectionVisible("input")) list.push("input") if (sectionVisible("streams")) list.push("streams") return list } function moveCursor(delta) { var sections = visibleSections if (sections.length === 0) return var sIdx = sections.indexOf(focusSection) if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return } var idx = selectedIndex var max = sectionCount(focusSection) - 1 // last device index var hasSlider = sectionHasSlider(focusSection) var floor = hasSlider ? -1 : 0 // -1 = slider row if (delta > 0) { if (idx < max) { selectedIndex = idx + 1; return } // Fall through to next section. if (sIdx < sections.length - 1) { focusSection = sections[sIdx + 1] selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 } } else { if (idx > floor) { selectedIndex = idx - 1; return } // Escape upward. if (sIdx > 0) { focusSection = sections[sIdx - 1] var prevMax = sectionCount(focusSection) - 1 selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0) } } } function moveSection(delta) { var sections = visibleSections if (sections.length === 0) return var current = sections.indexOf(focusSection) if (current < 0) current = delta > 0 ? -1 : 0 var next = (current + delta + sections.length) % sections.length focusSection = sections[next] selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 cursorActive = true } // Adjust the slider associated with the focused section. Output and // input sliders are real volume controls; on stream rows h/l adjusts // that stream's volume (so keyboard parity with the inline slider). // For device rows (selectedIndex >= 0 in output/input) h/l is a no-op // — the cursor is on a discrete row, not on the slider, and silently // moving the global slider would surprise the user. function adjustVolume(delta) { if (focusSection === "output" && selectedIndex === -1) { setOutputVolume(outputVolume + delta) return } if (focusSection === "input" && selectedIndex === -1) { setInputVolume(inputVolume + delta) return } if (focusSection === "streams" && selectedIndex >= 0 && selectedIndex < displayAudioStreams.length) { var s = displayAudioStreams[selectedIndex] if (s && s.audio) s.audio.volume = Math.max(0, Math.min(1.5, s.audio.volume + delta)) } } // Enter/Space: activate whatever the cursor is on. function activateCursor() { if (focusSection === "output") { if (selectedIndex === -1) { toggleOutputMute(); return } var sink = displayAudioSinks[selectedIndex] if (sink) setDefaultSink(sink) return } if (focusSection === "input") { if (selectedIndex === -1) { toggleInputMute(); return } var src = displayAudioSources[selectedIndex] if (src) setDefaultSource(src) return } if (focusSection === "streams" && selectedIndex >= 0) { var st = displayAudioStreams[selectedIndex] if (st && st.audio) st.audio.muted = !st.audio.muted } } onOpenedChanged: { if (opened) { refreshDisplayAudioModels() focusSection = "output" selectedIndex = -1 // first keyboard cursor reveal starts on the output slider cursorActive = false Qt.callLater(resetScroll) } else { clearDisplayAudioModels() } } // Clamp / repair the cursor whenever any list refreshes underneath us. onAudioSinksChanged: scheduleDisplayAudioModelRefresh() onAudioSourcesChanged: scheduleDisplayAudioModelRefresh() onAudioStreamsChanged: scheduleDisplayAudioModelRefresh() function listSnapshot(list) { return Model.listSnapshot(list) } function refreshDisplayAudioModels() { if (!opened) return displayAudioSinks = listSnapshot(audioSinks) displayAudioSources = listSnapshot(audioSources) displayAudioStreams = listSnapshot(audioStreams) clampCursor() } function scheduleDisplayAudioModelRefresh() { if (!opened) return audioModelRefreshTimer.restart() } function clearDisplayAudioModels() { audioModelRefreshTimer.stop() displayAudioSinks = [] displayAudioSources = [] displayAudioStreams = [] } // Keep the keyboard-focused row inside the visible viewport of the // ScrollView. Each cursor target (slider rows, SinkRow, SourceRow, // StreamRow) calls this when it gains hasCursor. Without it, j/k can // walk the selection off-screen — wifi uses ListView.positionViewAtIndex // for this; we don't have that affordance with a multi-section Column. function resetScroll() { if (!scrollArea) return var flick = scrollArea.contentItem if (flick && flick.contentY !== undefined) flick.contentY = 0 } function ensureCursorVisible(item) { if (!item || !scrollArea) return var flick = scrollArea.contentItem if (!flick || flick.contentY === undefined) return var margin = 6 var maxY = Math.max(0, (flick.contentHeight || 0) - flick.height) if (maxY <= Style.space(24) || (root.focusSection === "output" && root.selectedIndex === -1)) { flick.contentY = 0 return } var pt = item.mapToItem(flick.contentItem || flick, 0, 0) var top = pt.y var bottom = top + (item.height || 0) var viewTop = flick.contentY var viewBottom = viewTop + flick.height if (top < viewTop + margin) flick.contentY = Math.max(0, Math.min(maxY, top - margin)) else if (bottom > viewBottom - margin) flick.contentY = Math.max(0, Math.min(maxY, bottom + margin - flick.height)) } function clampCursor() { var sections = visibleSections if (!sections || !sections.length) return if (sections.indexOf(focusSection) < 0) { focusSection = visibleSections[0] selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 return } var count = sectionCount(focusSection) var hasSlider = sectionHasSlider(focusSection) var floor = hasSlider ? -1 : 0 if (selectedIndex > count - 1) selectedIndex = Math.max(floor, count - 1) if (selectedIndex < floor) selectedIndex = floor } function outputIcon() { // Match the old Waybar pulseaudio glyph set. The Material Design speaker // icons render visually smaller in JetBrainsMono Nerd Font. if (!sink || !sink.audio) return "" if (isHeadphones(sink)) return "󰋋" if (outputMuted) return "" var v = outputVolume if (v >= 0.67) return "" if (v >= 0.34) return "" if (v > 0) return "" return "" } function inputIcon() { if (!source || !source.audio) return "󰍭" return inputMuted ? "󰍭" : "󰍬" } // Playful mood-name for a given output volume. Mirrors the brightness // panel's brightnessName ladder; bands are wide enough that small // tweaks don't rename the room you're in. function outputVolumeName(volume, muted) { return Model.outputVolumeName(volume, muted) } function setOutputVolume(v) { if (!sink || !sink.audio) return sink.audio.volume = Math.max(0, Math.min(1, v)) } function setInputVolume(v) { if (!source || !source.audio) return source.audio.volume = Math.max(0, Math.min(1, v)) } function toggleOutputMute() { if (sink && sink.audio) sink.audio.muted = !sink.audio.muted } function toggleInputMute() { if (source && source.audio) source.audio.muted = !source.audio.muted } function setDefaultSink(node) { if (!node) return Pipewire.preferredDefaultAudioSink = node if (root.bar && node.id !== undefined && node.name) { Quickshell.execDetached([ root.bar.omarchyPath + "/bin/omarchy-audio-output-set-default", String(node.id), String(node.name) ]) } } function setDefaultSource(node) { if (!node) return Pipewire.preferredDefaultAudioSource = node if (root.bar && node.id !== undefined && node.name) { Quickshell.execDetached([ root.bar.omarchyPath + "/bin/omarchy-audio-input-set-default", String(node.id), String(node.name) ]) } } function sinkAvailable(node) { if (!node || !node.name || !sinkAvailabilityLoaded) return true var name = String(node.name) return sinkAvailability[name] !== false } function updateSinkAvailability(raw) { sinkAvailability = Model.parseSinkAvailability(raw) sinkAvailabilityLoaded = true } function friendlyDeviceLabel(text) { return Model.friendlyDeviceLabel(text) } function nodeLabel(node) { return Model.nodeLabel(node) } function nodeProps(node) { return Model.nodeProps(node) } function isHeadphones(node) { return Model.isHeadphones(node) } function sinkGlyph(node) { return Model.sinkGlyph(node) } function sourceGlyph(node) { return Model.sourceGlyph(node) } function friendlyStreamLabel(label) { return Model.friendlyStreamLabel(label) } function streamLabelKey(label) { return Model.streamLabelKey(label) } function streamLabelIsGeneric(label) { return Model.streamLabelIsGeneric(label) } function rawStreamLabel(node) { return Model.rawStreamLabel(node) } function mprisPlayerLabel(player) { return Model.mprisPlayerLabel(player) } function mprisPlayerIsProxy(player) { return Model.mprisPlayerIsProxy(player) } function streamRepresentsMprisPlayer(streamLabel, playerLabel) { return Model.streamRepresentsMprisPlayer(streamLabel, playerLabel) } function mprisLabelsFor(predicate) { return Model.mprisLabelsFor(mprisPlayers, predicate) } function matchingMprisStreamLabel(label) { return Model.matchingMprisStreamLabel(label, mprisPlayers) } function unmatchedMprisStreamLabel(label) { // Spotify exposes its PipeWire stream as "audio-src". For generic stream // names, use the one MPRIS player not already represented by another audio // stream (e.g. Chromium, or ALSA apps like cliamp). return Model.unmatchedMprisStreamLabel(label, mprisPlayers, displayAudioStreams) } function streamLabel(node) { return Model.streamLabel(node, mprisPlayers, displayAudioStreams) } function streamRepresentsPlayer(node, player) { return Model.streamRepresentsPlayer(node, player, mprisPlayers, displayAudioStreams) } implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight PwObjectTracker { objects: root.candidateSinks } PwObjectTracker { objects: root.candidateSources } PwObjectTracker { objects: root.audioStreams } PwNodePeakMonitor { id: inputPeakMonitor node: root.source enabled: root.opened && !!root.source } Process { id: sinkAvailabilityProc command: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-audio-sink-availability" : "omarchy-audio-sink-availability"] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.updateSinkAvailability(text) } } Timer { interval: 5000 running: root.opened repeat: true triggeredOnStart: true onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true } Timer { id: audioModelRefreshTimer interval: 75 repeat: false onTriggered: root.refreshDisplayAudioModels() } WidgetButton { id: button anchors.fill: parent bar: root.bar text: root.outputIcon() fontSize: Style.font.body onPressed: function(b) { if (b === Qt.RightButton) root.toggleOutputMute() else root.toggle() } onWheelMoved: function(delta) { var step = 0.05 root.setOutputVolume(root.outputVolume + (delta > 0 ? step : -step)) } } KeyboardPanel { id: panel anchorItem: button owner: root bar: root.bar open: root.opened focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560)) PanelKeyCatcher { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) else if (dx !== 0) root.adjustVolume(dx * 0.05) } onActivateRequested: if (root.cursorActive) root.activateCursor() onCloseRequested: root.close() onTabRequested: function(direction) { root.moveSection(direction) } onTextKey: function(t) { // 'm' mutes whatever the cursor is on: focused section's slider // for output/input, the focused stream for streams. if (t === "m" || t === "M") { if (!root.cursorActive) return if (root.focusSection === "streams" && root.selectedIndex >= 0 && root.selectedIndex < root.displayAudioStreams.length) { var s = root.displayAudioStreams[root.selectedIndex] if (s && s.audio) s.audio.muted = !s.audio.muted } else if (root.focusSection === "input") { root.toggleInputMute() } else { root.toggleOutputMute() } } } ScrollView { id: scrollArea anchors.fill: parent clip: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded Column { id: panelColumn width: scrollArea.availableWidth spacing: Style.space(14) // ---------- Hero: speaker icon · title/status ---------- Item { id: heroItem width: parent.width implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) Text { id: heroIcon text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.outputMuted ? 0.5 : 1.0 anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.toggleOutputMute() } } Column { id: heroLabels anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) Text { text: "Audio" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title font.bold: true elide: Text.ElideRight width: parent.width } Text { id: heroLabel text: root.outputVolumeName( outputSlider.dragging ? outputSlider.liveValue : root.outputVolume, root.outputMuted ).toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true font.letterSpacing: 1.2 elide: Text.ElideRight width: parent.width } } } // ---- Output devices ---- Column { width: parent.width spacing: Style.space(6) Item { width: parent.width implicitHeight: Math.max(outputHeader.implicitHeight, outputPercent.implicitHeight) PanelSectionHeader { id: outputHeader text: "OUTPUT" foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter } Text { id: outputPercent text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true anchors.right: parent.right anchors.rightMargin: Style.space(6) anchors.verticalCenter: parent.verticalCenter opacity: root.outputMuted ? 0.5 : 1.0 } } CursorSurface { id: outputSliderRow width: parent.width height: outputSlider.implicitHeight + Style.spacing.controlGap hasCursor: root.cursorActive && root.focusSection === "output" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(outputSliderRow) foreground: root.bar.foreground outline: true PanelSlider { id: outputSlider bar: root.bar anchors.fill: parent anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) minimum: 0 maximum: 1 step: 0.05 value: root.outputVolume opacity: root.outputMuted ? 0.5 : 1.0 enabled: !!root.sink onMoved: function(v) { root.setOutputVolume(v) } } HoverHandler { onHoveredChanged: if (hovered) { root.cursorActive = true root.focusSection = "output" root.selectedIndex = -1 } } } Repeater { model: root.displayAudioSinks SinkRow { required property var modelData required property int index width: panelColumn.width node: modelData rowIndex: index } } } // ---- Input ---- Column { width: parent.width spacing: Style.space(6) visible: root.displayAudioSources.length > 0 || !!root.source Item { width: parent.width implicitHeight: Math.max(microphoneHeader.implicitHeight, microphonePercent.implicitHeight) PanelSectionHeader { id: microphoneHeader text: "INPUT" foreground: root.bar.foreground fontFamily: root.bar.fontFamily anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter } Text { id: microphonePercent text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true anchors.right: parent.right anchors.rightMargin: Style.space(6) anchors.verticalCenter: parent.verticalCenter opacity: root.inputMuted ? 0.5 : 1.0 } } CursorSurface { id: inputSliderRow visible: !!root.source width: parent.width height: inputControls.implicitHeight + Style.spacing.controlGap hasCursor: root.cursorActive && root.focusSection === "input" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow) foreground: root.bar.foreground outline: true Column { id: inputControls anchors.fill: parent anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(5) PanelSlider { id: inputSlider bar: root.bar width: parent.width minimum: 0 maximum: 1 step: 0.05 value: root.inputVolume opacity: root.inputMuted ? 0.5 : 1.0 enabled: !!root.source onMoved: function(v) { root.setInputVolume(v) } } Rectangle { width: parent.width height: Math.max(Style.space(5), Style.spacing.xs) color: Util.alpha(root.bar.foreground, 0.18) opacity: root.inputMuted ? 0.35 : 1.0 Rectangle { height: parent.height width: parent.width * Math.max(0, Math.min(1, inputPeakMonitor.peak)) color: root.bar.foreground Behavior on width { NumberAnimation { duration: 70 } } } } } HoverHandler { onHoveredChanged: if (hovered) { root.cursorActive = true root.focusSection = "input" root.selectedIndex = -1 } } } Repeater { model: root.displayAudioSources SourceRow { required property var modelData required property int index width: panelColumn.width node: modelData rowIndex: index } } } // ---- Per-app streams ---- Column { width: parent.width spacing: Style.space(10) visible: root.displayAudioStreams.length > 0 PanelSectionHeader { text: "SOURCES" foreground: root.bar.foreground fontFamily: root.bar.fontFamily } Repeater { model: root.displayAudioStreams StreamRow { required property var modelData required property int index width: panelColumn.width node: modelData rowIndex: index } } } } } } } // ---- Reusable inline components ---- // Output device row — cursor target inside the "output" section. Mouse // hover updates the panel cursor at the root; visuals come entirely // from hasCursor/current via CursorSurface, never from containsMouse. component SinkRow: CursorSurface { id: sinkRow required property var node required property int rowIndex readonly property bool isActive: root.sink && node && root.sink.id === node.id hasCursor: root.cursorActive && root.focusSection === "output" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sinkRow) current: isActive foreground: root.bar.foreground fill: root.hoverFill currentFill: root.selectedFill implicitHeight: sinkInner.implicitHeight + Style.spacing.xl Row { id: sinkInner anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(8) Text { text: root.sinkGlyph(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title width: Style.space(22) horizontalAlignment: Text.AlignHCenter anchors.verticalCenter: parent.verticalCenter } Text { text: root.nodeLabel(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body font.bold: sinkRow.isActive elide: Text.ElideRight width: parent.width - Style.space(22) - Style.space(8) anchors.verticalCenter: parent.verticalCenter } } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onContainsMouseChanged: if (containsMouse) { root.cursorActive = true root.focusSection = "output" root.selectedIndex = sinkRow.rowIndex } onClicked: root.setDefaultSink(sinkRow.node) } } // Input device row — sibling of SinkRow for the "input" section. component SourceRow: CursorSurface { id: sourceRow required property var node required property int rowIndex readonly property bool isActive: root.source && node && root.source.id === node.id hasCursor: root.cursorActive && root.focusSection === "input" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sourceRow) current: isActive foreground: root.bar.foreground fill: root.hoverFill currentFill: root.selectedFill implicitHeight: sourceInner.implicitHeight + Style.spacing.xl Row { id: sourceInner anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(8) Text { text: root.sourceGlyph(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title width: Style.space(22) horizontalAlignment: Text.AlignHCenter anchors.verticalCenter: parent.verticalCenter } Text { text: root.nodeLabel(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body font.bold: sourceRow.isActive elide: Text.ElideRight width: parent.width - Style.space(22) - Style.space(8) anchors.verticalCenter: parent.verticalCenter } } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onContainsMouseChanged: if (containsMouse) { root.cursorActive = true root.focusSection = "input" root.selectedIndex = sourceRow.rowIndex } onClicked: root.setDefaultSource(sourceRow.node) } } // Per-app stream row — cursor target inside the "streams" section. // The stream has its own slider inline, so h/l from the keyboard // adjusts THIS stream's volume (not the global output) when the cursor // sits on this row. Enter/Space mutes the stream. component StreamRow: CursorSurface { id: streamRow required property var node required property int rowIndex readonly property real streamVolume: node && node.audio ? node.audio.volume : 0 readonly property bool streamMuted: node && node.audio ? node.audio.muted : false readonly property bool isActive: root.streamRepresentsPlayer(node, root.activeMediaPlayer) hasCursor: root.cursorActive && root.focusSection === "streams" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(streamRow) current: isActive foreground: root.bar.foreground fill: root.hoverFill currentFill: root.selectedFill implicitHeight: streamColumn.implicitHeight + Style.spacing.xl Column { id: streamColumn anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(2) Row { width: parent.width spacing: Style.space(8) Text { id: streamMuteIcon text: streamRow.streamMuted ? "󰝟" : "󰕾" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title width: Style.space(22) horizontalAlignment: Text.AlignHCenter anchors.verticalCenter: parent.verticalCenter opacity: streamRow.streamMuted ? 0.5 : 1.0 MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { if (streamRow.node && streamRow.node.audio) streamRow.node.audio.muted = !streamRow.node.audio.muted } } } Text { text: root.streamLabel(streamRow.node) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body font.bold: streamRow.isActive elide: Text.ElideRight width: parent.width - streamMuteIcon.width - streamPct.width - Style.space(16) anchors.verticalCenter: parent.verticalCenter } Text { id: streamPct text: Math.round(streamRow.streamVolume * 100) + "%" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true width: Style.space(36) horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter opacity: streamRow.streamMuted ? 0.5 : 1.0 } } PanelSlider { bar: root.bar width: parent.width minimum: 0 maximum: 1.5 step: 0.05 value: streamRow.streamVolume opacity: streamRow.streamMuted ? 0.5 : 1.0 onMoved: function(v) { if (streamRow.node && streamRow.node.audio) streamRow.node.audio.volume = v } } } MouseArea { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton propagateComposedEvents: true onContainsMouseChanged: if (containsMouse) { root.cursorActive = true root.focusSection = "streams" root.selectedIndex = streamRow.rowIndex } } } }