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:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user