__init__.py
Python script, ASCII text executable
1
"""
2
The MIT License (MIT)
3
4
Copyright (c) 2025 Scott Moreau <oreaus@gmail.com>
5
6
Permission is hereby granted, free of charge, to any person obtaining a copy
7
of this software and associated documentation files (the "Software"), to deal
8
in the Software without restriction, including without limitation the rights
9
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
copies of the Software, and to permit persons to whom the Software is
11
furnished to do so, subject to the following conditions:
12
13
The above copyright notice and this permission notice shall be included in
14
all copies or substantial portions of the Software.
15
16
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
SOFTWARE.
23
"""
24
import locale
25
26
import panorama_panel
27
28
import gi
29
gi.require_version("Gtk", "4.0")
30
31
from gi.repository import Gtk, Gdk, GLib, Gio
32
33
import asyncio
34
import pulsectl
35
import pulsectl_asyncio
36
37
class Volume(panorama_panel.Applet):
38
name = "Volume"
39
description = "Volume applet"
40
icon = Gio.ThemedIcon.new("audio-speakers")
41
42
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
43
super().__init__(**kwargs)
44
if config is None:
45
config = {}
46
self.popdown_after_manual = config.get("popdown_after_manual", 2000)
47
self.percentage_reveal = config.get("percentage_reveal", 1000)
48
self.percentage_animation_time = config.get("percentage_animation_time", 250)
49
self.icon_size = config.get("icon_size", 24)
50
self.scrolling_sign = config.get("scroll_direction", 1)
51
self.volume_step = config.get("volume_step", 0.05)
52
asyncio.create_task(self.main())
53
54
async def main(self):
55
self.menu_button = Gtk.MenuButton()
56
self.pulse = pulsectl_asyncio.PulseAsync("panorama-panel-volume-manager")
57
await self.pulse.connect()
58
server_info = await self.pulse.server_info()
59
self.default_sink = await self.pulse.get_sink_by_name(server_info.default_sink_name)
60
self.current_volume = self.default_sink.volume.value_flat
61
self.volume_adjustment = Gtk.Adjustment(value=0.5, lower=0.0, upper=1.0, step_increment=0.01, page_increment=0.1)
62
self.volume_scale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, self.volume_adjustment)
63
self.volume_scale.set_hexpand(True)
64
self.volume_scale.set_value(self.current_volume)
65
self.volume_scale.set_size_request(200, -1)
66
self.volume_scale_changed_handler = self.volume_scale.connect("value-changed", self.on_scale_changed)
67
self.volume_popover = Gtk.Popover()
68
self.volume_popover.set_child(self.volume_scale)
69
self.menu_button.set_popover(self.volume_popover)
70
panorama_panel.track_popover(self.volume_popover)
71
self.menu_button.set_has_frame(False)
72
self.button_content = Gtk.Box(orientation=self.get_orientation())
73
self.icon = Gtk.Image(pixel_size=self.icon_size)
74
self.button_content.append(self.icon)
75
self.menu_button.set_child(self.button_content)
76
scroll_controller = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL)
77
scroll_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
78
self.volume_popover.add_controller(scroll_controller)
79
scroll_controller.connect("scroll", self.on_popover_scroll)
80
scroll_controller = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL)
81
scroll_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
82
self.add_controller(scroll_controller)
83
scroll_controller.connect("scroll", self.on_button_scroll)
84
self.popdown_timeout = None
85
self.conceal_timeout = None
86
self.set_button_icon()
87
gesture_click = Gtk.GestureClick()
88
gesture_click.set_button(Gdk.BUTTON_MIDDLE)
89
gesture_click.connect("released", self.on_middle_click_released)
90
self.add_controller(gesture_click)
91
self.append(self.menu_button)
92
93
self.revealer = Gtk.Revealer(transition_duration=self.percentage_animation_time)
94
self.button_content.append(self.revealer)
95
96
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
97
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT)
98
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
99
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
100
101
self.percentage_label = Gtk.Label()
102
self.percentage_label.set_width_chars(5)
103
self.update_text()
104
self.revealer.set_child(self.percentage_label)
105
106
self.volume_popover.connect("show", self.popover_shown)
107
self.volume_popover.connect("closed", self.popover_hidden)
108
109
if not self.percentage_reveal:
110
self.revealer.set_reveal_child(True)
111
112
loop = asyncio.get_event_loop()
113
loop.create_task(self.listen())
114
115
def popover_shown(self, *args):
116
if self.percentage_reveal != -1:
117
self.revealer.set_reveal_child(True)
118
119
def popover_hidden(self, *args):
120
if self.percentage_reveal:
121
self.revealer.set_reveal_child(False)
122
123
def set_panel_position(self, position: Gtk.PositionType):
124
if not hasattr(self, "revealer"):
125
return
126
127
if position in {Gtk.PositionType.TOP, Gtk.PositionType.BOTTOM}:
128
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT)
129
self.button_content.set_orientation(Gtk.Orientation.HORIZONTAL)
130
elif position in {Gtk.PositionType.LEFT, Gtk.PositionType.RIGHT}:
131
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
132
self.button_content.set_orientation(Gtk.Orientation.VERTICAL)
133
134
def on_middle_click_released(self, gesture, n_press, x, y):
135
loop = asyncio.get_event_loop()
136
loop.create_task(self.idle_on_middle_click_released())
137
138
async def idle_on_middle_click_released(self):
139
if self.default_sink.mute:
140
await self.pulse.sink_mute(self.default_sink.index, False)
141
self.default_sink.mute = False
142
else:
143
await self.pulse.sink_mute(self.default_sink.index, True)
144
self.default_sink.mute = True
145
self.set_button_icon()
146
147
def set_button_icon(self):
148
volume = self.current_volume
149
threshold = 1 / 3
150
if volume == 0.0 or self.default_sink.mute:
151
self.icon.set_from_icon_name("audio-volume-muted-symbolic")
152
elif volume < threshold:
153
self.icon.set_from_icon_name("audio-volume-low-symbolic")
154
elif volume < threshold * 2:
155
self.icon.set_from_icon_name("audio-volume-medium-symbolic")
156
else:
157
self.icon.set_from_icon_name("audio-volume-high-symbolic")
158
159
def on_button_scroll(self, controller, dx, dy):
160
if dy:
161
self.menu_button.popup()
162
if dy * self.scrolling_sign > 0:
163
new_volume = self.current_volume - self.volume_step
164
elif dy * self.scrolling_sign < 0:
165
new_volume = self.current_volume + self.volume_step
166
else:
167
new_volume = self.current_volume
168
if new_volume < 0:
169
new_volume = 0.0
170
elif new_volume > 1:
171
new_volume = 1.0
172
if self.current_volume == new_volume:
173
return Gdk.EVENT_STOP
174
self.volume_scale.set_value(new_volume)
175
self.current_volume = new_volume
176
self.set_button_icon()
177
return Gdk.EVENT_STOP
178
179
def on_popover_scroll(self, controller, dx, dy):
180
if dy * self.scrolling_sign > 0:
181
new_volume = self.current_volume - self.volume_step
182
elif dy * self.scrolling_sign < 0:
183
new_volume = self.current_volume + self.volume_step
184
else:
185
new_volume = self.current_volume
186
if new_volume < 0:
187
new_volume = 0.0
188
elif new_volume > 1:
189
new_volume = 1.0
190
if self.current_volume == new_volume:
191
return Gdk.EVENT_STOP
192
self.volume_scale.set_value(new_volume)
193
self.current_volume = new_volume
194
self.set_button_icon()
195
196
if self.popdown_timeout is not None:
197
GLib.source_remove(self.popdown_timeout)
198
if self.popdown_after_manual:
199
self.popdown_timeout = GLib.timeout_add(self.popdown_after_manual, self.close_scale_popover)
200
return Gdk.EVENT_STOP
201
202
async def idle_volume_update(self):
203
new_volume = self.volume_scale.get_value()
204
await self.pulse.volume_set_all_chans(self.default_sink, new_volume)
205
self.current_volume = new_volume
206
self.set_button_icon()
207
208
if self.popdown_timeout is not None:
209
GLib.source_remove(self.popdown_timeout)
210
if self.popdown_after_manual:
211
self.popdown_timeout = GLib.timeout_add(self.popdown_after_manual, self.close_scale_popover)
212
return False
213
214
def on_scale_changed(self, scale, *args):
215
loop = asyncio.get_event_loop()
216
loop.create_task(self.idle_volume_update())
217
218
def update_text(self):
219
self.percentage_label.set_text(f"{locale.format_string("%d", self.current_volume * 100)}%")
220
221
async def idle_update_scale(self):
222
server_info = await self.pulse.server_info()
223
self.default_sink = await self.pulse.get_sink_by_name(server_info.default_sink_name)
224
new_volume = self.default_sink.volume.value_flat
225
if self.current_volume == new_volume:
226
self.set_button_icon()
227
return False
228
self.current_volume = new_volume
229
self.volume_scale.set_value(self.current_volume)
230
self.set_button_icon()
231
232
self.update_text()
233
if self.percentage_reveal != -1:
234
self.revealer.set_reveal_child(True)
235
236
if self.conceal_timeout is not None:
237
GLib.source_remove(self.conceal_timeout)
238
if self.percentage_reveal > 0:
239
self.conceal_timeout = GLib.timeout_add(self.percentage_reveal, self.conceal_percentage)
240
return False
241
242
def conceal_percentage(self):
243
if not self.percentage_reveal:
244
return
245
246
if not self.volume_popover.get_visible():
247
self.revealer.set_reveal_child(False)
248
self.conceal_timeout = None
249
250
def close_scale_popover(self):
251
self.popdown_timeout = None
252
self.menu_button.popdown()
253
return False
254
255
async def listen(self):
256
async for event in self.pulse.subscribe_events("all"):
257
if event.facility == pulsectl.PulseEventFacilityEnum.sink and \
258
event.t == pulsectl.PulseEventTypeEnum.change:
259
try:
260
sink = await self.pulse.sink_info(event.index)
261
loop = asyncio.get_event_loop()
262
loop.create_task(self.idle_update_scale())
263
except pulsectl_asyncio.PulseOperationFailed:
264
print(f"Could not retrieve info for sink index {event.index}")
265
266
def get_config(self):
267
return {
268
"percentage_reveal": self.percentage_reveal,
269
"popdown_after_manual": self.popdown_after_manual,
270
"percentage_animation_time": self.percentage_animation_time,
271
"icon_size": self.icon_size,
272
"scroll_direction": self.scrolling_sign,
273
"volume_step": self.volume_step,
274
}
275