Files
lintunes/lintunes/gui/spinner.py
T
travandClaude Opus 5 087c4bf103 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>
2026-08-13 21:49:53 -04:00

68 lines
2.1 KiB
Python

"""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()