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>
56 lines
1.9 KiB
Python
56 lines
1.9 KiB
Python
"""The small cast glyph under the volume slider. Click to disconnect."""
|
|
|
|
from PyQt6.QtCore import QSize, Qt
|
|
from PyQt6.QtGui import QIcon
|
|
from PyQt6.QtWidgets import QToolButton
|
|
|
|
from lintunes.gui.icons import transport_icon
|
|
|
|
HEIGHT = 18
|
|
|
|
|
|
class CastIndicator(QToolButton):
|
|
"""Shows what playback is coming out of, when it isn't this machine.
|
|
|
|
Never hidden, even when nothing is connected. `setVisible(False)` would
|
|
take it out of the layout's height budget, which lifts the volume slider
|
|
the instant you connect and drops it again when you disconnect — so idle
|
|
is a visible widget with an empty icon, holding its space.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._connected = False
|
|
self._device = ""
|
|
self.setAutoRaise(True)
|
|
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
self.setFixedHeight(HEIGHT)
|
|
self.setIconSize(QSize(HEIGHT, HEIGHT))
|
|
self._apply()
|
|
|
|
def set_connected(self, connected: bool, device_name: str = ""):
|
|
self._connected = bool(connected)
|
|
self._device = device_name or ""
|
|
self._apply()
|
|
|
|
def refresh_theme(self):
|
|
self._apply()
|
|
|
|
def sizeHint(self) -> QSize:
|
|
return QSize(HEIGHT + 6, HEIGHT)
|
|
|
|
def _apply(self):
|
|
if self._connected:
|
|
self.setIcon(transport_icon(
|
|
"cast_connected", self.palette().highlight().color()))
|
|
name = self._device or "a Chromecast"
|
|
self.setToolTip(f"Casting to {name} — click to stop")
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.setEnabled(True)
|
|
else:
|
|
# Empty icon rather than a hidden widget: holds the slot open.
|
|
self.setIcon(QIcon())
|
|
self.setToolTip("")
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
self.setEnabled(False)
|