Files
arthur-os/shell/plugins/panels/audio/Panel.qml
T

1105 lines
36 KiB
QML

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
fixedWidth: root.bar && root.bar.vertical ? -1 : Style.space(27)
fixedHeight: root.bar && root.bar.vertical ? Style.space(26) : -1
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.switchPanel(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 ----
PanelSeparator {
foreground: root.bar.foreground
}
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 ----
PanelSeparator {
visible: root.displayAudioSources.length > 0 || !!root.source
foreground: root.bar.foreground
}
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 ----
PanelSeparator {
visible: root.displayAudioStreams.length > 0
foreground: root.bar.foreground
}
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
}
}
}
}