v0.4.0: cast to a Chromecast from the Connections menu
The Device menu becomes Connections, with "Connect to Chromecast…" beside the Rabbit sync. It opens a dialog that spins while it searches and lists devices as they appear; picking one hands playback over, puts a small cast glyph under the volume slider, and clicking that glyph disconnects. Media-receiver model: lintunes serves the original file over the LAN on an ephemeral port and the Chromecast decodes it itself. Bit-exact — no transcode, no second lossy encode — and the device buffers for itself. The cost is that lintunes is a remote control while connected: no local PCM, so the visualizer shows the cast glyph instead of bars, and transport actions land with about a second of round trip. Player now walks its queue through a swappable PlaybackSink. It keeps owning the queue, shuffle walk, start/stop times and the play-count and scrobble bookkeeping, so casting counts plays and scrobbles exactly like local playback. set_sink() carries track, position and playing-state both ways, so connecting and disconnecting pick up mid-song. LocalSink stays in player.py because the Player tests stub Qt Multimedia in that namespace. The URL carries an opaque random token, never a path, so there is nothing to traverse with; only the last few played tracks stay resolvable and the whole map dies with the session. Range and HEAD are implemented because the device seeks by re-requesting ranges and won't report a duration without them. Failure handling, verified against a real device: a dropped socket gets a 15s grace period, since pychromecast retries on its own and a Wi-Fi blip heals itself. A real loss, another app taking the device, or a network change falls back to local playback still playing, at the same position — the sink reports the state lintunes last asked for rather than the IDLE status a dying connection pushes just before it goes. Quitting stops the device instead of leaving it fetching from a server that just died. The ~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar message; the other 21,000+ MP3 and AAC files cast natively. New dependency: pychromecast>=14.0.10, imported lazily so the app still launches where it isn't installed (the menu item then explains the install), and python_requires raised to >=3.11 to match its floor. NOTE: the other machine needs `pip install -e .` before casting appears. tests/test_round29.py: 67 tests; 440 pass overall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+14
-14
@@ -223,12 +223,12 @@ class TestPlayerShutdown:
|
||||
def test_shutdown_detaches_in_order_and_is_idempotent(self, qapp, tmp_path):
|
||||
player = _mock_player(qapp, tmp_path)
|
||||
player.shutdown()
|
||||
player._media.stop.assert_called_once()
|
||||
player._media.setSource.assert_called_once_with(QUrl())
|
||||
player._media.setAudioBufferOutput.assert_called_with(None)
|
||||
player._media.setAudioOutput.assert_called_with(None)
|
||||
player._local._media.stop.assert_called_once()
|
||||
player._local._media.setSource.assert_called_once_with(QUrl())
|
||||
player._local._media.setAudioBufferOutput.assert_called_with(None)
|
||||
player._local._media.setAudioOutput.assert_called_with(None)
|
||||
player.shutdown() # second call must be a no-op
|
||||
player._media.stop.assert_called_once()
|
||||
player._local._media.stop.assert_called_once()
|
||||
|
||||
|
||||
class TestVolumeReapply:
|
||||
@@ -239,18 +239,18 @@ class TestVolumeReapply:
|
||||
expected = QAudio.convertVolume(
|
||||
0.5, QAudio.VolumeScale.LogarithmicVolumeScale,
|
||||
QAudio.VolumeScale.LinearVolumeScale)
|
||||
player._audio.setVolume.assert_called_with(expected)
|
||||
player._local._audio.setVolume.assert_called_with(expected)
|
||||
|
||||
def test_resume_reapplies_volume(self, qapp, tmp_path, monkeypatch):
|
||||
player = _mock_player(qapp, tmp_path)
|
||||
player._current_track = Track(track_id=1, name="A")
|
||||
applied = []
|
||||
monkeypatch.setattr(player, "_apply_volume",
|
||||
monkeypatch.setattr(player._local, "_apply_volume",
|
||||
lambda: applied.append(True))
|
||||
monkeypatch.setattr(player, "is_playing", lambda: False)
|
||||
player.toggle_play() # paused with a cued track → resume
|
||||
assert applied == [True]
|
||||
player._media.play.assert_called_once()
|
||||
player._local._media.play.assert_called_once()
|
||||
|
||||
def test_long_pause_resume_seeks_in_place(self, qapp, tmp_path,
|
||||
monkeypatch):
|
||||
@@ -259,15 +259,15 @@ class TestVolumeReapply:
|
||||
player._current_track = Track(track_id=1, name="A")
|
||||
monkeypatch.setattr(player, "is_playing", lambda: False)
|
||||
# Short pause: no nudge.
|
||||
player._paused_at = time_module.monotonic() - 5
|
||||
player._local._paused_at = time_module.monotonic() - 5
|
||||
player.toggle_play()
|
||||
player._media.setPosition.assert_not_called()
|
||||
player._local._media.setPosition.assert_not_called()
|
||||
# Walk-away pause: seek-in-place re-primes the suspended sink.
|
||||
player._media.position.return_value = 12345
|
||||
player._paused_at = time_module.monotonic() - 600
|
||||
player._local._media.position.return_value = 12345
|
||||
player._local._paused_at = time_module.monotonic() - 600
|
||||
player.toggle_play()
|
||||
player._media.setPosition.assert_called_once_with(12345)
|
||||
assert player._paused_at is None
|
||||
player._local._media.setPosition.assert_called_once_with(12345)
|
||||
assert player._local._paused_at is None
|
||||
|
||||
|
||||
# ---- Task B: search bar lives in the header strip ----
|
||||
|
||||
Reference in New Issue
Block a user