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