mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-17 08:35:21 -04:00
90f8ce5035
Commit e3dbaed started skipping all system sounds (notifications, volume,
power, login) whenever an MPRIS player is playing, with no way to opt out.
Users who rely on notification sounds while listening to music lost them
entirely.
Add a 'Mute During Playback' toggle (SettingsData.muteSoundsWhenMediaPlaying,
default true) so the current behaviour is preserved, but users can disable
it to restore audible sounds while media is playing. The media check is
wrapped in shouldMuteForMedia(), which gates on the new setting before
querying the active player.
Closes #2616
1049 lines
36 KiB
QML
1049 lines
36 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtCore
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Pipewire
|
|
import qs.Common
|
|
import qs.Services
|
|
|
|
Singleton {
|
|
id: root
|
|
readonly property var log: Log.scoped("AudioService")
|
|
|
|
readonly property PwNode sink: Pipewire.defaultAudioSink
|
|
readonly property PwNode source: Pipewire.defaultAudioSource
|
|
|
|
readonly property bool soundsAvailable: MultimediaService.available
|
|
property bool gsettingsAvailable: false
|
|
property var availableSoundThemes: []
|
|
property string currentSoundTheme: ""
|
|
property var soundFilePaths: ({})
|
|
|
|
readonly property var volumeChangeSound: soundsLoader.item?.volumeChangeSound ?? null
|
|
readonly property var powerPlugSound: soundsLoader.item?.powerPlugSound ?? null
|
|
readonly property var powerUnplugSound: soundsLoader.item?.powerUnplugSound ?? null
|
|
readonly property var normalNotificationSound: soundsLoader.item?.normalNotificationSound ?? null
|
|
readonly property var criticalNotificationSound: soundsLoader.item?.criticalNotificationSound ?? null
|
|
readonly property var loginSound: soundsLoader.item?.loginSound ?? null
|
|
readonly property var mediaDevices: soundsLoader.item?.mediaDevices ?? null
|
|
property real notificationsVolume: 1.0
|
|
property bool notificationsAudioMuted: false
|
|
|
|
Loader {
|
|
id: soundsLoader
|
|
active: root.soundsAvailable
|
|
source: "AudioSoundPlayers.qml"
|
|
onLoaded: {
|
|
item.volume = Qt.binding(() => root.notificationsVolume);
|
|
item.volumeChangeSource = Qt.binding(() => root.getSoundPath("audio-volume-change"));
|
|
item.powerPlugSource = Qt.binding(() => root.getSoundPath("power-plug"));
|
|
item.powerUnplugSource = Qt.binding(() => root.getSoundPath("power-unplug"));
|
|
item.normalNotificationSource = Qt.binding(() => root.getSoundPath("message"));
|
|
item.criticalNotificationSource = Qt.binding(() => root.getSoundPath("message-new-instant"));
|
|
item.loginSource = Qt.binding(() => root.getSoundPath("desktop-login"));
|
|
}
|
|
}
|
|
|
|
property var deviceAliases: ({})
|
|
property string wireplumberConfigPath: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/wireplumber/wireplumber.conf.d/51-dms-audio-aliases.conf"
|
|
property bool wireplumberReloading: false
|
|
|
|
readonly property int sinkMaxVolume: {
|
|
const name = sink?.name ?? "";
|
|
if (!name)
|
|
return 100;
|
|
return SessionData.deviceMaxVolumes[name] ?? 100;
|
|
}
|
|
|
|
readonly property int wheelVolumeStep: SettingsData.audioWheelScrollAmount
|
|
|
|
signal micMuteChanged
|
|
signal audioOutputCycled(string deviceName, string deviceIcon)
|
|
signal deviceAliasChanged(string nodeName, string newAlias)
|
|
signal wireplumberReloadStarted
|
|
signal wireplumberReloadCompleted(bool success)
|
|
|
|
function getMaxVolumePercent(node) {
|
|
if (!node?.name)
|
|
return 100;
|
|
return SessionData.deviceMaxVolumes[node.name] ?? 100;
|
|
}
|
|
|
|
Connections {
|
|
target: SessionData
|
|
function onDeviceMaxVolumesChanged() {
|
|
if (!root.sink?.audio)
|
|
return;
|
|
const maxVol = root.sinkMaxVolume;
|
|
const currentPercent = Math.round(root.sink.audio.volume * 100);
|
|
if (currentPercent > maxVol)
|
|
root.sink.audio.volume = maxVol / 100;
|
|
}
|
|
}
|
|
|
|
// Used in playLoginSoundIfApplicable()
|
|
Process {
|
|
id: loginSoundChecker
|
|
onExited: exitCode => {
|
|
if (exitCode === 0) {
|
|
playLoginSound();
|
|
}
|
|
}
|
|
}
|
|
|
|
function getAvailableSinks() {
|
|
const hidden = SessionData.hiddenOutputDeviceNames ?? [];
|
|
return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name));
|
|
}
|
|
|
|
property list<PwNode> typedSinks: []
|
|
property list<PwNode> typedSources: []
|
|
|
|
function rebuildTypedNodeLists() {
|
|
const newSinks = [];
|
|
const newSources = [];
|
|
for (const node of Pipewire.nodes.values) {
|
|
if (!node?.audio || node.isStream)
|
|
continue;
|
|
if (node.isSink)
|
|
newSinks.push(node);
|
|
else
|
|
newSources.push(node);
|
|
}
|
|
typedSinks = newSinks;
|
|
typedSources = newSources;
|
|
}
|
|
|
|
Connections {
|
|
target: Pipewire.nodes
|
|
function onValuesChanged() {
|
|
root.rebuildTypedNodeLists();
|
|
}
|
|
}
|
|
|
|
function setSink(node: PwNode): bool {
|
|
if (!node)
|
|
return false;
|
|
Pipewire.preferredDefaultAudioSink = node;
|
|
return true;
|
|
}
|
|
|
|
function setSource(node: PwNode): bool {
|
|
if (!node)
|
|
return false;
|
|
Pipewire.preferredDefaultAudioSource = node;
|
|
return true;
|
|
}
|
|
|
|
function setDefaultSinkByName(name) {
|
|
if (!name)
|
|
return false;
|
|
for (const node of typedSinks) {
|
|
if (node?.name === name)
|
|
return setSink(node);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function setDefaultSourceByName(name) {
|
|
if (!name)
|
|
return false;
|
|
for (const node of typedSources) {
|
|
if (node?.name === name)
|
|
return setSource(node);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cycleAudioOutputDirection(forward) {
|
|
const sinks = getAvailableSinks();
|
|
if (sinks.length < 2)
|
|
return null;
|
|
|
|
const currentName = root.sink?.name ?? "";
|
|
const currentIndex = sinks.findIndex(s => s.name === currentName);
|
|
let nextIndex;
|
|
if (forward) {
|
|
nextIndex = (currentIndex + 1) % sinks.length;
|
|
} else {
|
|
nextIndex = (currentIndex - 1 + sinks.length) % sinks.length;
|
|
}
|
|
const nextSink = sinks[nextIndex];
|
|
setDefaultSinkByName(nextSink.name);
|
|
const name = displayName(nextSink);
|
|
audioOutputCycled(name, sinkIcon(nextSink));
|
|
return name;
|
|
}
|
|
|
|
function cycleAudioOutput() {
|
|
return cycleAudioOutputDirection(true);
|
|
}
|
|
|
|
function getDeviceAlias(nodeName) {
|
|
if (!nodeName)
|
|
return null;
|
|
return deviceAliases[nodeName] || null;
|
|
}
|
|
|
|
function hasDeviceAlias(nodeName) {
|
|
if (!nodeName)
|
|
return false;
|
|
return deviceAliases.hasOwnProperty(nodeName) && deviceAliases[nodeName] !== null && deviceAliases[nodeName] !== "";
|
|
}
|
|
|
|
function setDeviceAlias(nodeName, customAlias) {
|
|
if (!nodeName) {
|
|
log.error("Cannot set alias - nodeName is empty");
|
|
return false;
|
|
}
|
|
|
|
if (!customAlias || customAlias.trim() === "") {
|
|
return removeDeviceAlias(nodeName);
|
|
}
|
|
|
|
const trimmedAlias = customAlias.trim();
|
|
|
|
const updated = Object.assign({}, deviceAliases);
|
|
updated[nodeName] = trimmedAlias;
|
|
deviceAliases = updated;
|
|
|
|
writeWireplumberConfig();
|
|
deviceAliasChanged(nodeName, trimmedAlias);
|
|
return true;
|
|
}
|
|
|
|
function removeDeviceAlias(nodeName) {
|
|
if (!nodeName)
|
|
return false;
|
|
|
|
if (!hasDeviceAlias(nodeName))
|
|
return false;
|
|
|
|
const updated = Object.assign({}, deviceAliases);
|
|
delete updated[nodeName];
|
|
deviceAliases = updated;
|
|
|
|
writeWireplumberConfig();
|
|
deviceAliasChanged(nodeName, "");
|
|
return true;
|
|
}
|
|
|
|
function writeWireplumberConfig() {
|
|
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/wireplumber/wireplumber.conf.d";
|
|
const configContent = generateWireplumberConfig();
|
|
|
|
const shellCmd = `mkdir -p "${configDir}" && cat > "${wireplumberConfigPath}" << 'EOFCONFIG'
|
|
${configContent}
|
|
EOFCONFIG
|
|
`;
|
|
|
|
Proc.runCommand("writeWireplumberConfig", ["sh", "-c", shellCmd], (output, exitCode) => {
|
|
if (exitCode !== 0) {
|
|
log.error("Failed to write WirePlumber config. Exit code:", exitCode);
|
|
log.error("Error output:", output);
|
|
ToastService.showError(I18n.tr("Failed to save audio config"), output || "");
|
|
return;
|
|
}
|
|
|
|
reloadWireplumberConfig();
|
|
}, 0);
|
|
}
|
|
|
|
function generateWireplumberConfig() {
|
|
let config = "# Generated by DankMaterialShell - Audio Device Aliases\n";
|
|
config += "# Do not edit manually - changes will be overwritten\n";
|
|
config += "# Last updated: " + new Date().toISOString() + "\n\n";
|
|
|
|
const aliasKeys = Object.keys(deviceAliases);
|
|
if (aliasKeys.length === 0) {
|
|
config += "# No device aliases configured\n";
|
|
return config;
|
|
}
|
|
|
|
const alsaAliases = [];
|
|
const bluezAliases = [];
|
|
const otherAliases = [];
|
|
|
|
for (const nodeName of aliasKeys) {
|
|
const alias = deviceAliases[nodeName];
|
|
if (!alias)
|
|
continue;
|
|
|
|
const rule = {
|
|
nodeName: nodeName,
|
|
alias: alias
|
|
};
|
|
|
|
if (nodeName.includes("alsa")) {
|
|
alsaAliases.push(rule);
|
|
} else if (nodeName.includes("bluez")) {
|
|
bluezAliases.push(rule);
|
|
} else {
|
|
otherAliases.push(rule);
|
|
}
|
|
}
|
|
|
|
if (alsaAliases.length > 0) {
|
|
config += "monitor.alsa.rules = [\n";
|
|
for (let i = 0; i < alsaAliases.length; i++) {
|
|
const rule = alsaAliases[i];
|
|
config += " {\n";
|
|
config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`;
|
|
config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`;
|
|
config += " }";
|
|
if (i < alsaAliases.length - 1)
|
|
config += ",";
|
|
config += "\n";
|
|
}
|
|
config += "]\n\n";
|
|
}
|
|
|
|
if (bluezAliases.length > 0) {
|
|
config += "monitor.bluez.rules = [\n";
|
|
for (let i = 0; i < bluezAliases.length; i++) {
|
|
const rule = bluezAliases[i];
|
|
config += " {\n";
|
|
config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`;
|
|
config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`;
|
|
config += " }";
|
|
if (i < bluezAliases.length - 1)
|
|
config += ",";
|
|
config += "\n";
|
|
}
|
|
config += "]\n\n";
|
|
}
|
|
|
|
if (otherAliases.length > 0) {
|
|
config += "# Other device aliases (RAOP, USB, and other devices)\n";
|
|
config += "wireplumber.rules = [\n";
|
|
for (let i = 0; i < otherAliases.length; i++) {
|
|
const rule = otherAliases[i];
|
|
config += " {\n";
|
|
config += ` matches = [\n`;
|
|
config += ` { "node.name" = "${rule.nodeName}" }\n`;
|
|
config += ` ]\n`;
|
|
config += ` actions = {\n`;
|
|
config += ` update-props = {\n`;
|
|
config += ` "node.description" = "${rule.alias}"\n`;
|
|
config += ` "node.nick" = "${rule.alias}"\n`;
|
|
config += ` "device.description" = "${rule.alias}"\n`;
|
|
config += ` }\n`;
|
|
config += ` }\n`;
|
|
config += " }";
|
|
if (i < otherAliases.length - 1)
|
|
config += ",";
|
|
config += "\n";
|
|
}
|
|
config += "]\n";
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
function reloadWireplumberConfig() {
|
|
if (wireplumberReloading) {
|
|
return;
|
|
}
|
|
|
|
wireplumberReloading = true;
|
|
wireplumberReloadStarted();
|
|
|
|
Proc.runCommand("restartWireplumber", ["systemctl", "--user", "restart", "wireplumber"], (output, exitCode) => {
|
|
wireplumberReloading = false;
|
|
|
|
if (exitCode === 0) {
|
|
ToastService.showInfo(I18n.tr("Audio system restarted"), I18n.tr("Device names updated"));
|
|
wireplumberReloadCompleted(true);
|
|
} else {
|
|
log.error("Failed to restart WirePlumber:", output);
|
|
ToastService.showError(I18n.tr("Failed to restart audio system"), output);
|
|
wireplumberReloadCompleted(false);
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function loadDeviceAliases() {
|
|
const configPath = wireplumberConfigPath;
|
|
|
|
Proc.runCommand("readWireplumberConfig", ["cat", configPath], (output, exitCode) => {
|
|
if (exitCode !== 0) {
|
|
log.debug("No existing WirePlumber config found");
|
|
return;
|
|
}
|
|
|
|
const aliases = {};
|
|
const lines = output.split('\n');
|
|
let currentNodeName = null;
|
|
|
|
for (const line of lines) {
|
|
const nodeNameMatch = line.match(/"node\.name"\s*=\s*"([^"]+)"/);
|
|
if (nodeNameMatch) {
|
|
currentNodeName = nodeNameMatch[1];
|
|
}
|
|
|
|
const descriptionMatch = line.match(/"node\.description"\s*=\s*"([^"]+)"/);
|
|
if (descriptionMatch && currentNodeName) {
|
|
aliases[currentNodeName] = descriptionMatch[1];
|
|
currentNodeName = null;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(aliases).length > 0) {
|
|
deviceAliases = aliases;
|
|
log.debug("Loaded", Object.keys(aliases).length, "device aliases");
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
Connections {
|
|
target: root.sink?.audio ?? null
|
|
|
|
function onVolumeChanged() {
|
|
if (SessionData.suppressOSD)
|
|
return;
|
|
root.playVolumeChangeSoundIfEnabled();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: root.source?.audio ?? null
|
|
|
|
function onMutedChanged() {
|
|
root.micMuteChanged();
|
|
}
|
|
}
|
|
|
|
function checkGsettings() {
|
|
Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => {
|
|
gsettingsAvailable = (exitCode === 0);
|
|
if (gsettingsAvailable) {
|
|
scanSoundThemes();
|
|
getCurrentSoundTheme();
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function scanSoundThemes() {
|
|
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS");
|
|
const searchPaths = xdgDataDirs && xdgDataDirs.trim() !== "" ? xdgDataDirs.split(":").concat(Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))) : ["/usr/share", "/usr/local/share", Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))];
|
|
|
|
const basePaths = searchPaths.map(p => p + "/sounds").join(" ");
|
|
const script = `
|
|
for base_dir in ${basePaths}; do
|
|
[ -d "$base_dir" ] || continue
|
|
for theme_dir in "$base_dir"/*; do
|
|
[ -d "$theme_dir/stereo" ] || continue
|
|
basename "$theme_dir"
|
|
done
|
|
done | sort -u
|
|
`;
|
|
|
|
Proc.runCommand("scanSoundThemes", ["sh", "-c", script], (output, exitCode) => {
|
|
if (exitCode === 0 && output.trim()) {
|
|
const themes = output.trim().split('\n').filter(t => t && t.length > 0);
|
|
availableSoundThemes = themes;
|
|
} else {
|
|
availableSoundThemes = [];
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function getCurrentSoundTheme() {
|
|
Proc.runCommand("getCurrentSoundTheme", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null | sed \"s/'//g\""], (output, exitCode) => {
|
|
if (exitCode === 0 && output.trim()) {
|
|
currentSoundTheme = output.trim();
|
|
log.debug("Current system sound theme:", currentSoundTheme);
|
|
if (SettingsData.useSystemSoundTheme) {
|
|
discoverSoundFiles(currentSoundTheme);
|
|
}
|
|
} else {
|
|
currentSoundTheme = "";
|
|
log.debug("No system sound theme found");
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function setSoundTheme(themeName) {
|
|
if (!themeName || themeName === currentSoundTheme) {
|
|
return;
|
|
}
|
|
|
|
Proc.runCommand("setSoundTheme", ["sh", "-c", `gsettings set org.gnome.desktop.sound theme-name '${themeName}'`], (output, exitCode) => {
|
|
if (exitCode === 0) {
|
|
currentSoundTheme = themeName;
|
|
if (SettingsData.useSystemSoundTheme) {
|
|
discoverSoundFiles(themeName);
|
|
}
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function discoverSoundFiles(themeName) {
|
|
if (!themeName) {
|
|
soundFilePaths = {};
|
|
return;
|
|
}
|
|
|
|
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS");
|
|
const searchPaths = xdgDataDirs && xdgDataDirs.trim() !== "" ? xdgDataDirs.split(":").concat(Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))) : ["/usr/share", "/usr/local/share", Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))];
|
|
|
|
const extensions = ["oga", "ogg", "wav", "mp3", "flac"];
|
|
const themesToSearch = themeName !== "freedesktop" ? `${themeName} freedesktop` : themeName;
|
|
|
|
const script = `
|
|
for event_key in audio-volume-change power-plug power-unplug message message-new-instant desktop-login; do
|
|
found=0
|
|
|
|
case "$event_key" in
|
|
message)
|
|
names="dialog-information message message-lowpriority bell"
|
|
;;
|
|
message-new-instant)
|
|
names="dialog-warning message-new-instant message-highlight"
|
|
;;
|
|
*)
|
|
names="$event_key"
|
|
;;
|
|
esac
|
|
|
|
for theme in ${themesToSearch}; do
|
|
for event_name in $names; do
|
|
for base_path in ${searchPaths.join(" ")}; do
|
|
sounds_path="$base_path/sounds"
|
|
for ext in ${extensions.join(" ")}; do
|
|
file_path="$sounds_path/$theme/stereo/$event_name.$ext"
|
|
if [ -f "$file_path" ]; then
|
|
echo "$event_key=$file_path"
|
|
found=1
|
|
break
|
|
fi
|
|
done
|
|
[ $found -eq 1 ] && break
|
|
done
|
|
[ $found -eq 1 ] && break
|
|
done
|
|
[ $found -eq 1 ] && break
|
|
done
|
|
done
|
|
`;
|
|
|
|
Proc.runCommand("discoverSoundFiles", ["sh", "-c", script], (output, exitCode) => {
|
|
const paths = {};
|
|
if (exitCode === 0 && output.trim()) {
|
|
const lines = output.trim().split('\n');
|
|
for (let line of lines) {
|
|
const parts = line.split('=');
|
|
if (parts.length === 2) {
|
|
paths[parts[0]] = "file://" + parts[1];
|
|
}
|
|
}
|
|
}
|
|
soundFilePaths = paths;
|
|
}, 0);
|
|
}
|
|
|
|
function getSoundPath(soundEvent) {
|
|
const soundMap = {
|
|
"audio-volume-change": "../assets/sounds/freedesktop/audio-volume-change.wav",
|
|
"power-plug": "../assets/sounds/plasma/power-plug.wav",
|
|
"power-unplug": "../assets/sounds/plasma/power-unplug.wav",
|
|
"message": "../assets/sounds/freedesktop/message.wav",
|
|
"message-new-instant": "../assets/sounds/freedesktop/message-new-instant.wav",
|
|
"desktop-login": "../assets/sounds/freedesktop/desktop-login.wav"
|
|
};
|
|
|
|
const specialConditions = {
|
|
"smooth": ["audio-volume-change"]
|
|
};
|
|
|
|
const themeLower = currentSoundTheme.toLowerCase();
|
|
if (SettingsData.useSystemSoundTheme && specialConditions[themeLower]?.includes(soundEvent)) {
|
|
const bundledPath = Qt.resolvedUrl(soundMap[soundEvent] || "../assets/sounds/freedesktop/message.wav");
|
|
log.debug("Using bundled sound (special condition) for", soundEvent, ":", bundledPath);
|
|
return bundledPath;
|
|
}
|
|
|
|
if (SettingsData.useSystemSoundTheme && soundFilePaths[soundEvent]) {
|
|
log.debug("Using system sound for", soundEvent, ":", soundFilePaths[soundEvent]);
|
|
return soundFilePaths[soundEvent];
|
|
}
|
|
|
|
const bundledPath = Qt.resolvedUrl(soundMap[soundEvent] || "../assets/sounds/freedesktop/message.wav");
|
|
log.debug("Using bundled sound for", soundEvent, ":", bundledPath);
|
|
return bundledPath;
|
|
}
|
|
|
|
function reloadSounds() {
|
|
log.debug("Reloading sounds, useSystemSoundTheme:", SettingsData.useSystemSoundTheme, "currentSoundTheme:", currentSoundTheme);
|
|
if (SettingsData.useSystemSoundTheme && currentSoundTheme) {
|
|
discoverSoundFiles(currentSoundTheme);
|
|
} else {
|
|
soundFilePaths = {};
|
|
}
|
|
}
|
|
|
|
function isMediaPlaying() {
|
|
return MprisController.activePlayer?.isPlaying ?? false;
|
|
}
|
|
|
|
function shouldMuteForMedia() {
|
|
return SettingsData.muteSoundsWhenMediaPlaying && isMediaPlaying();
|
|
}
|
|
|
|
function playVolumeChangeSound() {
|
|
if (!soundsAvailable || !volumeChangeSound || notificationsAudioMuted || shouldMuteForMedia())
|
|
return;
|
|
volumeChangeSound.play();
|
|
}
|
|
|
|
function playPowerPlugSound() {
|
|
if (!soundsAvailable || !powerPlugSound || notificationsAudioMuted || shouldMuteForMedia())
|
|
return;
|
|
powerPlugSound.play();
|
|
}
|
|
|
|
function playPowerUnplugSound() {
|
|
if (!soundsAvailable || !powerUnplugSound || notificationsAudioMuted || shouldMuteForMedia())
|
|
return;
|
|
powerUnplugSound.play();
|
|
}
|
|
|
|
function playNormalNotificationSound() {
|
|
if (!soundsAvailable || !normalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || shouldMuteForMedia())
|
|
return;
|
|
normalNotificationSound.play();
|
|
}
|
|
|
|
function playCriticalNotificationSound() {
|
|
if (!soundsAvailable || !criticalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || shouldMuteForMedia())
|
|
return;
|
|
criticalNotificationSound.play();
|
|
}
|
|
|
|
function playLoginSound() {
|
|
if (!soundsAvailable || !loginSound || notificationsAudioMuted || shouldMuteForMedia()) {
|
|
return;
|
|
}
|
|
loginSound.play();
|
|
}
|
|
|
|
function playLoginSoundIfApplicable() {
|
|
if (SettingsData.soundsEnabled && SettingsData.soundLogin && !notificationsAudioMuted) {
|
|
// plays login sound on session start, but only if a specific file doesn't exist,
|
|
// to prevent it from playing on every DMS restart during the session
|
|
const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR");
|
|
const sessionId = Quickshell.env("XDG_SESSION_ID") || "0";
|
|
|
|
if (!runtimeDir)
|
|
return;
|
|
|
|
const loginFile = `${runtimeDir}/danklinux.login-${sessionId}`;
|
|
|
|
// if file doesn't exist, touch it (0)
|
|
// If it exists, do nothing (1)
|
|
loginSoundChecker.command = ["sh", "-c", `[ ! -f ${loginFile} ] && touch ${loginFile}`];
|
|
loginSoundChecker.running = true;
|
|
}
|
|
}
|
|
|
|
function playVolumeChangeSoundIfEnabled() {
|
|
if (SettingsData.soundsEnabled && SettingsData.soundVolumeChanged && !notificationsAudioMuted) {
|
|
playVolumeChangeSound();
|
|
}
|
|
}
|
|
|
|
function sinkIcon(node) {
|
|
if (!node)
|
|
return "speaker";
|
|
|
|
const props = node.properties || {};
|
|
const formFactor = (props["device.form-factor"] || "").toLowerCase();
|
|
|
|
switch (formFactor) {
|
|
case "headphone":
|
|
case "headset":
|
|
case "hands-free":
|
|
case "handset":
|
|
return "headset";
|
|
case "tv":
|
|
case "monitor":
|
|
return "tv";
|
|
case "speaker":
|
|
case "computer":
|
|
case "hifi":
|
|
case "portable":
|
|
case "car":
|
|
return "speaker";
|
|
}
|
|
|
|
const bus = (props["device.bus"] || "").toLowerCase();
|
|
if (bus === "bluetooth")
|
|
return "headset";
|
|
|
|
const name = (node.name || "").toLowerCase();
|
|
if (name.includes("hdmi"))
|
|
return "tv";
|
|
if (name.includes("iec958") || name.includes("spdif"))
|
|
return "speaker";
|
|
|
|
if (bus === "usb")
|
|
return "headset";
|
|
|
|
return "speaker";
|
|
}
|
|
|
|
function displayName(node) {
|
|
if (!node) {
|
|
return "";
|
|
}
|
|
|
|
// FIRST: Check if we have a custom alias in our deviceAliases map
|
|
// This ensures we always show the user's custom name, regardless of
|
|
// whether WirePlumber has applied it to the node properties yet
|
|
if (node.name && deviceAliases[node.name]) {
|
|
return deviceAliases[node.name];
|
|
}
|
|
|
|
// Check node.properties["node.description"] for WirePlumber-applied aliases
|
|
// This is the live property updated by WirePlumber rules
|
|
if (node.properties && node.properties["node.description"]) {
|
|
const desc = node.properties["node.description"];
|
|
if (desc !== node.name) {
|
|
return desc;
|
|
}
|
|
}
|
|
|
|
// Check cached description as fallback
|
|
if (node.description && node.description !== node.name) {
|
|
return node.description;
|
|
}
|
|
|
|
// Fallback to device description property
|
|
if (node.properties && node.properties["device.description"]) {
|
|
return node.properties["device.description"];
|
|
}
|
|
|
|
// Fallback to nickname
|
|
if (node.nickname && node.nickname !== node.name) {
|
|
return node.nickname;
|
|
}
|
|
|
|
// Fallback to friendly names based on node name patterns
|
|
if (node.name.includes("analog-stereo")) {
|
|
return "Built-in Audio Analog Stereo";
|
|
}
|
|
if (node.name.includes("bluez")) {
|
|
return "Bluetooth Audio";
|
|
}
|
|
if (node.name.includes("usb")) {
|
|
return "USB Audio";
|
|
}
|
|
if (node.name.includes("hdmi")) {
|
|
return "HDMI Audio";
|
|
}
|
|
|
|
return node.name;
|
|
}
|
|
|
|
function originalName(node) {
|
|
if (!node) {
|
|
return "";
|
|
}
|
|
|
|
// Get the original name without checking for custom aliases
|
|
// Check pattern-based friendly names FIRST (before device.description)
|
|
// This ensures we show user-friendly names like "Built-in Audio Analog Stereo"
|
|
// instead of hardware chip names like "ALC274 Analog"
|
|
if (node.name.includes("analog-stereo")) {
|
|
return "Built-in Audio Analog Stereo";
|
|
}
|
|
if (node.name.includes("bluez")) {
|
|
return "Bluetooth Audio";
|
|
}
|
|
if (node.name.includes("usb")) {
|
|
return "USB Audio";
|
|
}
|
|
if (node.name.includes("hdmi")) {
|
|
return "HDMI Audio";
|
|
}
|
|
if (node.name.includes("raop_sink")) {
|
|
// Extract friendly name from RAOP node name
|
|
const match = node.name.match(/raop_sink\.([^.]+)/);
|
|
if (match) {
|
|
return match[1].replace(/-/g, " ");
|
|
}
|
|
}
|
|
|
|
// Fallback to device.description property
|
|
if (node.properties && node.properties["device.description"]) {
|
|
return node.properties["device.description"];
|
|
}
|
|
|
|
// Fallback to nickname
|
|
if (node.nickname && node.nickname !== node.name) {
|
|
return node.nickname;
|
|
}
|
|
|
|
return node.name;
|
|
}
|
|
|
|
function subtitle(name) {
|
|
if (!name) {
|
|
return "";
|
|
}
|
|
|
|
if (name.includes('usb-')) {
|
|
if (name.includes('SteelSeries')) {
|
|
return "USB Gaming Headset";
|
|
}
|
|
if (name.includes('Generic')) {
|
|
return "USB Audio Device";
|
|
}
|
|
return "USB Audio";
|
|
}
|
|
|
|
if (name.includes('pci-')) {
|
|
if (name.includes('01_00.1') || name.includes('01:00.1')) {
|
|
return "NVIDIA GPU Audio";
|
|
}
|
|
return "PCI Audio";
|
|
}
|
|
|
|
if (name.includes('bluez')) {
|
|
return "Bluetooth Audio";
|
|
}
|
|
if (name.includes('analog')) {
|
|
return "Built-in Audio";
|
|
}
|
|
if (name.includes('hdmi')) {
|
|
return "HDMI Audio";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
PwObjectTracker {
|
|
objects: Pipewire.nodes.values.filter(node => node.audio && !node.isStream)
|
|
}
|
|
|
|
function setVolume(percentage) {
|
|
if (!root.sink?.audio)
|
|
return "No audio sink available";
|
|
|
|
const maxVol = root.sinkMaxVolume;
|
|
const clampedVolume = Math.max(0, Math.min(maxVol, percentage));
|
|
root.sink.audio.volume = clampedVolume / 100;
|
|
return `Volume set to ${clampedVolume}%`;
|
|
}
|
|
|
|
function toggleMute() {
|
|
if (!root.sink?.audio) {
|
|
return "No audio sink available";
|
|
}
|
|
|
|
root.sink.audio.muted = !root.sink.audio.muted;
|
|
return root.sink.audio.muted ? "Audio muted" : "Audio unmuted";
|
|
}
|
|
|
|
function handleNodeVolumeWheel(node, wheelEvent) {
|
|
if (!node?.audio)
|
|
return;
|
|
|
|
SessionData.suppressOSDTemporarily();
|
|
const delta = wheelEvent.angleDelta.y;
|
|
if (delta === 0)
|
|
return;
|
|
|
|
const current = Math.round(node.audio.volume * 100);
|
|
const maxVol = getMaxVolumePercent(node);
|
|
const newVolume = delta > 0 ? Math.min(maxVol, current + root.wheelVolumeStep) : Math.max(0, current - root.wheelVolumeStep);
|
|
|
|
node.audio.muted = false;
|
|
node.audio.volume = newVolume / 100;
|
|
|
|
if (node === sink) {
|
|
playVolumeChangeSoundIfEnabled();
|
|
}
|
|
wheelEvent.accepted = true;
|
|
}
|
|
|
|
function setMicVolume(percentage) {
|
|
if (!root.source?.audio) {
|
|
return "No audio source available";
|
|
}
|
|
|
|
const clampedVolume = Math.max(0, Math.min(100, percentage));
|
|
root.source.audio.volume = clampedVolume / 100;
|
|
return `Microphone volume set to ${clampedVolume}%`;
|
|
}
|
|
|
|
function toggleMicMute() {
|
|
if (!root.source?.audio) {
|
|
return "No audio source available";
|
|
}
|
|
|
|
root.source.audio.muted = !root.source.audio.muted;
|
|
return root.source.audio.muted ? "Microphone muted" : "Microphone unmuted";
|
|
}
|
|
|
|
function incrementMicVolume(step) {
|
|
if (!root.source?.audio)
|
|
return "No audio source available";
|
|
|
|
if (root.source.audio.muted)
|
|
root.source.audio.muted = false;
|
|
|
|
const currentVolume = Math.round(root.source.audio.volume * 100);
|
|
const stepValue = parseInt(step || "5");
|
|
const newVolume = Math.max(0, Math.min(100, currentVolume + stepValue));
|
|
|
|
root.source.audio.volume = newVolume / 100;
|
|
return `Microphone volume increased to ${newVolume}%`;
|
|
}
|
|
|
|
function decrementMicVolume(step) {
|
|
if (!root.source?.audio)
|
|
return "No audio source available";
|
|
|
|
if (root.source.audio.muted)
|
|
root.source.audio.muted = false;
|
|
|
|
const currentVolume = Math.round(root.source.audio.volume * 100);
|
|
const stepValue = parseInt(step || "5");
|
|
const newVolume = Math.max(0, Math.min(100, currentVolume - stepValue));
|
|
|
|
root.source.audio.volume = newVolume / 100;
|
|
return `Microphone volume decreased to ${newVolume}%`;
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "audio"
|
|
|
|
function setvolume(percentage: string): string {
|
|
return root.setVolume(parseInt(percentage));
|
|
}
|
|
|
|
function increment(step: string): string {
|
|
if (!root.sink?.audio)
|
|
return "No audio sink available";
|
|
|
|
if (root.sink.audio.muted)
|
|
root.sink.audio.muted = false;
|
|
|
|
const maxVol = root.sinkMaxVolume;
|
|
const currentVolume = Math.round(root.sink.audio.volume * 100);
|
|
const stepValue = parseInt(step || "5");
|
|
const newVolume = Math.max(0, Math.min(maxVol, currentVolume + stepValue));
|
|
|
|
root.sink.audio.volume = newVolume / 100;
|
|
return `Volume increased to ${newVolume}%`;
|
|
}
|
|
|
|
function decrement(step: string): string {
|
|
if (!root.sink?.audio)
|
|
return "No audio sink available";
|
|
|
|
if (root.sink.audio.muted)
|
|
root.sink.audio.muted = false;
|
|
|
|
const maxVol = root.sinkMaxVolume;
|
|
const currentVolume = Math.round(root.sink.audio.volume * 100);
|
|
const stepValue = parseInt(step || "5");
|
|
const newVolume = Math.max(0, Math.min(maxVol, currentVolume - stepValue));
|
|
|
|
root.sink.audio.volume = newVolume / 100;
|
|
return `Volume decreased to ${newVolume}%`;
|
|
}
|
|
|
|
function mute(): string {
|
|
return root.toggleMute();
|
|
}
|
|
|
|
function setmic(percentage: string): string {
|
|
return root.setMicVolume(parseInt(percentage));
|
|
}
|
|
|
|
function micmute(): string {
|
|
return root.toggleMicMute();
|
|
}
|
|
|
|
function status(): string {
|
|
let result = "Audio Status:\n";
|
|
|
|
if (root.sink?.audio) {
|
|
const volume = Math.round(root.sink.audio.volume * 100);
|
|
const muteStatus = root.sink.audio.muted ? " (muted)" : "";
|
|
const maxVol = root.sinkMaxVolume;
|
|
result += `Output: ${volume}%${muteStatus} (max: ${maxVol}%)\n`;
|
|
} else {
|
|
result += "Output: No sink available\n";
|
|
}
|
|
|
|
if (root.source?.audio) {
|
|
const micVolume = Math.round(root.source.audio.volume * 100);
|
|
const muteStatus = root.source.audio.muted ? " (muted)" : "";
|
|
result += `Input: ${micVolume}%${muteStatus}`;
|
|
} else {
|
|
result += "Input: No source available";
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function getmaxvolume(): string {
|
|
return `${root.sinkMaxVolume}`;
|
|
}
|
|
|
|
function setmaxvolume(percent: string): string {
|
|
if (!root.sink?.name)
|
|
return "No audio sink available";
|
|
const val = parseInt(percent);
|
|
if (isNaN(val))
|
|
return "Invalid percentage";
|
|
SessionData.setDeviceMaxVolume(root.sink.name, val);
|
|
return `Max volume set to ${SessionData.getDeviceMaxVolume(root.sink.name)}%`;
|
|
}
|
|
|
|
function getmaxvolumefor(nodeName: string): string {
|
|
if (!nodeName)
|
|
return "No node name specified";
|
|
return `${SessionData.getDeviceMaxVolume(nodeName)}`;
|
|
}
|
|
|
|
function setmaxvolumefor(nodeName: string, percent: string): string {
|
|
if (!nodeName)
|
|
return "No node name specified";
|
|
const val = parseInt(percent);
|
|
if (isNaN(val))
|
|
return "Invalid percentage";
|
|
SessionData.setDeviceMaxVolume(nodeName, val);
|
|
return `Max volume for ${nodeName} set to ${SessionData.getDeviceMaxVolume(nodeName)}%`;
|
|
}
|
|
|
|
function cycleoutput(): string {
|
|
const result = root.cycleAudioOutput();
|
|
if (!result)
|
|
return "Only one audio output available";
|
|
return `Switched to: ${result}`;
|
|
}
|
|
}
|
|
Connections {
|
|
target: SettingsData
|
|
function onUseSystemSoundThemeChanged() {
|
|
reloadSounds();
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
rebuildTypedNodeLists();
|
|
|
|
if (soundsAvailable)
|
|
checkGsettings();
|
|
|
|
loadDeviceAliases();
|
|
}
|
|
}
|