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.
This commit is contained in:
2026-08-13 21:49:53 -04:00
parent 4a3c66e7ff
commit a42665cacc
26 changed files with 2885 additions and 209 deletions
+129
View File
@@ -0,0 +1,129 @@
"""The "Connect to Chromecast…" picker.
Searching starts when the dialog opens and stops when it closes, whichever way
it closes. Connecting is not awaited here — the dialog's job ends at "which
device", and CastController reports the outcome on the status bar.
"""
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QVBoxLayout)
from lintunes.gui.spinner import Spinner
# How long to look before admitting there's nothing there. Searching carries on
# regardless — a speaker that wakes up later still shows up.
EMPTY_AFTER_MS = 12_000
class ChromecastDialog(QDialog):
"""Lists Chromecasts as they're discovered; returns the one picked."""
def __init__(self, discovery, parent=None):
super().__init__(parent)
self._discovery = discovery
self._selected = None
self.setWindowTitle("Connect to Chromecast")
self.setMinimumWidth(340)
layout = QVBoxLayout(self)
header = QHBoxLayout()
self._spinner = Spinner()
header.addWidget(self._spinner)
self._status = QLabel("Searching for Chromecasts…")
header.addWidget(self._status, stretch=1)
layout.addLayout(header)
self._list = QListWidget()
self._list.setAlternatingRowColors(True)
self._list.itemSelectionChanged.connect(self._on_selection_changed)
self._list.itemDoubleClicked.connect(lambda _: self._on_connect())
layout.addWidget(self._list, stretch=1)
self._buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
self._connect_button = self._buttons.addButton(
"Connect", QDialogButtonBox.ButtonRole.AcceptRole)
self._connect_button.setEnabled(False)
self._connect_button.setDefault(True)
self._buttons.rejected.connect(self.reject)
self._connect_button.clicked.connect(self._on_connect)
layout.addWidget(self._buttons)
discovery.device_found.connect(self._on_found)
discovery.device_lost.connect(self._on_lost)
discovery.failed.connect(self._on_failed)
self._empty_timer = QTimer(self)
self._empty_timer.setSingleShot(True)
self._empty_timer.setInterval(EMPTY_AFTER_MS)
self._empty_timer.timeout.connect(self._on_empty_timeout)
self._empty_timer.start()
# Anything discovery already knows about (it may still be running from
# a previous open) should be on screen before the first new callback.
for device in discovery.devices():
self._on_found(device)
discovery.start()
@property
def selected_device(self):
return self._selected
# ---- discovery ----
def _on_found(self, device):
for row in range(self._list.count()):
existing = self._list.item(row).data(Qt.ItemDataRole.UserRole)
if existing.uuid == device.uuid:
return
label = device.name
if device.model:
label = f"{device.name} · {device.model}"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, device)
self._list.addItem(item)
self._status.setText("Select a Chromecast to connect to.")
if self._list.count() == 1:
self._list.setCurrentRow(0)
def _on_lost(self, device):
for row in range(self._list.count()):
if self._list.item(row).data(
Qt.ItemDataRole.UserRole).uuid == device.uuid:
self._list.takeItem(row)
break
if self._list.count() == 0:
self._status.setText("Searching for Chromecasts…")
def _on_failed(self, message: str):
self._spinner.hide()
self._status.setText(message)
self._empty_timer.stop()
def _on_empty_timeout(self):
if self._list.count() == 0:
# The spinner keeps turning: discovery is still running, and a
# device that comes online in a minute will still appear.
self._status.setText("No Chromecasts found on this network yet.")
# ---- selection ----
def _on_selection_changed(self):
self._connect_button.setEnabled(self._list.currentItem() is not None)
def _on_connect(self):
item = self._list.currentItem()
if item is None:
return
self._selected = item.data(Qt.ItemDataRole.UserRole)
self.accept()
def done(self, result):
# The single exit funnel — accept, reject, Esc and the window's close
# button all land here, so discovery can't be left running.
self._empty_timer.stop()
self._spinner.stop()
self._discovery.stop()
super().done(result)
+55
View File
@@ -0,0 +1,55 @@
"""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)
+38
View File
@@ -69,10 +69,48 @@ def _shuffle(p, color):
p.drawPolygon(QPolygonF([QPointF(15, 11), QPointF(19, 14), QPointF(15, 17)]))
def _cast_outline(p, color, filled: bool):
"""The standard cast glyph: a screen with signal arcs at its lower left."""
pen = QPen(color, 1.6)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
# Screen, its lower-left corner left open for the arcs.
p.drawPolyline(QPolygonF([
QPointF(6.5, 15.5), QPointF(17.5, 15.5), QPointF(17.5, 4.5),
QPointF(3.5, 4.5), QPointF(3.5, 7.0),
]))
if filled:
# "Receiving": the screen is lit rather than empty.
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
p.drawRect(QRectF(8.5, 8.0, 7.2, 5.8))
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
# Two arcs radiating from the corner (16ths of a degree, 0..90).
for radius in (4.5, 8.0):
p.drawArc(QRectF(3.5 - radius, 15.5 - radius, radius * 2, radius * 2),
0, 90 * 16)
# The corner dot the arcs radiate from.
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
p.drawEllipse(QPointF(3.8, 15.2), 1.5, 1.5)
def _cast(p, color):
_cast_outline(p, color, filled=False)
def _cast_connected(p, color):
_cast_outline(p, color, filled=True)
_DRAWERS = {
"play": _play,
"pause": _pause,
"next": _next,
"previous": _previous,
"shuffle": _shuffle,
"cast": _cast,
"cast_connected": _cast_connected,
}
+71 -7
View File
@@ -11,11 +11,13 @@ from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@@ -71,6 +73,9 @@ class MainWindow(QMainWindow):
self._lastfm = lastfm
self._prefs_dialog = None
self.player = Player(manager, self)
# Owns the cast session, if any. Built before the transport bar, which
# takes it to drive the little cast indicator under the volume slider.
self._cast = CastController(self.player, self)
# Keep the machine awake while audio is actually playing.
self._inhibitor = SleepInhibitor()
# Context playback started from: "library" or "playlist:<pid>". Scopes
@@ -90,7 +95,8 @@ class MainWindow(QMainWindow):
layout.setSpacing(0)
self.setCentralWidget(central)
self._transport = TransportBar(self.player, manager, self._prefs)
self._transport = TransportBar(self.player, manager, self._prefs,
cast=self._cast)
layout.addWidget(self._transport)
splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -179,6 +185,12 @@ class MainWindow(QMainWindow):
manager.file_move_failed.connect(
lambda name, err: self.statusBar().showMessage(
f"Couldn't move the file for “{name}”: {err}", 8000))
self.player.track_unplayable.connect(self._on_track_unplayable)
self._cast.connecting.connect(self._on_cast_connecting)
self._cast.connected.connect(self._on_cast_connected)
self._cast.disconnected.connect(self._on_cast_disconnected)
self._cast.failed.connect(
lambda msg: QMessageBox.warning(self, "Chromecast", msg))
manager.library_reloaded.connect(self._on_library_reloaded)
manager.conflict_resolved.connect(self._on_conflict_resolved)
prefs.changed.connect(self._on_prefs_changed)
@@ -286,14 +298,18 @@ class MainWindow(QMainWindow):
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
self._go_to_current_song)
device_menu = bar.addMenu("&Device")
# Mnemonic on the "n": Alt+C already belongs to &Controls.
connections_menu = bar.addMenu("Co&nnections")
self._sync_action = self._add_action(
device_menu, "Sync Playlist to Rabbit", "",
connections_menu, "Sync Playlist to Rabbit", "",
self._sync_playlist_to_device)
self._sync_action.setEnabled(False)
connections_menu.addSeparator()
self._cast_action = self._add_action(
connections_menu, "Connect to Chromecast…", "", self._toggle_cast)
# Re-checked every time the menu opens: cheap (one gvfs listdir), and
# always reflects plug/unplug and the current view.
device_menu.aboutToShow.connect(self._refresh_device_actions)
# always reflects plug/unplug, the cast session and the current view.
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
def _add_action(self, menu, text, shortcut, slot):
action = QAction(text, self)
@@ -314,9 +330,9 @@ class MainWindow(QMainWindow):
self._redo_action.setText(
f"Redo {stack.redo_label()}" if can_redo else "Redo")
# ---- device sync ----
# ---- connections: device sync ----
def _refresh_device_actions(self):
def _refresh_connection_actions(self):
syncing = self._sync_worker is not None and self._sync_worker.busy()
on_playlist = (self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
@@ -324,6 +340,51 @@ class MainWindow(QMainWindow):
not syncing and on_playlist
and device_sync.find_device() is not None)
if self._cast.is_connected():
self._cast_action.setText(
f"Disconnect from {self._cast.device_name()}")
self._cast_action.setEnabled(True)
self._cast_action.setToolTip("")
return
self._cast_action.setText("Connect to Chromecast…")
available, why = self._cast.is_available()
self._cast_action.setEnabled(available)
# The self-updater ships code but not dependencies, so a machine that
# has pulled this round hasn't necessarily installed pychromecast yet.
self._cast_action.setToolTip(
"" if available else f"{why} — run: pip install -e .")
# ---- connections: casting ----
def _toggle_cast(self):
if self._cast.is_connected():
self._cast.disconnect()
return
available, why = self._cast.is_available()
if not available:
QMessageBox.information(
self, "Chromecast support not installed",
f"{why}.\n\nRun “pip install -e .” in the lintunes checkout, "
"then restart LinTunes.")
return
dialog = ChromecastDialog(self._cast.discovery(), self)
if dialog.exec() and dialog.selected_device is not None:
self._cast.connect_to(dialog.selected_device)
def _on_cast_connecting(self, device):
self.statusBar().showMessage(f"Connecting to {device.name}…", 8000)
def _on_cast_connected(self, name: str):
self.statusBar().showMessage(f"Casting to {name}", 6000)
def _on_cast_disconnected(self, reason: str):
self.statusBar().showMessage(
reason or "Stopped casting — playing here again", 8000)
def _on_track_unplayable(self, track, reason: str):
self.statusBar().showMessage(
f"Skipped “{track.name}” — {reason}", 6000)
def _sync_playlist_to_device(self):
# Everything may have changed since the menu opened — re-verify.
if self._sync_worker is not None and self._sync_worker.busy():
@@ -779,6 +840,9 @@ class MainWindow(QMainWindow):
return
self._inhibitor.release()
self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
self._cast.shutdown()
self.player.shutdown()
self._manager.flush()
super().closeEvent(event)
+67
View File
@@ -0,0 +1,67 @@
"""A small indeterminate spinner.
Everything visual in lintunes is hand-painted (see gui/icons.py) — there is no
SVG, no .qrc and no QMovie asset anywhere — so this is drawn too. The timer
runs only while the widget is visible, so it costs nothing sitting in a closed
dialog.
"""
from PyQt6.QtCore import QRectF, Qt, QTimer
from PyQt6.QtGui import QColor, QPainter, QPen
from PyQt6.QtWidgets import QWidget
PERIOD_MS = 900 # one full turn
FRAME_MS = 40
ARC_SPAN = 280 # degrees of the circle actually drawn
class Spinner(QWidget):
"""A rotating arc, in the theme highlight color unless told otherwise."""
def __init__(self, diameter: int = 18, parent=None):
super().__init__(parent)
self._angle = 0
self._color: QColor | None = None
self.setFixedSize(diameter, diameter)
self._timer = QTimer(self)
self._timer.setInterval(FRAME_MS)
self._timer.timeout.connect(self._advance)
def set_color(self, color: QColor):
self._color = color
self.update()
def start(self):
if not self._timer.isActive():
self._timer.start()
def stop(self):
self._timer.stop()
def showEvent(self, event):
super().showEvent(event)
self.start()
def hideEvent(self, event):
super().hideEvent(event)
self.stop()
def _advance(self):
self._angle = (self._angle + int(360 * FRAME_MS / PERIOD_MS)) % 360
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = self._color or self.palette().highlight().color()
pen = QPen(color, 2.0)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
inset = 2.0
box = QRectF(inset, inset,
self.width() - inset * 2, self.height() - inset * 2)
# Qt measures arcs in 16ths of a degree, counter-clockwise from 3
# o'clock; negate to spin the way every other spinner does.
painter.drawArc(box, -self._angle * 16, -ARC_SPAN * 16)
painter.end()
+22 -4
View File
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from lintunes.tap_tempo import TapTempo
from lintunes.gui.cast_indicator import CastIndicator
from lintunes.gui.icons import transport_icon
from lintunes.gui.track_table import format_time
from lintunes.gui.visualizer import VisualizerWidget
@@ -234,10 +235,11 @@ class TransportBar(QWidget):
play_clicked = pyqtSignal() # MainWindow decides what "play" means
def __init__(self, player, manager, prefs, parent=None):
def __init__(self, player, manager, prefs, cast=None, parent=None):
super().__init__(parent)
self._player = player
self._prefs = prefs
self._cast = cast
self._scrubbing = False
self._current_track = None
@@ -280,8 +282,9 @@ class TransportBar(QWidget):
layout.addSpacing(10)
# Master output volume: a slim horizontal slider in its own slot
# between the visualizer and the timeline. The HBox centers it
# vertically, so nothing stacks above or below it (iTunes-style).
# between the visualizer and the timeline, with the cast indicator
# parked directly beneath it. The column is shorter than the boxes
# beside it, so the HBox still centers it and BAR_HEIGHT is untouched.
self._volume_slider = ClickJumpSlider()
self._volume_slider.setRange(0, 100)
self._volume_slider.setFixedWidth(96)
@@ -297,7 +300,15 @@ class TransportBar(QWidget):
self._volume_save_timer.setInterval(400)
self._volume_save_timer.timeout.connect(self._save_volume)
self._volume_slider.valueChanged.connect(self._on_volume_changed)
layout.addWidget(self._volume_slider)
volume_col = QVBoxLayout()
volume_col.setSpacing(2)
volume_col.setContentsMargins(0, 0, 0, 0)
volume_col.addWidget(self._volume_slider,
alignment=Qt.AlignmentFlag.AlignHCenter)
self._cast_indicator = CastIndicator()
volume_col.addWidget(self._cast_indicator,
alignment=Qt.AlignmentFlag.AlignHCenter)
layout.addLayout(volume_col)
layout.addSpacing(10)
center = QVBoxLayout()
@@ -365,6 +376,12 @@ class TransportBar(QWidget):
player.playing_changed.connect(self._on_playing_changed)
player.position_changed.connect(self._on_position_changed)
player.duration_changed.connect(self._on_duration_changed)
if cast is not None:
cast.connected.connect(
lambda name: self._cast_indicator.set_connected(True, name))
cast.disconnected.connect(
lambda _reason: self._cast_indicator.set_connected(False))
self._cast_indicator.clicked.connect(lambda: cast.disconnect())
def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight
@@ -378,6 +395,7 @@ class TransportBar(QWidget):
box.setStyleSheet(_box_style(box_bg))
self._apply_now_playing_bg()
self._apply_now_playing_font()
self._cast_indicator.refresh_theme()
self._visualizer.update() # dim-mode bar color may have changed
def _apply_now_playing_bg(self):
+34
View File
@@ -12,6 +12,7 @@ from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme
from lintunes.gui.icons import transport_icon
BANDS = 20
@@ -21,6 +22,7 @@ FRAME_MS = 16 # ~60 fps
DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0
RADIUS = 8 # rounded corners, matching the transport button boxes
ICON_SIZE = 20 # the cast glyph shown in place of bars while casting
# Visualizer click cycles through these brightness modes in order.
MODE_ON = "on"
@@ -54,6 +56,9 @@ class VisualizerWidget(QWidget):
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
self._sample_rate = 44100
self._window = np.hanning(FFT_SIZE).astype(np.float32)
# True while audio is coming out of something other than this machine
# (a Chromecast), so there is no decoded PCM to draw.
self._no_pcm = False
self.setFixedWidth(140)
self.setMinimumHeight(36)
@@ -66,6 +71,23 @@ class VisualizerWidget(QWidget):
player.audio_buffer.connect(self._on_buffer)
player.playing_changed.connect(self._on_playing_changed)
player.track_changed.connect(self._on_track_changed)
if hasattr(player, "sink_changed"):
player.sink_changed.connect(self._on_sink_changed)
def _on_sink_changed(self, sink):
"""Casting means no local decode, so there is nothing to draw. Say so
with the cast glyph rather than animating a flat line off a buffer of
zeros 30 times a second."""
self._no_pcm = not getattr(sink, "provides_pcm", True)
if self._no_pcm:
self._timer.stop()
self._clear()
self.setToolTip("No spectrum while casting")
else:
self.setToolTip("Click to cycle: on / dim / off")
if self._mode != MODE_OFF and self._player.is_playing():
self._timer.start()
self.update()
# ---- audio intake ----
@@ -94,6 +116,8 @@ class VisualizerWidget(QWidget):
# ---- state ----
def _on_playing_changed(self, playing):
if self._no_pcm:
return # nothing to animate; don't let play/pause restart the timer
if playing:
if self._mode != MODE_OFF:
self._timer.start()
@@ -179,6 +203,16 @@ class VisualizerWidget(QWidget):
painter.setPen(QPen(self.palette().mid().color(), 1))
painter.drawPath(panel)
painter.setClipPath(panel) # keep bars inside the rounded shape
if self._no_pcm:
# Casting: the audio never passes through this machine, so show
# where it went instead of pretending to analyze silence.
glyph = transport_icon("cast_connected", self._bar_color()).pixmap(
ICON_SIZE, ICON_SIZE)
painter.drawPixmap(
(self.width() - ICON_SIZE) // 2,
(self.height() - ICON_SIZE) // 2, glyph)
painter.end()
return
inset = 3 # bar field sits 3px in from every edge
avail_w = self.width() - 2 * inset
avail_h = self.height() - 2 * inset