mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
1105 lines
36 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|