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>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""Round 10: master output volume slider in the transport bar.
|
|
|
|
1. Player.set_volume / volume round-trip (logical 0..1, clamped).
|
|
2. TransportBar builds a volume slider initialized from the prefs value and
|
|
sets the player volume to match.
|
|
3. Moving the slider updates the player live and (after the debounce) persists
|
|
the new volume to preferences.json.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from lintunes.models import Library, Track
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.preferences import Preferences
|
|
from lintunes import player as player_module
|
|
from lintunes.player import Player
|
|
from lintunes.gui.transport import TransportBar, ClickJumpSlider
|
|
|
|
|
|
def _mock_player():
|
|
"""A real Player with the Qt multimedia backend stubbed out (it blocks on
|
|
init headless — see tests/test_player.py). The audio output becomes a mock,
|
|
so set_volume's setVolume call is a harmless no-op."""
|
|
library = Library(tracks={1: Track(track_id=1, name="One")})
|
|
with patch.multiple(
|
|
player_module,
|
|
QMediaPlayer=MagicMock(),
|
|
QAudioOutput=MagicMock(),
|
|
QAudioBufferOutput=MagicMock(),
|
|
QMediaDevices=MagicMock(),
|
|
):
|
|
return Player(MagicMock(library=library))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 1. Player volume API
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestPlayerVolume:
|
|
def test_round_trip(self, qapp):
|
|
player = _mock_player()
|
|
player.set_volume(0.5)
|
|
assert player.volume() == 0.5
|
|
# The linear gain handed to QAudioOutput is set (perceptual->linear).
|
|
player._local._audio.setVolume.assert_called()
|
|
|
|
def test_clamps_out_of_range(self, qapp):
|
|
player = _mock_player()
|
|
player.set_volume(1.5)
|
|
assert player.volume() == 1.0
|
|
player.set_volume(-0.3)
|
|
assert player.volume() == 0.0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 2 + 3. TransportBar volume slider
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestTransportVolumeSlider:
|
|
def _build(self, tmp_path, prefs):
|
|
player = _mock_player()
|
|
manager = LibraryManager(
|
|
Library(tracks={1: Track(track_id=1, name="One")}), tmp_path)
|
|
return TransportBar(player, manager, prefs), player
|
|
|
|
def test_slider_initialized_from_prefs(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("volume", 0.3)
|
|
transport, player = self._build(tmp_path, prefs)
|
|
|
|
assert transport._volume_slider.value() == 30
|
|
assert player.volume() == 0.3
|
|
|
|
def test_moving_slider_updates_player_live(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
transport, player = self._build(tmp_path, prefs)
|
|
|
|
transport._volume_slider.setValue(20)
|
|
|
|
assert player.volume() == 0.2
|
|
|
|
def test_save_persists_to_disk(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
transport, player = self._build(tmp_path, prefs)
|
|
|
|
transport._volume_slider.setValue(20)
|
|
# The debounce timer normally fires this; call it directly.
|
|
transport._save_volume()
|
|
|
|
# A freshly loaded Preferences sees the persisted value.
|
|
assert Preferences(tmp_path).get("volume") == 0.2
|
|
|
|
def test_click_jumps_to_position(self, qapp, tmp_path):
|
|
"""Click-to-jump: the slider lands on the clicked spot, not a step."""
|
|
prefs = Preferences(tmp_path)
|
|
transport, player = self._build(tmp_path, prefs)
|
|
slider = transport._volume_slider
|
|
|
|
assert isinstance(slider, ClickJumpSlider)
|
|
slider.resize(100, 20)
|
|
# A click at the far-left maps to the minimum, mid to ~middle.
|
|
assert slider._value_at(0) == 0
|
|
assert 40 <= slider._value_at(50) <= 60
|
|
assert slider._value_at(100) == 100
|