Files
illogical-impulse/dots/.config/quickshell/ii/services/Audio.qml
T
2025-12-06 13:17:29 +01:00

140 lines
4.7 KiB
QML

pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
/**
* A nice wrapper for default Pipewire audio sink and source.
*/
Singleton {
id: root
// Misc props
property bool ready: Pipewire.defaultAudioSink?.ready ?? false
property PwNode sink: Pipewire.defaultAudioSink
property PwNode source: Pipewire.defaultAudioSource
readonly property real hardMaxValue: 2.00 // People keep joking about setting volume to 5172% so...
property string audioTheme: Config.options.sounds.theme
property real value: sink?.audio.volume ?? 0
function friendlyDeviceName(node) {
return (node.nickname || node.description || Translation.tr("Unknown"));
}
function appNodeDisplayName(node) {
return (node.properties["application.name"] || node.description || node.name)
}
// Lists
function correctType(node, isSink) {
return (node.isSink === isSink) && node.audio
}
function appNodes(isSink) {
return Pipewire.nodes.values.filter((node) => { // Should be list<PwNode> but it breaks ScriptModel
return root.correctType(node, isSink) && node.isStream
})
}
function devices(isSink) {
return Pipewire.nodes.values.filter(node => {
return root.correctType(node, isSink) && !node.isStream
})
}
readonly property list<var> outputAppNodes: root.appNodes(true)
readonly property list<var> inputAppNodes: root.appNodes(false)
readonly property list<var> outputDevices: root.devices(true)
readonly property list<var> inputDevices: root.devices(false)
// Signals
signal sinkProtectionTriggered(string reason);
// Controls
function toggleMute() {
Audio.sink.audio.muted = !Audio.sink.audio.muted
}
function toggleMicMute() {
Audio.source.audio.muted = !Audio.source.audio.muted
}
function incrementVolume() {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step);
}
function decrementVolume() {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume -= step;
}
function setDefaultSink(node) {
Pipewire.preferredDefaultAudioSink = node;
}
function setDefaultSource(node) {
Pipewire.preferredDefaultAudioSource = node;
}
// Internals
PwObjectTracker {
objects: [sink, source]
}
Connections { // Protection against sudden volume changes
target: sink?.audio ?? null
property bool lastReady: false
property real lastVolume: 0
function onVolumeChanged() {
if (!Config.options.audio.protection.enable) return;
const newVolume = sink.audio.volume;
// when resuming from suspend, we should not write volume to avoid pipewire volume reset issues
if (isNaN(newVolume) || newVolume === undefined || newVolume === null) {
lastReady = false;
lastVolume = 0;
return;
}
if (!lastReady) {
lastVolume = newVolume;
lastReady = true;
return;
}
const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100;
const maxAllowed = Config.options.audio.protection.maxAllowed / 100;
if (newVolume - lastVolume > maxAllowedIncrease) {
sink.audio.volume = lastVolume;
root.sinkProtectionTriggered(Translation.tr("Illegal increment"));
} else if (newVolume > maxAllowed || newVolume > root.hardMaxValue) {
root.sinkProtectionTriggered(Translation.tr("Exceeded max allowed"));
sink.audio.volume = Math.min(lastVolume, maxAllowed);
}
lastVolume = sink.audio.volume;
}
}
function playSystemSound(soundName) {
const ogaPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.oga`;
const oggPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.ogg`;
// Try playing .oga first
let command = [
"ffplay",
"-nodisp",
"-autoexit",
ogaPath
];
Quickshell.execDetached(command);
// Also try playing .ogg (ffplay will just fail silently if file doesn't exist)
command = [
"ffplay",
"-nodisp",
"-autoexit",
oggPath
];
Quickshell.execDetached(command);
}
}