pragma Singleton pragma ComponentBehavior: Bound import QtCore import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire import qs.Common Singleton { id: root readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode source: Pipewire.defaultAudioSource property bool soundsAvailable: false property bool gsettingsAvailable: false property var availableSoundThemes: [] property string currentSoundTheme: "" property var soundFilePaths: ({}) property var volumeChangeSound: null property var powerPlugSound: null property var powerUnplugSound: null property var normalNotificationSound: null property var criticalNotificationSound: null property var mediaDevices: null property var mediaDevicesConnections: null signal micMuteChanged Connections { target: root.sink?.audio ?? null function onVolumeChanged() { if (SessionData.suppressOSD) return; root.playVolumeChangeSoundIfEnabled(); } } function detectSoundsAvailability() { try { const testObj = Qt.createQmlObject(` import QtQuick import QtMultimedia Item {} `, root, "AudioService.TestComponent"); if (testObj) { testObj.destroy(); } soundsAvailable = true; return true; } catch (e) { soundsAvailable = false; return false; } } 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(); console.log("AudioService: Current system sound theme:", currentSoundTheme); if (SettingsData.useSystemSoundTheme) { discoverSoundFiles(currentSoundTheme); } } else { currentSoundTheme = ""; console.log("AudioService: 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 = {}; if (soundsAvailable) { destroySoundPlayers(); createSoundPlayers(); } 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; 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; if (soundsAvailable) { destroySoundPlayers(); createSoundPlayers(); } }, 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" }; 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"); console.log("AudioService: Using bundled sound (special condition) for", soundEvent, ":", bundledPath); return bundledPath; } if (SettingsData.useSystemSoundTheme && soundFilePaths[soundEvent]) { console.log("AudioService: Using system sound for", soundEvent, ":", soundFilePaths[soundEvent]); return soundFilePaths[soundEvent]; } const bundledPath = Qt.resolvedUrl(soundMap[soundEvent] || "../assets/sounds/freedesktop/message.wav"); console.log("AudioService: Using bundled sound for", soundEvent, ":", bundledPath); return bundledPath; } function reloadSounds() { console.log("AudioService: Reloading sounds, useSystemSoundTheme:", SettingsData.useSystemSoundTheme, "currentSoundTheme:", currentSoundTheme); if (SettingsData.useSystemSoundTheme && currentSoundTheme) { discoverSoundFiles(currentSoundTheme); } else { soundFilePaths = {}; if (soundsAvailable) { destroySoundPlayers(); createSoundPlayers(); } } } function setupMediaDevices() { if (!soundsAvailable || mediaDevices) { return; } try { mediaDevices = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaDevices { id: devices Component.onCompleted: { console.log("AudioService: MediaDevices initialized, default output:", defaultAudioOutput?.description) } } `, root, "AudioService.MediaDevices"); if (mediaDevices) { mediaDevicesConnections = Qt.createQmlObject(` import QtQuick Connections { target: root.mediaDevices function onDefaultAudioOutputChanged() { console.log("AudioService: Default audio output changed, recreating sound players") root.destroySoundPlayers() root.createSoundPlayers() } } `, root, "AudioService.MediaDevicesConnections"); } } catch (e) { console.log("AudioService: MediaDevices not available, using default audio output"); mediaDevices = null; } } function destroySoundPlayers() { if (volumeChangeSound) { volumeChangeSound.destroy(); volumeChangeSound = null; } if (powerPlugSound) { powerPlugSound.destroy(); powerPlugSound = null; } if (powerUnplugSound) { powerUnplugSound.destroy(); powerUnplugSound = null; } if (normalNotificationSound) { normalNotificationSound.destroy(); normalNotificationSound = null; } if (criticalNotificationSound) { criticalNotificationSound.destroy(); criticalNotificationSound = null; } } function createSoundPlayers() { if (!soundsAvailable) { return; } setupMediaDevices(); try { const deviceProperty = mediaDevices ? `device: root.mediaDevices.defaultAudioOutput\n ` : ""; const volumeChangePath = getSoundPath("audio-volume-change"); volumeChangeSound = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaPlayer { source: "${volumeChangePath}" audioOutput: AudioOutput { ${deviceProperty}volume: 1.0 } } `, root, "AudioService.VolumeChangeSound"); const powerPlugPath = getSoundPath("power-plug"); powerPlugSound = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaPlayer { source: "${powerPlugPath}" audioOutput: AudioOutput { ${deviceProperty}volume: 1.0 } } `, root, "AudioService.PowerPlugSound"); const powerUnplugPath = getSoundPath("power-unplug"); powerUnplugSound = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaPlayer { source: "${powerUnplugPath}" audioOutput: AudioOutput { ${deviceProperty}volume: 1.0 } } `, root, "AudioService.PowerUnplugSound"); const messagePath = getSoundPath("message"); normalNotificationSound = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaPlayer { source: "${messagePath}" audioOutput: AudioOutput { ${deviceProperty}volume: 1.0 } } `, root, "AudioService.NormalNotificationSound"); const messageNewInstantPath = getSoundPath("message-new-instant"); criticalNotificationSound = Qt.createQmlObject(` import QtQuick import QtMultimedia MediaPlayer { source: "${messageNewInstantPath}" audioOutput: AudioOutput { ${deviceProperty}volume: 1.0 } } `, root, "AudioService.CriticalNotificationSound"); } catch (e) { console.warn("AudioService: Error creating sound players:", e); } } function playVolumeChangeSound() { if (soundsAvailable && volumeChangeSound) { volumeChangeSound.play(); } } function playPowerPlugSound() { if (soundsAvailable && powerPlugSound) { powerPlugSound.play(); } } function playPowerUnplugSound() { if (soundsAvailable && powerUnplugSound) { powerUnplugSound.play(); } } function playNormalNotificationSound() { if (soundsAvailable && normalNotificationSound && !SessionData.doNotDisturb) { normalNotificationSound.play(); } } function playCriticalNotificationSound() { if (soundsAvailable && criticalNotificationSound && !SessionData.doNotDisturb) { criticalNotificationSound.play(); } } function playVolumeChangeSoundIfEnabled() { if (SettingsData.soundsEnabled && SettingsData.soundVolumeChanged) { playVolumeChangeSound(); } } function displayName(node) { if (!node) { return ""; } if (node.properties && node.properties["device.description"]) { return node.properties["device.description"]; } if (node.description && node.description !== node.name) { return node.description; } if (node.nickname && node.nickname !== node.name) { return node.nickname; } if (node.name.includes("analog-stereo")) { return "Built-in Speakers"; } 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 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 clampedVolume = Math.max(0, Math.min(100, 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 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"; } 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 currentVolume = Math.round(root.sink.audio.volume * 100); const stepValue = parseInt(step || "5"); const newVolume = Math.max(0, Math.min(100, 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 currentVolume = Math.round(root.sink.audio.volume * 100); const stepValue = parseInt(step || "5"); const newVolume = Math.max(0, Math.min(100, 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 { const result = root.toggleMicMute(); root.micMuteChanged(); return result; } 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)" : ""; result += `Output: ${volume}%${muteStatus}\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; } } Connections { target: SettingsData function onUseSystemSoundThemeChanged() { reloadSounds(); } } Component.onCompleted: { if (!detectSoundsAvailability()) { console.warn("AudioService: QtMultimedia not available - sound effects disabled"); } else { console.info("AudioService: Sound effects enabled"); checkGsettings(); Qt.callLater(createSoundPlayers); } } }