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 ADDED
@@ -0,0 +1,3 @@
1
+ """Cloudspin: a terminal music player that lives under a living sky."""
2
+
3
+ __version__ = "0.1.0"
cloudspin/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
cloudspin/app.py ADDED
@@ -0,0 +1,497 @@
1
+ """The app: one clock that moves the sky, the record and the music together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import queue
7
+ import threading
8
+ import time
9
+
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.color import Color
13
+ from textual.containers import Horizontal, Vertical
14
+
15
+ from . import library, mood, weather
16
+ from .config import Config, cache_dir
17
+ from pathlib import Path
18
+
19
+ from .player import Deck, Track
20
+ from .screens import Help, city_prompt, link_prompt
21
+ from .sky import Daylight, Weather
22
+ from .songlook import SongLook, look_for
23
+ from .spectrum import Spectrum
24
+ from .themes import Palette, load_themes
25
+ from .vinyl import Vinyl
26
+ from .widgets import Controls, DiscView, KeyHints, NowPlaying, QueueView, Seekbar, SkyView, SpectrumView
27
+
28
+ WEATHER_EVERY = 600.0 # seconds between forecast refreshes
29
+
30
+
31
+ class CloudspinApp(App[None]):
32
+ TITLE = "Cloudspin"
33
+ ENABLE_COMMAND_PALETTE = False
34
+
35
+ CSS = """
36
+ Screen { layout: vertical; overflow: hidden; }
37
+ #sky { height: 1fr; min-height: 3; }
38
+ #deck { height: 14; }
39
+ #disc { width: 40; height: 100%; }
40
+ #center { width: 1fr; height: 100%; padding: 1 3 0 2; }
41
+ NowPlaying { height: 3; }
42
+ SpectrumView { height: 1fr; min-height: 1; margin-top: 1; }
43
+ Seekbar { height: 1; margin-top: 1; }
44
+ Controls { height: 1; margin-top: 1; margin-bottom: 1; }
45
+ #queue { width: 38; height: 100%; padding: 1 3 0 1; }
46
+ #deck.short NowPlaying { height: 2; }
47
+ #deck.short Controls { margin-bottom: 0; }
48
+ #deck.tiny #center { padding-top: 0; }
49
+ #deck.tiny SpectrumView { margin-top: 0; }
50
+ #deck.tiny Seekbar { margin-top: 0; }
51
+ #deck.tiny #queue { padding-top: 0; }
52
+ KeyHints { height: 1; }
53
+ """
54
+
55
+ BINDINGS = [
56
+ Binding("space", "toggle_play", "Play or pause"),
57
+ Binding("n", "next_track", "Next"),
58
+ Binding("p", "previous_track", "Previous"),
59
+ Binding("right", "seek(5)", "Ahead"),
60
+ Binding("left", "seek(-5)", "Back"),
61
+ Binding("shift+right", "seek(30)", "Ahead 30s"),
62
+ Binding("shift+left", "seek(-30)", "Back 30s"),
63
+ Binding("plus,equals_sign", "volume(5)", "Louder"),
64
+ Binding("minus,underscore", "volume(-5)", "Quieter"),
65
+ Binding("s", "shuffle", "Shuffle"),
66
+ Binding("r", "repeat", "Repeat"),
67
+ Binding("w", "cycle_sky", "Sky"),
68
+ Binding("t", "cycle_colors", "Colours"),
69
+ Binding("o", "link", "Open a link"),
70
+ Binding("c", "city", "City"),
71
+ Binding("u", "units", "Units"),
72
+ Binding("question_mark", "help", "Help"),
73
+ Binding("q", "quit", "Quit"),
74
+ ]
75
+
76
+ def __init__(self, config: Config, deck: Deck, silent_reason: str = "", notice: str = "") -> None:
77
+ super().__init__()
78
+ self.config = config
79
+ self.deck = deck
80
+ self.silent_reason = silent_reason
81
+ self.themes = load_themes(config.themes)
82
+ if config.colors not in self.themes:
83
+ config.colors = "sky"
84
+ if config.sky not in ("song", "weather") and config.sky not in mood.SCENES:
85
+ config.sky = "song"
86
+ self.palette = Palette()
87
+ self.spectrum = Spectrum()
88
+ self.look: SongLook | None = None
89
+ self.mood: mood.Mood | None = None
90
+ self.report: weather.Report | None = None
91
+ self.place: weather.Place | None = None
92
+ if config.city and config.latitude is not None and config.longitude is not None:
93
+ self.place = weather.Place(config.city, config.latitude, config.longitude, config.country)
94
+ self.message = ""
95
+ self._message_until = 0.0
96
+ self._startup_notice = notice
97
+ self._inbox: queue.SimpleQueue[tuple] = queue.SimpleQueue()
98
+ self._looks: dict[str, SongLook] = {}
99
+ self._moods: dict[str, mood.Mood | None] = {}
100
+ self._tempos: dict[str, float] = {}
101
+ self._studying: set[str] = set()
102
+ self._seen_change = deck.changes
103
+ self._last = time.monotonic()
104
+ self._frame = 0
105
+ self._sky_dt = 0.0
106
+ self._palette_ready = False
107
+ self._screen_bg: tuple | None = None
108
+ self._weather_due = 0.0
109
+ self._weather_busy = False
110
+ self._closing = False
111
+ self._is_up = False
112
+
113
+ # -- layout --------------------------------------------------------------
114
+
115
+ def compose(self) -> ComposeResult:
116
+ yield SkyView(id="sky")
117
+ with Horizontal(id="deck"):
118
+ yield DiscView(id="disc")
119
+ with Vertical(id="center"):
120
+ yield NowPlaying()
121
+ yield SpectrumView()
122
+ yield Seekbar()
123
+ yield Controls()
124
+ yield QueueView(id="queue")
125
+ yield KeyHints()
126
+
127
+ def on_mount(self) -> None:
128
+ self._sky = self.query_one(SkyView)
129
+ self._disc = self.query_one(DiscView)
130
+ self._bars = self.query_one(SpectrumView)
131
+ self._painted = [
132
+ self.query_one(NowPlaying),
133
+ self.query_one(Seekbar),
134
+ self.query_one(Controls),
135
+ self.query_one(QueueView),
136
+ self.query_one(KeyHints),
137
+ ]
138
+ self._is_up = True
139
+ self._arrange()
140
+ if self.place is not None:
141
+ cached = weather.load_cache(cache_dir() / "weather.json", self.place, self.config.units)
142
+ if cached is not None:
143
+ self._set_report(cached)
144
+ else:
145
+ self._sky.note = "Press c to choose your city"
146
+ self._sky.hint = "The forecast, sunrise and sunset will follow it."
147
+ if self._startup_notice:
148
+ self.flash(self._startup_notice, 7.0)
149
+ threading.Thread(target=self._read_all_tags, name="cloudspin-tags", daemon=True).start()
150
+ self.set_interval(1.0 / max(4, self.config.fps), self._tick)
151
+ self._tick()
152
+
153
+ def on_resize(self) -> None:
154
+ if self._is_up:
155
+ self._arrange()
156
+
157
+ def _arrange(self) -> None:
158
+ width, height = self.size.width, self.size.height
159
+ deck_rows = max(8, min(20, round(height * 0.42)))
160
+ if height < 22:
161
+ deck_rows = max(6, height // 2)
162
+ deck = self.query_one("#deck")
163
+ deck.styles.height = deck_rows
164
+ deck.set_class(deck_rows < 14, "short")
165
+ deck.set_class(deck_rows < 10, "tiny")
166
+ disc_width = Vinyl.width_for(deck_rows * 2)
167
+ self._disc.display = width >= 56
168
+ self._disc.styles.width = disc_width
169
+ self.query_one("#queue").display = width >= disc_width + 38 + 44
170
+
171
+ # -- the clock -----------------------------------------------------------
172
+
173
+ def _tick(self) -> None:
174
+ now = time.monotonic()
175
+ dt = min(0.25, now - self._last)
176
+ self._last = now
177
+ self._frame += 1
178
+ self._drain()
179
+ deck = self.deck
180
+ deck.tick()
181
+ if deck.changes != self._seen_change:
182
+ self._seen_change = deck.changes
183
+ self._track_changed()
184
+ if deck.failure:
185
+ self.flash(f"Couldn't play this one: {deck.failure}. Links to YouTube and similar sites need yt-dlp.", 8.0)
186
+ deck.failure = ""
187
+ self._follow_stream()
188
+ if self.message and now >= self._message_until:
189
+ self.message = ""
190
+ if self.place is not None and now >= self._weather_due and not self._weather_busy:
191
+ self._refresh_weather()
192
+
193
+ sky = self._sky
194
+ sky_weather, daylight, caption = self._sky_now()
195
+ sky.scene.set_weather(sky_weather)
196
+ sky.scene.set_daylight(daylight)
197
+ sky.caption = caption
198
+ self._sky_dt += dt
199
+ if self._frame % 2 == 1:
200
+ sky.step(self._sky_dt)
201
+ self._sky_dt = 0.0
202
+ self._ease_palette(dt)
203
+
204
+ self.spectrum.step(dt, deck.playing, deck.volume / 100.0)
205
+ self._disc.step(dt)
206
+ self._bars.sync()
207
+ for widget in self._painted:
208
+ widget.sync()
209
+
210
+ def _sky_now(self) -> tuple[Weather, Daylight, str]:
211
+ mode = self.config.sky
212
+ if mode == "song" and self.mood is not None and self.deck.current is not None:
213
+ scene = mood.SCENES[self.mood.scene]
214
+ return scene.weather, scene.daylight, f"{scene.label}, {self.mood.why}"
215
+ if mode in mood.SCENES:
216
+ scene = mood.SCENES[mode]
217
+ return scene.weather, scene.daylight, f"{scene.label}, pinned"
218
+ if self.report is not None:
219
+ caption = "Live sky" if mode == "weather" else ""
220
+ return self.report.sky(), self.report.daylight(), caption
221
+ return Weather(cover=0.18), weather.clock_daylight(), ""
222
+
223
+ def _ease_palette(self, dt: float) -> None:
224
+ scene = self._sky.scene
225
+ theme = self.themes.get(self.config.colors) or self.themes["sky"]
226
+ first, second = scene.accents
227
+ look = self.look
228
+ if theme.name == "cover" and look is not None and self.deck.current is not None:
229
+ target = Palette(look.background, look.accent, look.accent2, look.accent, look.accent2)
230
+ elif theme.accent is not None:
231
+ second = theme.accent2 or theme.accent
232
+ target = Palette(
233
+ theme.background or scene.natural_ground,
234
+ theme.accent,
235
+ second,
236
+ theme.disc or theme.accent,
237
+ theme.disc2 or second,
238
+ )
239
+ else:
240
+ target = Palette(scene.natural_ground, first, second, first, second)
241
+ if not self._palette_ready:
242
+ self.palette = target
243
+ self._palette_ready = True
244
+ else:
245
+ self.palette.ease_to(target, 1.0 - math.exp(-dt / 0.45))
246
+ scene.ground_override = self.palette.bg
247
+ if self.palette.bg != self._screen_bg:
248
+ self._screen_bg = self.palette.bg
249
+ self.screen_stack[0].styles.background = Color(*self.palette.bg)
250
+
251
+ # -- songs ---------------------------------------------------------------
252
+
253
+ def _track_changed(self) -> None:
254
+ track = self.deck.current
255
+ if track is None:
256
+ return
257
+ self.spectrum.retune(track.path, self._tempos.get(track.path))
258
+ path = track.path
259
+ if path in self._looks:
260
+ self.look = self._looks[path]
261
+ if path in self._moods:
262
+ self.mood = self._moods[path]
263
+ if path not in self._studying and (path not in self._looks or path not in self._moods):
264
+ self._studying.add(path)
265
+ threading.Thread(target=self._study, args=(track,), name="cloudspin-study", daemon=True).start()
266
+
267
+ def _follow_stream(self) -> None:
268
+ """Links only learn their real name once they start playing."""
269
+ track = self.deck.current
270
+ if track is None or not track.is_stream:
271
+ return
272
+ backend = self.deck.backend
273
+ title = getattr(backend, "media_title", "")
274
+ artist = getattr(backend, "media_artist", "")
275
+ if (not title or title == track.title or title in track.path) and (not artist or artist == track.artist):
276
+ return
277
+ if title and title not in track.path:
278
+ track.title = title
279
+ if artist:
280
+ track.artist = artist
281
+ look = look_for(track.title, track.artist, track.album, None)
282
+ guess = mood.from_words(track.title, track.artist, track.album, track.genre, self.config.moods)
283
+ self._looks[track.path] = self.look = look
284
+ self._moods[track.path] = self.mood = guess
285
+
286
+ def _open(self, target: str | None) -> None:
287
+ if not target:
288
+ return
289
+ target = target.strip()
290
+ if "://" in target:
291
+ if self.deck.backend.silent:
292
+ self.flash("Links need mpv for sound. Install mpv, plus yt-dlp for YouTube links.", 7.0)
293
+ return
294
+ tracks = [Track(path=target, title=target.split("://", 1)[1].rstrip("/") or target)]
295
+ else:
296
+ tracks = library.scan([target]) if Path(target).expanduser().exists() else []
297
+ if not tracks:
298
+ self.flash("That isn't a link, and no music was found at that path.", 6.0)
299
+ return
300
+ first = self.deck.add(tracks)
301
+ self.deck.play(first)
302
+ if len(tracks) > 1:
303
+ self.flash(f"Added {len(tracks)} songs")
304
+ threading.Thread(target=self._read_all_tags, name="cloudspin-tags", daemon=True).start()
305
+ else:
306
+ self.flash("Opening the link…" if tracks[0].is_stream else "Added to the queue")
307
+
308
+ def _study(self, track) -> None:
309
+ """Runs off the main thread: read the song's tags, cover and character."""
310
+ path = track.path
311
+ try:
312
+ library.read_tags(track)
313
+ art = library.cover_art(path)
314
+ self._inbox.put(("look", path, look_for(track.title, track.artist, track.album, art)))
315
+ guess = mood.from_words(track.title, track.artist, track.album, track.genre, self.config.moods)
316
+ if guess is None and not track.is_stream:
317
+ features = mood.listen(path)
318
+ if features is not None:
319
+ self._inbox.put(("tempo", path, features.tempo))
320
+ guess = mood.from_features(features)
321
+ self._inbox.put(("mood", path, guess))
322
+ except Exception: # a broken file must never take the app down
323
+ self._inbox.put(("mood", path, None))
324
+
325
+ def _read_all_tags(self) -> None:
326
+ for track in list(self.deck.tracks):
327
+ if self._closing:
328
+ return
329
+ if not track.artist and not track.is_stream:
330
+ library.read_tags(track)
331
+
332
+ def _drain(self) -> None:
333
+ current = self.deck.current.path if self.deck.current else None
334
+ while True:
335
+ try:
336
+ item = self._inbox.get_nowait()
337
+ except queue.Empty:
338
+ return
339
+ kind = item[0]
340
+ if kind == "look":
341
+ self._looks[item[1]] = item[2]
342
+ if item[1] == current:
343
+ self.look = item[2]
344
+ elif kind == "mood":
345
+ self._moods[item[1]] = item[2]
346
+ self._studying.discard(item[1])
347
+ if item[1] == current:
348
+ self.mood = item[2]
349
+ elif kind == "tempo":
350
+ self._tempos[item[1]] = item[2]
351
+ if item[1] == current:
352
+ self.spectrum.retune(item[1], item[2])
353
+ elif kind == "report":
354
+ self._weather_busy = False
355
+ self._set_report(item[1])
356
+ elif kind == "place":
357
+ self._weather_busy = False
358
+ place, report = item[1], item[2]
359
+ self.place = place
360
+ self.config.city = place.name
361
+ self.config.country = place.country
362
+ self.config.latitude = place.latitude
363
+ self.config.longitude = place.longitude
364
+ self.config.save()
365
+ self._set_report(report)
366
+ self.flash(f"Weather now follows {place.label}")
367
+ elif kind == "weather_error":
368
+ self._weather_busy = False
369
+ self.flash(item[1], 6.0)
370
+ if self.report is None and self.place is None:
371
+ self._sky.note = "Press c to choose your city"
372
+
373
+ # -- weather -------------------------------------------------------------
374
+
375
+ def _set_report(self, report: weather.Report) -> None:
376
+ self.report = report
377
+ self._sky.note = ""
378
+ self._sky.hint = ""
379
+ self._sky.set_report(report)
380
+
381
+ def _refresh_weather(self) -> None:
382
+ place = self.place
383
+ if place is None:
384
+ return
385
+ self._weather_busy = True
386
+ self._weather_due = time.monotonic() + WEATHER_EVERY
387
+ units = self.config.units
388
+
389
+ def work() -> None:
390
+ try:
391
+ report = weather.fetch(place, units, cache_dir() / "weather.json")
392
+ except weather.WeatherError as error:
393
+ self._inbox.put(("weather_error", str(error)))
394
+ else:
395
+ self._inbox.put(("report", report))
396
+
397
+ threading.Thread(target=work, name="cloudspin-weather", daemon=True).start()
398
+
399
+ def _find_city(self, name: str | None) -> None:
400
+ if not name:
401
+ return
402
+ self.flash(f"Looking for {name}…", 10.0)
403
+ self._weather_busy = True
404
+ self._weather_due = time.monotonic() + WEATHER_EVERY
405
+ units = self.config.units
406
+
407
+ def work() -> None:
408
+ try:
409
+ place = weather.geocode(name)
410
+ report = weather.fetch(place, units, cache_dir() / "weather.json")
411
+ except weather.WeatherError as error:
412
+ self._inbox.put(("weather_error", str(error)))
413
+ else:
414
+ self._inbox.put(("place", place, report))
415
+
416
+ threading.Thread(target=work, name="cloudspin-city", daemon=True).start()
417
+
418
+ # -- actions -------------------------------------------------------------
419
+
420
+ def flash(self, message: str, seconds: float = 3.0) -> None:
421
+ self.message = message
422
+ self._message_until = time.monotonic() + seconds
423
+
424
+ def set_volume(self, volume: int) -> None:
425
+ self.deck.set_volume(volume)
426
+ self.config.volume = self.deck.volume
427
+
428
+ def action_toggle_play(self) -> None:
429
+ self.deck.toggle()
430
+
431
+ def action_next_track(self) -> None:
432
+ self.deck.next()
433
+
434
+ def action_previous_track(self) -> None:
435
+ self.deck.previous()
436
+
437
+ def action_seek(self, seconds: int) -> None:
438
+ self.deck.seek_by(seconds)
439
+
440
+ def action_volume(self, step: int) -> None:
441
+ self.set_volume(self.deck.volume + step)
442
+
443
+ def action_shuffle(self) -> None:
444
+ self.deck.toggle_shuffle()
445
+ self.flash("Shuffle is on" if self.deck.shuffle else "Shuffle is off")
446
+
447
+ def action_repeat(self) -> None:
448
+ self.deck.cycle_repeat()
449
+ self.flash({"off": "Repeat is off", "all": "Repeating the whole queue", "one": "Repeating this song"}[self.deck.repeat])
450
+
451
+ def action_cycle_sky(self) -> None:
452
+ modes = ["song", "weather", *mood.SCENES]
453
+ mode = modes[(modes.index(self.config.sky) + 1) % len(modes)]
454
+ self.config.sky = mode
455
+ self.config.save()
456
+ if mode == "song":
457
+ self.flash("The sky follows the song")
458
+ elif mode == "weather":
459
+ self.flash("The sky follows the weather" if self.place else "The sky follows the weather. Press c to choose your city")
460
+ else:
461
+ self.flash(f"The sky is pinned to {mood.SCENES[mode].label.lower()}")
462
+
463
+ def action_cycle_colors(self) -> None:
464
+ names = list(self.themes)
465
+ name = names[(names.index(self.config.colors) + 1) % len(names)]
466
+ self.config.colors = name
467
+ self.config.save()
468
+ label = self.themes[name].label
469
+ self.flash(label if name in ("sky", "cover") else f"Colours: {label}")
470
+
471
+ def action_city(self) -> None:
472
+ self.push_screen(city_prompt(self.config.city), self._find_city)
473
+
474
+ def action_link(self) -> None:
475
+ self.push_screen(link_prompt(), self._open)
476
+
477
+ def action_units(self) -> None:
478
+ self.config.units = "imperial" if self.config.units == "metric" else "metric"
479
+ self.config.save()
480
+ self.flash("Showing °F and mph" if self.config.units == "imperial" else "Showing °C and km/h")
481
+ if self.place is not None:
482
+ self._refresh_weather()
483
+
484
+ def action_help(self) -> None:
485
+ self.push_screen(Help())
486
+
487
+ def on_mouse_scroll_up(self) -> None:
488
+ self.set_volume(self.deck.volume + 2)
489
+
490
+ def on_mouse_scroll_down(self) -> None:
491
+ self.set_volume(self.deck.volume - 2)
492
+
493
+ def on_unmount(self) -> None:
494
+ self._closing = True
495
+ self.config.volume = self.deck.volume
496
+ self.config.save()
497
+ self.deck.close()