Visualizer: gray-mode color slider + mode persists across launches

- New prefs: visualizer_mode (on/dim/off, saved on every click) and
  color_visualizer_gray (None = keep the derived alternateBase look).
- VisualizerWidget takes prefs (optional, so existing call sites/tests
  keep working); restores its mode at startup and validates junk values.
- New "Visualizer (gray mode):" row in the Preferences color sliders;
  theme.default_gray mirrors the widget's 0.88-lightness derivation so
  an untouched slider shows the current appearance.
- TransportBar.refresh_theme() repaints the visualizer so slider drags
  preview live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:52:59 -04:00
parent d46374967d
commit 0d8c9dd033
6 changed files with 129 additions and 4 deletions

View File

@ -19,6 +19,7 @@ _COLOR_ROWS = [
("color_now_playing_bg", "Now-playing panel:"), ("color_now_playing_bg", "Now-playing panel:"),
("color_now_playing_text", "Now-playing text:"), ("color_now_playing_text", "Now-playing text:"),
("color_stripe", "Stripes:"), ("color_stripe", "Stripes:"),
("color_visualizer_gray", "Visualizer (gray mode):"),
] ]

View File

@ -255,7 +255,7 @@ class TransportBar(QWidget):
layout.addWidget(shuffle_box) layout.addWidget(shuffle_box)
layout.addSpacing(10) layout.addSpacing(10)
self._visualizer = VisualizerWidget(player) self._visualizer = VisualizerWidget(player, prefs)
self._visualizer.setFixedHeight(CONTROL_HEIGHT) self._visualizer.setFixedHeight(CONTROL_HEIGHT)
layout.addWidget(self._visualizer) layout.addWidget(self._visualizer)
layout.addSpacing(10) layout.addSpacing(10)
@ -359,6 +359,7 @@ 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._visualizer.update() # dim-mode bar color may have changed
def _apply_now_playing_bg(self): def _apply_now_playing_bg(self):
bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg")) bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg"))

View File

@ -11,6 +11,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF
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
from lintunes import theme
BANDS = 20 BANDS = 20
STEPS = 20 # vertical granularity per bar STEPS = 20 # vertical granularity per bar
@ -41,10 +43,12 @@ class VisualizerWidget(QWidget):
panel; off = blank. panel; off = blank.
""" """
def __init__(self, player, parent=None): def __init__(self, player, prefs=None, parent=None):
super().__init__(parent) super().__init__(parent)
self._player = player self._player = player
self._mode = MODE_ON self._prefs = prefs
saved = prefs.get("visualizer_mode") if prefs is not None else None
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._levels = np.zeros(BANDS) self._levels = np.zeros(BANDS)
self._bars = [0] * BANDS # quantized 0..STEPS self._bars = [0] * BANDS # quantized 0..STEPS
self._samples = np.zeros(FFT_SIZE, dtype=np.float32) self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
@ -113,6 +117,8 @@ class VisualizerWidget(QWidget):
# 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)]
if self._prefs is not None:
self._prefs.set("visualizer_mode", self._mode)
if self._mode == MODE_OFF: if self._mode == MODE_OFF:
self._timer.stop() self._timer.stop()
self._clear() # off = blank, not the last frame held in place self._clear() # off = blank, not the last frame held in place
@ -150,6 +156,10 @@ class VisualizerWidget(QWidget):
def _bar_color(self): def _bar_color(self):
if self._mode == MODE_DIM: if self._mode == MODE_DIM:
if self._prefs is not None:
saved = self._prefs.get("color_visualizer_gray")
if saved is not None:
return theme.gray(saved)
# Light gray just darker than the panel: take alternateBase and # Light gray just darker than the panel: take alternateBase and
# drop its lightness ~12% so it tracks theme/scale changes. # drop its lightness ~12% so it tracks theme/scale changes.
base = QColor(self.palette().alternateBase().color()) base = QColor(self.palette().alternateBase().color())

View File

@ -11,6 +11,7 @@ DEFAULTS = {
"now_playing_font": None, # None=prefer Century Gothic, ""=use app "now_playing_font": None, # None=prefer Century Gothic, ""=use app
# default, "Family"=use app # default, "Family"=use app
"volume": 0.8, # logical 0..1 master output volume "volume": 0.8, # logical 0..1 master output volume
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the # Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user # current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences. # drags a slider in Preferences.
@ -19,6 +20,7 @@ DEFAULTS = {
"color_now_playing_bg": None, # panel behind the now-playing text "color_now_playing_bg": None, # panel behind the now-playing text
"color_now_playing_text": None, # the now-playing title/artist text "color_now_playing_text": None, # the now-playing title/artist text
"color_stripe": None, # alternating row stripes (QPalette.AlternateBase) "color_stripe": None, # alternating row stripes (QPalette.AlternateBase)
"color_visualizer_gray": None, # visualizer bars in dim (gray) mode
"lastfm": { "lastfm": {
"api_key": "", "api_key": "",
"api_secret": "", "api_secret": "",

View File

@ -70,7 +70,17 @@ def default_gray(key: str) -> int:
current appearance, so showing the slider doesn't imply a change.""" current appearance, so showing the slider doesn't imply a change."""
if key in _FIXED_GRAY_DEFAULT: if key in _FIXED_GRAY_DEFAULT:
return _FIXED_GRAY_DEFAULT[key] return _FIXED_GRAY_DEFAULT[key]
color = _base().color(QPalette.ColorGroup.Active, _PALETTE_GRAY_ROLE[key]) if key == "color_visualizer_gray":
# Dim-mode bars derive from alternateBase dropped ~12% in lightness
# (see VisualizerWidget._bar_color); mirror that so the slider shows
# the current appearance.
color = _base().color(QPalette.ColorGroup.Active,
QPalette.ColorRole.AlternateBase)
h, s, l, a = color.getHslF()
color.setHslF(h, s, l * 0.88, a)
else:
color = _base().color(QPalette.ColorGroup.Active,
_PALETTE_GRAY_ROLE[key])
return round(0.299 * color.red() + 0.587 * color.green() return round(0.299 * color.red() + 0.587 * color.green()
+ 0.114 * color.blue()) + 0.114 * color.blue())

101
tests/test_round18.py Normal file
View File

@ -0,0 +1,101 @@
"""Round 18: the 2026-07-02 "New backlog" batch.
1. Visualizer: gray-mode color slider pref + mode persists across launches.
2. External file drops insert at the drop position in a playlist.
3. Renaming artist/album/album_artist moves the file to keep the music
folder organized (Artist/Album, Unknown Artist fallback).
"""
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QColor
from lintunes import theme
from lintunes.preferences import Preferences
from lintunes.gui.visualizer import (
VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF)
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self):
super().__init__()
self._playing = False
def is_playing(self):
return self._playing
# --------------------------------------------------------------------------
# 1. Visualizer: gray color pref + persistent mode
# --------------------------------------------------------------------------
class TestVisualizerModePersistence:
def test_no_prefs_defaults_on(self, qapp):
vis = VisualizerWidget(_FakePlayer())
assert vis._mode == MODE_ON
def test_saved_mode_restored(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_DIM)
vis = VisualizerWidget(_FakePlayer(), prefs)
assert vis._mode == MODE_DIM
def test_saved_off_restored_and_stays_idle(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
vis = VisualizerWidget(_FakePlayer(), prefs)
assert vis._mode == MODE_OFF
vis._on_playing_changed(True) # playback starts
assert not vis._timer.isActive()
def test_bogus_saved_mode_falls_back_to_on(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", "sparkle") # hand-edited prefs file
vis = VisualizerWidget(_FakePlayer(), prefs)
assert vis._mode == MODE_ON
def test_click_persists_mode_to_disk(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis.mousePressEvent(None) # on -> dim
assert prefs.get("visualizer_mode") == MODE_DIM
# A fresh Preferences (= next launch) sees it too.
assert Preferences(tmp_path).get("visualizer_mode") == MODE_DIM
class TestVisualizerGrayColor:
def test_default_gray_resolves_for_visualizer_key(self, qapp):
value = theme.default_gray("color_visualizer_gray")
assert isinstance(value, int)
assert 0 <= value <= 255
def test_slider_pref_drives_dim_color(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("color_visualizer_gray", 100)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis._mode = MODE_DIM
assert vis._bar_color() == QColor(100, 100, 100)
def test_unset_pref_keeps_derived_color(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
with_prefs = VisualizerWidget(_FakePlayer(), prefs)
without = VisualizerWidget(_FakePlayer())
with_prefs._mode = MODE_DIM
without._mode = MODE_DIM
assert with_prefs._bar_color() == without._bar_color()
def test_on_mode_ignores_gray_pref(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("color_visualizer_gray", 100)
vis = VisualizerWidget(_FakePlayer(), prefs)
assert vis._mode == MODE_ON
assert vis._bar_color() == QColor(vis.palette().highlight().color())
def test_visualizer_row_in_preferences_dialog(self, qapp):
from lintunes.gui.preferences_dialog import _COLOR_ROWS
assert "color_visualizer_gray" in [key for key, _label in _COLOR_ROWS]