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:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.5.1"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user