AudioPanel: add app volume controls

This commit is contained in:
Ly-sec
2025-12-18 11:44:32 +01:00
parent b184ded81a
commit 1877e74bb1
12 changed files with 687 additions and 103 deletions
+16
View File
@@ -956,6 +956,15 @@
"label": "Visualisierungstyp"
}
},
"panel": {
"applications": {
"empty": "Derzeit geben keine Anwendungen Audio wieder."
},
"tabs": {
"applications": "Anwendungen",
"devices": "Geräte"
}
},
"title": "Audio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Heruntergeladen",
"not-downloaded": "Nicht heruntergeladen"
},
"hot-reload": {
"description": "Plugins automatisch neu laden, wenn sich ihre Dateien ändern. Nützlich für die Plugin-Entwicklung.",
"label": "Heißes Neuladen (Entwicklermodus)"
},
"hot-reloaded": "Plugin neu geladen: {name}",
"install": "Installieren",
"install-error": "Installation fehlgeschlagen: {error}",
"install-success": "{plugin} erfolgreich installiert",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Mittlere Sektion",
"move-to-left-section": "Linke Sektion",
"move-to-right-section": "Rechte Sektion",
"mute": "Stummschalten",
"next-media": "Nächster Titel",
"next-month": "Nächster Monat",
"night-light-disabled": "Nachtlicht",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Bildschirmrekorder",
"switch-to-dark-mode": "Dunkler Modus",
"switch-to-light-mode": "Heller Modus",
"unmute": "Stummschaltung aufheben",
"up": "Übergeordnetes Verzeichnis",
"volume-at": "Lautstärke: {volume}%",
"wallpaper-selector": "Hintergrundbild-Auswahl",
+17 -6
View File
@@ -956,6 +956,15 @@
"label": "Visualization type"
}
},
"panel": {
"applications": {
"empty": "No applications are currently playing audio"
},
"tabs": {
"applications": "Applications",
"devices": "Devices"
}
},
"title": "Audio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Downloaded",
"not-downloaded": "Not Downloaded"
},
"hot-reload": {
"description": "Automatically reload plugins when their files change. Useful for plugin development.",
"label": "Hot reload (dev mode)"
},
"hot-reloaded": "Reloaded plugin: {name}",
"install": "Install",
"install-error": "Failed to install: {error}",
"install-success": "Successfully installed {plugin}",
@@ -2123,12 +2137,7 @@
"update-incompatible": "Requires Noctalia v{version} or higher",
"update-success": "Updated {plugin} to v{version}",
"update-version": "v{current} → v{new}",
"updating": "Updating {plugin}...",
"hot-reload": {
"description": "Automatically reload plugins when their files change. Useful for plugin development.",
"label": "Hot reload (dev mode)"
},
"hot-reloaded": "Reloaded plugin: {name}"
"updating": "Updating {plugin}..."
},
"screen-recorder": {
"audio": {
@@ -2638,6 +2647,7 @@
"move-to-center-section": "Center section",
"move-to-left-section": "Left section",
"move-to-right-section": "Right section",
"mute": "Mute",
"next-media": "Next track",
"next-month": "Next month",
"night-light-disabled": "Night Light",
@@ -2672,6 +2682,7 @@
"stop-screen-recording": "Screen recorder",
"switch-to-dark-mode": "Dark Mode",
"switch-to-light-mode": "Light Mode",
"unmute": "Unmute",
"up": "Parent directory",
"volume-at": "Output volume: {volume}%",
"wallpaper-selector": "Wallpaper selector",
+16
View File
@@ -956,6 +956,15 @@
"label": "Tipo de visualización"
}
},
"panel": {
"applications": {
"empty": "Actualmente no hay aplicaciones reproduciendo audio."
},
"tabs": {
"applications": "Aplicaciones",
"devices": "Dispositivos"
}
},
"title": "Audio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Descargado",
"not-downloaded": "No descargado"
},
"hot-reload": {
"description": "Recarga automáticamente los plugins cuando sus archivos cambian. Útil para el desarrollo de plugins.",
"label": "Recarga en caliente (modo desarrollo)"
},
"hot-reloaded": "Plugin recargado: {name}",
"install": "Instalar",
"install-error": "Error al instalar: {error}",
"install-success": "Se instaló {plugin} correctamente.",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Sección central",
"move-to-left-section": "Sección izquierda",
"move-to-right-section": "Sección derecha",
"mute": "Silenciar",
"next-media": "Siguiente pista",
"next-month": "Mes siguiente",
"night-light-disabled": "Luz nocturna",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Grabadora de pantalla",
"switch-to-dark-mode": "Modo oscuro",
"switch-to-light-mode": "Modo claro",
"unmute": "Activar el audio",
"up": "Directorio superior",
"volume-at": "Volumen de salida: {volume}%",
"wallpaper-selector": "Selector de fondos de pantalla",
+16
View File
@@ -956,6 +956,15 @@
"label": "Type de visualisation"
}
},
"panel": {
"applications": {
"empty": "Aucune application ne lit actuellement de l'audio."
},
"tabs": {
"applications": "Applications",
"devices": "Appareils"
}
},
"title": "Audio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Téléchargé",
"not-downloaded": "Non téléchargé"
},
"hot-reload": {
"description": "Recharger automatiquement les plugins lorsque leurs fichiers sont modifiés. Utile pour le développement de plugins.",
"label": "Rechargement à chaud (mode développement)"
},
"hot-reloaded": "Plugin rechargé : {name}",
"install": "Installer",
"install-error": "Échec de l'installation : {error}",
"install-success": "Installation de {plugin} réussie.",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Section centrale",
"move-to-left-section": "Section de gauche",
"move-to-right-section": "Section de droite",
"mute": "Muet / Couper le son",
"next-media": "Piste suivante",
"next-month": "Mois suivant",
"night-light-disabled": "Éclairage nocturne",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Enregistreur d'écran",
"switch-to-dark-mode": "Mode sombre",
"switch-to-light-mode": "Mode clair",
"unmute": "Réactiver le micro",
"up": "Répertoire parent",
"volume-at": "Volume de sortie : {volume}%",
"wallpaper-selector": "Sélecteur de fond d'écran",
+16
View File
@@ -956,6 +956,15 @@
"label": "ビジュアライザーの種類"
}
},
"panel": {
"applications": {
"empty": "現在、音声を再生しているアプリケーションはありません。"
},
"tabs": {
"applications": "アプリケーション",
"devices": "デバイス"
}
},
"title": "サウンド",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "ダウンロード済み",
"not-downloaded": "ダウンロードされていません"
},
"hot-reload": {
"description": "プラグインのファイルが変更されたときに、自動的にプラグインを再読み込みします。プラグイン開発に便利です。",
"label": "ホットリロード(開発モード)"
},
"hot-reloaded": "プラグインを再読み込みしました: {name}",
"install": "インストール",
"install-error": "インストールに失敗しました: {error}",
"install-success": "{plugin} のインストールに成功しました。",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "中央セクション",
"move-to-left-section": "左セクション",
"move-to-right-section": "右セクション",
"mute": "ミュート",
"next-media": "次のトラック",
"next-month": "翌月",
"night-light-disabled": "夜間モード",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "画面録画",
"switch-to-dark-mode": "ダークモード",
"switch-to-light-mode": "ライトモード",
"unmute": "ミュート解除",
"up": "親ディレクトリ",
"volume-at": "出力音量: {volume}%",
"wallpaper-selector": "壁紙選択パネル",
+16
View File
@@ -956,6 +956,15 @@
"label": "Type visualisatie"
}
},
"panel": {
"applications": {
"empty": "Er spelen momenteel geen applicaties audio af."
},
"tabs": {
"applications": "Toepassingen",
"devices": "Apparaten"
}
},
"title": "Audio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Gedownload",
"not-downloaded": "Niet gedownload"
},
"hot-reload": {
"description": "Laad plugins automatisch opnieuw wanneer hun bestanden veranderen. Handig voor plugin-ontwikkeling.",
"label": "Hot reload (ontwikkelmodus)"
},
"hot-reloaded": "Plugin opnieuw geladen: {name}",
"install": "Installeren",
"install-error": "Installatie mislukt: {error}",
"install-success": "{plugin} succesvol geïnstalleerd",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Middelste sectie",
"move-to-left-section": "Linker sectie",
"move-to-right-section": "Rechter sectie",
"mute": "Dempen",
"next-media": "Volgende track",
"next-month": "Volgende maand",
"night-light-disabled": "Nachtlicht",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Schermrecorder",
"switch-to-dark-mode": "Donkere modus",
"switch-to-light-mode": "Lichte modus",
"unmute": "Dempen opheffen",
"up": "Bovenliggende map",
"volume-at": "Uitvoervolume: {volume}%",
"wallpaper-selector": "Achtergrondkiezer",
+16
View File
@@ -956,6 +956,15 @@
"label": "Tipo de visualização"
}
},
"panel": {
"applications": {
"empty": "Nenhum aplicativo está reproduzindo áudio no momento."
},
"tabs": {
"applications": "Aplicações",
"devices": "Dispositivos"
}
},
"title": "Áudio",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Baixado",
"not-downloaded": "Não Baixado"
},
"hot-reload": {
"description": "Recarrega automaticamente os plugins quando seus arquivos são alterados. Útil para o desenvolvimento de plugins.",
"label": "Recarga a quente (modo de desenvolvimento)"
},
"hot-reloaded": "Plugin recarregado: {name}",
"install": "Instalar",
"install-error": "Falha ao instalar: {error}",
"install-success": "{plugin} instalado com sucesso.",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Seção central",
"move-to-left-section": "Seção esquerda",
"move-to-right-section": "Seção direita",
"mute": "Silenciar",
"next-media": "Próxima faixa",
"next-month": "Próximo mês",
"night-light-disabled": "Luz noturna",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Gravador de tela",
"switch-to-dark-mode": "Modo escuro",
"switch-to-light-mode": "Modo claro",
"unmute": "Desativar o mudo",
"up": "Diretório superior",
"volume-at": "Volume de saída: {volume}%",
"wallpaper-selector": "Seletor de papel de parede",
+16
View File
@@ -956,6 +956,15 @@
"label": "Тип визуализации"
}
},
"panel": {
"applications": {
"empty": "В данный момент нет приложений, воспроизводящих звук."
},
"tabs": {
"applications": "Приложения",
"devices": "Устройства"
}
},
"title": "Аудио",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Скачано",
"not-downloaded": "Не загружено"
},
"hot-reload": {
"description": "Автоматически перезагружать плагины при изменении их файлов. Полезно для разработки плагинов.",
"label": "Горячая перезагрузка (режим разработки)"
},
"hot-reloaded": "Перезагружен плагин: {name}",
"install": "Установить",
"install-error": "Не удалось установить: {error}",
"install-success": "Успешно установлен {plugin}",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Центральная секция",
"move-to-left-section": "Левая секция",
"move-to-right-section": "Правая секция",
"mute": "Отключить звук",
"next-media": "Следующий трек",
"next-month": "Следующий месяц",
"night-light-disabled": "Ночной свет",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Запись экрана",
"switch-to-dark-mode": "Темный режим",
"switch-to-light-mode": "Светлый режим",
"unmute": "Включить звук",
"up": "Родительский каталог",
"volume-at": "Громкость вывода: {volume}%",
"wallpaper-selector": "Выбор обоев",
+16
View File
@@ -956,6 +956,15 @@
"label": "Görselleştirme türü"
}
},
"panel": {
"applications": {
"empty": "Şu anda ses çalan uygulama yok."
},
"tabs": {
"applications": "Uygulamalar",
"devices": "Cihazlar"
}
},
"title": "Ses",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "İndirildi",
"not-downloaded": "İndirilmedi"
},
"hot-reload": {
"description": "Eklenti dosyaları değiştiğinde eklentileri otomatik olarak yeniden yükle. Eklenti geliştirme için kullanışlıdır.",
"label": "Hızlı yeniden yükleme (geliştirme modu)"
},
"hot-reloaded": "Yeniden yüklenen eklenti: {name}",
"install": "Yükle",
"install-error": "Yükleme başarısız oldu: {error}",
"install-success": "{plugin} başarıyla kuruldu",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Orta bölüm",
"move-to-left-section": "Sol bölüm",
"move-to-right-section": "Sağ bölüm",
"mute": "Sessiz",
"next-media": "Sonraki parça",
"next-month": "Sonraki ay",
"night-light-disabled": "Gece ışığı",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Ekran kaydedici",
"switch-to-dark-mode": "Koyu mod",
"switch-to-light-mode": "Açık mod",
"unmute": "Sessizliği aç",
"up": "Üst dizin",
"volume-at": "Çıkış sesi: %{volume}",
"wallpaper-selector": "Duvar kâğıdı seçici",
+16
View File
@@ -956,6 +956,15 @@
"label": "Тип візуалізації"
}
},
"panel": {
"applications": {
"empty": "Зараз жоден додаток не відтворює аудіо."
},
"tabs": {
"applications": "Застосунки",
"devices": "Пристрої"
}
},
"title": "Аудіо",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "Завантажено",
"not-downloaded": "Не завантажено"
},
"hot-reload": {
"description": "Автоматично перезавантажувати плагіни при зміні їхніх файлів. Корисно для розробки плагінів.",
"label": "Гаряче перезавантаження (режим розробки)"
},
"hot-reloaded": "Перезавантажено плагін: {name}",
"install": "Встановити",
"install-error": "Не вдалося встановити: {error}",
"install-success": "Успішно встановлено {plugin}",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "Центральна секція",
"move-to-left-section": "Ліва секція",
"move-to-right-section": "Права секція",
"mute": "Вимкнути звук",
"next-media": "Наступний трек",
"next-month": "Наступний місяць",
"night-light-disabled": "Нічне світло",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "Запис екрана",
"switch-to-dark-mode": "Темний режим",
"switch-to-light-mode": "Світлий режим",
"unmute": "Увімкнути звук",
"up": "Батьківський каталог",
"volume-at": "Вихідна гучність: {volume}%",
"wallpaper-selector": "Вибір шпалер",
+16
View File
@@ -956,6 +956,15 @@
"label": "可视化类型"
}
},
"panel": {
"applications": {
"empty": "目前没有应用程序正在播放音频。"
},
"tabs": {
"applications": "应用",
"devices": "设备"
}
},
"title": "音频",
"volumes": {
"input-volume": {
@@ -2068,6 +2077,11 @@
"downloaded": "已下载",
"not-downloaded": "未下载"
},
"hot-reload": {
"description": "当插件文件发生更改时自动重新加载插件。对插件开发很有用。",
"label": "热重载(开发模式)"
},
"hot-reloaded": "已重新加载插件:{name}",
"install": "安装",
"install-error": "安装失败:{error}",
"install-success": "成功安装 {plugin}",
@@ -2633,6 +2647,7 @@
"move-to-center-section": "中央部分",
"move-to-left-section": "左侧部分",
"move-to-right-section": "右侧部分",
"mute": "静音",
"next-media": "下一首",
"next-month": "下个月",
"night-light-disabled": "夜间模式",
@@ -2667,6 +2682,7 @@
"stop-screen-recording": "屏幕录制器",
"switch-to-dark-mode": "深色模式",
"switch-to-light-mode": "浅色模式",
"unmute": "取消静音",
"up": "上级目录",
"volume-at": "输出音量:{volume}%",
"wallpaper-selector": "壁纸选择器",
+510 -97
View File
@@ -19,6 +19,73 @@ SmartPanel {
property bool localInputVolumeChanging: false
property int lastSourceId: -1
property int currentTabIndex: 0
// Find application streams that are actually playing audio (connected to default sink)
// Use linkGroups to find nodes connected to the default audio sink
// Note: We need to use link IDs since source/target properties require binding
readonly property var appStreams: {
if (!Pipewire.ready || !AudioService.sink) {
return [];
}
var defaultSink = AudioService.sink;
var defaultSinkId = defaultSink.id;
var connectedStreamIds = {};
var connectedStreams = [];
// Use PwNodeLinkTracker to get properly bound link groups
if (!sinkLinkTracker.linkGroups) {
return [];
}
// Check if linkGroups is an array or ObjectModel
var linkGroupsCount = 0;
if (sinkLinkTracker.linkGroups.length !== undefined) {
linkGroupsCount = sinkLinkTracker.linkGroups.length;
} else if (sinkLinkTracker.linkGroups.count !== undefined) {
linkGroupsCount = sinkLinkTracker.linkGroups.count;
} else {
return [];
}
// Get source nodes from link groups (these are properly bound via PwNodeLinkTracker)
for (var i = 0; i < linkGroupsCount; i++) {
var linkGroup;
if (sinkLinkTracker.linkGroups.get) {
// ObjectModel
linkGroup = sinkLinkTracker.linkGroups.get(i);
} else {
// Array
linkGroup = sinkLinkTracker.linkGroups[i];
}
if (!linkGroup || !linkGroup.source) {
continue;
}
var sourceNode = linkGroup.source;
// Include stream nodes (applications) - these can be virtual sinks created by apps
// Virtual sinks (isStream: true, isSink: true) are application-created nodes
// Hardware sinks (isStream: false, isSink: true) are actual audio devices
if (sourceNode.isStream && sourceNode.audio) {
// Avoid duplicates
if (!connectedStreamIds[sourceNode.id]) {
connectedStreamIds[sourceNode.id] = true;
connectedStreams.push(sourceNode);
}
}
}
return connectedStreams;
}
// Track links to the default sink using PwNodeLinkTracker (properly binds links)
PwNodeLinkTracker {
id: sinkLinkTracker
node: AudioService.sink
}
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(420 * Style.uiScaleRatio)
@@ -202,127 +269,473 @@ SmartPanel {
}
}
NScrollView {
// Tab Bar
NTabBar {
id: tabBar
Layout.fillWidth: true
currentIndex: root.currentTabIndex
onCurrentIndexChanged: root.currentTabIndex = currentIndex
NTabButton {
Layout.fillWidth: true
text: I18n.tr("settings.audio.panel.tabs.devices")
tabIndex: 0
checked: tabBar.currentIndex === 0
}
NTabButton {
Layout.fillWidth: true
text: I18n.tr("settings.audio.panel.tabs.applications")
tabIndex: 1
checked: tabBar.currentIndex === 1
}
}
// Content Stack
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
clip: true
contentWidth: availableWidth
currentIndex: root.currentTabIndex
// AudioService Devices
ColumnLayout {
spacing: Style.marginM
width: parent.width
// Devices Tab
NScrollView {
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
clip: true
contentWidth: availableWidth
// -------------------------------
// Output Devices
ButtonGroup {
id: sinks
}
// AudioService Devices
ColumnLayout {
spacing: Style.marginM
width: parent.width
NBox {
Layout.fillWidth: true
Layout.preferredHeight: outputColumn.implicitHeight + (Style.marginM * 2)
// -------------------------------
// Output Devices
ButtonGroup {
id: sinks
}
ColumnLayout {
id: outputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginS
NBox {
Layout.fillWidth: true
Layout.preferredHeight: outputColumn.implicitHeight + (Style.marginM * 2)
NText {
text: I18n.tr("settings.audio.devices.output-device.label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
ColumnLayout {
id: outputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginS
// Output Volume Slider
NValueSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localOutputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: localOutputVolume = value
onPressedChanged: localOutputVolumeChanging = pressed
text: Math.round(localOutputVolume * 100) + "%"
Layout.bottomMargin: Style.marginM
}
NText {
text: I18n.tr("settings.audio.devices.output-device.label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
Repeater {
model: AudioService.sinks
NRadioButton {
ButtonGroup.group: sinks
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.sink?.id === modelData.id
onClicked: {
AudioService.setAudioSink(modelData);
localOutputVolume = AudioService.volume;
}
// Output Volume Slider
NValueSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localOutputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: function (value) {
localOutputVolume = value;
}
onPressedChanged: function (pressed) {
localOutputVolumeChanging = pressed;
}
text: Math.round(localOutputVolume * 100) + "%"
Layout.bottomMargin: Style.marginM
}
Repeater {
model: AudioService.sinks
NRadioButton {
ButtonGroup.group: sinks
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.sink?.id === modelData.id
onClicked: {
AudioService.setAudioSink(modelData);
localOutputVolume = AudioService.volume;
}
Layout.fillWidth: true
}
}
}
}
// -------------------------------
// Input Devices
ButtonGroup {
id: sources
}
NBox {
Layout.fillWidth: true
Layout.preferredHeight: inputColumn.implicitHeight + (Style.marginM * 2)
ColumnLayout {
id: inputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Style.marginM
spacing: Style.marginS
NText {
text: I18n.tr("settings.audio.devices.input-device.label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
// Input Volume Slider
NValueSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localInputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: function (value) {
localInputVolume = value;
}
onPressedChanged: function (pressed) {
localInputVolumeChanging = pressed;
}
text: Math.round(localInputVolume * 100) + "%"
Layout.bottomMargin: Style.marginM
}
Repeater {
model: AudioService.sources
NRadioButton {
ButtonGroup.group: sources
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.source?.id === modelData.id
onClicked: AudioService.setAudioSource(modelData)
Layout.fillWidth: true
}
}
}
}
}
}
// -------------------------------
// Input Devices
ButtonGroup {
id: sources
}
// Applications Tab
NScrollView {
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
clip: true
contentWidth: availableWidth
NBox {
Layout.fillWidth: true
Layout.preferredHeight: inputColumn.implicitHeight + (Style.marginM * 2)
ColumnLayout {
spacing: Style.marginM
width: parent.width
ColumnLayout {
id: inputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Style.marginM
spacing: Style.marginS
// Bind all app stream nodes to access their audio properties
PwObjectTracker {
id: appStreamsTracker
objects: root.appStreams
}
NText {
text: I18n.tr("settings.audio.devices.input-device.label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
Repeater {
model: root.appStreams
// Input Volume Slider
NValueSlider {
NBox {
id: appBox
required property PwNode modelData
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localInputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: localInputVolume = value
onPressedChanged: localInputVolumeChanging = pressed
text: Math.round(localInputVolume * 100) + "%"
Layout.bottomMargin: Style.marginM
}
Layout.preferredHeight: appRow.implicitHeight + (Style.marginM * 2)
visible: !isCaptureStream
Repeater {
model: AudioService.sources
NRadioButton {
ButtonGroup.group: sources
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.source?.id === modelData.id
onClicked: AudioService.setAudioSource(modelData)
Layout.fillWidth: true
// Track individual node to ensure properties are bound
PwObjectTracker {
objects: modelData ? [modelData] : []
}
property PwNodeAudio nodeAudio: (modelData && modelData.audio) ? modelData.audio : null
property real appVolume: (nodeAudio && nodeAudio.volume !== undefined) ? nodeAudio.volume : 0.0
property bool appMuted: (nodeAudio && nodeAudio.muted !== undefined) ? nodeAudio.muted : false
property bool volumeChanging: false
// Check if this is a capture stream (after node is bound)
readonly property bool isCaptureStream: {
if (!modelData || !modelData.properties)
return false;
const props = modelData.properties;
// Exclude capture streams - check for stream.capture.sink property
if (props["stream.capture.sink"] !== undefined) {
return true;
}
const mediaClass = props["media.class"] || "";
// Exclude Stream/Input (capture) but allow Stream/Output (playback)
if (mediaClass.includes("Capture") || mediaClass === "Stream/Input" || mediaClass === "Stream/Input/Audio") {
return true;
}
const mediaRole = props["media.role"] || "";
if (mediaRole === "Capture") {
return true;
}
return false;
}
// Get app name from properties (reactive computed property)
// Access modelData.ready to ensure reactivity when node becomes ready
readonly property string appName: {
if (!modelData) {
return "Unknown App";
}
var props = modelData.properties;
var desc = modelData.description || "";
var name = modelData.name || "";
// If properties aren't available yet, try description or name
if (!props) {
if (desc) {
return desc;
}
if (name) {
// Try to extract meaningful name from node name
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
var extracted = nameParts[0];
if (extracted) {
return extracted.charAt(0).toUpperCase() + extracted.slice(1);
}
}
return name;
}
return "Unknown App";
}
// Try to get application name from various properties
var computedAppName = props["application.name"] || "";
var mediaName = props["media.name"] || "";
var mediaTitle = props["media.title"] || "";
var appId = props["application.id"] || "";
var binaryName = props["application.process.binary"] || "";
// If we have application.id, try to extract app name from it (e.g., "firefox.desktop" -> "firefox")
if (!computedAppName && appId) {
var parts = appId.split(".");
if (parts.length > 0) {
computedAppName = parts[0];
// Capitalize first letter and format nicely
if (computedAppName) {
computedAppName = computedAppName.charAt(0).toUpperCase() + computedAppName.slice(1);
}
}
}
// Try binary name as fallback
if (!computedAppName && binaryName) {
var binParts = binaryName.split("/");
if (binParts.length > 0) {
computedAppName = binParts[binParts.length - 1];
if (computedAppName) {
computedAppName = computedAppName.charAt(0).toUpperCase() + computedAppName.slice(1);
}
}
}
// Priority: application.name > media.title > media.name > binary > description > name
var result = computedAppName || mediaTitle || mediaName || binaryName || desc || name;
// If we still don't have a good name, try to extract from node name
if (!result || result === "" || result === "Unknown App") {
if (name) {
// Try to extract meaningful name from node name (e.g., "firefox-1234" -> "firefox")
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
result = nameParts[0];
// Capitalize first letter
if (result) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
}
}
}
return result || "Unknown App";
}
// Get app icon from properties (returns file path)
readonly property string appIcon: {
if (!modelData) {
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
}
var props = modelData.properties;
if (!props) {
// Try to get icon from app name
var name = modelData.name || "";
if (name) {
// Extract app name from node name (e.g., "firefox-1234" -> "firefox")
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
var appName = nameParts[0].toLowerCase();
return ThemeIcons.iconFromName(appName, "application-x-executable");
}
}
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
}
// Try application.icon-name first (from Pipewire)
var iconName = props["application.icon-name"] || "";
if (iconName) {
var iconPath = ThemeIcons.iconFromName(iconName, "");
if (iconPath && iconPath !== "") {
return iconPath;
}
}
// Try to get app ID and resolve from desktop entry
var appId = props["application.id"] || "";
if (appId) {
var iconPathFromId = ThemeIcons.iconForAppId(appId.toLowerCase(), "");
if (iconPathFromId && iconPathFromId !== "") {
return iconPathFromId;
}
}
// Try application.name
var appName = props["application.name"] || "";
if (appName) {
var iconPathFromName = ThemeIcons.iconFromName(appName.toLowerCase(), "");
if (iconPathFromName && iconPathFromName !== "") {
return iconPathFromName;
}
}
// Try binary name
var binaryName = props["application.process.binary"] || "";
if (binaryName) {
var binParts = binaryName.split("/");
if (binParts.length > 0) {
var binName = binParts[binParts.length - 1].toLowerCase();
var iconPathFromBinary = ThemeIcons.iconFromName(binName, "");
if (iconPathFromBinary && iconPathFromBinary !== "") {
return iconPathFromBinary;
}
}
}
// Try node name as fallback
var name = modelData.name || "";
if (name) {
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
var extractedName = nameParts[0].toLowerCase();
var iconPathFromNodeName = ThemeIcons.iconFromName(extractedName, "");
if (iconPathFromNodeName && iconPathFromNodeName !== "") {
return iconPathFromNodeName;
}
}
}
// Final fallback
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
}
RowLayout {
id: appRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// App Icon
Image {
id: appIconImage
Layout.preferredWidth: Style.baseWidgetSize
Layout.preferredHeight: Style.baseWidgetSize
source: appBox.appIcon
sourceSize.width: Style.baseWidgetSize * 2
sourceSize.height: Style.baseWidgetSize * 2
smooth: true
mipmap: true
antialiasing: true
fillMode: Image.PreserveAspectFit
cache: true
asynchronous: true
// Fallback icon if image fails to load
NIcon {
anchors.fill: parent
icon: "apps"
pointSize: Style.fontSizeXL
color: Color.mPrimary
visible: appIconImage.status === Image.Error || appIconImage.status === Image.Null || appBox.appIcon === ""
}
}
// App Name and Volume Slider
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: appBox.appName || "Unknown App"
pointSize: Style.fontSizeM
color: Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NValueSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: (appBox.appVolume !== undefined) ? appBox.appVolume : 0.0
stepSize: 0.01
heightRatio: 0.5
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
onMoved: function (value) {
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
appBox.nodeAudio.volume = value;
}
}
onPressedChanged: function (pressed) {
appBox.volumeChanging = pressed;
}
text: Math.round((appBox.appVolume !== undefined ? appBox.appVolume : 0.0) * 100) + "%"
}
}
// Mute Button
NIconButton {
icon: (appBox.appMuted === true) ? "volume-mute" : "volume-high"
tooltipText: (appBox.appMuted === true) ? I18n.tr("tooltips.unmute") : I18n.tr("tooltips.mute")
baseSize: Style.baseWidgetSize * 0.8
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
onClicked: {
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
appBox.nodeAudio.muted = !appBox.appMuted;
}
}
}
}
}
}
// Empty state
NText {
visible: root.appStreams.length === 0
text: I18n.tr("settings.audio.panel.applications.empty")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: Style.marginXL
}
}
}
}