feat(quickshell): add native audio mixer
This commit is contained in:
parent
8551f8357b
commit
c81b943fa0
9 changed files with 522 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
tests/audio_mixer/test_model.js
Normal file
48
tests/audio_mixer/test_model.js
Normal file
|
|
@ -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");
|
||||
Loading…
Reference in a new issue