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