Skip to content

Commit 408b042

Browse files
committed
Adding Mpris2 support to SnapCast widget
1 parent fafe4b2 commit 408b042

File tree

6 files changed

+678
-73
lines changed

6 files changed

+678
-73
lines changed

CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
2025-03-07: [FEATURE] Add Mpris2 support to `Snapcast` widget
12
2025-02-22: [FEATURE] Add ability to stroke `PowerLineDecoration` shape
23
2025-01-07: [RELEASE] v0.30.0 release - compatible with qtile 0.30.0
34
2025-01-02: [FEATURE] More methods for generating `PopupMenu` objects
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) 2024 elParaguayo
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
from qtile_extras.resources.snapcast.snapcontrol import SnapControl as SnapControl
21+
from qtile_extras.resources.snapcast.snapmpris import SnapMprisPlayer as SnapMprisPlayer
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Copyright (c) 2024 elParaguayo
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
import asyncio
21+
import json
22+
from collections import defaultdict
23+
from enum import Enum, auto
24+
25+
from libqtile.utils import create_task
26+
27+
# Snapcast JSONRPC: https://github.com/badaix/snapcast/blob/develop/doc/json_rpc_api/control.md
28+
# Requests
29+
CLIENT_GETSTATUS = "Client.GetStatus"
30+
CLIENT_SETVOLUME = "Client.SetVolume"
31+
CLIENT_SETLATENCY = "Client.SetLatency"
32+
CLIENT_SETNAME = "Client.SetName"
33+
GROUP_GETSTATUS = "Group.GetStatus"
34+
GROUP_SETMUTE = "Group.SetMute"
35+
GROUP_SETSTREAM = "Group.SetStream"
36+
GROUP_SETCLIENTS = "Group.SetClients"
37+
GROUP_SETNAME = "Group.SetName"
38+
SERVER_GETRPCVERSION = "Server.GetRPCVersion"
39+
SERVER_GETSTATUS = "Server.GetStatus"
40+
SERVER_DELETECLIENT = "Server.DeleteClient"
41+
STREAM_ADDSTREAM = "Stream.AddStream"
42+
STREAM_REMOVESTREAM = "Stream.RemoveStream"
43+
STREAM_CONTROL = "Stream.Control"
44+
STREAM_SETPROPERTY = "Stream.SetProperty"
45+
46+
# Notifications
47+
CLIENT_ONCONNECT = "Client.OnConnect"
48+
CLIENT_ONDISCONNECT = "Client.OnDisconnect"
49+
CLIENT_ONVOLUMECHANGED = "Client.OnVolumeChanged"
50+
CLIENT_ONLATENCYCHANGED = "Client.OnLatencyChanged"
51+
CLIENT_ONNAMECHANGED = "Client.OnNameChanged"
52+
GROUP_ONMUTE = "Group.OnMute"
53+
GROUP_ONSTREAMCHANGED = "Group.OnStreamChanged"
54+
GROUP_ONNAMECHANGED = "Group.OnNameChanged"
55+
STREAM_ONPROPERTIES = "Stream.OnProperties"
56+
STREAM_ONUPDATE = "Stream.OnUpdate"
57+
SERVER_ONUPDATE = "Server.OnUpdate"
58+
59+
# Custom
60+
SERVER_ONDISCONNECT = "Server.OnDisconnect"
61+
62+
63+
class MessageType(Enum):
64+
Notification = auto()
65+
Response = auto()
66+
67+
68+
class SnapMessage(dict):
69+
def __init__(self, *args, **kwargs):
70+
super().__init__(*args, **kwargs)
71+
self.__dict__ = self
72+
73+
def __getattr__(self, attr):
74+
return SnapMessage()
75+
76+
def get_message_type(self):
77+
if self.id:
78+
return MessageType.Response
79+
else:
80+
return MessageType.Notification
81+
82+
@classmethod
83+
def from_json(cls, message, parse=True):
84+
if parse:
85+
try:
86+
message = json.loads(message)
87+
except json.JSONDecodeError:
88+
return message
89+
90+
if not isinstance(message, dict):
91+
return message
92+
else:
93+
return cls(
94+
{key: cls.from_json(message[key], parse=False) for key in message},
95+
parse=False,
96+
)
97+
98+
99+
SERVER_DISCONNECT_MESSAGE = SnapMessage({"method": SERVER_ONDISCONNECT, "params": {}})
100+
101+
102+
class SnapControl:
103+
msg_id = 0
104+
105+
def __init__(self, uri, port=1705):
106+
self.uri = uri
107+
self.port = port
108+
self.connected = False
109+
self.reader = None
110+
self.writer = None
111+
self.subscriptions = defaultdict(list)
112+
self.request_queue = {}
113+
114+
def subscribe(self, method, callback):
115+
self.subscriptions[method].append(callback)
116+
117+
def unsubscribe(self, method, callback):
118+
while callback in self.subscriptions[method]:
119+
self.subscriptions[method].remove(callback)
120+
121+
async def start(self):
122+
if self.connected:
123+
return True
124+
125+
try:
126+
self.reader, self.writer = await asyncio.open_connection(
127+
host=self.uri, port=self.port
128+
)
129+
except ConnectionRefusedError:
130+
return False
131+
132+
self.connected = True
133+
self.start_listener()
134+
await asyncio.sleep(0)
135+
return True
136+
137+
def start_listener(self):
138+
if self.reader is None:
139+
return
140+
141+
task = create_task(self.listen())
142+
task.add_done_callback(self._connection_ended)
143+
144+
def _connection_ended(self, task):
145+
self.process_notification(SERVER_DISCONNECT_MESSAGE)
146+
147+
async def listen(self):
148+
while self.connected:
149+
message = await self.reader.readline()
150+
message = message.decode()
151+
152+
if not message.endswith("\r\n"):
153+
break
154+
155+
snmsg = SnapMessage.from_json(message)
156+
157+
match snmsg.get_message_type():
158+
case MessageType.Response:
159+
self.process_response(snmsg)
160+
case MessageType.Notification:
161+
self.process_notification(snmsg)
162+
163+
def process_notification(self, message):
164+
for callback in self.subscriptions[message.method]:
165+
callback(message.params)
166+
167+
def process_response(self, message):
168+
if message.id not in self.request_queue:
169+
return
170+
171+
self.request_queue[message.id]["result"] = message.result or None
172+
self.request_queue[message.id]["error"] = message.error or None
173+
self.request_queue[message.id]["event"].set()
174+
175+
async def finalize(self):
176+
self.writer.close()
177+
await self.writer.wait_closed()
178+
self.writer = None
179+
self.reader = None
180+
181+
async def _send(self, method, params, callback=None):
182+
SnapControl.msg_id += 1
183+
msgid = SnapControl.msg_id
184+
data = {"id": msgid, "jsonrpc": "2.0", "method": method}
185+
if params:
186+
data["params"] = params
187+
188+
datastr = f"{json.dumps(data)}\r\n".encode()
189+
self.request_queue[msgid] = {"event": asyncio.Event()}
190+
191+
if self.writer.is_closing():
192+
return
193+
194+
self.writer.write(datastr)
195+
await self.writer.drain()
196+
await self.request_queue[msgid]["event"].wait()
197+
result = self.request_queue[msgid].get("result")
198+
error = self.request_queue[msgid].get("error")
199+
del self.request_queue[msgid]
200+
201+
if callback:
202+
callback(result, error)
203+
204+
def send(self, method, params=dict(), callback=None):
205+
create_task(self._send(method, params, callback))

0 commit comments

Comments
 (0)