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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,9 +127,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
`CastBrowser`; `sink.py` is the `PlaybackSink`; `controller.py` owns the
|
`CastBrowser`; `sink.py` is the `PlaybackSink`; `controller.py` owns the
|
||||||
session and its own `SleepInhibitor`. **pychromecast is imported lazily**, never
|
session and its own `SleepInhibitor`. **pychromecast is imported lazily**, never
|
||||||
at module scope, so the app still launches where the dep isn't installed yet.
|
at module scope, so the app still launches where the dep isn't installed yet.
|
||||||
While casting there is no local PCM, so the visualizer shows the cast glyph
|
While casting there is no local PCM, so the **visualizer panel doubles as the
|
||||||
instead of bars, and position comes from a 500 ms poll of
|
cast indicator**: it shows the cast glyph instead of bars and a click there
|
||||||
`adjusted_current_time` (only trusted while PLAYING — it creeps while paused).
|
stops casting (the brightness cycle is suppressed — it means nothing with no
|
||||||
|
bars). That is the only cast control in the transport bar. Position comes from
|
||||||
|
a 500 ms poll of `adjusted_current_time` (only trusted while PLAYING — it
|
||||||
|
creeps while paused).
|
||||||
|
|
||||||
## Conventions & gotchas
|
## Conventions & gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""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
|
SIZE = 20
|
||||||
|
|
||||||
|
|
||||||
def transport_icon(kind: str, color: QColor) -> QIcon:
|
def transport_icon(kind: str, color: QColor, size: int = SIZE) -> QIcon:
|
||||||
key = (kind, color.name())
|
"""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:
|
if key in _cache:
|
||||||
return _cache[key]
|
return _cache[key]
|
||||||
pixmap = QPixmap(SIZE, SIZE)
|
pixmap = QPixmap(size, size)
|
||||||
pixmap.fill(Qt.GlobalColor.transparent)
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
painter = QPainter(pixmap)
|
painter = QPainter(pixmap)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
if size != SIZE:
|
||||||
|
painter.scale(size / SIZE, size / SIZE)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(color)
|
painter.setBrush(color)
|
||||||
_DRAWERS[kind](painter, 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)]))
|
p.drawPolygon(QPolygonF([QPointF(15, 11), QPointF(19, 14), QPointF(15, 17)]))
|
||||||
|
|
||||||
|
|
||||||
def _cast_outline(p, color, filled: bool):
|
def _cast(p, color):
|
||||||
"""The standard cast glyph: a screen with signal arcs at its lower left."""
|
"""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 = QPen(color, 1.6)
|
||||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
p.setPen(pen)
|
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(6.5, 15.5), QPointF(17.5, 15.5), QPointF(17.5, 4.5),
|
||||||
QPointF(3.5, 4.5), QPointF(3.5, 7.0),
|
QPointF(3.5, 4.5), QPointF(3.5, 7.0),
|
||||||
]))
|
]))
|
||||||
if filled:
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
# "Receiving": the screen is lit rather than empty.
|
p.setBrush(color)
|
||||||
p.setPen(Qt.PenStyle.NoPen)
|
p.drawRect(QRectF(8.5, 8.0, 7.2, 5.8))
|
||||||
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).
|
# 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):
|
for radius in (4.5, 8.0):
|
||||||
p.drawArc(QRectF(3.5 - radius, 15.5 - radius, radius * 2, radius * 2),
|
p.drawArc(QRectF(3.5 - radius, 15.5 - radius, radius * 2, radius * 2),
|
||||||
0, 90 * 16)
|
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)
|
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 = {
|
_DRAWERS = {
|
||||||
"play": _play,
|
"play": _play,
|
||||||
"pause": _pause,
|
"pause": _pause,
|
||||||
@@ -112,5 +109,4 @@ _DRAWERS = {
|
|||||||
"previous": _previous,
|
"previous": _previous,
|
||||||
"shuffle": _shuffle,
|
"shuffle": _shuffle,
|
||||||
"cast": _cast,
|
"cast": _cast,
|
||||||
"cast_connected": _cast_connected,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
|||||||
from PyQt6.QtGui import QColor, QFont
|
from PyQt6.QtGui import QColor, QFont
|
||||||
|
|
||||||
from lintunes.tap_tempo import TapTempo
|
from lintunes.tap_tempo import TapTempo
|
||||||
from lintunes.gui.cast_indicator import CastIndicator
|
|
||||||
from lintunes.gui.icons import transport_icon
|
from lintunes.gui.icons import transport_icon
|
||||||
from lintunes.gui.track_table import format_time
|
from lintunes.gui.track_table import format_time
|
||||||
from lintunes.gui.visualizer import VisualizerWidget
|
from lintunes.gui.visualizer import VisualizerWidget
|
||||||
@@ -282,9 +281,8 @@ class TransportBar(QWidget):
|
|||||||
layout.addSpacing(10)
|
layout.addSpacing(10)
|
||||||
|
|
||||||
# Master output volume: a slim horizontal slider in its own slot
|
# Master output volume: a slim horizontal slider in its own slot
|
||||||
# between the visualizer and the timeline, with the cast indicator
|
# between the visualizer and the timeline. The HBox centers it
|
||||||
# parked directly beneath it. The column is shorter than the boxes
|
# vertically, so nothing stacks above or below it (iTunes-style).
|
||||||
# beside it, so the HBox still centers it and BAR_HEIGHT is untouched.
|
|
||||||
self._volume_slider = ClickJumpSlider()
|
self._volume_slider = ClickJumpSlider()
|
||||||
self._volume_slider.setRange(0, 100)
|
self._volume_slider.setRange(0, 100)
|
||||||
self._volume_slider.setFixedWidth(96)
|
self._volume_slider.setFixedWidth(96)
|
||||||
@@ -300,15 +298,7 @@ class TransportBar(QWidget):
|
|||||||
self._volume_save_timer.setInterval(400)
|
self._volume_save_timer.setInterval(400)
|
||||||
self._volume_save_timer.timeout.connect(self._save_volume)
|
self._volume_save_timer.timeout.connect(self._save_volume)
|
||||||
self._volume_slider.valueChanged.connect(self._on_volume_changed)
|
self._volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||||
volume_col = QVBoxLayout()
|
layout.addWidget(self._volume_slider)
|
||||||
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)
|
layout.addSpacing(10)
|
||||||
|
|
||||||
center = QVBoxLayout()
|
center = QVBoxLayout()
|
||||||
@@ -377,11 +367,10 @@ class TransportBar(QWidget):
|
|||||||
player.position_changed.connect(self._on_position_changed)
|
player.position_changed.connect(self._on_position_changed)
|
||||||
player.duration_changed.connect(self._on_duration_changed)
|
player.duration_changed.connect(self._on_duration_changed)
|
||||||
if cast is not None:
|
if cast is not None:
|
||||||
cast.connected.connect(
|
# While casting, the visualizer panel *is* the cast indicator —
|
||||||
lambda name: self._cast_indicator.set_connected(True, name))
|
# there is no spectrum to show, so a click there stops casting.
|
||||||
cast.disconnected.connect(
|
self._visualizer.cast_stop_requested.connect(
|
||||||
lambda _reason: self._cast_indicator.set_connected(False))
|
lambda: cast.disconnect())
|
||||||
self._cast_indicator.clicked.connect(lambda: cast.disconnect())
|
|
||||||
|
|
||||||
def refresh_theme(self):
|
def refresh_theme(self):
|
||||||
"""Re-apply everything driven by Preferences: button glyphs (highlight
|
"""Re-apply everything driven by Preferences: button glyphs (highlight
|
||||||
@@ -395,7 +384,6 @@ class TransportBar(QWidget):
|
|||||||
box.setStyleSheet(_box_style(box_bg))
|
box.setStyleSheet(_box_style(box_bg))
|
||||||
self._apply_now_playing_bg()
|
self._apply_now_playing_bg()
|
||||||
self._apply_now_playing_font()
|
self._apply_now_playing_font()
|
||||||
self._cast_indicator.refresh_theme()
|
|
||||||
self._visualizer.update() # dim-mode bar color may have changed
|
self._visualizer.update() # dim-mode bar color may have changed
|
||||||
|
|
||||||
def _apply_now_playing_bg(self):
|
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
|
import numpy as np
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QWidget
|
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.QtGui import QPainter, QColor, QPainterPath, QPen
|
||||||
from PyQt6.QtMultimedia import QAudioFormat
|
from PyQt6.QtMultimedia import QAudioFormat
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ FRAME_MS = 16 # ~60 fps
|
|||||||
DECAY = 0.82 # slow decay; attack is instant
|
DECAY = 0.82 # slow decay; attack is instant
|
||||||
FLOOR_DB = -55.0
|
FLOOR_DB = -55.0
|
||||||
RADIUS = 8 # rounded corners, matching the transport button boxes
|
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.
|
# Visualizer click cycles through these brightness modes in order.
|
||||||
MODE_ON = "on"
|
MODE_ON = "on"
|
||||||
@@ -43,8 +43,13 @@ class VisualizerWidget(QWidget):
|
|||||||
|
|
||||||
On = highlight-colored bars; dim = light-gray bars just darker than the
|
On = highlight-colored bars; dim = light-gray bars just darker than the
|
||||||
panel; off = blank.
|
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):
|
def __init__(self, player, prefs=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._player = player
|
self._player = player
|
||||||
@@ -59,6 +64,7 @@ class VisualizerWidget(QWidget):
|
|||||||
# True while audio is coming out of something other than this machine
|
# True while audio is coming out of something other than this machine
|
||||||
# (a Chromecast), so there is no decoded PCM to draw.
|
# (a Chromecast), so there is no decoded PCM to draw.
|
||||||
self._no_pcm = False
|
self._no_pcm = False
|
||||||
|
self._device = ""
|
||||||
|
|
||||||
self.setFixedWidth(140)
|
self.setFixedWidth(140)
|
||||||
self.setMinimumHeight(36)
|
self.setMinimumHeight(36)
|
||||||
@@ -75,16 +81,20 @@ class VisualizerWidget(QWidget):
|
|||||||
player.sink_changed.connect(self._on_sink_changed)
|
player.sink_changed.connect(self._on_sink_changed)
|
||||||
|
|
||||||
def _on_sink_changed(self, sink):
|
def _on_sink_changed(self, sink):
|
||||||
"""Casting means no local decode, so there is nothing to draw. Say so
|
"""Casting means no local decode, so there is nothing to draw. The
|
||||||
with the cast glyph rather than animating a flat line off a buffer of
|
panel becomes the cast indicator instead: it shows where the audio
|
||||||
zeros 30 times a second."""
|
went, and clicking it stops casting."""
|
||||||
self._no_pcm = not getattr(sink, "provides_pcm", True)
|
self._no_pcm = not getattr(sink, "provides_pcm", True)
|
||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
|
self._device = getattr(sink, "name", "") or "a Chromecast"
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
self._clear()
|
self._clear()
|
||||||
self.setToolTip("No spectrum while casting")
|
self.setToolTip(f"Casting to {self._device} — click to stop")
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
else:
|
else:
|
||||||
|
self._device = ""
|
||||||
self.setToolTip("Click to cycle: on / dim / off")
|
self.setToolTip("Click to cycle: on / dim / off")
|
||||||
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
if self._mode != MODE_OFF and self._player.is_playing():
|
if self._mode != MODE_OFF and self._player.is_playing():
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
self.update()
|
self.update()
|
||||||
@@ -138,6 +148,12 @@ class VisualizerWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
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.
|
# Cycle on -> dim -> off -> on.
|
||||||
idx = _MODE_CYCLE.index(self._mode)
|
idx = _MODE_CYCLE.index(self._mode)
|
||||||
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
||||||
@@ -206,8 +222,9 @@ class VisualizerWidget(QWidget):
|
|||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
# Casting: the audio never passes through this machine, so show
|
# Casting: the audio never passes through this machine, so show
|
||||||
# where it went instead of pretending to analyze silence.
|
# where it went instead of pretending to analyze silence.
|
||||||
glyph = transport_icon("cast_connected", self._bar_color()).pixmap(
|
glyph = transport_icon(
|
||||||
ICON_SIZE, ICON_SIZE)
|
"cast", self.palette().highlight().color(),
|
||||||
|
size=ICON_SIZE).pixmap(ICON_SIZE, ICON_SIZE)
|
||||||
painter.drawPixmap(
|
painter.drawPixmap(
|
||||||
(self.width() - ICON_SIZE) // 2,
|
(self.width() - ICON_SIZE) // 2,
|
||||||
(self.height() - ICON_SIZE) // 2, glyph)
|
(self.height() - ICON_SIZE) // 2, glyph)
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Round 31 (2026-08-13) — One cast control, not two (v0.5.1)
|
||||||
|
|
||||||
|
The cast glyph appeared both under the volume slider and inside the visualizer,
|
||||||
|
which was redundant. Dropped the volume-slider indicator (`gui/cast_indicator.py`
|
||||||
|
deleted); the visualizer panel is now the single cast control.
|
||||||
|
|
||||||
|
- [x] **The visualizer panel is the indicator** — bigger glyph (20 → 40 px),
|
||||||
|
always painted in the theme highlight rather than following the brightness
|
||||||
|
mode, and clicking it stops casting instead of cycling on/dim/off (which
|
||||||
|
meant nothing with no bars to dim). New `cast_stop_requested` signal.
|
||||||
|
- [x] **`transport_icon(kind, color, size=…)`** scales the painter instead of
|
||||||
|
upscaling a 20px pixmap, so the panel-sized glyph is crisp; `size` joins the
|
||||||
|
cache key.
|
||||||
|
- [x] Collapsed the `cast`/`cast_connected` glyph pair into one — only the
|
||||||
|
connected state was ever drawn.
|
||||||
|
|
||||||
### Round 30 (2026-08-13) — Album art on the cast device (v0.5.0)
|
### Round 30 (2026-08-13) — Album art on the cast device (v0.5.0)
|
||||||
|
|
||||||
The Chromecast is on a TV, so it should show the cover. `play_media` now
|
The Chromecast is on a TV, so it should show the cover. `play_media` now
|
||||||
|
|||||||
+60
-34
@@ -29,7 +29,6 @@ from lintunes.cast.support import (
|
|||||||
content_type_for, image_type_for, local_ip_for, parse_range,
|
content_type_for, image_type_for, local_ip_for, parse_range,
|
||||||
uncastable_reason)
|
uncastable_reason)
|
||||||
from lintunes.gui.cast_dialog import ChromecastDialog
|
from lintunes.gui.cast_dialog import ChromecastDialog
|
||||||
from lintunes.gui.cast_indicator import CastIndicator
|
|
||||||
from lintunes.gui.icons import transport_icon
|
from lintunes.gui.icons import transport_icon
|
||||||
from lintunes.gui.spinner import Spinner
|
from lintunes.gui.spinner import Spinner
|
||||||
from lintunes.models import Track
|
from lintunes.models import Track
|
||||||
@@ -866,32 +865,8 @@ def _device(uuid="u1", name="Kitchen", model="Chromecast Audio"):
|
|||||||
host="10.0.0.5", port=8009)
|
host="10.0.0.5", port=8009)
|
||||||
|
|
||||||
|
|
||||||
class TestCastIndicator:
|
|
||||||
def test_holds_its_space_when_idle(self, qapp):
|
|
||||||
indicator = CastIndicator()
|
|
||||||
indicator.show()
|
|
||||||
# Hiding it would collapse the layout and shift the volume slider the
|
|
||||||
# moment you connect — idle is a visible widget with no icon.
|
|
||||||
assert indicator.icon().isNull()
|
|
||||||
assert not indicator.isEnabled()
|
|
||||||
idle_height = indicator.sizeHint().height()
|
|
||||||
|
|
||||||
indicator.set_connected(False)
|
|
||||||
assert not indicator.isHidden() # never hides itself
|
|
||||||
|
|
||||||
indicator.set_connected(True, "Kitchen")
|
|
||||||
assert not indicator.icon().isNull()
|
|
||||||
assert indicator.isEnabled()
|
|
||||||
assert "Kitchen" in indicator.toolTip()
|
|
||||||
assert indicator.sizeHint().height() == idle_height
|
|
||||||
|
|
||||||
indicator.set_connected(False)
|
|
||||||
assert indicator.icon().isNull()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransportLayout:
|
class TestTransportLayout:
|
||||||
def test_cast_indicator_does_not_move_the_volume_slider(
|
def test_casting_leaves_the_bar_layout_alone(self, qapp, tmp_path):
|
||||||
self, qapp, tmp_path):
|
|
||||||
from lintunes.gui.transport import BAR_HEIGHT, TransportBar
|
from lintunes.gui.transport import BAR_HEIGHT, TransportBar
|
||||||
from lintunes.preferences import Preferences
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
@@ -901,14 +876,40 @@ class TestTransportLayout:
|
|||||||
bar.show()
|
bar.show()
|
||||||
idle_y = bar._volume_slider.pos().y()
|
idle_y = bar._volume_slider.pos().y()
|
||||||
|
|
||||||
bar._cast_indicator.set_connected(True, "Kitchen")
|
# The visualizer panel doubles as the cast indicator, so connecting
|
||||||
|
# adds no widget and nothing moves.
|
||||||
|
player.set_sink(_FakeSink())
|
||||||
bar.layout().activate()
|
bar.layout().activate()
|
||||||
|
|
||||||
assert bar._volume_slider.pos().y() == idle_y
|
assert bar._volume_slider.pos().y() == idle_y
|
||||||
# The whole column has to stay inside the bar's documented height.
|
|
||||||
assert bar.sizeHint().height() <= BAR_HEIGHT
|
assert bar.sizeHint().height() <= BAR_HEIGHT
|
||||||
bar.hide()
|
bar.hide()
|
||||||
|
|
||||||
|
def test_clicking_the_visualizer_while_casting_disconnects(
|
||||||
|
self, qapp, tmp_path):
|
||||||
|
from lintunes.gui.transport import TransportBar
|
||||||
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
|
player = _player(qapp, [])
|
||||||
|
cast = MagicMock()
|
||||||
|
bar = TransportBar(player, MagicMock(), Preferences(tmp_path),
|
||||||
|
cast=cast)
|
||||||
|
vis = bar._visualizer
|
||||||
|
before = vis._mode
|
||||||
|
|
||||||
|
# Not casting: a click still cycles the brightness as it always did.
|
||||||
|
vis.mousePressEvent(None)
|
||||||
|
assert vis._mode != before
|
||||||
|
cast.disconnect.assert_not_called()
|
||||||
|
|
||||||
|
player.set_sink(_FakeSink())
|
||||||
|
mode_while_casting = vis._mode
|
||||||
|
vis.mousePressEvent(None)
|
||||||
|
|
||||||
|
cast.disconnect.assert_called_once()
|
||||||
|
# Brightness must not cycle — it means nothing when there are no bars.
|
||||||
|
assert vis._mode == mode_while_casting
|
||||||
|
|
||||||
|
|
||||||
class TestCastDialog:
|
class TestCastDialog:
|
||||||
def test_devices_appear_and_disappear(self, qapp):
|
def test_devices_appear_and_disappear(self, qapp):
|
||||||
@@ -966,13 +967,24 @@ class TestSpinner:
|
|||||||
|
|
||||||
|
|
||||||
class TestCastIcons:
|
class TestCastIcons:
|
||||||
def test_cast_glyphs_render_and_differ(self, qapp):
|
def test_cast_glyph_renders(self, qapp):
|
||||||
from PyQt6.QtGui import QColor
|
from PyQt6.QtGui import QColor
|
||||||
idle = transport_icon("cast", QColor("#4A4A4A"))
|
assert not transport_icon("cast", QColor("#4A4A4A")).isNull()
|
||||||
live = transport_icon("cast_connected", QColor("#4A4A4A"))
|
|
||||||
assert not idle.isNull() and not live.isNull()
|
def test_a_bigger_glyph_is_repainted_not_upscaled(self, qapp):
|
||||||
assert (idle.pixmap(20, 20).toImage()
|
from PyQt6.QtGui import QColor
|
||||||
!= live.pixmap(20, 20).toImage())
|
big = transport_icon("cast", QColor("#4A4A4A"), size=40)
|
||||||
|
# Rendered at the requested size rather than a stretched 20px pixmap,
|
||||||
|
# so the panel-sized glyph stays crisp.
|
||||||
|
assert big.pixmap(40, 40).size().width() == 40
|
||||||
|
assert big.availableSizes()[0].width() == 40
|
||||||
|
|
||||||
|
def test_size_is_part_of_the_cache_key(self, qapp):
|
||||||
|
from PyQt6.QtGui import QColor
|
||||||
|
small = transport_icon("cast", QColor("#4A4A4A"), size=20)
|
||||||
|
big = transport_icon("cast", QColor("#4A4A4A"), size=40)
|
||||||
|
assert small.availableSizes()[0].width() == 20
|
||||||
|
assert big.availableSizes()[0].width() == 40
|
||||||
|
|
||||||
|
|
||||||
class TestVisualizerWhileCasting:
|
class TestVisualizerWhileCasting:
|
||||||
@@ -992,3 +1004,17 @@ class TestVisualizerWhileCasting:
|
|||||||
|
|
||||||
player.set_sink(None)
|
player.set_sink(None)
|
||||||
assert vis._no_pcm is False
|
assert vis._no_pcm is False
|
||||||
|
|
||||||
|
def test_names_the_device_it_is_casting_to(self, qapp, tmp_path):
|
||||||
|
from lintunes.gui.visualizer import VisualizerWidget
|
||||||
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
|
player = _player(qapp, [])
|
||||||
|
vis = VisualizerWidget(player, Preferences(tmp_path))
|
||||||
|
|
||||||
|
player.set_sink(_FakeSink()) # _FakeSink.name == "Kitchen"
|
||||||
|
assert "Kitchen" in vis.toolTip()
|
||||||
|
assert "click to stop" in vis.toolTip()
|
||||||
|
|
||||||
|
player.set_sink(None)
|
||||||
|
assert "cycle" in vis.toolTip() # back to the brightness control
|
||||||
|
|||||||
Reference in New Issue
Block a user