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/__init__.py +3 -0
- cloudspin/__main__.py +4 -0
- cloudspin/app.py +497 -0
- cloudspin/canvas.py +321 -0
- cloudspin/cli.py +98 -0
- cloudspin/color.py +78 -0
- cloudspin/config.py +105 -0
- cloudspin/library.py +140 -0
- cloudspin/mood.py +245 -0
- cloudspin/player.py +447 -0
- cloudspin/screens.py +130 -0
- cloudspin/sky.py +829 -0
- cloudspin/songlook.py +116 -0
- cloudspin/spectrum.py +83 -0
- cloudspin/themes.py +100 -0
- cloudspin/vinyl.py +182 -0
- cloudspin/weather.py +310 -0
- cloudspin/widgets.py +750 -0
- cloudspin-0.1.0.dist-info/METADATA +206 -0
- cloudspin-0.1.0.dist-info/RECORD +23 -0
- cloudspin-0.1.0.dist-info/WHEEL +4 -0
- cloudspin-0.1.0.dist-info/entry_points.txt +2 -0
- cloudspin-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|