tinyjukebox 0.47.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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""STMPlayAlbum - the state machine that plays one album (next/prev/play/pause buttons, prev held: from the
|
|
2
|
+
beginning, pause between tracks, resume, switching between speaker and headset, waiting for a track that is still
|
|
3
|
+
being fetched from the library)."""
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.albumplayer.player_interface import Hook, PlayerInterface
|
|
15
|
+
from tinyjukebox.core import unexpected
|
|
16
|
+
from tinyjukebox.core.logging import get_child
|
|
17
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
18
|
+
from tinyjukebox.data.activity import ActivityAlbum
|
|
19
|
+
from tinyjukebox.data.target import TargetAlbum
|
|
20
|
+
from tinyjukebox.messages import (
|
|
21
|
+
BUTTON_HELD,
|
|
22
|
+
BUTTON_PRESSED,
|
|
23
|
+
BUTTON_RELEASED,
|
|
24
|
+
JOURNAL,
|
|
25
|
+
PLAYBACK,
|
|
26
|
+
Button,
|
|
27
|
+
JournalEvent,
|
|
28
|
+
PlaybackStatus,
|
|
29
|
+
)
|
|
30
|
+
from tinyjukebox.rgbled import WAITING, Color, RGBLed
|
|
31
|
+
from tinyjukebox.stm.base import OutputPause, PauseReason, PeriodicTask, State, create_machine, write_dot_file
|
|
32
|
+
from tinyjukebox.sync import SOFTWARE, actor
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from tinyjukebox.sync.files import AlbumFiles
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BOTH_BUTTONS_WINDOW",
|
|
39
|
+
"OUTPUT_REWIND",
|
|
40
|
+
"BothButtons",
|
|
41
|
+
"PlayerFactory",
|
|
42
|
+
"STMPlayAlbum",
|
|
43
|
+
"States",
|
|
44
|
+
"Transitions",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
PlayerFactory = Callable[[logging.Logger, TargetAlbum, Hook, Hook], PlayerInterface]
|
|
48
|
+
"""(logger, album, end_of_track_hook, end_of_album_hook) -> player"""
|
|
49
|
+
|
|
50
|
+
OUTPUT_REWIND = 1.0
|
|
51
|
+
"""Seconds a track goes back when the output changes or is lost - the sound in the buffers has not been heard."""
|
|
52
|
+
|
|
53
|
+
MAX_ERROR_LENGTH = 500
|
|
54
|
+
"""Characters of what the decoder said about a damaged file that are noted for the library - its end."""
|
|
55
|
+
|
|
56
|
+
BOTH_BUTTONS_WINDOW = 2.0
|
|
57
|
+
"""Seconds: 'next' pressed at most this long before 'prev' (and not released) - or after it - makes a held 'prev' the
|
|
58
|
+
gesture of both buttons (pairing a headset, see :mod:`tinyjukebox.headset`) and not a restart of the album. Presses
|
|
59
|
+
without release (web page, buttons of the headset) are forgotten after it."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BothButtons:
|
|
63
|
+
"""Tells whether 'next' is pressed together with a held 'prev'. Fed by the button handlers, which all run in the
|
|
64
|
+
pubsub dispatch thread."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
67
|
+
self._clock = clock
|
|
68
|
+
self._pressed_at: dict[Button, float] = {}
|
|
69
|
+
|
|
70
|
+
def pressed(self, button: object) -> None:
|
|
71
|
+
if isinstance(button, Button):
|
|
72
|
+
self._pressed_at[button] = self._clock()
|
|
73
|
+
|
|
74
|
+
def released(self, button: object) -> None:
|
|
75
|
+
if isinstance(button, Button):
|
|
76
|
+
self._pressed_at.pop(button, None)
|
|
77
|
+
|
|
78
|
+
def next_is_pressed(self) -> bool:
|
|
79
|
+
"""Called when 'prev' is held."""
|
|
80
|
+
next_pressed_at = self._pressed_at.get(Button.NEXT)
|
|
81
|
+
if next_pressed_at is None:
|
|
82
|
+
return False
|
|
83
|
+
prev_pressed_at = self._pressed_at.get(Button.PREV, self._clock()) # held without press: web page
|
|
84
|
+
return next_pressed_at >= prev_pressed_at - BOTH_BUTTONS_WINDOW
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class States(Enum):
|
|
88
|
+
"""States of the STMPlayAlbum state machine."""
|
|
89
|
+
|
|
90
|
+
INIT = "init" # loading the album
|
|
91
|
+
PLAY = "play" # playing the current track
|
|
92
|
+
TONEXT = "tonext" # switching to the next track
|
|
93
|
+
DELAY = "delay" # pause between two tracks
|
|
94
|
+
TOPREV = "toprev" # switching to the previous track
|
|
95
|
+
PREV = "prev" # deciding: rewind the current track or go to the previous one
|
|
96
|
+
REWIND = "rewind" # rewinding the current track
|
|
97
|
+
FINISHED = "finished" # album played to its end
|
|
98
|
+
END = "end" # final state
|
|
99
|
+
FIRST = "first" # switching to the first track
|
|
100
|
+
PAUSED = "paused" # paused (play/pause button or headset lost) - the position is kept
|
|
101
|
+
RESUME = "resume" # deciding after the headset came back: continue or stay paused
|
|
102
|
+
WAIT = "wait" # the file of the current track has not arrived from the library yet
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Transitions(Enum):
|
|
106
|
+
"""Transitions of the STMPlayAlbum state machine."""
|
|
107
|
+
|
|
108
|
+
END = "end"
|
|
109
|
+
PLAY = "play"
|
|
110
|
+
NEXT = "next"
|
|
111
|
+
DELAY = "delay"
|
|
112
|
+
PREV = "prev"
|
|
113
|
+
TOPREV = "toprev"
|
|
114
|
+
REWIND = "rewind"
|
|
115
|
+
FIRST = "first"
|
|
116
|
+
FINISHED = "finished"
|
|
117
|
+
PAUSE = "pause" # play/pause button
|
|
118
|
+
OUTPUT_LOST = "output_lost" # the headset has gone - pause
|
|
119
|
+
OUTPUT_CHANGED = "output_changed" # the box plays on another output now (headset connected)
|
|
120
|
+
STAY = "stay" # stay paused
|
|
121
|
+
WAIT = "wait" # the file of the current track is not there yet
|
|
122
|
+
ARRIVED = "arrived" # a file of the album has arrived
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _PlayerState(State):
|
|
126
|
+
"""State with access to the album player (created in INIT)."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, state_id: States, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
129
|
+
super().__init__(state_id, logger)
|
|
130
|
+
self._owner = owner
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def _player(self) -> PlayerInterface:
|
|
134
|
+
player = self._owner.player
|
|
135
|
+
if player is None:
|
|
136
|
+
raise RuntimeError("album player not initialized")
|
|
137
|
+
return player
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Init(_PlayerState):
|
|
141
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
|
|
142
|
+
super().__init__(States.INIT, logger, owner)
|
|
143
|
+
self._rgbled = rgbled
|
|
144
|
+
|
|
145
|
+
def _on_entry(self) -> Enum | None:
|
|
146
|
+
self._rgbled.set_color(Color.AQUA)
|
|
147
|
+
activity = self._owner.activity
|
|
148
|
+
self._logger.info("initializing album: %s", self._owner.album.to_text())
|
|
149
|
+
player = self._owner.create_player()
|
|
150
|
+
player.start_player()
|
|
151
|
+
if activity.finished:
|
|
152
|
+
self._logger.info("album has been finished last time - starting from the beginning")
|
|
153
|
+
elif not 0 <= activity.track_id < player.album_track_count():
|
|
154
|
+
# e.g. the playlist has been changed since the position has been stored
|
|
155
|
+
self._logger.warning(
|
|
156
|
+
"stored track id %s is not valid for an album with %s tracks - starting from the beginning",
|
|
157
|
+
activity.track_id,
|
|
158
|
+
player.album_track_count(),
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
player.set_track(activity.track_id, activity.track_time)
|
|
162
|
+
self._owner.player = player
|
|
163
|
+
return Transitions.PLAY
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Play(_PlayerState):
|
|
167
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed, update_schedule: float) -> None:
|
|
168
|
+
super().__init__(States.PLAY, logger, owner)
|
|
169
|
+
self._rgbled = rgbled
|
|
170
|
+
self._update_schedule = update_schedule
|
|
171
|
+
self._update_task: PeriodicTask | None = None
|
|
172
|
+
self._started: tuple[int, float] | None = None # track and position at which the playback began
|
|
173
|
+
|
|
174
|
+
def _update_activity(self) -> None:
|
|
175
|
+
player = self._player
|
|
176
|
+
self._owner.save_position(player.current_track_id(), player.current_track_time(), player.album_end_reached())
|
|
177
|
+
|
|
178
|
+
def _on_entry(self) -> Enum | None:
|
|
179
|
+
if not self._player.current_track_ready():
|
|
180
|
+
return Transitions.WAIT
|
|
181
|
+
self._rgbled.set_color(Color.BLUE)
|
|
182
|
+
self._update_activity()
|
|
183
|
+
try:
|
|
184
|
+
self._player.play()
|
|
185
|
+
except OSError as error:
|
|
186
|
+
if not self._owner.started:
|
|
187
|
+
raise # the first track at the start: the album cannot be played (STMOperation shows it as broken)
|
|
188
|
+
# later ffmpeg or aplay cannot be started (removed meanwhile, no process left): paused - "play" tries again
|
|
189
|
+
self._logger.error("the track cannot be played: %s", error)
|
|
190
|
+
unexpected.report(error, "album player")
|
|
191
|
+
return Transitions.PAUSE
|
|
192
|
+
self._started = (self._player.current_track_id(), self._player.current_track_time())
|
|
193
|
+
self._owner.report(PlaybackStatus.PLAYING)
|
|
194
|
+
# started after play() succeeded - on_exit is not called if on_entry raises
|
|
195
|
+
self._update_task = PeriodicTask(
|
|
196
|
+
self._update_schedule, self._update_activity, "STMPlayAlbum.update", self._logger
|
|
197
|
+
)
|
|
198
|
+
self._update_task.start()
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _on_exit(self) -> None:
|
|
202
|
+
self._player.pause()
|
|
203
|
+
if self._update_task is not None:
|
|
204
|
+
self._update_task.stop()
|
|
205
|
+
self._update_task = None
|
|
206
|
+
self._update_activity()
|
|
207
|
+
if self._started is not None:
|
|
208
|
+
# for the journal: which track was listened to, for how long, and whether to its end
|
|
209
|
+
player, (track, position) = self._player, self._started
|
|
210
|
+
self._started = None
|
|
211
|
+
self._owner.note(
|
|
212
|
+
"track",
|
|
213
|
+
track=track,
|
|
214
|
+
seconds=round(max(0.0, player.current_track_time() - position), 1),
|
|
215
|
+
to_end=player.current_track_id() == track and player.current_track_end_reached(),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ToNext(_PlayerState):
|
|
220
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
221
|
+
super().__init__(States.TONEXT, logger, owner)
|
|
222
|
+
|
|
223
|
+
def _on_entry(self) -> Enum | None:
|
|
224
|
+
return Transitions.PLAY if self._player.to_next() else Transitions.FINISHED
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Prev(_PlayerState):
|
|
228
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rewind_max_time: float) -> None:
|
|
229
|
+
super().__init__(States.PREV, logger, owner)
|
|
230
|
+
self._rewind_max_time = rewind_max_time
|
|
231
|
+
|
|
232
|
+
def _on_entry(self) -> Enum | None:
|
|
233
|
+
# within the first seconds of a track 'prev' goes to the previous track, later it rewinds the current one
|
|
234
|
+
if self._player.current_track_time() <= self._rewind_max_time:
|
|
235
|
+
return Transitions.TOPREV
|
|
236
|
+
return Transitions.REWIND
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Rewind(_PlayerState):
|
|
240
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
241
|
+
super().__init__(States.REWIND, logger, owner)
|
|
242
|
+
|
|
243
|
+
def _on_entry(self) -> Enum | None:
|
|
244
|
+
self._player.rewind_track()
|
|
245
|
+
return Transitions.PLAY
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class ToPrev(_PlayerState):
|
|
249
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
250
|
+
super().__init__(States.TOPREV, logger, owner)
|
|
251
|
+
|
|
252
|
+
def _on_entry(self) -> Enum | None:
|
|
253
|
+
self._player.to_prev()
|
|
254
|
+
return Transitions.PLAY
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class First(_PlayerState):
|
|
258
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
259
|
+
super().__init__(States.FIRST, logger, owner)
|
|
260
|
+
|
|
261
|
+
def _on_entry(self) -> Enum | None:
|
|
262
|
+
self._player.to_first()
|
|
263
|
+
return Transitions.PLAY
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class Finished(_PlayerState):
|
|
267
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
|
|
268
|
+
super().__init__(States.FINISHED, logger, owner)
|
|
269
|
+
self._rgbled = rgbled
|
|
270
|
+
|
|
271
|
+
def _on_entry(self) -> Enum | None:
|
|
272
|
+
self._rgbled.set_blink_color(Color.BLUE, 1)
|
|
273
|
+
player = self._player
|
|
274
|
+
self._owner.save_position(player.current_track_id(), player.current_track_time(), True)
|
|
275
|
+
self._owner.report(PlaybackStatus.STOPPED)
|
|
276
|
+
self._owner.note("end", reason="album finished")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class Paused(_PlayerState):
|
|
281
|
+
"""Nothing plays, the position is stored; the tag stays on the box. LED: white/blue, slowly alternating."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
|
|
284
|
+
super().__init__(States.PAUSED, logger, owner)
|
|
285
|
+
self._rgbled = rgbled
|
|
286
|
+
|
|
287
|
+
def _on_entry(self) -> Enum | None:
|
|
288
|
+
self._rgbled.set_alternate_colors(Color.WHITE, Color.BLUE, 1)
|
|
289
|
+
player = self._player
|
|
290
|
+
self._owner.save_position(player.current_track_id(), player.current_track_time(), False)
|
|
291
|
+
self._owner.report(PlaybackStatus.PAUSED)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Wait(_PlayerState):
|
|
296
|
+
"""The playback is halted until the file of the current track has arrived from the library. LED: green, aqua,
|
|
297
|
+
blue flowing back and forth."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum", rgbled: RGBLed) -> None:
|
|
300
|
+
super().__init__(States.WAIT, logger, owner)
|
|
301
|
+
self._rgbled = rgbled
|
|
302
|
+
|
|
303
|
+
def _on_entry(self) -> Enum | None:
|
|
304
|
+
self._rgbled.set_sequence(WAITING)
|
|
305
|
+
player = self._player
|
|
306
|
+
self._owner.save_position(player.current_track_id(), player.current_track_time(), False)
|
|
307
|
+
# "next" on a missing track pulls this track forward in the download
|
|
308
|
+
self._owner.want_track(player.current_track_id())
|
|
309
|
+
return Transitions.ARRIVED if player.current_track_ready() else None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Resume(_PlayerState):
|
|
313
|
+
"""The headset has come back: a pause caused by its loss is continued (within the resume window)."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
316
|
+
super().__init__(States.RESUME, logger, owner)
|
|
317
|
+
|
|
318
|
+
def _on_entry(self) -> Enum | None:
|
|
319
|
+
return Transitions.PLAY if self._owner.pause.resumes_on_output() else Transitions.STAY
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class End(_PlayerState):
|
|
323
|
+
def __init__(self, logger: logging.Logger, owner: "STMPlayAlbum") -> None:
|
|
324
|
+
super().__init__(States.END, logger, owner)
|
|
325
|
+
|
|
326
|
+
def _on_entry(self) -> Enum | None:
|
|
327
|
+
if self._owner.player is not None:
|
|
328
|
+
self._owner.player.stop_player()
|
|
329
|
+
self._owner.report(PlaybackStatus.STOPPED)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class Delay(State):
|
|
334
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
335
|
+
super().__init__(States.DELAY, logger)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class STMPlayAlbum:
|
|
339
|
+
"""Plays ``album`` from the position stored in ``activity`` and keeps the position up to date.
|
|
340
|
+
|
|
341
|
+
The next/prev/play/pause buttons arrive on :data:`~tinyjukebox.messages.BUTTON_PRESSED`. 'prev' held
|
|
342
|
+
(:data:`~tinyjukebox.messages.BUTTON_HELD`, after the ``hold_timeout`` of its input driver) plays the album from the
|
|
343
|
+
beginning - unless 'next' is pressed as well (pairing a headset). All events - buttons, end of
|
|
344
|
+
track, web page, headset - are queued (``asyncoperate``); handlers never block. The playback status is published
|
|
345
|
+
to :data:`~tinyjukebox.messages.PLAYBACK` (for the headset).
|
|
346
|
+
|
|
347
|
+
Pause: play and next continue at the same position, prev goes back at once (as while playing). A pause caused by
|
|
348
|
+
the loss of the headset (:meth:`cmd_output_lost`) is continued when the headset comes back within
|
|
349
|
+
:data:`~tinyjukebox.stm.base.RESUME_WINDOW` (:meth:`cmd_output_changed`).
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(
|
|
353
|
+
self,
|
|
354
|
+
config: Mapping[str, Any],
|
|
355
|
+
pubsub_client: PubSubClient,
|
|
356
|
+
logger: logging.Logger,
|
|
357
|
+
rgbled: RGBLed,
|
|
358
|
+
album: TargetAlbum,
|
|
359
|
+
activity: ActivityAlbum,
|
|
360
|
+
player_factory: PlayerFactory,
|
|
361
|
+
files: "AlbumFiles | None" = None,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""``files``: fetches the album from the library while it plays (:mod:`tinyjukebox.sync`)."""
|
|
364
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
365
|
+
self._files = files
|
|
366
|
+
self._pubsub_client = pubsub_client
|
|
367
|
+
self._player_factory = player_factory
|
|
368
|
+
self._pause_between_tracks: float = config["pause-between-tracks"]
|
|
369
|
+
self.album = album
|
|
370
|
+
self.activity = activity
|
|
371
|
+
self.player: PlayerInterface | None = None
|
|
372
|
+
self.pause = OutputPause()
|
|
373
|
+
self._reported: PlaybackStatus | None = None
|
|
374
|
+
self._saving_failed = False # the position could not be written last time
|
|
375
|
+
self.started = False # the machine has started: a track that cannot be played pauses the album from then on
|
|
376
|
+
self._both_buttons = BothButtons()
|
|
377
|
+
|
|
378
|
+
self._machine = create_machine(self._logger)
|
|
379
|
+
for state in [
|
|
380
|
+
End(self._logger, self),
|
|
381
|
+
Finished(self._logger, self, rgbled),
|
|
382
|
+
Init(self._logger, self, rgbled),
|
|
383
|
+
ToNext(self._logger, self),
|
|
384
|
+
Play(self._logger, self, rgbled, config["update-schedule"]),
|
|
385
|
+
Prev(self._logger, self, config["rewind-max-time"]),
|
|
386
|
+
ToPrev(self._logger, self),
|
|
387
|
+
Rewind(self._logger, self),
|
|
388
|
+
First(self._logger, self),
|
|
389
|
+
Delay(self._logger),
|
|
390
|
+
Paused(self._logger, self, rgbled),
|
|
391
|
+
Resume(self._logger, self),
|
|
392
|
+
Wait(self._logger, self, rgbled),
|
|
393
|
+
]:
|
|
394
|
+
self._machine.addstate(state)
|
|
395
|
+
self._add_transitions()
|
|
396
|
+
|
|
397
|
+
def _add_transitions(self) -> None:
|
|
398
|
+
machine = self._machine
|
|
399
|
+
machine.setstartstate(States.INIT)
|
|
400
|
+
machine.addtransition(States.INIT, Transitions.PLAY, States.PLAY)
|
|
401
|
+
machine.addtransition(States.PLAY, Transitions.FIRST, States.FIRST)
|
|
402
|
+
machine.addtransition(States.PLAY, Transitions.NEXT, States.TONEXT)
|
|
403
|
+
machine.addtransition(States.PLAY, Transitions.DELAY, States.DELAY)
|
|
404
|
+
machine.addtransition(States.PLAY, Transitions.PREV, States.PREV)
|
|
405
|
+
machine.addtransition(States.PLAY, Transitions.FINISHED, States.FINISHED)
|
|
406
|
+
machine.addtransition(States.PREV, Transitions.TOPREV, States.TOPREV)
|
|
407
|
+
machine.addtransition(States.PREV, Transitions.REWIND, States.REWIND)
|
|
408
|
+
machine.addtransition(States.TONEXT, Transitions.PLAY, States.PLAY)
|
|
409
|
+
machine.addtransition(States.TONEXT, Transitions.FINISHED, States.FINISHED)
|
|
410
|
+
machine.addtransition(States.TOPREV, Transitions.PLAY, States.PLAY)
|
|
411
|
+
machine.addtransition(States.DELAY, Transitions.NEXT, States.TONEXT)
|
|
412
|
+
machine.addtransition(States.DELAY, Transitions.PREV, States.TOPREV)
|
|
413
|
+
machine.addtransition(States.REWIND, Transitions.PLAY, States.PLAY)
|
|
414
|
+
machine.addtransition(States.FIRST, Transitions.PLAY, States.PLAY)
|
|
415
|
+
machine.addtransition(States.FINISHED, Transitions.PREV, States.REWIND) # the last track once more
|
|
416
|
+
machine.addtransition(States.FINISHED, Transitions.FIRST, States.FIRST)
|
|
417
|
+
# pause - the actions run between on_exit of the old state (the player is paused) and on_entry of PAUSED
|
|
418
|
+
machine.addtransition(States.PLAY, Transitions.PAUSE, States.PAUSED, self._paused, (PauseReason.USER, False))
|
|
419
|
+
machine.addtransition(
|
|
420
|
+
States.PLAY, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, False)
|
|
421
|
+
)
|
|
422
|
+
# between two tracks the next track is selected - it starts when the pause ends
|
|
423
|
+
machine.addtransition(States.DELAY, Transitions.PAUSE, States.PAUSED, self._paused, (PauseReason.USER, True))
|
|
424
|
+
machine.addtransition(
|
|
425
|
+
States.DELAY, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, True)
|
|
426
|
+
)
|
|
427
|
+
machine.addtransition(States.PAUSED, Transitions.PLAY, States.PLAY)
|
|
428
|
+
machine.addtransition(States.PAUSED, Transitions.NEXT, States.PLAY) # the 1st press continues
|
|
429
|
+
machine.addtransition(States.PAUSED, Transitions.PREV, States.PREV)
|
|
430
|
+
machine.addtransition(States.PAUSED, Transitions.FIRST, States.FIRST)
|
|
431
|
+
machine.addtransition(States.PAUSED, Transitions.OUTPUT_CHANGED, States.RESUME)
|
|
432
|
+
machine.addtransition(States.RESUME, Transitions.PLAY, States.PLAY)
|
|
433
|
+
machine.addtransition(States.RESUME, Transitions.STAY, States.PAUSED)
|
|
434
|
+
# the file of the track that is due has not arrived yet: PLAY sends on to WAIT, a file that arrives back
|
|
435
|
+
machine.addtransition(States.PLAY, Transitions.WAIT, States.WAIT)
|
|
436
|
+
machine.addtransition(States.WAIT, Transitions.ARRIVED, States.PLAY)
|
|
437
|
+
machine.addtransition(States.WAIT, Transitions.NEXT, States.TONEXT)
|
|
438
|
+
machine.addtransition(States.WAIT, Transitions.PREV, States.TOPREV)
|
|
439
|
+
machine.addtransition(States.WAIT, Transitions.FIRST, States.FIRST)
|
|
440
|
+
# the headset goes while the box waits for a file: paused, so that the file that arrives does not start the
|
|
441
|
+
# track on the speaker
|
|
442
|
+
machine.addtransition(
|
|
443
|
+
States.WAIT, Transitions.OUTPUT_LOST, States.PAUSED, self._paused, (PauseReason.OUTPUT, False, False)
|
|
444
|
+
)
|
|
445
|
+
# the track continues on the new output - PLAY is left (player paused) and entered again
|
|
446
|
+
machine.addtransition(States.PLAY, Transitions.OUTPUT_CHANGED, States.PLAY, self._rewind_output)
|
|
447
|
+
for state in States:
|
|
448
|
+
if state != States.END:
|
|
449
|
+
machine.addtransition(state, Transitions.END, States.END)
|
|
450
|
+
machine.addtimeoutevent(States.DELAY, Transitions.NEXT, self._pause_between_tracks)
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def machine(self) -> object:
|
|
454
|
+
"""The tantamount machine (diagram export, tests)."""
|
|
455
|
+
return self._machine
|
|
456
|
+
|
|
457
|
+
def create_player(self) -> PlayerInterface:
|
|
458
|
+
player = self._player_factory(self._logger, self.album, self._end_of_track, self._album_finished)
|
|
459
|
+
player.set_output_lost_hook(self.cmd_output_lost)
|
|
460
|
+
player.set_track_failed_hook(self._track_failed)
|
|
461
|
+
return player
|
|
462
|
+
|
|
463
|
+
def _track_failed(self, track: int, error: str) -> None:
|
|
464
|
+
"""The decoder found the file of a track damaged: noted for the library, which collects the broken
|
|
465
|
+
files."""
|
|
466
|
+
name = os.path.relpath(self.album.get_tracks()[track], str(self.album.full_album_folder))
|
|
467
|
+
self.note("broken", track=track, file=name.replace(os.sep, "/"), error=error[-MAX_ERROR_LENGTH:])
|
|
468
|
+
|
|
469
|
+
def save_position(self, track_id: int, track_time: float, finished: bool) -> None:
|
|
470
|
+
"""Store the position. A card that cannot be written - full, or read-only after a fault - stops nothing: the
|
|
471
|
+
album plays on, and the error is logged once."""
|
|
472
|
+
try:
|
|
473
|
+
self.activity.update(track_id, track_time, finished)
|
|
474
|
+
except OSError as error:
|
|
475
|
+
if not self._saving_failed:
|
|
476
|
+
self._saving_failed = True
|
|
477
|
+
self._logger.error("the position cannot be saved: %s - the album plays on", error)
|
|
478
|
+
return
|
|
479
|
+
if self._saving_failed:
|
|
480
|
+
self._saving_failed = False
|
|
481
|
+
self._logger.info("the position is saved again")
|
|
482
|
+
|
|
483
|
+
def report(self, status: PlaybackStatus) -> None:
|
|
484
|
+
"""Publish the playback status (for the headset) if it changed."""
|
|
485
|
+
if status != self._reported:
|
|
486
|
+
self._reported = status
|
|
487
|
+
self._pubsub_client.publish(PLAYBACK, status)
|
|
488
|
+
|
|
489
|
+
def note(self, event: str, **fields: object) -> None:
|
|
490
|
+
"""Something for the journal of the box about this album."""
|
|
491
|
+
self._pubsub_client.publish(JOURNAL, JournalEvent.of(event, album=str(self.album.target_id), **fields))
|
|
492
|
+
|
|
493
|
+
def _control(self, action: str) -> None:
|
|
494
|
+
"""A command changed the playback - by a button (of the box or the headset) or by a user of the web page."""
|
|
495
|
+
who = actor()
|
|
496
|
+
self.note("control", action=action, by="button" if who == SOFTWARE else who)
|
|
497
|
+
|
|
498
|
+
def _paused(self, reason: PauseReason, next_track: bool, rewind: bool = True) -> None:
|
|
499
|
+
"""Transition action into PAUSED: remember why; ``next_track``: paused between two tracks; ``rewind``: the
|
|
500
|
+
end of the playback was not heard (not while waiting for a file - nothing played)."""
|
|
501
|
+
self.pause.paused(reason)
|
|
502
|
+
player = self.player
|
|
503
|
+
if player is None:
|
|
504
|
+
return
|
|
505
|
+
if next_track:
|
|
506
|
+
player.to_next()
|
|
507
|
+
elif reason is PauseReason.OUTPUT and rewind:
|
|
508
|
+
self._rewind_output()
|
|
509
|
+
|
|
510
|
+
def _rewind_output(self) -> None:
|
|
511
|
+
"""Transition action: back :data:`OUTPUT_REWIND` seconds - the end of the playback has not been heard."""
|
|
512
|
+
player = self.player
|
|
513
|
+
if player is not None:
|
|
514
|
+
player.set_track(player.current_track_id(), max(0.0, player.current_track_time() - OUTPUT_REWIND))
|
|
515
|
+
|
|
516
|
+
def want_track(self, track_id: int) -> None:
|
|
517
|
+
"""Ask for the file of the track ``track_id`` first."""
|
|
518
|
+
if self._files is not None:
|
|
519
|
+
self._files.need(str(self.album.target_id), track_id)
|
|
520
|
+
|
|
521
|
+
def _file_arrived(self, album: str) -> None:
|
|
522
|
+
"""A file of an album arrived (thread of the download) - only WAIT reacts."""
|
|
523
|
+
if album == str(self.album.target_id):
|
|
524
|
+
self._machine.asyncoperate(Transitions.ARRIVED)
|
|
525
|
+
|
|
526
|
+
def _end_of_track(self) -> None:
|
|
527
|
+
"""Called by the player when a track has been played to its end."""
|
|
528
|
+
self._machine.asyncoperate(Transitions.NEXT if self._pause_between_tracks == 0 else Transitions.DELAY)
|
|
529
|
+
|
|
530
|
+
def _album_finished(self) -> None:
|
|
531
|
+
"""Called by the player when the last track has been played to its end."""
|
|
532
|
+
self._machine.asyncoperate(Transitions.FINISHED)
|
|
533
|
+
|
|
534
|
+
def cmd_next_handler(self) -> None:
|
|
535
|
+
self._control("next")
|
|
536
|
+
self._machine.asyncoperate(Transitions.NEXT)
|
|
537
|
+
|
|
538
|
+
def cmd_prev_handler(self) -> None:
|
|
539
|
+
self._control("prev")
|
|
540
|
+
self._machine.asyncoperate(Transitions.PREV)
|
|
541
|
+
|
|
542
|
+
def cmd_restart_album(self) -> None:
|
|
543
|
+
self._control("restart")
|
|
544
|
+
self._machine.asyncoperate(Transitions.FIRST)
|
|
545
|
+
|
|
546
|
+
def cmd_pause(self) -> None:
|
|
547
|
+
self._control("pause")
|
|
548
|
+
self._machine.asyncoperate(Transitions.PAUSE)
|
|
549
|
+
|
|
550
|
+
def cmd_play(self) -> None:
|
|
551
|
+
self._control("play")
|
|
552
|
+
self._machine.asyncoperate(Transitions.PLAY)
|
|
553
|
+
|
|
554
|
+
def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
|
|
555
|
+
"""The headset has gone: pause - unless the album is paused or finished already. ``then`` is called in the
|
|
556
|
+
worker once that has been handled."""
|
|
557
|
+
self._machine.asyncoperate(Transitions.OUTPUT_LOST)
|
|
558
|
+
if then is not None:
|
|
559
|
+
self._machine.call_soon(then)
|
|
560
|
+
|
|
561
|
+
def cmd_output_changed(self) -> None:
|
|
562
|
+
"""The output has changed (headset connected): a playing track continues on it, a pause caused by the loss
|
|
563
|
+
of the headset ends."""
|
|
564
|
+
self._machine.asyncoperate(Transitions.OUTPUT_CHANGED)
|
|
565
|
+
|
|
566
|
+
def get_state(self) -> States:
|
|
567
|
+
return self._machine.get_active_state().id # type: ignore[no-any-return]
|
|
568
|
+
|
|
569
|
+
def wait_until_idle(self) -> None:
|
|
570
|
+
"""Block until all queued events have been processed (tests)."""
|
|
571
|
+
self._machine.wait_until_idle()
|
|
572
|
+
|
|
573
|
+
def get_info(self) -> dict[str, Any]:
|
|
574
|
+
player = self.player
|
|
575
|
+
info: dict[str, Any] = {
|
|
576
|
+
"target_id": self.album.target_id,
|
|
577
|
+
"target_type": "album",
|
|
578
|
+
"album_folder": self.album.album_folder,
|
|
579
|
+
"playlist": self.album.playlist,
|
|
580
|
+
"state": self.get_state().value,
|
|
581
|
+
"tracks": self.album.get_tracks(),
|
|
582
|
+
"track": {"track_id": None, "filename": None, "pos": None, "length": None},
|
|
583
|
+
}
|
|
584
|
+
if player is not None:
|
|
585
|
+
info["track"] = {
|
|
586
|
+
"track_id": player.current_track_id(),
|
|
587
|
+
"filename": player.current_track_filename(),
|
|
588
|
+
"pos": player.current_track_time(),
|
|
589
|
+
"length": player.current_track_length(),
|
|
590
|
+
}
|
|
591
|
+
return info
|
|
592
|
+
|
|
593
|
+
def dump_to_dot(self, filename: str | Path) -> None:
|
|
594
|
+
write_dot_file(self._machine, filename)
|
|
595
|
+
|
|
596
|
+
def _button_handler(self, message: object) -> None:
|
|
597
|
+
"""Handler for :data:`~tinyjukebox.messages.BUTTON_PRESSED` - reacts on next/prev/play/pause, ignores the other
|
|
598
|
+
buttons."""
|
|
599
|
+
self._both_buttons.pressed(message)
|
|
600
|
+
command = {
|
|
601
|
+
Button.NEXT: self.cmd_next_handler,
|
|
602
|
+
Button.PREV: self.cmd_prev_handler,
|
|
603
|
+
Button.PLAY: self.cmd_play,
|
|
604
|
+
Button.PAUSE: self.cmd_pause,
|
|
605
|
+
}.get(message) # type: ignore[call-overload] # any payload - unknown ones are ignored
|
|
606
|
+
if command is not None:
|
|
607
|
+
self._logger.info("_button_handler - received: %s", message)
|
|
608
|
+
command()
|
|
609
|
+
|
|
610
|
+
def _released_handler(self, message: object) -> None:
|
|
611
|
+
"""Handler for :data:`~tinyjukebox.messages.BUTTON_RELEASED`."""
|
|
612
|
+
self._both_buttons.released(message)
|
|
613
|
+
|
|
614
|
+
def _held_handler(self, message: object) -> None:
|
|
615
|
+
"""Handler for :data:`~tinyjukebox.messages.BUTTON_HELD` - 'prev' held alone: the album from the beginning."""
|
|
616
|
+
if message != Button.PREV:
|
|
617
|
+
return
|
|
618
|
+
if self._both_buttons.next_is_pressed():
|
|
619
|
+
self._logger.info("_held_handler - prev held together with next: no restart")
|
|
620
|
+
return
|
|
621
|
+
self._logger.info("_held_handler - prev held: restarting the album")
|
|
622
|
+
self.cmd_restart_album()
|
|
623
|
+
|
|
624
|
+
def start(self) -> None:
|
|
625
|
+
"""Load the album and start playing. :raises Exception: if the album cannot be loaded (machine stopped)."""
|
|
626
|
+
try:
|
|
627
|
+
self._machine.start()
|
|
628
|
+
except Exception:
|
|
629
|
+
# e.g. album cannot be loaded in INIT - stop the machine, otherwise its worker thread is left running
|
|
630
|
+
self._machine.stop()
|
|
631
|
+
raise
|
|
632
|
+
self.started = True
|
|
633
|
+
self._pubsub_client.subscribe(BUTTON_PRESSED, self._button_handler)
|
|
634
|
+
self._pubsub_client.subscribe(BUTTON_HELD, self._held_handler)
|
|
635
|
+
self._pubsub_client.subscribe(BUTTON_RELEASED, self._released_handler)
|
|
636
|
+
if self._files is not None:
|
|
637
|
+
self._files.subscribe(self._file_arrived)
|
|
638
|
+
|
|
639
|
+
def stop(self) -> None:
|
|
640
|
+
if self._files is not None:
|
|
641
|
+
self._files.unsubscribe(self._file_arrived)
|
|
642
|
+
self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._button_handler, ignore_not_found=True)
|
|
643
|
+
self._pubsub_client.unsubscribe(BUTTON_HELD, self._held_handler, ignore_not_found=True)
|
|
644
|
+
self._pubsub_client.unsubscribe(BUTTON_RELEASED, self._released_handler, ignore_not_found=True)
|
|
645
|
+
try:
|
|
646
|
+
self._machine.operate(Transitions.END)
|
|
647
|
+
except Exception as error:
|
|
648
|
+
# what ended in an exception is logged; the machine and its threads end all the same, and so does the sound
|
|
649
|
+
self._logger.exception("the album does not end cleanly")
|
|
650
|
+
unexpected.report(error, "end of an album")
|
|
651
|
+
if self.player is not None:
|
|
652
|
+
with contextlib.suppress(Exception):
|
|
653
|
+
self.player.stop_player()
|
|
654
|
+
finally:
|
|
655
|
+
self._machine.stop()
|