pragma Singleton import QtQuick import Quickshell import Quickshell.Services.Pipewire import qs.Commons Singleton { id: root // Devices readonly property PwNode sink: Pipewire.ready ? Pipewire.defaultAudioSink : null readonly property PwNode source: validatedSource readonly property bool hasInput: !!source readonly property list sinks: deviceNodes.sinks readonly property list sources: deviceNodes.sources readonly property real epsilon: 0.005 // Output Volume - read directly from device readonly property real volume: { if (!sink?.audio) return 0; const vol = sink.audio.volume; if (vol === undefined || isNaN(vol)) return 0; const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; return Math.max(0, Math.min(maxVolume, vol)); } readonly property bool muted: sink?.audio?.muted ?? true // Input Volume - read directly from device readonly property real inputVolume: { if (!source?.audio) return 0; const vol = source.audio.volume; if (vol === undefined || isNaN(vol)) return 0; const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; return Math.max(0, Math.min(maxVolume, vol)); } readonly property bool inputMuted: source?.audio?.muted ?? true // Allow callers to skip the next OSD notification when they are already // presenting volume state (e.g. the Audio Panel UI). We track this as a short // time window so suppression applies to every monitor, not just the first one // that receives the signal. property double outputOSDSuppressedUntilMs: 0 property double inputOSDSuppressedUntilMs: 0 function suppressOutputOSD(durationMs = 400) { const target = Date.now() + durationMs; outputOSDSuppressedUntilMs = Math.max(outputOSDSuppressedUntilMs, target); } function suppressInputOSD(durationMs = 400) { const target = Date.now() + durationMs; inputOSDSuppressedUntilMs = Math.max(inputOSDSuppressedUntilMs, target); } function consumeOutputOSDSuppression(): bool { return Date.now() < outputOSDSuppressedUntilMs; } function consumeInputOSDSuppression(): bool { return Date.now() < inputOSDSuppressedUntilMs; } readonly property real stepVolume: Settings.data.audio.volumeStep / 100.0 // Filtered device nodes (non-stream sinks and sources) readonly property var deviceNodes: Pipewire.ready ? Pipewire.nodes.values.reduce((acc, node) => { if (!node.isStream) { if (node.isSink) { acc.sinks.push(node); } else if (node.audio) { acc.sources.push(node); } } return acc; }, { "sources": [], "sinks": [] }) : { "sources": [], "sinks": [] } // Validated source (ensures it's a proper audio source, not a sink) readonly property PwNode validatedSource: { if (!Pipewire.ready) { return null; } const raw = Pipewire.defaultAudioSource; if (!raw || raw.isSink || !raw.audio) { return null; } // Optional: check type if available (type reflects media.class per docs) if (raw.type && typeof raw.type === "string" && !raw.type.startsWith("Audio/Source")) { return null; } return raw; } // Internal state for feedback loop prevention property bool isSettingOutputVolume: false property bool isSettingInputVolume: false // Bind default sink and source to ensure their properties are available PwObjectTracker { id: sinkTracker objects: root.sink ? [root.sink] : [] } PwObjectTracker { id: sourceTracker objects: root.source ? [root.source] : [] } // Bind all devices to ensure their properties are available PwObjectTracker { objects: [...root.sinks, ...root.sources] } // Watch output device changes for clamping Connections { target: sink?.audio ?? null function onVolumeChanged() { // Ignore volume changes if we're the one setting it (to prevent feedback loop) if (root.isSettingOutputVolume) { return; } if (!root.sink?.audio) { return; } const vol = root.sink.audio.volume; if (vol === undefined || isNaN(vol)) { return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; // If volume exceeds max, clamp it (but only if we didn't just set it) if (vol > maxVolume) { root.isSettingOutputVolume = true; Qt.callLater(() => { if (root.sink?.audio && root.sink.audio.volume > maxVolume) { root.sink.audio.volume = maxVolume; } root.isSettingOutputVolume = false; }); } } } // Watch input device changes for clamping Connections { target: source?.audio ?? null function onVolumeChanged() { // Ignore volume changes if we're the one setting it (to prevent feedback loop) if (root.isSettingInputVolume) { return; } if (!root.source?.audio) { return; } const vol = root.source.audio.volume; if (vol === undefined || isNaN(vol)) { return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; // If volume exceeds max, clamp it (but only if we didn't just set it) if (vol > maxVolume) { root.isSettingInputVolume = true; Qt.callLater(() => { if (root.source?.audio && root.source.audio.volume > maxVolume) { root.source.audio.volume = maxVolume; } root.isSettingInputVolume = false; }); } } } // Output Control function increaseVolume() { if (!Pipewire.ready || !sink?.audio) { return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; if (volume >= maxVolume) { return; } setVolume(Math.min(maxVolume, volume + stepVolume)); } function decreaseVolume() { if (!Pipewire.ready || !sink?.audio) { return; } if (volume <= 0) { return; } setVolume(Math.max(0, volume - stepVolume)); } function setVolume(newVolume: real) { if (!Pipewire.ready || !sink?.ready || !sink?.audio) { Logger.w("AudioService", "No sink available or not ready"); return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume)); const delta = Math.abs(clampedVolume - sink.audio.volume); if (delta < root.epsilon) { return; } // Set flag to prevent feedback loop, then set the actual volume isSettingOutputVolume = true; sink.audio.muted = false; sink.audio.volume = clampedVolume; // Clear flag after a short delay to allow external changes to be detected Qt.callLater(() => { isSettingOutputVolume = false; }); } function setOutputMuted(muted: bool) { if (!Pipewire.ready || !sink?.audio) { Logger.w("AudioService", "No sink available or Pipewire not ready"); return; } sink.audio.muted = muted; } function getOutputIcon() { if (muted) return "volume-mute"; const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; const clampedVolume = Math.max(0, Math.min(volume, maxVolume)); // Show volume-x icon when volume is effectively 0% (within rounding threshold) if (clampedVolume < root.epsilon) { return "volume-x"; } if (clampedVolume <= 0.5) { return "volume-low"; } return "volume-high"; } // Input Control function increaseInputVolume() { if (!Pipewire.ready || !source?.audio) { return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; if (inputVolume >= maxVolume) { return; } setInputVolume(Math.min(maxVolume, inputVolume + stepVolume)); } function decreaseInputVolume() { if (!Pipewire.ready || !source?.audio) { return; } setInputVolume(Math.max(0, inputVolume - stepVolume)); } function setInputVolume(newVolume: real) { if (!Pipewire.ready || !source?.ready || !source?.audio) { Logger.w("AudioService", "No source available or not ready"); return; } const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume)); const delta = Math.abs(clampedVolume - source.audio.volume); if (delta < root.epsilon) { return; } // Set flag to prevent feedback loop, then set the actual volume isSettingInputVolume = true; source.audio.muted = false; source.audio.volume = clampedVolume; // Clear flag after a short delay to allow external changes to be detected Qt.callLater(() => { isSettingInputVolume = false; }); } function setInputMuted(muted: bool) { if (!Pipewire.ready || !source?.audio) { Logger.w("AudioService", "No source available or Pipewire not ready"); return; } source.audio.muted = muted; } function getInputIcon() { if (inputMuted || inputVolume <= Number.EPSILON) { return "microphone-mute"; } return "microphone"; } // Device Selection function setAudioSink(newSink: PwNode): void { if (!Pipewire.ready) { Logger.w("AudioService", "Pipewire not ready"); return; } Pipewire.preferredDefaultAudioSink = newSink; } function setAudioSource(newSource: PwNode): void { if (!Pipewire.ready) { Logger.w("AudioService", "Pipewire not ready"); return; } Pipewire.preferredDefaultAudioSource = newSource; } }