From c81b943fa0c2b21f2f2dfd591a72307e620a57cc Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 8 Oct 2026 22:30:37 +0200 Subject: [PATCH] feat(quickshell): add native audio mixer --- .../.config/quickshell/labfy-sway/README.md | 4 + .../labfy-sway/controlcenter/AUDIO_MIXER.md | 44 ++++ .../controlcenter/AudioMixerModel.js | 59 +++++ .../controlcenter/AudioMixerPage.qml | 235 ++++++++++++++++++ .../controlcenter/ControlCenter.qml | 10 +- .../controlcenter/MixerLevelRow.qml | 118 +++++++++ .../labfy-sway/status/RightStatusArea.qml | 2 + .../labfy-sway/status/ScrollIcon.qml | 5 +- tests/audio_mixer/test_model.js | 48 ++++ 9 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/AUDIO_MIXER.md create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerPage.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/MixerLevelRow.qml create mode 100644 tests/audio_mixer/test_model.js diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index cc521312c..f6feb764e 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -5,6 +5,10 @@ Le [moteur de thème commun](theme/README.md) fournit les couleurs effectives Barre principale et serveur de notifications de la session SwayFX. +Le haut-parleur de la barre ouvre le [mixeur audio du Control Center](controlcenter/AUDIO_MIXER.md), +avec sorties, flux de lecture et microphones. Le volume général conserve son +contrôleur partagé avec la barre et ses pas de 1 point au défilement. + Le bouton Arch **Applications**, premier élément à gauche même en mode Resize, ouvre le [menu d'applications](applications/README.md) sous la barre. `Mod+Alt+L` ouvre ce menu sur la sortie focalisée. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AUDIO_MIXER.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/AUDIO_MIXER.md new file mode 100644 index 000000000..67beb7be9 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AUDIO_MIXER.md @@ -0,0 +1,44 @@ +# Mixeur audio du Control Center + +Le clic sur l'indicateur de volume de la barre ouvre la page **Audio** du +Control Center. Le défilement de la barre conserve ses pas de 1 point et son +contrôleur `VolumeSlider`. La page est intégrée au `StackLayout` existant et +possède un bouton Retour vers Réglages rapides ; elle ne crée ni fenêtre ni +service supplémentaire. + +La page utilise `Quickshell.Services.Pipewire`. `Pipewire.nodes` fournit les +objets vivants et `PwObjectTracker` lie les nœuds audio uniquement tant que la +page est active. Le volume général lit exactement le `sinkAudio` du +`VolumeSlider` déjà partagé avec la barre. Les trois listes se fondent sur +`media.class` et les propriétés de direction : `Audio/Sink` pour les sorties, +`Audio/Source` pour les microphones et `Stream/Output/Audio` pour les flux de +lecture. Les flux de capture, de vidéo et les nœuds techniques de monitoring +ou de groupe loopback sont exclus. Chaque objet de flux garde sa propre ligne ; +aucune identité temporaire n'est persistée et l'ordre des survivants suit le +catalogue PipeWire pendant les interactions. + +Choisir une sortie ou une entrée écrit `preferredDefaultAudioSink` ou +`preferredDefaultAudioSource`. La coche et le libellé « utilisée » suivent +uniquement `defaultAudioSink` et `defaultAudioSource` effectivement observés. +La préférence peut être différente de la valeur effective ou prendre du temps +à s'appliquer. Un changement de sortie par défaut ne garantit pas le transfert +des flux déjà ouverts. Le routage par application et les profils Bluetooth ne +font pas partie de cette V1. + +Chaque curseur affiche le volume réel, borné à 0–100 % seulement pour une +nouvelle demande utilisateur. Une valeur externe supérieure à 100 % demeure +affichée sans écriture automatique. Le bouton muet est indépendant du curseur : +modifier le volume n'active jamais un flux ou un microphone muet. La page ne +lance aucune capture de microphone et n'écrit aucun paramètre à l'ouverture ou +à la fermeture. Les commandes restent des propriétés natives QuickShell ; +`wpctl` n'est utilisé que pour la confirmation des tests. + +`AudioMixerModel.js` contient le filtrage et les conversions vérifiées par +`node tests/audio_mixer/test_model.js`. Les essais réels de V1 ont utilisé deux +flux `pw-play` silencieux et non reliés (`--target 0`) : leurs volumes ont été +réglés indépendamment depuis l'interface et confirmés par PipeWire. La +sourdine d'un flux est restée active après un déplacement de son curseur ; une +modification externe et une disparition pendant le réglage ont aussi été +reflétées dans la page. Aucun flux utilisateur n'a été modifié. Le choix réel +d'une autre sortie ou entrée et la sourdine matérielle du microphone restent à +contrôler manuellement, sans déconnecter la sortie courante ni activer le micro. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js b/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js new file mode 100644 index 000000000..8af251b77 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js @@ -0,0 +1,59 @@ +.pragma library + +// CONTRACT: classify only ready, tracked PipeWire audio nodes by media.class +// and direction. A capture stream or technical node cannot become playback UI. +function mediaClass(node) { + return node && node.ready && node.properties + ? String(node.properties["media.class"] || "") : ""; +} + +function outputDevices(nodes) { + return nodes.filter(node => node && node.audio && !node.isStream && node.isSink + && mediaClass(node) === "Audio/Sink"); +} + +function inputDevices(nodes) { + return nodes.filter(node => node && node.audio && !node.isStream && !node.isSink + && mediaClass(node) === "Audio/Source" + && !node.properties["node.group"] + && node.properties["node.virtual"] !== true + && node.properties["node.virtual"] !== "true" + && node.properties["node.monitor"] !== true + && node.properties["node.monitor"] !== "true"); +} + +function playbackStreams(nodes) { + // Keep PipeWire's insertion order. New or removed streams do not reshuffle + // surviving controls while a user is dragging one of their sliders. + return nodes.filter(node => node && node.audio && node.isStream + && mediaClass(node) === "Stream/Output/Audio"); +} + +function deviceName(node) { + return node ? (node.description || node.nickname || node.name || "Périphérique audio") + : "Périphérique indisponible"; +} + +function streamName(node) { + if (!node) return "Application"; + const props = node.properties || {}; + return props["application.name"] || node.description || node.name || "Application"; +} + +function streamDetail(node) { + if (!node) return ""; + const name = streamName(node); + return node.name && node.name !== name ? node.name : "Flux audio · " + node.id; +} + +function streamIcon(node) { + return node && node.properties ? String(node.properties["application.icon-name"] || "") : ""; +} + +function displayPercent(volume) { + return Math.round(Number(volume) * 100); +} + +function requestedVolume(percent) { + return Math.max(0, Math.min(100, Math.round(percent))) / 100; +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerPage.qml new file mode 100644 index 000000000..cb245817e --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerPage.qml @@ -0,0 +1,235 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Services.Pipewire +import "../theme" +import "AudioMixerModel.js" as MixerModel + +Item { + id: page + required property bool activePage + required property var volumeControl + signal backRequested() + property var pendingSink: null + property var pendingSource: null + + readonly property var audioNodes: Pipewire.nodes.values.filter(node => node && node.audio) + readonly property var outputDevices: MixerModel.outputDevices(audioNodes) + readonly property var inputDevices: MixerModel.inputDevices(audioNodes) + readonly property var playbackStreams: MixerModel.playbackStreams(audioNodes) + readonly property var effectiveSink: Pipewire.defaultAudioSink + readonly property var effectiveSource: Pipewire.defaultAudioSource + + + // CONTRACT: bind audio nodes only while the mixer is visible. The always + // active VolumeSlider continues to own tracking of the shared default sink. + PwObjectTracker { objects: page.activePage ? page.audioNodes : [] } + + onEffectiveSinkChanged: if (pendingSink === effectiveSink) pendingSink = null + onEffectiveSourceChanged: if (pendingSource === effectiveSource) pendingSource = null + onOutputDevicesChanged: if (pendingSink && !outputDevices.includes(pendingSink)) pendingSink = null + onInputDevicesChanged: if (pendingSource && !inputDevices.includes(pendingSource)) pendingSource = null + onActivePageChanged: { + if (!activePage) { pendingSink = null; pendingSource = null; } + else resetScroll.start(); + } + Timer { + id: resetScroll + interval: 0; repeat: false + onTriggered: if (scroll.contentItem) scroll.contentItem.contentY = 0 + } + + Column { + anchors.fill: parent + spacing: 9 + Row { + width: parent.width; height: 30; spacing: 8 + Button { + width: 40; height: 30 + Accessible.name: "Retour aux réglages rapides" + onClicked: page.backRequested() + background: Rectangle { + radius: 4 + color: parent.hovered ? Theme.buttonHover : Theme.buttonBackground + border.color: parent.activeFocus ? Theme.lavender : "transparent" + } + contentItem: Text { + text: ""; color: Theme.foreground + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Text { + height: 30; verticalAlignment: Text.AlignVCenter + text: "Audio" + color: Theme.foreground; font.pixelSize: 16; font.bold: true + } + } + ScrollView { + id: scroll + width: parent.width + height: Math.max(0, parent.height - y) + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + Column { + width: scroll.availableWidth + spacing: 8 + + Text { text: "SORTIE AUDIO"; color: Theme.lavender; font.pixelSize: 11; font.bold: true } + Text { + width: parent.width + text: !Pipewire.ready ? "Connexion audio en cours…" + : page.effectiveSink ? "Utilisée : " + MixerModel.deviceName(page.effectiveSink) + : "Aucune sortie active" + color: Theme.secondaryForeground; font.pixelSize: 11 + elide: Text.ElideRight + } + Repeater { + model: page.outputDevices + delegate: Button { + required property var modelData + width: scroll.availableWidth; height: 31 + enabled: page.activePage && modelData.ready + Accessible.name: "Choisir la sortie " + MixerModel.deviceName(modelData) + onClicked: { + if (page.effectiveSink === modelData) return; + page.pendingSink = modelData; + Pipewire.preferredDefaultAudioSink = modelData; + } + background: Rectangle { + radius: 4 + color: page.effectiveSink === modelData ? Theme.buttonPressed + : parent.hovered ? Theme.buttonHover : Theme.buttonBackground + border.color: parent.activeFocus ? Theme.lavender : "transparent" + } + contentItem: Row { + spacing: 7 + Text { + width: 18; height: 18; anchors.verticalCenter: parent.verticalCenter + text: "󰕾"; color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 15 + } + Text { + width: scroll.availableWidth - 54 + anchors.verticalCenter: parent.verticalCenter + text: MixerModel.deviceName(modelData) + color: Theme.foreground; font.pixelSize: 11; elide: Text.ElideRight + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: page.effectiveSink === modelData ? "✓" : "" + color: Theme.lavender; font.pixelSize: 12 + } + } + } + } + Text { + visible: page.outputDevices.length === 0 && Pipewire.ready + text: "Aucune sortie disponible" + color: Theme.secondaryForeground; font.pixelSize: 11 + } + Text { + visible: page.pendingSink !== null && page.pendingSink !== page.effectiveSink + text: "Changement de sortie demandé…" + color: Theme.secondaryForeground; font.pixelSize: 10 + } + MixerLevelRow { + width: scroll.availableWidth + title: "Volume général" + fallbackGlyph: "󰕾" + audio: page.volumeControl.sinkAudio + } + + Rectangle { width: scroll.availableWidth; height: 1; color: Theme.separator } + Text { text: "APPLICATIONS"; color: Theme.lavender; font.pixelSize: 11; font.bold: true } + Text { + visible: page.playbackStreams.length === 0 + text: Pipewire.ready ? "Aucun flux de lecture" : "Recherche des flux…" + color: Theme.secondaryForeground; font.pixelSize: 11 + } + Repeater { + model: page.playbackStreams + delegate: MixerLevelRow { + required property var modelData + width: scroll.availableWidth + title: MixerModel.streamName(modelData) + detail: MixerModel.streamDetail(modelData) + iconName: MixerModel.streamIcon(modelData) + fallbackGlyph: "󰀻" + audio: modelData && modelData.ready ? modelData.audio : null + } + } + + Rectangle { width: scroll.availableWidth; height: 1; color: Theme.separator } + Text { text: "MICROPHONE"; color: Theme.lavender; font.pixelSize: 11; font.bold: true } + Text { + width: parent.width + text: !Pipewire.ready ? "Connexion audio en cours…" + : page.effectiveSource ? "Utilisé : " + MixerModel.deviceName(page.effectiveSource) + : "Aucun microphone actif" + color: Theme.secondaryForeground; font.pixelSize: 11 + elide: Text.ElideRight + } + Repeater { + model: page.inputDevices + delegate: Button { + required property var modelData + width: scroll.availableWidth; height: 31 + enabled: page.activePage && modelData.ready + Accessible.name: "Choisir le microphone " + MixerModel.deviceName(modelData) + onClicked: { + if (page.effectiveSource === modelData) return; + page.pendingSource = modelData; + Pipewire.preferredDefaultAudioSource = modelData; + } + background: Rectangle { + radius: 4 + color: page.effectiveSource === modelData ? Theme.buttonPressed + : parent.hovered ? Theme.buttonHover : Theme.buttonBackground + border.color: parent.activeFocus ? Theme.lavender : "transparent" + } + contentItem: Row { + spacing: 7 + Text { + width: 18; height: 18; anchors.verticalCenter: parent.verticalCenter + text: ""; color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 15 + } + Text { + width: scroll.availableWidth - 54 + anchors.verticalCenter: parent.verticalCenter + text: MixerModel.deviceName(modelData) + color: Theme.foreground; font.pixelSize: 11; elide: Text.ElideRight + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: page.effectiveSource === modelData ? "✓" : "" + color: Theme.lavender; font.pixelSize: 12 + } + } + } + } + Text { + visible: page.inputDevices.length === 0 && Pipewire.ready + text: "Aucun microphone disponible" + color: Theme.secondaryForeground; font.pixelSize: 11 + } + Text { + visible: page.pendingSource !== null && page.pendingSource !== page.effectiveSource + text: "Changement de microphone demandé…" + color: Theme.secondaryForeground; font.pixelSize: 10 + } + MixerLevelRow { + width: scroll.availableWidth + title: "Niveau du microphone" + fallbackGlyph: "" + microphone: true + audio: page.effectiveSource && page.effectiveSource.ready + ? page.effectiveSource.audio : null + } + Item { width: 1; height: 5 } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index ca6ab0973..45ff5f165 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -17,7 +17,7 @@ PopupWindow { readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12, - checkpointFailure: 13, avatar: 14 }) + checkpointFailure: 13, avatar: 14, audio: 15 }) property int currentPage: pages.main function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() @@ -125,7 +125,8 @@ PopupWindow { : currentPage === 5 ? batteryPage.implicitHeight + 32 : currentPage === pages.wallpaper ? 550 : currentPage === pages.appearance ? 610 - : currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 : 440 + : currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 + : currentPage === pages.audio ? 620 : 440 visible: false // CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après // fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible @@ -330,6 +331,11 @@ PopupWindow { activePage: popup.visible && popup.currentPage === popup.pages.avatar onBackRequested: popup.openPage("appearance") } + AudioMixerPage { + activePage: popup.visible && popup.currentPage === popup.pages.audio + volumeControl: volumeSlider + onBackRequested: popup.openPage("main") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/MixerLevelRow.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/MixerLevelRow.qml new file mode 100644 index 000000000..e0f16e358 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/MixerLevelRow.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import "../theme" +import "AudioMixerModel.js" as MixerModel + +Item { + id: row + required property string title + property string detail: "" + property string iconName: "" + property string fallbackGlyph: "" + property bool microphone: false + property var audio: null + readonly property int percent: audio ? MixerModel.displayPercent(audio.volume) : 0 + implicitHeight: detail ? 74 : 61 + + Image { + id: icon + anchors.left: parent.left + anchors.top: parent.top + width: 22; height: 22 + source: row.iconName ? Quickshell.iconPath(row.iconName) : "" + sourceSize.width: 22; sourceSize.height: 22 + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + } + Text { + anchors.centerIn: icon + visible: !icon.visible + text: row.fallbackGlyph + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 17 + color: Theme.lavender + } + Text { + id: titleText + anchors.left: icon.right; anchors.leftMargin: 8 + anchors.right: valueText.left; anchors.rightMargin: 5 + anchors.top: parent.top + text: row.title + color: Theme.foreground + font.pixelSize: 12 + elide: Text.ElideRight + } + Text { + visible: row.detail !== "" + anchors.left: titleText.left; anchors.right: titleText.right + anchors.top: titleText.bottom; anchors.topMargin: 1 + text: row.detail + color: Theme.secondaryForeground + font.pixelSize: 10 + elide: Text.ElideRight + } + Text { + id: valueText + anchors.right: muteButton.left; anchors.rightMargin: 5 + anchors.top: parent.top + width: 43 + horizontalAlignment: Text.AlignRight + text: row.audio ? row.percent + " %" : "—" + color: Theme.secondaryForeground + font.pixelSize: 11 + } + Button { + id: muteButton + anchors.right: parent.right; anchors.top: parent.top + width: 27; height: 25 + enabled: row.audio !== null + Accessible.name: row.audio && row.audio.muted + ? "Réactiver " + row.title : "Couper " + row.title + onClicked: if (row.audio) row.audio.muted = !row.audio.muted + background: Rectangle { + radius: 4 + color: row.audio && row.audio.muted ? Theme.buttonPressed + : muteButton.hovered ? Theme.buttonHover : Theme.buttonBackground + border.color: muteButton.activeFocus ? Theme.lavender : "transparent" + } + contentItem: Text { + text: row.microphone ? (row.audio && row.audio.muted ? "" : "") + : row.audio && row.audio.muted ? "󰝟" : "󰕾" + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 15 + color: row.audio && row.audio.muted ? Theme.danger : Theme.foreground + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Slider { + id: level + anchors.left: parent.left; anchors.right: parent.right + anchors.bottom: parent.bottom + height: 29 + from: 0; to: 100; stepSize: 1 + enabled: row.audio !== null + // WHY: an external value above 100 % remains visible in the label; + // clamping the slider's display must never write back on its own. + value: row.audio ? Math.max(0, Math.min(100, row.percent)) : 0 + Accessible.name: "Volume de " + row.title + onMoved: if (row.audio) row.audio.volume = MixerModel.requestedVolume(value) + background: Rectangle { + x: level.leftPadding + y: level.topPadding + level.availableHeight / 2 - height / 2 + width: level.availableWidth; height: 8; radius: 4 + color: Theme.buttonBackground + Rectangle { + width: level.visualPosition * parent.width + height: parent.height; radius: 4; color: Theme.accent + } + } + handle: Rectangle { + x: level.leftPadding + level.visualPosition * (level.availableWidth - width) + y: level.topPadding + level.availableHeight / 2 - height / 2 + width: 16; height: 16; radius: 4 + color: level.activeFocus ? Theme.lavender : Theme.foreground + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index 48a6da15e..0608b077c 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -39,6 +39,8 @@ Row { label: "Volume" percent: area.audioBrightnessController.volumePercent available: area.audioBrightnessController.volumeAvailable + clickable: true + onActivated: area.openPageRequested(15) onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps) } ScrollIcon { diff --git a/quickshell/.config/quickshell/labfy-sway/status/ScrollIcon.qml b/quickshell/.config/quickshell/labfy-sway/status/ScrollIcon.qml index e72bf40a8..742a8e33b 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/ScrollIcon.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/ScrollIcon.qml @@ -11,7 +11,9 @@ Item { property int glyphPixelSize: 22 property color glyphColor: Theme.foreground property bool available: false + property bool clickable: false signal adjusted(int steps) + signal activated() // CONTRACT: la hitbox et la valeur partagent la même largeur stable que // la batterie ; le texte débute à 4 px de l'icône, sans vide variable. width: content.width @@ -47,8 +49,9 @@ Item { MouseArea { id: pointer anchors.fill: parent - acceptedButtons: Qt.NoButton + acceptedButtons: iconButton.clickable ? Qt.LeftButton : Qt.NoButton hoverEnabled: true + onClicked: iconButton.activated() } StatusTooltip { target: iconButton diff --git a/tests/audio_mixer/test_model.js b/tests/audio_mixer/test_model.js new file mode 100644 index 000000000..5ca0880a9 --- /dev/null +++ b/tests/audio_mixer/test_model.js @@ -0,0 +1,48 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); + +const source = fs.readFileSync(path.join(__dirname, + "../../quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js"), "utf8") + .replace(/^\.pragma library\s*\n/, ""); +const model = vm.createContext({}); +vm.runInContext(source, model); + +function node(id, media, stream, sink, extra = {}) { + return { id, ready: true, audio: { volume: 0.4, muted: false }, + isStream: stream, isSink: sink, name: "node-" + id, + properties: { "media.class": media, ...extra } }; +} +const output = node(1, "Audio/Sink", false, true); +const input = node(2, "Audio/Source", false, false); +const playbackA = node(3, "Stream/Output/Audio", true, true, + { "application.name": "Test", "application.icon-name": "test-icon" }); +const playbackB = node(4, "Stream/Output/Audio", true, true, + { "application.name": "Test" }); +const capture = node(5, "Stream/Input/Audio", true, false); +const internal = node(6, "Stream/Input/Audio/Internal", true, true); +const monitor = node(7, "Audio/Source", false, false, { "node.monitor": true }); +const grouped = node(8, "Audio/Source", false, false, { "node.group": "loopback" }); +const video = node(9, "Video/Source", false, false); +const unready = { ...node(10, "Stream/Output/Audio", true, true), ready: false }; +const all = [output, input, playbackA, playbackB, capture, internal, monitor, + grouped, video, unready]; + +assert.deepEqual(Array.from(model.outputDevices(all), n => n.id), [1]); +assert.deepEqual(Array.from(model.inputDevices(all), n => n.id), [2]); +assert.deepEqual(Array.from(model.playbackStreams(all), n => n.id), [3, 4]); +assert.deepEqual(Array.from(model.playbackStreams([])), []); +assert.deepEqual(Array.from(model.playbackStreams([playbackB])), [playbackB]); +assert.deepEqual(Array.from(model.playbackStreams(all.filter(n => n !== playbackA)), n => n.id), [4]); +assert.equal(model.streamName(playbackA), "Test"); +assert.equal(model.streamDetail(playbackA), "node-3"); +assert.equal(model.streamIcon(playbackA), "test-icon"); +assert.equal(model.streamIcon(playbackB), ""); +assert.equal(model.streamName(node(11, "Stream/Output/Audio", true, true)), "node-11"); +assert.equal(model.deviceName(null), "Périphérique indisponible"); +assert.equal(model.displayPercent(1.25), 125); +assert.equal(model.requestedVolume(-4), 0); +assert.equal(model.requestedVolume(53.6), 0.54); +assert.equal(model.requestedVolume(120), 1); +console.log("AudioMixerModel: PASS");