aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sound/SoundPage.qml
blob: 3f39c48288891b4698f7f9aa1430275b98fcc9a8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
import "../.."

Column {
    id: page

    required property var service
    signal back

    spacing: 16

    // A device's human name, most readable first.
    function devLabel(n) {
        if (!n) return "—";
        return n.description || n.nickname || n.name || ("node " + n.id);
    }

    // The state glyph for a level row, the same shapes the OSD uses: a
    // microphone for the input, a speaker whose fill follows the level for the
    // output, red when muted.
    function stateIcon(audio, input) {
        if (input) return audio?.muted ? "\uf131" : "\uf130";
        if (!audio || audio.muted || audio.volume <= 0) return "\uf026";
        return audio.volume < 0.5 ? "\uf027" : "\uf028";
    }

    // The meter. The hit area is taller than the 8px bar so it is easy to
    // grab; preventStealing stops the page's Flickable taking the press, and
    // the wheel is accepted here so the page does not scroll instead. Click
    // sets, drag scrubs, wheel steps, wheel-up unmutes (the same thing the
    // volume keybind does). Past 100% a tick marks where boost starts.
    component Meter: Item {
        id: meter

        property var audio: null
        property real max: 1
        readonly property real level: Math.min((audio?.volume ?? 0) / max, 1)

        width: parent.width
        height: 20

        Rectangle {
            anchors.verticalCenter: parent.verticalCenter
            width: parent.width
            height: 8
            radius: 4
            color: Theme.surface

            Rectangle {
                height: parent.height
                radius: parent.radius
                width: parent.width * meter.level
                color: meter.audio?.muted ? Theme.red : Theme.accent
                opacity: meter.audio?.muted ? 0.5 : 1
            }
        }

        Rectangle {
            visible: meter.max > 1
            anchors.verticalCenter: parent.verticalCenter
            x: parent.width / meter.max - 1
            width: 2
            height: 14
            radius: 1
            color: Theme.overlay
        }

        MouseArea {
            anchors.fill: parent
            enabled: meter.audio !== null
            preventStealing: true
            cursorShape: Qt.PointingHandCursor

            onPressed: mouse => set(mouse.x)
            onPositionChanged: mouse => { if (pressed) set(mouse.x); }

            // Wheel-up never lowers: a level already past max (set by
            // something else) stays where it is rather than snapping down.
            onWheel: wheel => {
                const a = meter.audio;
                if (!a || wheel.angleDelta.y === 0) return;
                if (wheel.angleDelta.y > 0) {
                    a.muted = false;
                    a.volume = Math.max(a.volume, Math.min(meter.max, a.volume + 0.05));
                } else {
                    a.volume = Math.max(0, a.volume - 0.05);
                }
                wheel.accepted = true;
            }

            function set(x) {
                if (meter.audio) meter.audio.volume = Math.max(0, Math.min(meter.max, x / width * meter.max));
            }
        }
    }

    // Output and input. Each shows the current device, a slider for it, and an
    // in-place device list that sets the default. The list expands within the
    // page rather than opening a popup: a QtQuick.Controls popup is a separate
    // window and does not position reliably on this layer surface.
    Repeater {
        model: [
            { label: "Output", node: page.service.sink, devices: page.service.outputs,
              setDefault: n => Pipewire.preferredDefaultAudioSink = n },
            { label: "Input", node: page.service.source, devices: page.service.inputs,
              setDefault: n => Pipewire.preferredDefaultAudioSource = n },
        ]

        Column {
            id: section
            required property var modelData
            readonly property var audio: modelData.node?.audio ?? null
            property bool expanded: false

            width: page.width
            spacing: 6

            Item {
                width: parent.width
                implicitHeight: name.implicitHeight

                Text {
                    id: name
                    anchors.left: parent.left
                    text: modelData.label
                    font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
                    color: Theme.text
                }

                // State glyph and level, clickable to mute/unmute.
                Row {
                    id: levelRow
                    anchors { right: parent.right; verticalCenter: parent.verticalCenter }
                    spacing: 6

                    Text {
                        anchors.verticalCenter: parent.verticalCenter
                        text: page.stateIcon(audio, modelData.label === "Input")
                        font { family: Theme.iconFamily; pixelSize: Theme.fontSize }
                        color: audio?.muted ? Theme.red : Theme.accent
                    }

                    Text {
                        anchors.verticalCenter: parent.verticalCenter
                        text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%"
                        font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
                        color: audio?.muted ? Theme.red : Theme.subtext
                    }
                }

                MouseArea {
                    anchors.fill: levelRow
                    enabled: audio !== null
                    cursorShape: Qt.PointingHandCursor
                    onClicked: if (audio) audio.muted = !audio.muted
                }
            }

            // The current device; click to open the device list.
            Item {
                width: parent.width
                implicitHeight: devRow.implicitHeight

                Row {
                    id: devRow
                    width: parent.width
                    spacing: 6

                    Text {
                        width: parent.width - chevron.implicitWidth - parent.spacing
                        elide: Text.ElideRight
                        text: page.devLabel(modelData.node)
                        font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
                        color: Theme.text
                    }

                    Text {
                        id: chevron
                        text: section.expanded ? "\uf077" : "\uf078"
                        font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
                        color: Theme.overlay
                    }
                }

                MouseArea {
                    anchors.fill: parent
                    cursorShape: Qt.PointingHandCursor
                    onClicked: section.expanded = !section.expanded
                }
            }

            // The devices, expanded in place. The current one is highlighted;
            // clicking a row makes it the default and folds the list away.
            Column {
                width: parent.width
                spacing: 2
                visible: section.expanded

                Repeater {
                    model: section.modelData.devices

                    Rectangle {
                        required property var modelData
                        readonly property bool current: section.modelData.node === modelData

                        width: section.width
                        implicitHeight: devText.implicitHeight + 8
                        radius: 6
                        color: devArea.containsMouse ? Qt.alpha(Theme.accent, 0.18)
                             : current ? Qt.alpha(Theme.accent, 0.10) : "transparent"

                        Text {
                            id: devText
                            anchors {
                                left: parent.left; right: parent.right
                                verticalCenter: parent.verticalCenter
                                leftMargin: 8; rightMargin: 8
                            }
                            elide: Text.ElideRight
                            text: page.devLabel(modelData)
                            font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; bold: current }
                            color: current ? Theme.accent : Theme.text
                        }

                        MouseArea {
                            id: devArea
                            anchors.fill: parent
                            hoverEnabled: true
                            cursorShape: Qt.PointingHandCursor
                            onClicked: {
                                section.modelData.setDefault(modelData);
                                section.expanded = false;
                            }
                        }
                    }
                }
            }

            Meter {
                audio: section.audio
                // Output goes to 150%, the ceiling the volume keybind already
                // uses (wpctl -l 1.5). A boosted microphone only clips.
                max: section.modelData.label === "Output" ? 1.5 : 1
            }
        }
    }

    // Applications playing right now, each with its own level. Playback
    // streams only: a recording stream is the privacy dot's business. Nothing
    // binds these outside the page, so the tracker lives here and stops when
    // the drawer closes.
    PwObjectTracker { objects: page.service.streams }

    Column {
        width: parent.width
        spacing: 10
        visible: page.service.streams.length > 0

        Text {
            text: "Applications"
            font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
            color: Theme.text
        }

        Repeater {
            model: page.service.streams

            Column {
                id: stream
                required property var modelData
                readonly property var audio: modelData.audio

                width: page.width
                spacing: 2

                Item {
                    width: parent.width
                    implicitHeight: appName.implicitHeight

                    Text {
                        id: appName
                        anchors { left: parent.left; right: appLevel.left; rightMargin: 8 }
                        elide: Text.ElideRight
                        text: stream.modelData.properties?.["application.name"] || page.devLabel(stream.modelData)
                        font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
                        color: Theme.text
                    }

                    Text {
                        id: appLevel
                        anchors { right: parent.right; verticalCenter: parent.verticalCenter }
                        text: stream.audio.muted ? "muted" : Math.round(stream.audio.volume * 100) + "%"
                        font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
                        color: stream.audio.muted ? Theme.red : Theme.subtext

                        MouseArea {
                            anchors.fill: parent
                            cursorShape: Qt.PointingHandCursor
                            onClicked: stream.audio.muted = !stream.audio.muted
                        }
                    }
                }

                // What the app is playing, when it says: two browser tabs are
                // otherwise two identical rows.
                Text {
                    width: parent.width
                    visible: text !== ""
                    elide: Text.ElideRight
                    text: stream.modelData.properties?.["media.name"] ?? ""
                    font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
                    color: Theme.subtext
                }

                Meter { audio: stream.audio }
            }
        }
    }
}