v0.55.0: Kaleidostrobe smooth again; visualizer tile hover controls
The cones move only on the plugin's own 25 fps frames again: driving them from fresh audio every refresh made bass-heavy songs twitch (a jump on 84% of refreshes against 41%). The continuous turn, texture crossfade and the GPU big window stay. The tile: a click opens it big (and it stays blank while big); hovering shows a colour wheel (colour / gray / off) and up/down arrows (previous / next visualizer). No more double-click or click-to-cycle. The big album art closes on a click or Esc, like the big visualizer.
This commit is contained in:
@@ -186,6 +186,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
share of the width. Since the Library button left the
|
share of the width. Since the Library button left the
|
||||||
sidebar, `MainWindow` must hand it the first focus, or the playlist tree
|
sidebar, `MainWindow` must hand it the first focus, or the playlist tree
|
||||||
takes it and opens its first playlist at startup.
|
takes it and opens its first playlist at startup.
|
||||||
|
The transport-bar visualizer tile (`gui/visualizer.py`, Round 86): a
|
||||||
|
click opens it big, and while big the tile stays blank; hovering paints
|
||||||
|
two controls over it — a colour wheel (left: colour / gray / off) and ▲/▼
|
||||||
|
(right: previous/next style, wrapping) — hit-tested on release. No
|
||||||
|
double-click. Both big windows (visualizer, album art) close on a click
|
||||||
|
or Esc.
|
||||||
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop
|
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop
|
||||||
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
|
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
|
||||||
for every add-to-playlist path. `theme.py` applies the palette (highlight
|
for every add-to-playlist path. `theme.py` applies the palette (highlight
|
||||||
@@ -281,11 +287,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins)
|
scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins)
|
||||||
is the one calibrated stand-in. **On screen it is 60 fps anyway**
|
is the one calibrated stand-in. **On screen it is 60 fps anyway**
|
||||||
(Round 85): between the 25 Hz frames `_compose` turns the cones by a
|
(Round 85): between the 25 Hz frames `_compose` turns the cones by a
|
||||||
fraction of a frame, crossfades the textures, and takes height/depth from
|
fraction of a frame and crossfades the textures — smoothing only. The
|
||||||
`engine.preview` — the plugin's analysis run on a throwaway copy with the
|
cones' height/depth change **only on the plugin's frames**: Round 85 also
|
||||||
newest audio, so a beat pumps the cone one refresh later, not one 40 ms
|
drove them from the newest audio every refresh (snappier beats), and on
|
||||||
tick later. `preview` must never touch real state (a golden test
|
bass-heavy songs it twitched — a jump on 84% of refreshes against 41% —
|
||||||
interleaves it). Painting: the panel uses QPainter — each GL triangle is
|
so Round 86 took it out. The 25 fps cap was hiding that jitter; don't
|
||||||
|
reintroduce fresh-audio geometry between frames. Painting: the panel uses QPainter — each GL triangle is
|
||||||
planar, drawn in texture space through a projective **world** transform
|
planar, drawn in texture space through a projective **world** transform
|
||||||
(Qt mis-draws a strongly projective *brush* transform). The **big window
|
(Qt mis-draws a strongly projective *brush* transform). The **big window
|
||||||
replays the original's GL calls** (`paint_gl` in a `gui/kaleido_gl.py`
|
replays the original's GL calls** (`paint_gl` in a `gui/kaleido_gl.py`
|
||||||
|
|||||||
@@ -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.54.0"
|
__version__ = "0.55.0"
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ def _aspect(pixmap: QPixmap | None) -> float | None:
|
|||||||
|
|
||||||
class ArtWindow(QWidget):
|
class ArtWindow(QWidget):
|
||||||
"""Non-modal window showing the current track's album art as large as
|
"""Non-modal window showing the current track's album art as large as
|
||||||
possible. Just an album art window — closing it has no side effects."""
|
possible. Just an album art window — closing it has no side effects.
|
||||||
|
A click anywhere on the art (or Esc) closes it, like the big
|
||||||
|
visualizer."""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self._pixmap: QPixmap | None = None
|
self._pixmap: QPixmap | None = None
|
||||||
self.setMinimumSize(120, 120)
|
self.setMinimumSize(120, 120)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
||||||
|
self.setToolTip("Click to close")
|
||||||
|
|
||||||
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
|
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
|
||||||
"""Open (or re-raise) the window, sized to the art on ``screen``."""
|
"""Open (or re-raise) the window, sized to the art on ``screen``."""
|
||||||
@@ -86,3 +89,16 @@ class ArtWindow(QWidget):
|
|||||||
x = area.x() + (area.width() - scaled.width()) // 2
|
x = area.x() + (area.width() - scaled.width()) // 2
|
||||||
y = area.y() + (area.height() - scaled.height()) // 2
|
y = area.y() + (area.height() - scaled.height()) // 2
|
||||||
painter.drawPixmap(x, y, scaled)
|
painter.drawPixmap(x, y, scaled)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if (event.button() == Qt.MouseButton.LeftButton
|
||||||
|
and self.contentsRect().contains(event.position().toPoint())):
|
||||||
|
self.close()
|
||||||
|
return
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
if event.key() == Qt.Key.Key_Escape:
|
||||||
|
self.close()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|||||||
@@ -75,12 +75,7 @@ def _fade_text(seconds: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# visualizer_styles' style keys, as the radios name them.
|
# visualizer_styles' style keys, as the radios name them.
|
||||||
_VISUALIZER_STYLES = {
|
_VISUALIZER_STYLES = visualizer_styles.STYLE_NAMES
|
||||||
"bars": "Bars",
|
|
||||||
"radial": "Radial dots",
|
|
||||||
"laser": "Laser",
|
|
||||||
"kaleidostrobe": "Kaleidostrobe",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_text(ms: int) -> str:
|
def _sync_text(ms: int) -> str:
|
||||||
|
|||||||
+153
-26
@@ -11,8 +11,8 @@ import time
|
|||||||
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, pyqtSignal
|
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF, pyqtSignal
|
||||||
from PyQt6.QtGui import QPainter, QColor
|
from PyQt6.QtGui import QPainter, QColor, QConicalGradient, QPen, QPolygonF
|
||||||
from PyQt6.QtMultimedia import QAudioFormat
|
from PyQt6.QtMultimedia import QAudioFormat
|
||||||
|
|
||||||
from lintunes import theme
|
from lintunes import theme
|
||||||
@@ -20,7 +20,7 @@ from lintunes.config import load_config
|
|||||||
from lintunes.gui.icons import transport_icon
|
from lintunes.gui.icons import transport_icon
|
||||||
from lintunes.gui import visualizer_styles as styles
|
from lintunes.gui import visualizer_styles as styles
|
||||||
from lintunes.gui.visualizer_styles import (
|
from lintunes.gui.visualizer_styles import (
|
||||||
BANDS, HISTORY, STYLES, STYLE_BARS, Frame, make_renderer)
|
BANDS, HISTORY, STYLES, STYLE_BARS, STYLE_NAMES, Frame, make_renderer)
|
||||||
|
|
||||||
|
|
||||||
FRAME_MS = 16 # ~60 fps
|
FRAME_MS = 16 # ~60 fps
|
||||||
@@ -30,13 +30,16 @@ HISTORY_S = 1.5 # decoded audio kept, so a frame can pick what's *heard*
|
|||||||
PENDING_S = 0.25 # the laser catches up at most this much at once
|
PENDING_S = 0.25 # the laser catches up at most this much at once
|
||||||
GAP_MS = 50 # a buffer this far from the last one's end is a seek
|
GAP_MS = 50 # a buffer this far from the last one's end is a seek
|
||||||
|
|
||||||
# Visualizer click cycles through these brightness modes in order.
|
# The colour-wheel button cycles through these modes in order.
|
||||||
MODE_ON = "on"
|
MODE_ON = "on"
|
||||||
MODE_DIM = "dim"
|
MODE_DIM = "dim"
|
||||||
MODE_OFF = "off"
|
MODE_OFF = "off"
|
||||||
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
||||||
|
_MODE_NAMES = {MODE_ON: "colour", MODE_DIM: "gray", MODE_OFF: "off"}
|
||||||
|
|
||||||
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge"
|
_TOOLTIP = "Click to see it big"
|
||||||
|
BUTTON = 24 # the hover buttons: colour wheel left, ▲/▼ right
|
||||||
|
BUTTON_MARGIN = 4
|
||||||
|
|
||||||
_SAMPLE_DTYPES = {
|
_SAMPLE_DTYPES = {
|
||||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||||
@@ -52,11 +55,13 @@ def _style(prefs) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class VisualizerWidget(QWidget):
|
class VisualizerWidget(QWidget):
|
||||||
"""Animated visualizer; click to cycle brightness: on / dim / off, and
|
"""Animated visualizer. Hovering shows two controls over it — a colour
|
||||||
double-click to open it big (``VisualizerWindow``).
|
wheel (left) that cycles colour / gray / off, and ▲/▼ (right) that step
|
||||||
|
through the styles; a click anywhere else opens it big
|
||||||
|
(``VisualizerWindow``), and while it is big the panel stays blank.
|
||||||
|
|
||||||
On = highlight-colored; dim = light gray just darker than the panel;
|
Colour = highlight-colored; gray = light gray just darker than the panel;
|
||||||
off = blank (the big window, if open, keeps going in color).
|
off = blank (the big window, if open, keeps going).
|
||||||
|
|
||||||
While casting there is no local PCM to analyze, so the panel doubles as the
|
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 indicator: it shows the device glyph and a click stops casting.
|
||||||
@@ -70,7 +75,6 @@ class VisualizerWidget(QWidget):
|
|||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
||||||
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
||||||
self._mode_before_press = self._mode
|
|
||||||
self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
|
self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
|
||||||
# Decoded audio, newest last, and the stream time its end belongs
|
# Decoded audio, newest last, and the stream time its end belongs
|
||||||
# to. Decoding runs ahead of the speakers — normally by a buffer or
|
# to. Decoding runs ahead of the speakers — normally by a buffer or
|
||||||
@@ -93,6 +97,9 @@ class VisualizerWidget(QWidget):
|
|||||||
self.setFixedWidth(140)
|
self.setFixedWidth(140)
|
||||||
self.setMinimumHeight(36)
|
self.setMinimumHeight(36)
|
||||||
self.setToolTip(_TOOLTIP)
|
self.setToolTip(_TOOLTIP)
|
||||||
|
self.setMouseTracking(True) # which hover button is under the mouse
|
||||||
|
self._hovering = False
|
||||||
|
self._hot = None # "mode" / "prev" / "next" / None
|
||||||
|
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(FRAME_MS)
|
self._timer.setInterval(FRAME_MS)
|
||||||
@@ -270,41 +277,157 @@ class VisualizerWidget(QWidget):
|
|||||||
if self._mode == MODE_OFF and not self._big_visible():
|
if self._mode == MODE_OFF and not self._big_visible():
|
||||||
self._clear() # off = blank, not the last frame held in place
|
self._clear() # off = blank, not the last frame held in place
|
||||||
self._sync_timer()
|
self._sync_timer()
|
||||||
|
self._update_tooltip()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def cycle_mode(self):
|
||||||
if self._no_pcm:
|
"""The colour wheel: colour → gray → off → colour."""
|
||||||
# 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.
|
|
||||||
self._mode_before_press = self._mode
|
|
||||||
idx = _MODE_CYCLE.index(self._mode)
|
idx = _MODE_CYCLE.index(self._mode)
|
||||||
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
|
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event):
|
def step_style(self, step: int):
|
||||||
"""Qt sends press, release, *double-click*: the first press already
|
"""▲/▼: the previous or next style, wrapping around."""
|
||||||
cycled the brightness, so put it back before opening big. A brief
|
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
|
||||||
flicker, in exchange for a single click that never waits to see
|
idx = STYLES.index(current) if current in STYLES else 0
|
||||||
whether a second one is coming."""
|
style = STYLES[(idx + step) % len(STYLES)]
|
||||||
|
if self._prefs is not None:
|
||||||
|
self._prefs.set("visualizer_style", style) # swaps the renderer
|
||||||
|
else:
|
||||||
|
self._renderer = make_renderer(style, styles.settings(None))
|
||||||
|
self._update_tooltip()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ---- the hover controls ----
|
||||||
|
|
||||||
|
def _button_rects(self) -> dict:
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
m = BUTTON_MARGIN
|
||||||
|
arrow_h = min(BUTTON, (h - 3 * m) / 2)
|
||||||
|
right = w - m - BUTTON
|
||||||
|
return {
|
||||||
|
"mode": QRectF(m, (h - BUTTON) / 2, BUTTON, BUTTON),
|
||||||
|
"prev": QRectF(right, h / 2 - m / 2 - arrow_h, BUTTON, arrow_h),
|
||||||
|
"next": QRectF(right, h / 2 + m / 2, BUTTON, arrow_h),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _hit(self, pos):
|
||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
return # the first click already stopped casting
|
return None
|
||||||
self._set_mode(self._mode_before_press)
|
for name, rect in self._button_rects().items():
|
||||||
|
if rect.contains(QPointF(pos)):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_tooltip(self):
|
||||||
|
if self._no_pcm:
|
||||||
|
return
|
||||||
|
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
|
||||||
|
idx = STYLES.index(current) if current in STYLES else 0
|
||||||
|
tips = {
|
||||||
|
"mode": f"Colour / gray / off — now {_MODE_NAMES[self._mode]}",
|
||||||
|
"prev": f"Previous visualizer: {STYLE_NAMES[STYLES[idx - 1]]}",
|
||||||
|
"next": f"Next visualizer: {STYLE_NAMES[STYLES[(idx + 1) % len(STYLES)]]}",
|
||||||
|
}
|
||||||
|
self.setToolTip(tips.get(self._hot, _TOOLTIP))
|
||||||
|
|
||||||
|
def enterEvent(self, event):
|
||||||
|
self._hovering = True
|
||||||
|
self.update()
|
||||||
|
super().enterEvent(event)
|
||||||
|
|
||||||
|
def leaveEvent(self, event):
|
||||||
|
self._hovering = False
|
||||||
|
self._hot = None
|
||||||
|
self.update()
|
||||||
|
super().leaveEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
hot = self._hit(event.position())
|
||||||
|
if hot != self._hot:
|
||||||
|
self._hot = hot
|
||||||
|
self._update_tooltip()
|
||||||
|
self.update()
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
if self._no_pcm:
|
||||||
|
# While casting the panel is the cast indicator, not a picture:
|
||||||
|
# a click stops casting instead.
|
||||||
|
self.cast_stop_requested.emit()
|
||||||
|
return
|
||||||
|
event.accept() # acted on at release, like a button
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if self._no_pcm or event.button() != Qt.MouseButton.LeftButton:
|
||||||
|
return
|
||||||
|
if not self.rect().contains(event.position().toPoint()):
|
||||||
|
return # dragged off: no click
|
||||||
|
hit = self._hit(event.position())
|
||||||
|
if hit == "mode":
|
||||||
|
self.cycle_mode()
|
||||||
|
elif hit == "prev":
|
||||||
|
self.step_style(-1)
|
||||||
|
elif hit == "next":
|
||||||
|
self.step_style(1)
|
||||||
|
else:
|
||||||
self.open_big()
|
self.open_big()
|
||||||
|
|
||||||
|
def _paint_controls(self, painter):
|
||||||
|
"""The hover buttons, over whatever is showing."""
|
||||||
|
pal = self.palette()
|
||||||
|
face = QColor(pal.window().color())
|
||||||
|
ink = QColor(pal.windowText().color())
|
||||||
|
painter.save()
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
for name, rect in self._button_rects().items():
|
||||||
|
fill = QColor(face)
|
||||||
|
fill.setAlphaF(0.92 if name == self._hot else 0.72)
|
||||||
|
painter.setPen(QPen(QColor(ink.red(), ink.green(), ink.blue(),
|
||||||
|
110 if name == self._hot else 50), 1))
|
||||||
|
painter.setBrush(fill)
|
||||||
|
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 5, 5)
|
||||||
|
# colour wheel: a hue ring (colour), a gray one (gray), an outline (off)
|
||||||
|
wheel = self._button_rects()["mode"].adjusted(5, 5, -5, -5)
|
||||||
|
center = wheel.center()
|
||||||
|
if self._mode == MODE_OFF:
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.setPen(QPen(ink, 1.5))
|
||||||
|
else:
|
||||||
|
grad = QConicalGradient(center, 90)
|
||||||
|
for k in range(7):
|
||||||
|
c = QColor.fromHsvF((k / 6) % 1.0, 0.85, 0.95)
|
||||||
|
if self._mode == MODE_DIM: # a gray sweep, light to dark
|
||||||
|
v = int(225 - 150 * abs(k - 3) / 3)
|
||||||
|
c = QColor(v, v, v)
|
||||||
|
grad.setColorAt(k / 6, c)
|
||||||
|
painter.setBrush(grad)
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 90), 1))
|
||||||
|
painter.drawEllipse(wheel)
|
||||||
|
# ▲ / ▼
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(ink)
|
||||||
|
for name, up in (("prev", True), ("next", False)):
|
||||||
|
r = self._button_rects()[name]
|
||||||
|
cx, cy, s = r.center().x(), r.center().y(), min(r.width(), r.height()) * 0.22
|
||||||
|
tip = -s if up else s
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(cx - s * 1.2, cy - tip * 0.6),
|
||||||
|
QPointF(cx + s * 1.2, cy - tip * 0.6),
|
||||||
|
QPointF(cx, cy + tip * 0.9)]))
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
def open_big(self):
|
def open_big(self):
|
||||||
from lintunes.gui.visualizer_window import VisualizerWindow
|
from lintunes.gui.visualizer_window import VisualizerWindow
|
||||||
if self._big is None:
|
if self._big is None:
|
||||||
self._big = VisualizerWindow(self)
|
self._big = VisualizerWindow(self)
|
||||||
self._big.open(self.screen())
|
self._big.open(self.screen())
|
||||||
self._sync_timer()
|
self._sync_timer()
|
||||||
|
self.update() # the panel goes blank while it is big
|
||||||
|
|
||||||
def big_window_closed(self):
|
def big_window_closed(self):
|
||||||
if self._mode == MODE_OFF:
|
if self._mode == MODE_OFF:
|
||||||
self._clear()
|
self._clear()
|
||||||
self._sync_timer()
|
self._sync_timer()
|
||||||
|
self.update()
|
||||||
|
|
||||||
# ---- frame computation ----
|
# ---- frame computation ----
|
||||||
|
|
||||||
@@ -348,6 +471,8 @@ class VisualizerWidget(QWidget):
|
|||||||
regardless (in color), since opening it is asking to see it."""
|
regardless (in color), since opening it is asking to see it."""
|
||||||
if self._no_pcm or (self._mode == MODE_OFF and not big):
|
if self._no_pcm or (self._mode == MODE_OFF and not big):
|
||||||
return
|
return
|
||||||
|
if not big and self._big_visible():
|
||||||
|
return # it's showing big; once is enough
|
||||||
self._tell_mode()
|
self._tell_mode()
|
||||||
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
|
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
|
||||||
|
|
||||||
@@ -388,3 +513,5 @@ class VisualizerWidget(QWidget):
|
|||||||
painter.end()
|
painter.end()
|
||||||
return
|
return
|
||||||
self.paint_renderer(painter, QRectF(self.rect()))
|
self.paint_renderer(painter, QRectF(self.rect()))
|
||||||
|
if self._hovering:
|
||||||
|
self._paint_controls(painter)
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ way its OpenGL did.
|
|||||||
- **The screen's clock.** Between those frames the picture is still drawn
|
- **The screen's clock.** Between those frames the picture is still drawn
|
||||||
at 60 fps (``advance`` → ``_compose``): the turn continues smoothly (the
|
at 60 fps (``advance`` → ``_compose``): the turn continues smoothly (the
|
||||||
rotation is a frame count, so a fraction of a frame is well defined), the
|
rotation is a frame count, so a fraction of a frame is well defined), the
|
||||||
textures crossfade from the last frame to the newest, and the cones'
|
textures crossfade from the last frame to the newest. The cones' height
|
||||||
height and depth come from the plugin's own analysis run on the newest
|
and depth stay the plugin's own, changing only on its frames: driving
|
||||||
audio (``engine.preview``) — so a beat pumps the cone on the next screen
|
them from the newest audio at every refresh (Round 85) made the picture
|
||||||
refresh rather than at the next 40 ms tick.
|
twitch on bass-heavy songs — on 84% of refreshes against 41% — which is
|
||||||
|
the jitter the 25 fps cap had been hiding all along.
|
||||||
- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes;
|
- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes;
|
||||||
how it scaled them was never documented, so ``itunes_spectrum`` is a
|
how it scaled them was never documented, so ``itunes_spectrum`` is a
|
||||||
calibrated stand-in (``SPECTRUM_GAIN``).
|
calibrated stand-in (``SPECTRUM_GAIN``).
|
||||||
@@ -162,20 +163,19 @@ class KaleidostrobeRenderer:
|
|||||||
self._frame = self._engine.step(spectrum, self._ticks())
|
self._frame = self._engine.step(spectrum, self._ticks())
|
||||||
if self._frame is None:
|
if self._frame is None:
|
||||||
return False
|
return False
|
||||||
self._compose(spectrum)
|
self._compose()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _ticks(self, ahead=0.0):
|
def _ticks(self, ahead=0.0):
|
||||||
return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S)
|
return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S)
|
||||||
|
|
||||||
def _compose(self, spectrum):
|
def _compose(self):
|
||||||
"""This screen refresh's picture, a fraction ``t`` of the way from
|
"""This screen refresh's picture, a fraction ``t`` of the way from
|
||||||
the plugin's last frame to its next one."""
|
the plugin's last frame to its next one."""
|
||||||
e = self._engine
|
e = self._engine
|
||||||
t = self._behind / STEP_S
|
t = self._behind / STEP_S
|
||||||
mid, signal = e.preview(spectrum, self._ticks(self._behind))
|
|
||||||
drawn = (e.rotation - 1) % ks.ROTATION_STEPS # the last frame's turn
|
drawn = (e.rotation - 1) % ks.ROTATION_STEPS # the last frame's turn
|
||||||
layers = e.layers(mid, signal, drawn + t)
|
layers = e.layers(e.mid, e.avg_signal, drawn + t)
|
||||||
cur = self._frame.textures
|
cur = self._frame.textures
|
||||||
prev = self._prev_textures if self._prev_textures is not None else cur
|
prev = self._prev_textures if self._prev_textures is not None else cur
|
||||||
if t > 0.0 and prev is not cur:
|
if t > 0.0 and prev is not cur:
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ STYLE_RADIAL = "radial"
|
|||||||
STYLE_LASER = "laser"
|
STYLE_LASER = "laser"
|
||||||
STYLE_KALEIDOSTROBE = "kaleidostrobe"
|
STYLE_KALEIDOSTROBE = "kaleidostrobe"
|
||||||
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE)
|
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE)
|
||||||
|
STYLE_NAMES = {
|
||||||
|
STYLE_BARS: "Bars",
|
||||||
|
STYLE_RADIAL: "Radial dots",
|
||||||
|
STYLE_LASER: "Laser",
|
||||||
|
STYLE_KALEIDOSTROBE: "Kaleidostrobe",
|
||||||
|
}
|
||||||
|
|
||||||
BANDS = 20
|
BANDS = 20
|
||||||
STEPS = 20 # vertical granularity per bar
|
STEPS = 20 # vertical granularity per bar
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ original handed OpenGL: two 128×128 RGBA textures and, per layer, the
|
|||||||
triangles to draw (object-space vertices, texture coordinates, and the
|
triangles to draw (object-space vertices, texture coordinates, and the
|
||||||
modelview that places them). ``visualizer_kaleido`` paints that.
|
modelview that places them). ``visualizer_kaleido`` paints that.
|
||||||
"""
|
"""
|
||||||
import copy
|
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -611,20 +610,6 @@ class Kaleidostrobe:
|
|||||||
self.top_object, height, f32(depth * 5.0)))
|
self.top_object, height, f32(depth * 5.0)))
|
||||||
return [bottom, top]
|
return [bottom, top]
|
||||||
|
|
||||||
def preview(self, spectrum, ticks):
|
|
||||||
"""What the plugin's analysis would say if it rendered now: (mid,
|
|
||||||
avg_signal) from a throwaway copy, so nothing here is changed. The
|
|
||||||
renderer draws between the plugin's 25 fps frames with these, so a
|
|
||||||
beat moves the cone on the next screen refresh instead of up to
|
|
||||||
40 ms later; at the next real frame the numbers are the same."""
|
|
||||||
ghost = copy.copy(self)
|
|
||||||
ghost.band_peak = [list(row) for row in self.band_peak]
|
|
||||||
ghost.lp = {k: list(v) for k, v in self.lp.items()}
|
|
||||||
ghost.beat_periods = list(self.beat_periods)
|
|
||||||
ghost.frame_count += 1
|
|
||||||
ghost._process(spectrum, ticks)
|
|
||||||
return ghost.mid, ghost.avg_signal
|
|
||||||
|
|
||||||
def _object(self, k, h, z):
|
def _object(self, k, h, z):
|
||||||
if k == 0:
|
if k == 0:
|
||||||
return self._draw8(h, z)
|
return self._draw8(h, z)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TestVisualizerModePersistence:
|
|||||||
prefs = Preferences(tmp_path)
|
prefs = Preferences(tmp_path)
|
||||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||||
|
|
||||||
vis.mousePressEvent(None) # on -> dim
|
vis.cycle_mode() # on -> dim
|
||||||
|
|
||||||
assert prefs.get("visualizer_mode") == MODE_DIM
|
assert prefs.get("visualizer_mode") == MODE_DIM
|
||||||
# A fresh Preferences (= next launch) sees it too.
|
# A fresh Preferences (= next launch) sees it too.
|
||||||
|
|||||||
@@ -900,8 +900,8 @@ class TestTransportLayout:
|
|||||||
vis = bar._visualizer
|
vis = bar._visualizer
|
||||||
before = vis._mode
|
before = vis._mode
|
||||||
|
|
||||||
# Not casting: a click still cycles the brightness as it always did.
|
# Not casting: the colour wheel cycles the brightness.
|
||||||
vis.mousePressEvent(None)
|
vis.cycle_mode()
|
||||||
assert vis._mode != before
|
assert vis._mode != before
|
||||||
cast.disconnect.assert_not_called()
|
cast.disconnect.assert_not_called()
|
||||||
|
|
||||||
@@ -1020,4 +1020,4 @@ class TestVisualizerWhileCasting:
|
|||||||
assert "click to stop" in vis.toolTip()
|
assert "click to stop" in vis.toolTip()
|
||||||
|
|
||||||
player.set_sink(None)
|
player.set_sink(None)
|
||||||
assert "cycle" in vis.toolTip() # back to the brightness control
|
assert "big" in vis.toolTip() # back to the tile's own tooltip
|
||||||
|
|||||||
@@ -432,11 +432,10 @@ class TestBarsFade:
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestBigWindow:
|
class TestBigWindow:
|
||||||
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
|
def test_opening_keeps_the_brightness(self, qapp, tmp_path):
|
||||||
prefs = Preferences(tmp_path)
|
prefs = Preferences(tmp_path)
|
||||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||||
vis.mousePressEvent(None) # Qt's first press: on -> dim
|
vis.open_big() # Round 85: a single click on the tile
|
||||||
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
|
|
||||||
try:
|
try:
|
||||||
assert vis._mode == MODE_ON
|
assert vis._mode == MODE_ON
|
||||||
assert prefs.get("visualizer_mode") == MODE_ON
|
assert prefs.get("visualizer_mode") == MODE_ON
|
||||||
|
|||||||
+9
-16
@@ -1,9 +1,9 @@
|
|||||||
"""Round 85: Kaleidostrobe at 60 fps, its big window on the GPU, gray mode
|
"""Round 85: Kaleidostrobe at 60 fps, its big window on the GPU, gray mode
|
||||||
as two tones, and a sync slider that can also say "sooner".
|
as two tones, and a sync slider that can also say "sooner".
|
||||||
|
|
||||||
1. Between the plugin's 25 fps frames: a smooth turn, crossfaded textures,
|
1. Between the plugin's 25 fps frames: a smooth turn and crossfaded
|
||||||
the cone driven by a preview of the newest audio — which must never
|
textures. (The cone also followed a preview of the newest audio here;
|
||||||
change the original's own frames.
|
Round 86 took that out — it made the picture twitch.)
|
||||||
2. Gray mode: the gray-mode colour on the panel background, nothing else.
|
2. Gray mode: the gray-mode colour on the panel background, nothing else.
|
||||||
3. The sync slider below zero.
|
3. The sync slider below zero.
|
||||||
4. The big window: an OpenGL canvas for Kaleidostrobe, QPainter without GL.
|
4. The big window: an OpenGL canvas for Kaleidostrobe, QPainter without GL.
|
||||||
@@ -17,24 +17,17 @@ from lintunes.gui import visualizer_styles as vs
|
|||||||
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
|
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
|
||||||
from lintunes.preferences import Preferences
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
from test_round84 import GOLDEN, _digest, _frame, _paint, _FakePlayer, _LastFm
|
from test_round84 import _frame, _paint, _FakePlayer, _LastFm
|
||||||
|
|
||||||
|
|
||||||
|
SEED = 7
|
||||||
|
|
||||||
|
|
||||||
class TestBetweenFrames:
|
class TestBetweenFrames:
|
||||||
def test_previews_between_frames_change_nothing(self):
|
|
||||||
"""The renderer asks for a preview every screen refresh; the frames
|
|
||||||
must still be the original's."""
|
|
||||||
engine = ks.Kaleidostrobe(seed=1000, ticks=1000)
|
|
||||||
for f, (spectrum, ticks) in enumerate(zip(GOLDEN["spectra"], GOLDEN["ticks"])):
|
|
||||||
engine.preview(spectrum, int(ticks) + 1)
|
|
||||||
engine.preview(GOLDEN["spectra"][-1 - f], int(ticks) + 2)
|
|
||||||
frame = engine.step(spectrum, int(ticks))
|
|
||||||
assert _digest(frame.textures[0]) == GOLDEN["tex_digest"][f][0], f"frame {f}"
|
|
||||||
assert _digest(frame.textures[1]) == GOLDEN["tex_digest"][f][1], f"frame {f}"
|
|
||||||
|
|
||||||
def test_gray_mode_is_two_tone(self, qapp):
|
def test_gray_mode_is_two_tone(self, qapp):
|
||||||
"""Only the gray-mode colour and the panel's background, blended."""
|
"""Only the gray-mode colour and the panel's background, blended."""
|
||||||
r = KaleidostrobeRenderer()
|
r = KaleidostrobeRenderer()
|
||||||
|
r._engine = ks.Kaleidostrobe(seed=SEED, ticks=1000)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
r.advance(_frame(dt=0.04))
|
r.advance(_frame(dt=0.04))
|
||||||
r.dimmed = True # the widget then passes its gray
|
r.dimmed = True # the widget then passes its gray
|
||||||
@@ -44,7 +37,7 @@ class TestBetweenFrames:
|
|||||||
k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink
|
k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink
|
||||||
assert np.ptp(k, axis=-1).max() < 0.03 # on the line between
|
assert np.ptp(k, axis=-1).max() < 0.03 # on the line between
|
||||||
assert k.min() > -0.02 and k.max() < 1.02
|
assert k.min() > -0.02 and k.max() < 1.02
|
||||||
assert k.max() - k.min() > 0.3 # and still a picture
|
assert k.max() - k.min() > 0.15 # and still a picture
|
||||||
|
|
||||||
def test_draws_between_the_plugins_frames(self, qapp):
|
def test_draws_between_the_plugins_frames(self, qapp):
|
||||||
"""60 fps on screen: the turn moves every refresh, frames at 25."""
|
"""60 fps on screen: the turn moves every refresh, frames at 25."""
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Round 86: Kaleidostrobe smooth again, and the visualizer tile's controls.
|
||||||
|
|
||||||
|
1. The cones move only on the plugin's own 25 fps frames (driving them from
|
||||||
|
fresh audio at every refresh, Round 85, made bass-heavy songs twitch).
|
||||||
|
2. The tile: hovering shows a colour wheel (colour / gray / off) and ▲/▼
|
||||||
|
(style); a click anywhere else opens it big; no double-click any more.
|
||||||
|
3. While it's big, the tile stays blank.
|
||||||
|
4. The big album art closes on a click, like the big visualizer.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QEvent, QObject, QPointF, Qt, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QColor, QImage, QKeyEvent, QMouseEvent, QPainter
|
||||||
|
|
||||||
|
from lintunes.gui import visualizer_styles as vs
|
||||||
|
from lintunes.gui.visualizer import MODE_DIM, MODE_OFF, MODE_ON, VisualizerWidget
|
||||||
|
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
|
||||||
|
from lintunes.preferences import Preferences
|
||||||
|
|
||||||
|
from test_round84 import _frame
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlayer(QObject):
|
||||||
|
audio_buffer = pyqtSignal(object)
|
||||||
|
playing_changed = pyqtSignal(bool)
|
||||||
|
track_changed = pyqtSignal(object)
|
||||||
|
|
||||||
|
def is_playing(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _click(widget, pos, button=Qt.MouseButton.LeftButton):
|
||||||
|
pos = QPointF(pos)
|
||||||
|
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
|
||||||
|
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
|
||||||
|
event = QMouseEvent(kind, pos, pos, button, buttons,
|
||||||
|
Qt.KeyboardModifier.NoModifier)
|
||||||
|
if kind == QEvent.Type.MouseButtonPress:
|
||||||
|
widget.mousePressEvent(event)
|
||||||
|
else:
|
||||||
|
widget.mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _tile(tmp_path, style="bars"):
|
||||||
|
prefs = Preferences(tmp_path)
|
||||||
|
prefs.set("visualizer_style", style)
|
||||||
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||||
|
vis.resize(140, 62)
|
||||||
|
return prefs, vis
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. Smooth again
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSmooth:
|
||||||
|
def test_the_cone_moves_only_on_the_plugins_frames(self, qapp):
|
||||||
|
"""Height and depth are the plugin's own: driving them from fresh
|
||||||
|
audio every refresh made the picture twitch."""
|
||||||
|
r = KaleidostrobeRenderer()
|
||||||
|
r.advance(_frame(dt=0.04))
|
||||||
|
seen = []
|
||||||
|
for i in range(30):
|
||||||
|
r.advance(_frame(dt=1 / 60, hz=55 + 40 * (i % 3)))
|
||||||
|
apex = r._shown[0][0].triangles[0].xyz[0][2]
|
||||||
|
seen.append((r._engine.frame_count, apex))
|
||||||
|
for (fa, za), (fb, zb) in zip(seen, seen[1:]):
|
||||||
|
if fa == fb:
|
||||||
|
assert za == zb
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. The tile's controls
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTile:
|
||||||
|
def test_a_click_in_the_middle_opens_big(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path)
|
||||||
|
_click(vis, vis.rect().center())
|
||||||
|
try:
|
||||||
|
assert vis._big is not None and vis._big.isVisible()
|
||||||
|
assert vis._mode == MODE_ON # brightness untouched
|
||||||
|
finally:
|
||||||
|
vis._big.close()
|
||||||
|
|
||||||
|
def test_the_colour_wheel_cycles_and_does_not_open(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path)
|
||||||
|
wheel = vis._button_rects()["mode"].center()
|
||||||
|
seen = []
|
||||||
|
for _ in range(3):
|
||||||
|
_click(vis, wheel)
|
||||||
|
seen.append(vis._mode)
|
||||||
|
assert seen == [MODE_DIM, MODE_OFF, MODE_ON]
|
||||||
|
assert prefs.get("visualizer_mode") == MODE_ON
|
||||||
|
assert vis._big is None
|
||||||
|
|
||||||
|
def test_arrows_step_through_every_style_and_wrap(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path, "bars")
|
||||||
|
down = vis._button_rects()["next"].center()
|
||||||
|
up = vis._button_rects()["prev"].center()
|
||||||
|
seen = []
|
||||||
|
for _ in range(len(vs.STYLES)):
|
||||||
|
_click(vis, down)
|
||||||
|
seen.append(prefs.get("visualizer_style"))
|
||||||
|
assert seen == list(vs.STYLES[1:]) + [vs.STYLES[0]]
|
||||||
|
_click(vis, up) # bars → the last one
|
||||||
|
assert prefs.get("visualizer_style") == vs.STYLES[-1]
|
||||||
|
assert isinstance(vis._renderer, KaleidostrobeRenderer)
|
||||||
|
assert vis._big is None
|
||||||
|
|
||||||
|
def test_a_double_click_does_nothing_special(self, qapp, tmp_path):
|
||||||
|
assert "mouseDoubleClickEvent" not in VisualizerWidget.__dict__
|
||||||
|
|
||||||
|
def test_controls_show_only_on_hover(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path)
|
||||||
|
prefs.set("visualizer_mode", MODE_OFF)
|
||||||
|
|
||||||
|
def shot():
|
||||||
|
return vis.grab().toImage().pixel(int(vis._button_rects()["mode"].center().x()),
|
||||||
|
int(vis._button_rects()["mode"].center().y()))
|
||||||
|
plain = shot()
|
||||||
|
vis._hovering = True
|
||||||
|
assert shot() != plain
|
||||||
|
vis._hovering = False
|
||||||
|
assert shot() == plain
|
||||||
|
|
||||||
|
def test_tooltips_say_what_each_part_does(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path, "bars")
|
||||||
|
vis._hot = "next"
|
||||||
|
vis._update_tooltip()
|
||||||
|
assert "Radial dots" in vis.toolTip()
|
||||||
|
vis._hot = "mode"
|
||||||
|
vis._update_tooltip()
|
||||||
|
assert "colour" in vis.toolTip()
|
||||||
|
vis._hot = None
|
||||||
|
vis._update_tooltip()
|
||||||
|
assert "big" in vis.toolTip()
|
||||||
|
|
||||||
|
def test_casting_click_still_stops_casting(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path)
|
||||||
|
stopped = MagicMock()
|
||||||
|
vis.cast_stop_requested.connect(stopped)
|
||||||
|
vis._no_pcm = True
|
||||||
|
_click(vis, vis._button_rects()["mode"].center())
|
||||||
|
stopped.assert_called_once()
|
||||||
|
assert vis._mode == MODE_ON
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. Blank while big
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBlankWhileBig:
|
||||||
|
def test_tile_draws_nothing_while_big_is_open(self, qapp, tmp_path):
|
||||||
|
prefs, vis = _tile(tmp_path)
|
||||||
|
vis._renderer = MagicMock(spec=["paint", "advance", "clear", "name"])
|
||||||
|
img = QImage(140, 62, QImage.Format.Format_ARGB32)
|
||||||
|
painter = QPainter(img)
|
||||||
|
vis.paint_renderer(painter, vis.rect())
|
||||||
|
assert vis._renderer.paint.call_count == 1
|
||||||
|
vis.open_big()
|
||||||
|
try:
|
||||||
|
vis.paint_renderer(painter, vis.rect()) # the tile
|
||||||
|
assert vis._renderer.paint.call_count == 1
|
||||||
|
vis.paint_renderer(painter, vis.rect(), big=True) # the window
|
||||||
|
assert vis._renderer.paint.call_count == 2
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
vis._big.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4. Big album art
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestArtWindow:
|
||||||
|
def test_a_click_closes_it(self, qapp):
|
||||||
|
from lintunes.gui.art_window import ArtWindow
|
||||||
|
win = ArtWindow()
|
||||||
|
win.resize(300, 300)
|
||||||
|
win.show()
|
||||||
|
_click(win, win.contentsRect().center())
|
||||||
|
assert not win.isVisible()
|
||||||
|
|
||||||
|
def test_escape_closes_it(self, qapp):
|
||||||
|
from lintunes.gui.art_window import ArtWindow
|
||||||
|
win = ArtWindow()
|
||||||
|
win.show()
|
||||||
|
win.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape,
|
||||||
|
Qt.KeyboardModifier.NoModifier))
|
||||||
|
assert not win.isVisible()
|
||||||
@@ -80,11 +80,11 @@ class TestVisualizerToggle:
|
|||||||
vis = VisualizerWidget(player)
|
vis = VisualizerWidget(player)
|
||||||
|
|
||||||
assert vis._mode == MODE_ON
|
assert vis._mode == MODE_ON
|
||||||
vis.mousePressEvent(None) # on -> dim
|
vis.cycle_mode() # on -> dim
|
||||||
assert vis._mode == MODE_DIM
|
assert vis._mode == MODE_DIM
|
||||||
vis.mousePressEvent(None) # dim -> off
|
vis.cycle_mode() # dim -> off
|
||||||
assert vis._mode == MODE_OFF
|
assert vis._mode == MODE_OFF
|
||||||
vis.mousePressEvent(None) # off -> on (wraps)
|
vis.cycle_mode() # off -> on (wraps)
|
||||||
assert vis._mode == MODE_ON
|
assert vis._mode == MODE_ON
|
||||||
|
|
||||||
def test_off_goes_blank_and_stops(self, qapp):
|
def test_off_goes_blank_and_stops(self, qapp):
|
||||||
@@ -94,8 +94,8 @@ class TestVisualizerToggle:
|
|||||||
vis = VisualizerWidget(player)
|
vis = VisualizerWidget(player)
|
||||||
vis._bars = [7] * BANDS # pretend mid-animation
|
vis._bars = [7] * BANDS # pretend mid-animation
|
||||||
|
|
||||||
vis.mousePressEvent(None) # on -> dim
|
vis.cycle_mode() # on -> dim
|
||||||
vis.mousePressEvent(None) # dim -> off
|
vis.cycle_mode() # dim -> off
|
||||||
|
|
||||||
assert vis._mode == MODE_OFF
|
assert vis._mode == MODE_OFF
|
||||||
assert vis._bars == [0] * BANDS # blank, not the held frame
|
assert vis._bars == [0] * BANDS # blank, not the held frame
|
||||||
@@ -108,7 +108,7 @@ class TestVisualizerToggle:
|
|||||||
player._playing = True
|
player._playing = True
|
||||||
vis = VisualizerWidget(player)
|
vis = VisualizerWidget(player)
|
||||||
|
|
||||||
vis.mousePressEvent(None) # on -> dim
|
vis.cycle_mode() # on -> dim
|
||||||
|
|
||||||
assert vis._mode == MODE_DIM
|
assert vis._mode == MODE_DIM
|
||||||
assert vis._timer.isActive() # dim animates while playing
|
assert vis._timer.isActive() # dim animates while playing
|
||||||
@@ -119,9 +119,9 @@ class TestVisualizerToggle:
|
|||||||
player._playing = True
|
player._playing = True
|
||||||
vis = VisualizerWidget(player)
|
vis = VisualizerWidget(player)
|
||||||
|
|
||||||
vis.mousePressEvent(None) # on -> dim
|
vis.cycle_mode() # on -> dim
|
||||||
vis.mousePressEvent(None) # dim -> off
|
vis.cycle_mode() # dim -> off
|
||||||
vis.mousePressEvent(None) # off -> on again
|
vis.cycle_mode() # off -> on again
|
||||||
|
|
||||||
assert vis._mode == MODE_ON
|
assert vis._mode == MODE_ON
|
||||||
assert vis._timer.isActive()
|
assert vis._timer.isActive()
|
||||||
|
|||||||
Reference in New Issue
Block a user