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
|
||||
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.
|
||||
While casting there is no local PCM, so the visualizer shows the cast glyph
|
||||
instead of bars, and position comes from a 500 ms poll of
|
||||
`adjusted_current_time` (only trusted while PLAYING — it creeps while paused).
|
||||
While casting there is no local PCM, so the **visualizer panel doubles as the
|
||||
cast indicator**: it shows the cast glyph instead of bars and a click there
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
## 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)
|
||||
|
||||
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,
|
||||
uncastable_reason)
|
||||
from lintunes.gui.cast_dialog import ChromecastDialog
|
||||
from lintunes.gui.cast_indicator import CastIndicator
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui.spinner import Spinner
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
def test_cast_indicator_does_not_move_the_volume_slider(
|
||||
self, qapp, tmp_path):
|
||||
def test_casting_leaves_the_bar_layout_alone(self, qapp, tmp_path):
|
||||
from lintunes.gui.transport import BAR_HEIGHT, TransportBar
|
||||
from lintunes.preferences import Preferences
|
||||
|
||||
@@ -901,14 +876,40 @@ class TestTransportLayout:
|
||||
bar.show()
|
||||
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()
|
||||
|
||||
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
|
||||
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:
|
||||
def test_devices_appear_and_disappear(self, qapp):
|
||||
@@ -966,13 +967,24 @@ class TestSpinner:
|
||||
|
||||
|
||||
class TestCastIcons:
|
||||
def test_cast_glyphs_render_and_differ(self, qapp):
|
||||
def test_cast_glyph_renders(self, qapp):
|
||||
from PyQt6.QtGui import QColor
|
||||
idle = transport_icon("cast", QColor("#4A4A4A"))
|
||||
live = transport_icon("cast_connected", QColor("#4A4A4A"))
|
||||
assert not idle.isNull() and not live.isNull()
|
||||
assert (idle.pixmap(20, 20).toImage()
|
||||
!= live.pixmap(20, 20).toImage())
|
||||
assert not transport_icon("cast", QColor("#4A4A4A")).isNull()
|
||||
|
||||
def test_a_bigger_glyph_is_repainted_not_upscaled(self, qapp):
|
||||
from PyQt6.QtGui import QColor
|
||||
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:
|
||||
@@ -992,3 +1004,17 @@ class TestVisualizerWhileCasting:
|
||||
|
||||
player.set_sink(None)
|
||||
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