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:
@ -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):"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"))
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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": "",
|
||||||
|
|||||||
@ -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
101
tests/test_round18.py
Normal 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]
|
||||||
Reference in New Issue
Block a user