Typing lessons like duolingo & musicmouse cleanup
This commit is contained in:
@@ -297,13 +297,17 @@ def _build_app(
|
||||
return app
|
||||
|
||||
|
||||
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
|
||||
return next((s for s in services if isinstance(s, kind)), None)
|
||||
|
||||
|
||||
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
||||
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
||||
as background analysis lands, instead of only after a manual reload. `None` when
|
||||
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
||||
tell".
|
||||
"""
|
||||
web_service = next((s for s in services if isinstance(s, WebService)), None)
|
||||
web_service = _service_of_type(services, WebService)
|
||||
return web_service.hub.broadcast_library if web_service else None
|
||||
|
||||
|
||||
@@ -339,7 +343,7 @@ def _build_services(
|
||||
|
||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||
# into its folder, so there is nothing to gate here with its own config section.
|
||||
web_service = next((s for s in services if isinstance(s, WebService)), None)
|
||||
web_service = _service_of_type(services, WebService)
|
||||
services.append(
|
||||
PodcastFeedService(
|
||||
app.library,
|
||||
|
||||
@@ -12,6 +12,7 @@ from musicmouse.config import Config, FigureColors
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -55,6 +56,10 @@ class App:
|
||||
_log.warning("No playlist for figure %r", figure)
|
||||
return playlist
|
||||
|
||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||
"""The library album a playlist came from, if any."""
|
||||
return self.library.get(playlist.album_id if playlist else None)
|
||||
|
||||
async def rescan_library(
|
||||
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -267,6 +267,8 @@ class VlcPlayer(PlayerBase):
|
||||
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
|
||||
|
||||
def play(self) -> None:
|
||||
if self._playing:
|
||||
return
|
||||
self._list_player.play()
|
||||
|
||||
def play_from_start(self) -> None:
|
||||
@@ -279,6 +281,8 @@ class VlcPlayer(PlayerBase):
|
||||
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._playing:
|
||||
return
|
||||
self._media_player.set_pause(1)
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
@@ -41,7 +41,7 @@ __all__ = [
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
"PlaylistFinished",
|
||||
"PrevTrackRequested",
|
||||
"PreviousTrackRequested",
|
||||
"RfidTokenRead",
|
||||
"RotaryTurned",
|
||||
"SetVolumeRequested",
|
||||
@@ -135,8 +135,6 @@ class RotaryTurned(InputEvent):
|
||||
class PlaylistFinished(InputEvent):
|
||||
"""The player reached the end of the playlist."""
|
||||
|
||||
figure: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DeviceConnected(InputEvent):
|
||||
@@ -168,7 +166,7 @@ class NextTrackRequested(IntentEvent):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PrevTrackRequested(IntentEvent):
|
||||
class PreviousTrackRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def web_playback(event: PlaybackChanged, app: App) -> None:
|
||||
off_animation(app)
|
||||
return
|
||||
|
||||
album = app.library.get(event.playlist.album_id if event.playlist else None)
|
||||
album = app.album_for(event.playlist)
|
||||
if album is None:
|
||||
return
|
||||
web_animation(app, parse_color(album.colors[0]))
|
||||
|
||||
@@ -20,7 +20,7 @@ from musicmouse.events import (
|
||||
PlaylistFinished,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
@@ -118,7 +118,7 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
|
||||
if event.action is not ButtonAction.PRESSED:
|
||||
return
|
||||
if event.button is Button.LEFT and app.player.is_playing:
|
||||
app.bus.emit(PrevTrackRequested(source="device"))
|
||||
app.bus.emit(PreviousTrackRequested(source="device"))
|
||||
elif event.button is Button.RIGHT and app.player.is_playing:
|
||||
app.bus.emit(NextTrackRequested(source="device"))
|
||||
# The rotary press is published to Home Assistant by the MQTT service; what it
|
||||
@@ -143,8 +143,8 @@ def next_track(event: NextTrackRequested, app: App) -> None:
|
||||
app.player.next_track()
|
||||
|
||||
|
||||
@on(PrevTrackRequested)
|
||||
def previous_track(event: PrevTrackRequested, app: App) -> None:
|
||||
@on(PreviousTrackRequested)
|
||||
def previous_track(event: PreviousTrackRequested, app: App) -> None:
|
||||
_log.debug("Previous track (%s)", event.source)
|
||||
app.player.previous_track()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from musicmouse.events import (
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
@@ -40,8 +40,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
# pauses, like its pause button.
|
||||
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_STOP": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
@@ -22,7 +23,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlaybackChanged,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
@@ -37,9 +38,11 @@ __all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
|
||||
class PlayerSensor(Entity):
|
||||
component = "sensor"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> None:
|
||||
self.mouse = mouse
|
||||
self.player = player
|
||||
self._figure: str | None = None
|
||||
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
@@ -56,9 +59,7 @@ class PlayerSensor(Entity):
|
||||
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
|
||||
self.bus.subscribe(event_type, self._on_change)
|
||||
|
||||
async def _on_change(self, event: Event) -> None:
|
||||
if isinstance(event, ActiveFigureChanged):
|
||||
self._figure = event.figure
|
||||
async def _on_change(self, _event: Event) -> None:
|
||||
await self.publish_state()
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
@@ -68,7 +69,7 @@ class PlayerSensor(Entity):
|
||||
await self.publish(
|
||||
f"{self.base_topic}/attributes",
|
||||
{
|
||||
"figure": self._figure,
|
||||
"figure": self.mouse.active_figure,
|
||||
"playlist": playlist.name if playlist else None,
|
||||
"track_index": self.player.track_index,
|
||||
"track_count": len(playlist) if playlist else 0,
|
||||
@@ -128,7 +129,7 @@ class TransportButton(Entity):
|
||||
#: Button object id -> the intent pressing it emits.
|
||||
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
|
||||
"next": NextTrackRequested,
|
||||
"previous": PrevTrackRequested,
|
||||
"previous": PreviousTrackRequested,
|
||||
"play": PlayRequested,
|
||||
"pause": PauseRequested,
|
||||
}
|
||||
@@ -154,7 +155,9 @@ class TransportButton(Entity):
|
||||
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
|
||||
|
||||
|
||||
def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]:
|
||||
def player_entities(
|
||||
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> list[Entity]:
|
||||
names = {
|
||||
"next": "Music Mouse Next",
|
||||
"previous": "Music Mouse Previous",
|
||||
@@ -162,7 +165,7 @@ def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[E
|
||||
"pause": "Music Mouse Pause",
|
||||
}
|
||||
return [
|
||||
PlayerSensor(bus, config, player),
|
||||
PlayerSensor(bus, config, mouse, player),
|
||||
VolumeNumber(bus, config, player),
|
||||
*(TransportButton(bus, config, action, name) for action, name in names.items()),
|
||||
]
|
||||
|
||||
@@ -41,7 +41,7 @@ def build_entities(
|
||||
"""Everything this backend exposes to Home Assistant."""
|
||||
return [
|
||||
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
|
||||
*player_entities(bus, config, player),
|
||||
*player_entities(bus, config, mouse, player),
|
||||
*trigger_entities(bus, config),
|
||||
]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
)
|
||||
@@ -152,7 +152,7 @@ def build_router(
|
||||
|
||||
@router.post("/previous", status_code=204)
|
||||
def previous_track() -> Response:
|
||||
return emit(PrevTrackRequested(source="web"))
|
||||
return emit(PreviousTrackRequested(source="web"))
|
||||
|
||||
@router.post("/seek", status_code=204)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
|
||||
@@ -34,6 +34,8 @@ __all__ = [
|
||||
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return general.min_volume
|
||||
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ __all__ = ["snapshot"]
|
||||
def snapshot(app: App) -> PlayerStateOut:
|
||||
player = app.player
|
||||
playlist = player.playlist
|
||||
album = app.library.get(playlist.album_id if playlist else None)
|
||||
album = app.album_for(playlist)
|
||||
index = player.track_index
|
||||
# The player only knows a file path, and a tagged file's name is rarely its title
|
||||
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
|
||||
|
||||
@@ -26,7 +26,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
@@ -164,7 +164,7 @@ class SimulatorDriver:
|
||||
await self.settle()
|
||||
|
||||
async def emit_prev(self) -> None:
|
||||
self.app.bus.emit(PrevTrackRequested(source="simulator"))
|
||||
self.app.bus.emit(PreviousTrackRequested(source="simulator"))
|
||||
await self.settle()
|
||||
|
||||
async def set_volume(self, volume: int) -> None:
|
||||
@@ -235,9 +235,7 @@ class SimulatorDriver:
|
||||
case "playlist":
|
||||
return player.playlist.name if player.playlist else "none"
|
||||
case "album":
|
||||
album = self.app.library.get(
|
||||
player.playlist.album_id if player.playlist else None
|
||||
)
|
||||
album = self.app.album_for(player.playlist)
|
||||
return album.title if album else "none"
|
||||
case "position":
|
||||
return f"{player.position:.1f}"
|
||||
|
||||
@@ -42,7 +42,6 @@ class FakePlayer(PlayerBase):
|
||||
self._remaining = track_duration
|
||||
self._started_at: float | None = None
|
||||
self._timer: asyncio.Task[None] | None = None
|
||||
self.closed = False
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@@ -112,12 +111,8 @@ class FakePlayer(PlayerBase):
|
||||
if self._playing:
|
||||
self._start_timer()
|
||||
|
||||
async def run(self) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._cancel_timer()
|
||||
self.closed = True
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from musicmouse.events import (
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc import LircService
|
||||
@@ -160,7 +160,7 @@ async def test_transport_and_volume_buttons_emit_intents(
|
||||
PlayRequested,
|
||||
PauseRequested,
|
||||
PauseRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
NextTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
]
|
||||
|
||||
@@ -419,14 +419,18 @@ async def test_transport_buttons_emit_intents(
|
||||
|
||||
|
||||
async def test_the_player_sensor_reports_the_current_track(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
|
||||
bus: EventBus,
|
||||
mqtt_config: MqttConfig,
|
||||
mouse: MusicMouseDevice,
|
||||
player: FakePlayer,
|
||||
publisher: RecordingPublisher,
|
||||
) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.media import Playlist, Track
|
||||
from musicmouse.services.mqtt.player import PlayerSensor
|
||||
|
||||
entity = PlayerSensor(bus, mqtt_config, player)
|
||||
entity = PlayerSensor(bus, mqtt_config, mouse, player)
|
||||
entity.attach(publisher)
|
||||
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
|
||||
player.play_from_start()
|
||||
@@ -439,6 +443,29 @@ async def test_the_player_sensor_reports_the_current_track(
|
||||
assert attributes["volume"] == 40
|
||||
|
||||
|
||||
async def test_the_player_sensor_reports_the_mouse_s_active_figure(
|
||||
bus: EventBus,
|
||||
mqtt_config: MqttConfig,
|
||||
transport: FakeTransport,
|
||||
mouse: MusicMouseDevice,
|
||||
player: FakePlayer,
|
||||
publisher: RecordingPublisher,
|
||||
) -> None:
|
||||
"""The sensor reads ``mouse.active_figure`` rather than keeping its own copy of it,
|
||||
so it agrees with the mouse even if it missed the event that changed it."""
|
||||
from musicmouse.services.mqtt.player import PlayerSensor
|
||||
|
||||
entity = PlayerSensor(bus, mqtt_config, mouse, player)
|
||||
entity.attach(publisher)
|
||||
|
||||
transport.inject(RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"))
|
||||
await bus.drain()
|
||||
|
||||
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
|
||||
assert attributes["figure"] == "fuchs"
|
||||
assert mouse.active_figure == "fuchs"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- triggers
|
||||
|
||||
|
||||
|
||||
@@ -334,20 +334,76 @@ class _FakeMedia:
|
||||
|
||||
|
||||
class _FakeMediaPlayer:
|
||||
"""Just enough of libVLC's media player to exercise index syncing."""
|
||||
"""Just enough of libVLC's media player to exercise index syncing, pause/seek and
|
||||
the volume/playing poll."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.media: _FakeMedia | None = None
|
||||
self.paused = False
|
||||
self.time_ms = 0
|
||||
self.length_ms = 0
|
||||
self.volume = 50
|
||||
|
||||
def get_media(self) -> _FakeMedia | None:
|
||||
return self.media
|
||||
|
||||
def set_pause(self, flag: int) -> None:
|
||||
self.paused = bool(flag)
|
||||
|
||||
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer:
|
||||
def set_time(self, ms: int) -> None:
|
||||
self.time_ms = ms
|
||||
|
||||
def get_time(self) -> int:
|
||||
return self.time_ms
|
||||
|
||||
def get_length(self) -> int:
|
||||
return self.length_ms
|
||||
|
||||
def audio_set_volume(self, volume: int) -> None:
|
||||
self.volume = volume
|
||||
|
||||
def audio_get_volume(self) -> int:
|
||||
return self.volume
|
||||
|
||||
|
||||
class _FakeListPlayer:
|
||||
"""Just enough of libVLC's list player to exercise play/stop and the is-playing
|
||||
half of the poll."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.playing = False
|
||||
self.play_calls = 0
|
||||
self.stop_calls = 0
|
||||
self.played_index: int | None = None
|
||||
|
||||
def play(self) -> None:
|
||||
self.play_calls += 1
|
||||
self.playing = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
self.playing = False
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
return self.playing
|
||||
|
||||
def play_item_at_index(self, index: int) -> None:
|
||||
self.played_index = index
|
||||
self.playing = True
|
||||
|
||||
|
||||
def _vlc_like(
|
||||
bus: EventBus,
|
||||
playlist: Playlist,
|
||||
*,
|
||||
media_player: _FakeMediaPlayer | None = None,
|
||||
list_player: _FakeListPlayer | None = None,
|
||||
) -> VlcPlayer:
|
||||
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
|
||||
player = object.__new__(VlcPlayer)
|
||||
PlayerBase.__init__(player, bus)
|
||||
player._media_player = _FakeMediaPlayer() # type: ignore[assignment]
|
||||
player._media_player = media_player or _FakeMediaPlayer()
|
||||
player._list_player = list_player or _FakeListPlayer()
|
||||
player._playlist = playlist
|
||||
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
|
||||
return player
|
||||
@@ -365,7 +421,7 @@ async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> Non
|
||||
events: list[Event] = []
|
||||
bus.subscribe(TrackChanged, events.append)
|
||||
|
||||
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined]
|
||||
player._media_player.media = _FakeMedia("file:///music/2.mp3")
|
||||
player._on_next_item(object())
|
||||
await bus.drain()
|
||||
|
||||
@@ -379,10 +435,193 @@ async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
|
||||
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
|
||||
player = _vlc_like(bus, playlist)
|
||||
|
||||
player._media_player.media = None # type: ignore[attr-defined]
|
||||
player._media_player.media = None
|
||||
player._on_next_item(object())
|
||||
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined]
|
||||
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3")
|
||||
player._on_next_item(object())
|
||||
await bus.drain()
|
||||
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
# ------------------------------------------------------ VlcPlayer actions, stubbed
|
||||
|
||||
|
||||
async def test_vlc_play_starts_playback_when_idle(bus: EventBus) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
|
||||
player.play()
|
||||
|
||||
assert list_player.play_calls == 1
|
||||
|
||||
|
||||
async def test_vlc_play_is_a_no_op_once_already_playing(bus: EventBus) -> None:
|
||||
"""Mirrors `FakePlayer.play()`'s own guard, so the two `Player` implementations
|
||||
agree on what a repeated play() does instead of leaving it to libVLC."""
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
player._set_playing(True)
|
||||
|
||||
player.play()
|
||||
|
||||
assert list_player.play_calls == 0
|
||||
|
||||
|
||||
async def test_vlc_pause_pauses_when_playing(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
player._set_playing(True)
|
||||
|
||||
player.pause()
|
||||
|
||||
assert media_player.paused is True
|
||||
|
||||
|
||||
async def test_vlc_pause_is_a_no_op_when_not_playing(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.pause()
|
||||
|
||||
assert media_player.paused is False
|
||||
|
||||
|
||||
async def test_vlc_play_track_clamps_to_the_playlist_bounds(bus: EventBus) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(count=3), list_player=list_player)
|
||||
|
||||
player.play_track(99)
|
||||
|
||||
assert list_player.played_index == 2
|
||||
|
||||
|
||||
async def test_vlc_play_track_on_an_empty_playlist_does_nothing(
|
||||
bus: EventBus, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, Playlist(name="leer", tracks=()), list_player=list_player)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
player.play_track(0)
|
||||
|
||||
assert list_player.played_index is None
|
||||
assert "playlist is empty" in caplog.text
|
||||
|
||||
|
||||
async def test_vlc_seek_sets_the_time_in_milliseconds(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.seek(12.5)
|
||||
|
||||
assert media_player.time_ms == 12500
|
||||
|
||||
|
||||
async def test_vlc_seek_clamps_negative_positions_to_zero(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.seek(-5.0)
|
||||
|
||||
assert media_player.time_ms == 0
|
||||
|
||||
|
||||
async def test_vlc_position_and_duration_read_the_media_player(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.time_ms = 4_000
|
||||
media_player.length_ms = 180_000
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
assert player.position == 4.0
|
||||
assert player.duration == 180.0
|
||||
|
||||
|
||||
async def test_vlc_position_floors_libvlcs_minus_one_at_zero(bus: EventBus) -> None:
|
||||
"""libVLC reports -1 for both until a media is actually opened."""
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.time_ms = -1
|
||||
media_player.length_ms = -1
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
assert player.position == 0.0
|
||||
assert player.duration == 0.0
|
||||
|
||||
|
||||
async def test_vlc_apply_volume_pushes_to_the_media_player(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.set_volume(77)
|
||||
|
||||
assert media_player.volume == 77
|
||||
|
||||
|
||||
async def test_vlc_poll_announces_a_volume_change_made_outside_the_app(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
"""The physical volume can move without going through `set_volume` - a hardware
|
||||
knob on the amp, or another process - so the poll is what catches libVLC drifting
|
||||
from what `PlayerBase` last announced."""
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.volume = 50
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
media_player.volume = 65
|
||||
player._poll()
|
||||
await bus.drain()
|
||||
|
||||
assert player.volume == 65
|
||||
assert only(seen, VolumeChanged) == [VolumeChanged(volume=65, source="player")]
|
||||
|
||||
|
||||
async def test_vlc_poll_ignores_a_negative_volume_reading(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
media_player.volume = -1
|
||||
|
||||
player._poll()
|
||||
|
||||
assert player.volume == 50
|
||||
|
||||
|
||||
async def test_vlc_poll_syncs_is_playing_from_the_list_player(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
list_player.playing = True
|
||||
|
||||
player._poll()
|
||||
await bus.drain()
|
||||
|
||||
assert player.is_playing
|
||||
assert only(seen, PlaybackChanged)[-1].playing is True
|
||||
|
||||
|
||||
async def test_vlc_on_playing_and_on_stopped_announce_playback_changed(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
player = _vlc_like(bus, playlist())
|
||||
|
||||
player._on_playing(object())
|
||||
await bus.drain()
|
||||
assert player.is_playing
|
||||
|
||||
player._on_stopped(object())
|
||||
await bus.drain()
|
||||
assert not player.is_playing
|
||||
assert [e.playing for e in only(seen, PlaybackChanged)] == [True, False]
|
||||
|
||||
|
||||
async def test_vlc_on_playlist_end_stops_and_announces_playlist_finished(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
player = _vlc_like(bus, playlist())
|
||||
player._set_playing(True)
|
||||
|
||||
player._on_playlist_end(object())
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
|
||||
Reference in New Issue
Block a user