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/__init__.py
ADDED
cloudspin/__main__.py
ADDED
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()
|