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