By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 __init__.py

View raw Download
text/plain • 11.43 kiB
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
self.menu_button.set_has_frame(False)
71
self.button_content = Gtk.Box(orientation=self.get_orientation())
72
self.icon = Gtk.Image(pixel_size=self.icon_size)
73
self.button_content.append(self.icon)
74
self.menu_button.set_child(self.button_content)
75
scroll_controller = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL)
76
scroll_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
77
self.volume_popover.add_controller(scroll_controller)
78
scroll_controller.connect("scroll", self.on_popover_scroll)
79
scroll_controller = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL)
80
scroll_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
81
self.add_controller(scroll_controller)
82
scroll_controller.connect("scroll", self.on_button_scroll)
83
self.popdown_timeout = None
84
self.conceal_timeout = None
85
self.set_button_icon()
86
gesture_click = Gtk.GestureClick()
87
gesture_click.set_button(Gdk.BUTTON_MIDDLE)
88
gesture_click.connect("released", self.on_middle_click_released)
89
self.add_controller(gesture_click)
90
self.append(self.menu_button)
91
92
self.revealer = Gtk.Revealer(transition_duration=self.percentage_animation_time)
93
self.button_content.append(self.revealer)
94
95
if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
96
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT)
97
elif self.get_orientation() == Gtk.Orientation.VERTICAL:
98
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
99
100
self.percentage_label = Gtk.Label()
101
self.percentage_label.set_width_chars(5)
102
self.update_text()
103
self.revealer.set_child(self.percentage_label)
104
105
self.volume_popover.connect("show", self.popover_shown)
106
self.volume_popover.connect("closed", self.popover_hidden)
107
108
if not self.percentage_reveal:
109
self.revealer.set_reveal_child(True)
110
111
loop = asyncio.get_event_loop()
112
loop.create_task(self.listen())
113
114
def popover_shown(self, *args):
115
if self.percentage_reveal != -1:
116
self.revealer.set_reveal_child(True)
117
118
def popover_hidden(self, *args):
119
if self.percentage_reveal:
120
self.revealer.set_reveal_child(False)
121
122
def set_panel_position(self, position: Gtk.PositionType):
123
if not hasattr(self, "revealer"):
124
return
125
126
if position in {Gtk.PositionType.TOP, Gtk.PositionType.BOTTOM}:
127
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT)
128
self.button_content.set_orientation(Gtk.Orientation.HORIZONTAL)
129
elif position in {Gtk.PositionType.LEFT, Gtk.PositionType.RIGHT}:
130
self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
131
self.button_content.set_orientation(Gtk.Orientation.VERTICAL)
132
133
def on_middle_click_released(self, gesture, n_press, x, y):
134
loop = asyncio.get_event_loop()
135
loop.create_task(self.idle_on_middle_click_released())
136
137
async def idle_on_middle_click_released(self):
138
if self.default_sink.mute:
139
await self.pulse.sink_mute(self.default_sink.index, False)
140
self.default_sink.mute = False
141
else:
142
await self.pulse.sink_mute(self.default_sink.index, True)
143
self.default_sink.mute = True
144
self.set_button_icon()
145
146
def set_button_icon(self):
147
volume = self.current_volume
148
threshold = 1 / 3
149
if volume == 0.0 or self.default_sink.mute:
150
self.icon.set_from_icon_name("audio-volume-muted-symbolic")
151
elif volume < threshold:
152
self.icon.set_from_icon_name("audio-volume-low-symbolic")
153
elif volume < threshold * 2:
154
self.icon.set_from_icon_name("audio-volume-medium-symbolic")
155
else:
156
self.icon.set_from_icon_name("audio-volume-high-symbolic")
157
158
def on_button_scroll(self, controller, dx, dy):
159
if dy:
160
self.menu_button.popup()
161
if dy * self.scrolling_sign > 0:
162
new_volume = self.current_volume - self.volume_step
163
elif dy * self.scrolling_sign < 0:
164
new_volume = self.current_volume + self.volume_step
165
else:
166
new_volume = self.current_volume
167
if new_volume < 0:
168
new_volume = 0.0
169
elif new_volume > 1:
170
new_volume = 1.0
171
if self.current_volume == new_volume:
172
return Gdk.EVENT_STOP
173
self.volume_scale.set_value(new_volume)
174
self.current_volume = new_volume
175
self.set_button_icon()
176
return Gdk.EVENT_STOP
177
178
def on_popover_scroll(self, controller, dx, dy):
179
if dy * self.scrolling_sign > 0:
180
new_volume = self.current_volume - self.volume_step
181
elif dy * self.scrolling_sign < 0:
182
new_volume = self.current_volume + self.volume_step
183
else:
184
new_volume = self.current_volume
185
if new_volume < 0:
186
new_volume = 0.0
187
elif new_volume > 1:
188
new_volume = 1.0
189
if self.current_volume == new_volume:
190
return Gdk.EVENT_STOP
191
self.volume_scale.set_value(new_volume)
192
self.current_volume = new_volume
193
self.set_button_icon()
194
195
if self.popdown_timeout is not None:
196
GLib.source_remove(self.popdown_timeout)
197
if self.popdown_after_manual:
198
self.popdown_timeout = GLib.timeout_add(self.popdown_after_manual, self.close_scale_popover)
199
return Gdk.EVENT_STOP
200
201
async def idle_volume_update(self):
202
new_volume = self.volume_scale.get_value()
203
await self.pulse.volume_set_all_chans(self.default_sink, new_volume)
204
self.current_volume = new_volume
205
self.set_button_icon()
206
207
if self.popdown_timeout is not None:
208
GLib.source_remove(self.popdown_timeout)
209
if self.popdown_after_manual:
210
self.popdown_timeout = GLib.timeout_add(self.popdown_after_manual, self.close_scale_popover)
211
return False
212
213
def on_scale_changed(self, scale, *args):
214
loop = asyncio.get_event_loop()
215
loop.create_task(self.idle_volume_update())
216
217
def update_text(self):
218
self.percentage_label.set_text(f"{locale.format_string("%d", self.current_volume * 100)}%")
219
220
async def idle_update_scale(self):
221
server_info = await self.pulse.server_info()
222
self.default_sink = await self.pulse.get_sink_by_name(server_info.default_sink_name)
223
new_volume = self.default_sink.volume.value_flat
224
if self.current_volume == new_volume:
225
self.set_button_icon()
226
return False
227
self.current_volume = new_volume
228
self.volume_scale.set_value(self.current_volume)
229
self.set_button_icon()
230
231
self.update_text()
232
if self.percentage_reveal != -1:
233
self.revealer.set_reveal_child(True)
234
235
if self.conceal_timeout is not None:
236
GLib.source_remove(self.conceal_timeout)
237
if self.percentage_reveal > 0:
238
self.conceal_timeout = GLib.timeout_add(self.percentage_reveal, self.conceal_percentage)
239
return False
240
241
def conceal_percentage(self):
242
if not self.percentage_reveal:
243
return
244
245
if not self.volume_popover.get_visible():
246
self.revealer.set_reveal_child(False)
247
self.conceal_timeout = None
248
249
def close_scale_popover(self):
250
self.popdown_timeout = None
251
self.menu_button.popdown()
252
return False
253
254
async def listen(self):
255
async for event in self.pulse.subscribe_events("all"):
256
if event.facility == pulsectl.PulseEventFacilityEnum.sink and \
257
event.t == pulsectl.PulseEventTypeEnum.change:
258
try:
259
sink = await self.pulse.sink_info(event.index)
260
loop = asyncio.get_event_loop()
261
loop.create_task(self.idle_update_scale())
262
except pulsectl_asyncio.PulseOperationFailed:
263
print(f"Could not retrieve info for sink index {event.index}")
264
265
def get_config(self):
266
return {
267
"percentage_reveal": self.percentage_reveal,
268
"popdown_after_manual": self.popdown_after_manual,
269
"percentage_animation_time": self.percentage_animation_time,
270
"icon_size": self.icon_size,
271
"scroll_direction": self.scrolling_sign,
272
"volume_step": self.volume_step,
273
}
274