cloudspin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cloudspin/player.py ADDED
@@ -0,0 +1,447 @@
1
+ """Playback: an mpv backend driven over its JSON IPC socket, a silent demo
2
+ backend, and the ``Deck`` that turns either into a queue with shuffle and repeat.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import random
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass
20
+ class Track:
21
+ path: str
22
+ title: str
23
+ artist: str = ""
24
+ album: str = ""
25
+ duration: float = 0.0
26
+ genre: str = ""
27
+
28
+ @property
29
+ def is_stream(self) -> bool:
30
+ return "://" in self.path
31
+
32
+
33
+ class BackendError(Exception):
34
+ """The audio backend could not start; the message is written for the user."""
35
+
36
+
37
+ class DemoBackend:
38
+ """Keeps time without making a sound. Used for ``--demo`` and when mpv is missing."""
39
+
40
+ name = "demo"
41
+ silent = True
42
+
43
+ def __init__(self) -> None:
44
+ self.position = 0.0
45
+ self.duration = 0.0
46
+ self.media_title = ""
47
+ self.alive = True
48
+ self._paused = True
49
+ self._ended = False
50
+ self._last = time.monotonic()
51
+
52
+ def load(self, track: Track) -> None:
53
+ self.position = 0.0
54
+ self.duration = track.duration or 180.0
55
+ self._paused = False
56
+ self._ended = False
57
+ self._last = time.monotonic()
58
+
59
+ def set_paused(self, paused: bool) -> None:
60
+ self._paused = paused
61
+ self._last = time.monotonic()
62
+
63
+ def seek(self, seconds: float) -> None:
64
+ self.position = max(0.0, min(seconds, max(0.0, self.duration - 0.2)))
65
+
66
+ def set_volume(self, volume: int) -> None:
67
+ pass
68
+
69
+ def poll(self) -> None:
70
+ now = time.monotonic()
71
+ if not self._paused and self.duration:
72
+ self.position += now - self._last
73
+ if self.position >= self.duration:
74
+ self.position = self.duration
75
+ self._paused = True
76
+ self._ended = True
77
+ self._last = now
78
+
79
+ def take_ended(self) -> bool:
80
+ ended, self._ended = self._ended, False
81
+ return ended
82
+
83
+ def take_error(self) -> str:
84
+ return ""
85
+
86
+ def close(self) -> None:
87
+ self.alive = False
88
+
89
+
90
+ class MpvBackend:
91
+ """Plays audio by running mpv in the background and talking to its IPC socket."""
92
+
93
+ name = "mpv"
94
+ silent = False
95
+
96
+ def __init__(self, volume: int = 70, binary: str | None = None) -> None:
97
+ self.position = 0.0
98
+ self.duration = 0.0
99
+ self.media_title = ""
100
+ self.media_artist = ""
101
+ self.alive = False
102
+ self._ended = False
103
+ self._error = ""
104
+ self._lock = threading.Lock()
105
+ self._sock: socket.socket | None = None
106
+ self._dir = ""
107
+ self._process: subprocess.Popen | None = None
108
+
109
+ binary = binary or shutil.which("mpv")
110
+ if not binary:
111
+ raise BackendError("mpv isn't installed, so there's no sound yet. Install mpv and start Cloudspin again.")
112
+ self._dir = tempfile.mkdtemp(prefix="cloudspin-")
113
+ path = str(Path(self._dir) / "mpv.sock")
114
+ try:
115
+ self._process = subprocess.Popen(
116
+ [
117
+ binary,
118
+ "--idle=yes",
119
+ "--no-video",
120
+ "--no-terminal",
121
+ "--audio-display=no",
122
+ "--keep-open=no",
123
+ f"--volume={int(volume)}",
124
+ f"--input-ipc-server={path}",
125
+ ],
126
+ stdin=subprocess.DEVNULL,
127
+ stdout=subprocess.DEVNULL,
128
+ stderr=subprocess.DEVNULL,
129
+ )
130
+ except OSError as error:
131
+ self._cleanup()
132
+ raise BackendError(f"mpv couldn't be started ({error.strerror or error}).") from error
133
+ self._connect(path)
134
+ self.alive = True
135
+ threading.Thread(target=self._read_loop, name="cloudspin-mpv", daemon=True).start()
136
+ for index, prop in enumerate(("time-pos", "duration", "media-title", "metadata"), start=1):
137
+ self._send(["observe_property", index, prop])
138
+
139
+ def _connect(self, path: str) -> None:
140
+ deadline = time.monotonic() + 5.0
141
+ last: OSError | None = None
142
+ while time.monotonic() < deadline:
143
+ if self._process is not None and self._process.poll() is not None:
144
+ break
145
+ try:
146
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
147
+ sock.connect(path)
148
+ self._sock = sock
149
+ return
150
+ except OSError as error:
151
+ last = error
152
+ sock.close()
153
+ time.sleep(0.05)
154
+ self.close()
155
+ raise BackendError("mpv started but didn't open its control socket.") from last
156
+
157
+ def _send(self, command: list) -> None:
158
+ sock = self._sock
159
+ if sock is None:
160
+ return
161
+ data = (json.dumps({"command": command}) + "\n").encode("utf-8")
162
+ try:
163
+ with self._lock:
164
+ sock.sendall(data)
165
+ except OSError:
166
+ self.alive = False
167
+
168
+ def _read_loop(self) -> None:
169
+ sock = self._sock
170
+ buffer = b""
171
+ while sock is not None:
172
+ try:
173
+ chunk = sock.recv(65536)
174
+ except OSError:
175
+ break
176
+ if not chunk:
177
+ break
178
+ buffer += chunk
179
+ while b"\n" in buffer:
180
+ line, buffer = buffer.split(b"\n", 1)
181
+ if not line.strip():
182
+ continue
183
+ try:
184
+ self._handle(json.loads(line))
185
+ except (ValueError, TypeError):
186
+ continue
187
+ self.alive = False
188
+
189
+ def _handle(self, message: dict) -> None:
190
+ event = message.get("event")
191
+ if event == "property-change":
192
+ name, value = message.get("name"), message.get("data")
193
+ if name == "time-pos" and isinstance(value, (int, float)):
194
+ self.position = float(value)
195
+ elif name == "duration" and isinstance(value, (int, float)):
196
+ self.duration = float(value)
197
+ elif name == "media-title" and isinstance(value, str):
198
+ self.media_title = value
199
+ elif name == "metadata" and isinstance(value, dict):
200
+ tags = {str(k).lower(): v for k, v in value.items()}
201
+ artist = tags.get("artist") or tags.get("uploader") or tags.get("icy-name") or ""
202
+ self.media_artist = str(artist)
203
+ elif event == "start-file":
204
+ self._ended = False
205
+ elif event == "end-file":
206
+ reason = message.get("reason")
207
+ if reason == "eof":
208
+ self._ended = True
209
+ elif reason == "error":
210
+ self._error = str(message.get("file_error") or "it could not be opened")
211
+
212
+ def load(self, track: Track) -> None:
213
+ self.position = 0.0
214
+ self.duration = track.duration
215
+ self.media_title = ""
216
+ self.media_artist = ""
217
+ self._ended = False
218
+ self._error = ""
219
+ self._send(["loadfile", track.path, "replace"])
220
+ self._send(["set_property", "pause", False])
221
+
222
+ def set_paused(self, paused: bool) -> None:
223
+ self._send(["set_property", "pause", bool(paused)])
224
+
225
+ def seek(self, seconds: float) -> None:
226
+ self.position = max(0.0, seconds)
227
+ self._send(["seek", round(max(0.0, seconds), 2), "absolute"])
228
+
229
+ def set_volume(self, volume: int) -> None:
230
+ self._send(["set_property", "volume", int(volume)])
231
+
232
+ def poll(self) -> None:
233
+ if self._process is not None and self._process.poll() is not None:
234
+ self.alive = False
235
+
236
+ def take_ended(self) -> bool:
237
+ ended, self._ended = self._ended, False
238
+ return ended
239
+
240
+ def take_error(self) -> str:
241
+ error, self._error = self._error, ""
242
+ return error
243
+
244
+ def _cleanup(self) -> None:
245
+ if self._dir:
246
+ shutil.rmtree(self._dir, ignore_errors=True)
247
+ self._dir = ""
248
+
249
+ def close(self) -> None:
250
+ self.alive = False
251
+ if self._sock is not None:
252
+ try:
253
+ self._sock.sendall(b'{"command": ["quit"]}\n')
254
+ except OSError:
255
+ pass
256
+ try:
257
+ self._sock.close()
258
+ except OSError:
259
+ pass
260
+ self._sock = None
261
+ if self._process is not None:
262
+ try:
263
+ self._process.wait(timeout=1.0)
264
+ except subprocess.TimeoutExpired:
265
+ self._process.kill()
266
+ self._process = None
267
+ self._cleanup()
268
+
269
+
270
+ REPEAT_MODES = ("off", "all", "one")
271
+
272
+
273
+ class Deck:
274
+ """A queue of tracks on top of a backend."""
275
+
276
+ def __init__(self, backend, tracks: list[Track] | None = None, volume: int = 70, seed: int | None = None) -> None:
277
+ self.backend = backend
278
+ self.tracks: list[Track] = list(tracks or [])
279
+ self.index = -1
280
+ self.paused = True
281
+ self.shuffle = False
282
+ self.repeat = "off"
283
+ self.volume = max(0, min(100, int(volume)))
284
+ self.changes = 0 # bumps whenever a different track starts
285
+ self.failure = "" # why the last track could not be played, until read
286
+ self._rng = random.Random(seed)
287
+ self._order: list[int] = list(range(len(self.tracks)))
288
+ self._history: list[int] = []
289
+
290
+ # -- state ---------------------------------------------------------------
291
+
292
+ @property
293
+ def current(self) -> Track | None:
294
+ return self.tracks[self.index] if 0 <= self.index < len(self.tracks) else None
295
+
296
+ @property
297
+ def playing(self) -> bool:
298
+ return self.current is not None and not self.paused
299
+
300
+ @property
301
+ def position(self) -> float:
302
+ return self.backend.position if self.current else 0.0
303
+
304
+ @property
305
+ def duration(self) -> float:
306
+ track = self.current
307
+ if track is None:
308
+ return 0.0
309
+ return self.backend.duration or track.duration
310
+
311
+ @property
312
+ def progress(self) -> float:
313
+ duration = self.duration
314
+ return max(0.0, min(1.0, self.position / duration)) if duration > 0 else 0.0
315
+
316
+ def upcoming(self, count: int) -> list[int]:
317
+ """Indexes of the tracks that will play next, in order."""
318
+ if not self.tracks:
319
+ return []
320
+ if self.index < 0:
321
+ return self._order[:count]
322
+ at = self._order.index(self.index)
323
+ rest = self._order[at + 1 :]
324
+ if self.repeat == "all":
325
+ rest += self._order[: at + 1]
326
+ return rest[:count]
327
+
328
+ def set_tracks(self, tracks: list[Track]) -> None:
329
+ self.tracks = list(tracks)
330
+ self.index = -1
331
+ self.paused = True
332
+ self._history.clear()
333
+ self._reorder()
334
+
335
+ def add(self, tracks: list[Track]) -> int:
336
+ """Append tracks to the queue; returns the index of the first new one."""
337
+ first = len(self.tracks)
338
+ self.tracks.extend(tracks)
339
+ self._order.extend(range(first, len(self.tracks)))
340
+ return first
341
+
342
+ def _reorder(self) -> None:
343
+ order = list(range(len(self.tracks)))
344
+ if self.shuffle:
345
+ self._rng.shuffle(order)
346
+ if self.index in order:
347
+ order.remove(self.index)
348
+ order.insert(0, self.index)
349
+ self._order = order
350
+
351
+ # -- transport -----------------------------------------------------------
352
+
353
+ def play(self, index: int, remember: bool = True) -> None:
354
+ if not 0 <= index < len(self.tracks):
355
+ return
356
+ if remember and self.index >= 0 and self.index != index:
357
+ self._history.append(self.index)
358
+ del self._history[:-200]
359
+ self.index = index
360
+ self.paused = False
361
+ self.changes += 1
362
+ self.backend.load(self.tracks[index])
363
+
364
+ def toggle(self) -> None:
365
+ if self.current is None:
366
+ if self.tracks:
367
+ self.play(self._order[0])
368
+ return
369
+ self.paused = not self.paused
370
+ self.backend.set_paused(self.paused)
371
+
372
+ def next(self, automatic: bool = False) -> None:
373
+ if not self.tracks:
374
+ return
375
+ if self.index < 0:
376
+ self.play(self._order[0])
377
+ return
378
+ if automatic and self.repeat == "one":
379
+ self.play(self.index, remember=False)
380
+ return
381
+ at = self._order.index(self.index)
382
+ if at + 1 < len(self._order):
383
+ self.play(self._order[at + 1])
384
+ elif self.repeat == "all" or not automatic:
385
+ if self.shuffle:
386
+ self._reorder()
387
+ first = self._order[1] if len(self._order) > 1 else self._order[0]
388
+ self.play(first)
389
+ else:
390
+ self.play(self._order[0])
391
+ else:
392
+ # The queue ran out: stop at the end of the last track.
393
+ self.paused = True
394
+ self.backend.set_paused(True)
395
+
396
+ def previous(self) -> None:
397
+ if self.current is None:
398
+ return
399
+ if self.position > 3.0 or not self._history:
400
+ self.backend.seek(0.0)
401
+ return
402
+ self.play(self._history.pop(), remember=False)
403
+
404
+ def seek_by(self, seconds: float) -> None:
405
+ if self.current is not None:
406
+ limit = max(0.0, self.duration - 0.5) if self.duration else float("inf")
407
+ self.backend.seek(max(0.0, min(self.position + seconds, limit)))
408
+
409
+ def seek_to(self, fraction: float) -> None:
410
+ if self.current is not None and self.duration > 0:
411
+ self.backend.seek(max(0.0, min(1.0, fraction)) * max(0.0, self.duration - 0.5))
412
+
413
+ def set_volume(self, volume: int) -> None:
414
+ self.volume = max(0, min(100, int(volume)))
415
+ self.backend.set_volume(self.volume)
416
+
417
+ def toggle_shuffle(self) -> None:
418
+ self.shuffle = not self.shuffle
419
+ self._reorder()
420
+
421
+ def cycle_repeat(self) -> None:
422
+ self.repeat = REPEAT_MODES[(REPEAT_MODES.index(self.repeat) + 1) % len(REPEAT_MODES)]
423
+
424
+ def tick(self) -> None:
425
+ self.backend.poll()
426
+ error = self.backend.take_error()
427
+ if error and self.current is not None:
428
+ # Stay on the broken track instead of racing through the queue.
429
+ self.failure = error
430
+ self.paused = True
431
+ elif self.backend.take_ended() and self.current is not None:
432
+ self.next(automatic=True)
433
+
434
+ def close(self) -> None:
435
+ self.backend.close()
436
+
437
+
438
+ DEMO_TRACKS = [
439
+ Track("demo://1", "Petrichor", "Low Tide Radio", "Weather Report for Nobody", 214, "Lo-fi"),
440
+ Track("demo://2", "Paper Lanterns", "Marigold Arcade", "Rooftop Season", 187, "Indie pop"),
441
+ Track("demo://3", "Static Bloom", "Kestrel & the Wires", "Small Hours", 201, "Drift phonk"),
442
+ Track("demo://4", "Monsoon Window", "Low Tide Radio", "Weather Report for Nobody", 243, "Ambient"),
443
+ Track("demo://5", "Blue Hour", "Nine Volt Garden", "Slow Shutter", 229, "Synthwave"),
444
+ Track("demo://6", "Cumulus", "Kestrel & the Wires", "Small Hours", 195, "Dream pop"),
445
+ Track("demo://7", "First Light", "Marigold Arcade", "Rooftop Season", 176, "New age"),
446
+ Track("demo://8", "Sleigh Tracks", "Nine Volt Garden", "Slow Shutter", 258, "Holiday"),
447
+ ]
cloudspin/screens.py ADDED
@@ -0,0 +1,130 @@
1
+ """The two small dialogs: choosing a city and the list of keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Input, Static
9
+
10
+ _CARD = """
11
+ align: center middle;
12
+ background: #05070c 55%;
13
+ """
14
+
15
+
16
+ class Prompt(ModalScreen[str | None]):
17
+ """A one-line question."""
18
+
19
+ DEFAULT_CSS = (
20
+ "Prompt {" + _CARD + "}"
21
+ """
22
+ Prompt #card {
23
+ width: 64;
24
+ max-width: 92%;
25
+ height: auto;
26
+ padding: 1 3;
27
+ background: #121826;
28
+ border: round #3a455e;
29
+ }
30
+ Prompt #title { text-style: bold; color: #eef1f6; }
31
+ Prompt #lede { color: #9aa5ba; margin-bottom: 1; }
32
+ Prompt #foot { color: #6f7b92; margin-top: 1; }
33
+ Prompt Input, Prompt Input:focus {
34
+ border: tall #2a3550;
35
+ height: 3;
36
+ padding: 0 1;
37
+ background: #1e2638;
38
+ color: #eef1f6;
39
+ }
40
+ """
41
+ )
42
+ BINDINGS = [("escape", "cancel", "Cancel")]
43
+
44
+ def __init__(self, title: str, lede: str, placeholder: str = "", value: str = "", confirm: str = "save") -> None:
45
+ super().__init__()
46
+ self._title = title
47
+ self._lede = lede
48
+ self._placeholder = placeholder
49
+ self._value = value
50
+ self._confirm = confirm
51
+
52
+ def compose(self) -> ComposeResult:
53
+ with Vertical(id="card"):
54
+ yield Static(self._title, id="title")
55
+ yield Static(self._lede, id="lede")
56
+ yield Input(value=self._value, placeholder=self._placeholder)
57
+ yield Static(f"Enter to {self._confirm}, Esc to go back", id="foot")
58
+
59
+ def on_mount(self) -> None:
60
+ self.query_one(Input).focus()
61
+
62
+ def on_input_submitted(self, event: Input.Submitted) -> None:
63
+ self.dismiss(event.value.strip() or None)
64
+
65
+ def action_cancel(self) -> None:
66
+ self.dismiss(None)
67
+
68
+
69
+ def city_prompt(current: str = "") -> Prompt:
70
+ return Prompt(
71
+ "Where should the weather come from?",
72
+ "Type a city. The forecast, sunrise and sunset will follow it.",
73
+ placeholder="New Delhi",
74
+ value=current,
75
+ )
76
+
77
+
78
+ def link_prompt() -> Prompt:
79
+ return Prompt(
80
+ "Play from a link",
81
+ "Paste a link to a song, a radio stream or a video. A file or folder path works too.",
82
+ placeholder="https://",
83
+ confirm="play",
84
+ )
85
+
86
+
87
+ _KEYS = (
88
+ ("space", "Play or pause"),
89
+ ("n p", "Next song, previous song"),
90
+ ("← →", "Skip back or ahead 5 seconds (hold Shift for 30)"),
91
+ ("+ −", "Volume up or down"),
92
+ ("s", "Shuffle on or off"),
93
+ ("r", "Repeat: off, all, one"),
94
+ ("w", "Sky: follow the song, follow the weather, or pin a scene"),
95
+ ("t", "Colours: follow the sky, follow the cover art, or pick a theme"),
96
+ ("o", "Open a link, file or folder and play it"),
97
+ ("c", "Choose the city for the weather"),
98
+ ("u", "Switch between °C and °F"),
99
+ ("q", "Quit"),
100
+ )
101
+
102
+
103
+ class Help(ModalScreen[None]):
104
+ DEFAULT_CSS = (
105
+ "Help {" + _CARD + "}"
106
+ """
107
+ Help #card {
108
+ width: 74;
109
+ max-width: 94%;
110
+ height: auto;
111
+ padding: 1 3;
112
+ background: #121826;
113
+ border: round #3a455e;
114
+ }
115
+ Help #title { text-style: bold; color: #eef1f6; margin-bottom: 1; }
116
+ Help .row { color: #9aa5ba; }
117
+ Help #foot { color: #6f7b92; margin-top: 1; }
118
+ """
119
+ )
120
+ BINDINGS = [("escape,question_mark,q,enter,space", "close", "Close")]
121
+
122
+ def compose(self) -> ComposeResult:
123
+ with Vertical(id="card"):
124
+ yield Static("Keys", id="title")
125
+ for keys, what in _KEYS:
126
+ yield Static(f"[b #eef1f6]{keys:<7}[/] {what}", classes="row")
127
+ yield Static("Click the buttons, the progress line, the volume or a song in Up next too.", id="foot")
128
+
129
+ def action_close(self) -> None:
130
+ self.dismiss(None)