v0.5.1: one cast control instead of two

The cast glyph showed up both under the volume slider and inside the
visualizer, which was redundant. Dropped the volume-slider indicator
(gui/cast_indicator.py deleted) and made the visualizer panel the single
cast control — the volume slider goes back to sitting on its own, and
nothing shifts position when a session starts.

The panel's glyph is bigger (20 -> 40px) and always painted in the theme
highlight rather than following the brightness mode. Clicking it now stops
casting instead of cycling on/dim/off, which meant nothing when there are
no bars to dim; that click was the useful behavior the volume-slider icon
had, so it moves here rather than being lost.

transport_icon() takes a size and scales the painter, so the bigger glyph
is repainted crisply rather than being a blown-up 20px pixmap; size joins
the cache key. The cast/cast_connected glyph pair collapses into one,
since only the connected state was ever drawn.

450 tests pass.
This commit is contained in:
2026-08-13 22:24:31 -04:00
parent 5dbe6e6366
commit d73aa3190d
8 changed files with 132 additions and 141 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.5.0"
__version__ = "0.5.1"
-55
View File
@@ -1,55 +0,0 @@
"""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)
+17 -21
View File
@@ -10,14 +10,19 @@ _cache: dict[tuple, QIcon] = {}
SIZE = 20
def transport_icon(kind: str, color: QColor) -> QIcon:
key = (kind, color.name())
def transport_icon(kind: str, color: QColor, size: int = SIZE) -> QIcon:
"""A glyph painted at `size` px. The drawers work in a fixed SIZE-square
coordinate space and the painter is scaled to fit, so a bigger icon is
re-rendered crisply rather than being a blown-up 20px pixmap."""
key = (kind, color.name(), size)
if key in _cache:
return _cache[key]
pixmap = QPixmap(SIZE, SIZE)
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
if size != SIZE:
painter.scale(size / SIZE, size / SIZE)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
_DRAWERS[kind](painter, color)
@@ -69,8 +74,10 @@ 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."""
def _cast(p, color):
"""The standard cast glyph: a screen with signal arcs at its lower left.
The screen is lit rather than empty — it is only ever drawn while a device
is actually receiving."""
pen = QPen(color, 1.6)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
p.setPen(pen)
@@ -80,14 +87,12 @@ def _cast_outline(p, color, filled: bool):
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)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
p.drawRect(QRectF(8.5, 8.0, 7.2, 5.8))
# Two arcs radiating from the corner (16ths of a degree, 0..90).
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
for radius in (4.5, 8.0):
p.drawArc(QRectF(3.5 - radius, 15.5 - radius, radius * 2, radius * 2),
0, 90 * 16)
@@ -97,14 +102,6 @@ def _cast_outline(p, color, filled: bool):
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,
@@ -112,5 +109,4 @@ _DRAWERS = {
"previous": _previous,
"shuffle": _shuffle,
"cast": _cast,
"cast_connected": _cast_connected,
}
+7 -19
View File
@@ -8,7 +8,6 @@ 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
@@ -282,9 +281,8 @@ class TransportBar(QWidget):
layout.addSpacing(10)
# Master output volume: a slim horizontal slider in its own slot
# 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.
# between the visualizer and the timeline. The HBox centers it
# vertically, so nothing stacks above or below it (iTunes-style).
self._volume_slider = ClickJumpSlider()
self._volume_slider.setRange(0, 100)
self._volume_slider.setFixedWidth(96)
@@ -300,15 +298,7 @@ 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)
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.addWidget(self._volume_slider)
layout.addSpacing(10)
center = QVBoxLayout()
@@ -377,11 +367,10 @@ class TransportBar(QWidget):
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())
# While casting, the visualizer panel *is* the cast indicator —
# there is no spectrum to show, so a click there stops casting.
self._visualizer.cast_stop_requested.connect(
lambda: cast.disconnect())
def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight
@@ -395,7 +384,6 @@ 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):
+25 -8
View File
@@ -7,7 +7,7 @@ no extra sync buffering is needed — we just FFT the newest samples.
import numpy as np
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer, QRectF
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
from PyQt6.QtMultimedia import QAudioFormat
@@ -22,7 +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
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
# Visualizer click cycles through these brightness modes in order.
MODE_ON = "on"
@@ -43,8 +43,13 @@ class VisualizerWidget(QWidget):
On = highlight-colored bars; dim = light-gray bars just darker than the
panel; off = blank.
While casting there is no local PCM to analyze, so the panel doubles as the
cast indicator: it shows the device glyph and a click stops casting.
"""
cast_stop_requested = pyqtSignal()
def __init__(self, player, prefs=None, parent=None):
super().__init__(parent)
self._player = player
@@ -59,6 +64,7 @@ class VisualizerWidget(QWidget):
# 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._device = ""
self.setFixedWidth(140)
self.setMinimumHeight(36)
@@ -75,16 +81,20 @@ class VisualizerWidget(QWidget):
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."""
"""Casting means no local decode, so there is nothing to draw. The
panel becomes the cast indicator instead: it shows where the audio
went, and clicking it stops casting."""
self._no_pcm = not getattr(sink, "provides_pcm", True)
if self._no_pcm:
self._device = getattr(sink, "name", "") or "a Chromecast"
self._timer.stop()
self._clear()
self.setToolTip("No spectrum while casting")
self.setToolTip(f"Casting to {self._device} — click to stop")
self.setCursor(Qt.CursorShape.PointingHandCursor)
else:
self._device = ""
self.setToolTip("Click to cycle: on / dim / off")
self.setCursor(Qt.CursorShape.ArrowCursor)
if self._mode != MODE_OFF and self._player.is_playing():
self._timer.start()
self.update()
@@ -138,6 +148,12 @@ class VisualizerWidget(QWidget):
self.update()
def mousePressEvent(self, event):
if self._no_pcm:
# While casting the panel is the cast indicator, not a spectrum:
# cycling its brightness would be meaningless, so a click stops
# casting instead.
self.cast_stop_requested.emit()
return
# Cycle on -> dim -> off -> on.
idx = _MODE_CYCLE.index(self._mode)
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
@@ -206,8 +222,9 @@ class VisualizerWidget(QWidget):
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)
glyph = transport_icon(
"cast", self.palette().highlight().color(),
size=ICON_SIZE).pixmap(ICON_SIZE, ICON_SIZE)
painter.drawPixmap(
(self.width() - ICON_SIZE) // 2,
(self.height() - ICON_SIZE) // 2, glyph)