Files
noctalia-shell/Services/Media/AudioService.qml
T
2025-11-26 20:08:47 +01:00

426 lines
14 KiB
QML

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<PwNode> sinks: deviceNodes.sinks
readonly property list<PwNode> sources: deviceNodes.sources
// Output Volume - read directly from device (like friend's version)
// Make it a regular property that updates when sink or volume changes
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 (like friend's version)
// Make it a regular property that updates when source or volume changes
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
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
// Track device changes to prevent old volume from being applied
property int currentSinkId: -1
property int currentSourceId: -1
property bool blockOutputVolumeChanges: false
property bool blockInputVolumeChanges: false
Timer {
id: unblockOutputTimer
interval: 1500
onTriggered: {
blockOutputVolumeChanges = false;
}
}
Timer {
id: unblockInputTimer
interval: 1500
onTriggered: {
blockInputVolumeChanges = 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]
}
// Track sink changes and force volume property to update
onSinkChanged: {
if (sink) {
const newSinkId = sink.id;
if (newSinkId !== currentSinkId) {
currentSinkId = newSinkId;
// Block volume changes for 1.5 seconds after device change
blockOutputVolumeChanges = true;
unblockOutputTimer.restart();
// Force volume property to update by reading new device's volume
Qt.callLater(() => {
if (root.sink?.audio) {
const vol = root.sink.audio.volume;
if (vol !== undefined && !isNaN(vol)) {
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
const clampedVol = Math.max(0, Math.min(maxVolume, vol));
root.volume = clampedVol; // This will trigger volumeChanged signal
}
}
});
}
} else {
currentSinkId = -1;
blockOutputVolumeChanges = true;
unblockOutputTimer.restart();
root.volume = 0; // This will trigger volumeChanged signal
}
}
// Watch output device changes for clamping and updating volume property
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;
const clampedVol = Math.max(0, Math.min(maxVolume, vol));
// Update volume property to trigger change signal
if (Math.abs(root.volume - clampedVol) > 0.001) {
root.volume = clampedVol;
}
// 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;
});
}
}
}
// Track source changes and force volume property to update
onSourceChanged: {
if (source) {
const newSourceId = source.id;
if (newSourceId !== currentSourceId) {
currentSourceId = newSourceId;
// Block volume changes for 1.5 seconds after device change
blockInputVolumeChanges = true;
unblockInputTimer.restart();
// Force volume property to update by reading new device's volume
Qt.callLater(() => {
if (root.source?.audio) {
const vol = root.source.audio.volume;
if (vol !== undefined && !isNaN(vol)) {
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
const clampedVol = Math.max(0, Math.min(maxVolume, vol));
root.inputVolume = clampedVol; // This will trigger inputVolumeChanged signal
}
}
});
}
} else {
currentSourceId = -1;
blockInputVolumeChanges = true;
unblockInputTimer.restart();
root.inputVolume = 0; // This will trigger inputVolumeChanged signal
}
}
// Watch input device changes for clamping and updating volume property
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;
const clampedVol = Math.max(0, Math.min(maxVolume, vol));
// Update inputVolume property to trigger change signal
if (Math.abs(root.inputVolume - clampedVol) > 0.001) {
root.inputVolume = clampedVol;
}
// 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;
}
// Block volume changes for 1.5 seconds after device change to prevent old values
if (blockOutputVolumeChanges) {
const currentVol = sink.audio.volume;
if (currentVol !== undefined && !isNaN(currentVol)) {
const diff = Math.abs(newVolume - currentVol);
// Only block if difference is significant (>5%) - allow small adjustments
if (diff > 0.05) {
Logger.w("AudioService", "Ignoring setVolume - device changed recently, volume difference:", diff, "requested:", newVolume, "current:", currentVol);
return;
}
} else {
// If we can't read current volume yet, block all changes
Logger.w("AudioService", "Ignoring setVolume - device changed recently and volume not ready yet");
return;
}
}
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume));
// 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 < 0.005)
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;
}
// Block volume changes for 1.5 seconds after device change to prevent old values
if (blockInputVolumeChanges) {
const currentVol = source.audio.volume;
if (currentVol !== undefined && !isNaN(currentVol)) {
const diff = Math.abs(newVolume - currentVol);
// Only block if difference is significant (>5%) - allow small adjustments
if (diff > 0.05) {
Logger.w("AudioService", "Ignoring setInputVolume - device changed recently, volume difference:", diff, "requested:", newVolume, "current:", currentVol);
return;
}
} else {
// If we can't read current volume yet, block all changes
Logger.w("AudioService", "Ignoring setInputVolume - device changed recently and volume not ready yet");
return;
}
}
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume));
// 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;
}
}