Add per-area color preference sliders
Five grayscale (black->white) sliders in Preferences that update live:
outer-chrome background (QPalette.Window), the rounded transport button
boxes, the now-playing panel, the now-playing text, and the row stripes
(QPalette.AlternateBase). Defaults are None ("inherit current") so nothing
changes until the user drags. Decouples the button boxes from the stripe
color (they used to share palette alternate-base). Live preview via
Preferences.set_live (no disk write); persisted ~0.4s after the drag.
This commit is contained in:
@@ -3,14 +3,24 @@ from PyQt6.QtWidgets import (
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
|
||||
_SCALES = ["small", "medium", "large"]
|
||||
|
||||
# (preference key, row label) for the per-area grayscale sliders.
|
||||
_COLOR_ROWS = [
|
||||
("color_background", "Background:"),
|
||||
("color_button", "Buttons:"),
|
||||
("color_now_playing_bg", "Now-playing panel:"),
|
||||
("color_now_playing_text", "Now-playing text:"),
|
||||
("color_stripe", "Stripes:"),
|
||||
]
|
||||
|
||||
|
||||
def _swatch(color_hex: str) -> QIcon:
|
||||
pixmap = QPixmap(18, 18)
|
||||
@@ -26,8 +36,16 @@ class PreferencesDialog(QDialog):
|
||||
self.setWindowTitle("Preferences")
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
# Color sliders preview live (set_live emits without writing); persist
|
||||
# once the user pauses so a drag doesn't hammer the disk.
|
||||
self._color_save_timer = QTimer(self)
|
||||
self._color_save_timer.setSingleShot(True)
|
||||
self._color_save_timer.setInterval(400)
|
||||
self._color_save_timer.timeout.connect(self._prefs.save)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._build_general_group())
|
||||
layout.addWidget(self._build_colors_group())
|
||||
layout.addWidget(self._build_lastfm_group())
|
||||
layout.addStretch()
|
||||
|
||||
@@ -81,6 +99,30 @@ class PreferencesDialog(QDialog):
|
||||
self._size_label.setText(scale.capitalize())
|
||||
self._prefs.set("ui_scale", scale)
|
||||
|
||||
# ---- Colors ----
|
||||
|
||||
def _build_colors_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Colors")
|
||||
form = QFormLayout(group)
|
||||
note = QLabel("Each slider runs black → white and updates instantly.")
|
||||
note.setWordWrap(True)
|
||||
form.addRow(note)
|
||||
|
||||
self._color_sliders = {}
|
||||
for key, label in _COLOR_ROWS:
|
||||
slider = QSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 255)
|
||||
slider.setValue(theme.gray_value(self._prefs, key)) # before connect
|
||||
slider.valueChanged.connect(
|
||||
lambda value, k=key: self._on_color_changed(k, value))
|
||||
self._color_sliders[key] = slider
|
||||
form.addRow(label, slider)
|
||||
return group
|
||||
|
||||
def _on_color_changed(self, key: str, value: int):
|
||||
self._prefs.set_live(key, value) # live preview, no disk write yet
|
||||
self._color_save_timer.start() # persist once the drag settles
|
||||
|
||||
# ---- Last.fm ----
|
||||
|
||||
def _build_lastfm_group(self) -> QGroupBox:
|
||||
|
||||
+49
-21
@@ -29,19 +29,22 @@ BAR_HEIGHT = 84
|
||||
# at ~46px. Tune to taste.
|
||||
CONTROL_HEIGHT = 62
|
||||
|
||||
_BOX_STYLE = """
|
||||
QFrame#transportBox {
|
||||
def _box_style(bg: QColor) -> str:
|
||||
return f"""
|
||||
QFrame#transportBox {{
|
||||
border: 1px solid palette(mid);
|
||||
border-radius: 8px;
|
||||
background: palette(alternate-base);
|
||||
}
|
||||
background: {bg.name()};
|
||||
}}
|
||||
"""
|
||||
|
||||
_NOWPLAYING_STYLE = """
|
||||
QFrame#nowPlayingBox {
|
||||
background: white;
|
||||
|
||||
def _nowplaying_style(bg: QColor) -> str:
|
||||
return f"""
|
||||
QFrame#nowPlayingBox {{
|
||||
background: {bg.name()};
|
||||
border-radius: 8px;
|
||||
}
|
||||
}}
|
||||
"""
|
||||
|
||||
# Title sits this many points above the artist/album line, both following the
|
||||
@@ -86,15 +89,17 @@ class FlashButton(QToolButton):
|
||||
self.setIcon(transport_icon(self._kind, color))
|
||||
|
||||
|
||||
def _box(*widgets, margins=(10, 3, 10, 3), hug=False, height=None) -> QFrame:
|
||||
def _box(*widgets, bg: QColor, margins=(10, 3, 10, 3), hug=False,
|
||||
height=None) -> QFrame:
|
||||
"""A small rounded frame with its content centered inside.
|
||||
|
||||
With hug=True the frame stays at its size hint instead of stretching
|
||||
to fill the row it sits in. height pins the frame's height (width is
|
||||
left untouched), so several boxes can be lined up to the same height."""
|
||||
left untouched), so several boxes can be lined up to the same height.
|
||||
bg is the rounded-rectangle fill (a user-tunable grayscale)."""
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transportBox")
|
||||
frame.setStyleSheet(_BOX_STYLE)
|
||||
frame.setStyleSheet(_box_style(bg))
|
||||
inner = QHBoxLayout(frame)
|
||||
inner.setContentsMargins(*margins)
|
||||
inner.addStretch()
|
||||
@@ -229,16 +234,25 @@ class TransportBar(QWidget):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 3, 8, 3)
|
||||
|
||||
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
|
||||
# from the stripe color they used to share via palette(alternate-base).
|
||||
self._boxes = []
|
||||
box_bg = theme.gray(theme.gray_value(prefs, "color_button"))
|
||||
|
||||
self._prev_btn = FlashButton("previous")
|
||||
self._play_btn = FlashButton("play")
|
||||
self._next_btn = FlashButton("next")
|
||||
layout.addWidget(_box(self._prev_btn, self._play_btn, self._next_btn,
|
||||
height=CONTROL_HEIGHT))
|
||||
transport_box = _box(self._prev_btn, self._play_btn, self._next_btn,
|
||||
bg=box_bg, height=CONTROL_HEIGHT)
|
||||
self._boxes.append(transport_box)
|
||||
layout.addWidget(transport_box)
|
||||
|
||||
self._shuffle_btn = FlashButton("shuffle")
|
||||
self._shuffle_btn.setCheckable(True)
|
||||
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
|
||||
layout.addWidget(_box(self._shuffle_btn, height=CONTROL_HEIGHT))
|
||||
shuffle_box = _box(self._shuffle_btn, bg=box_bg, height=CONTROL_HEIGHT)
|
||||
self._boxes.append(shuffle_box)
|
||||
layout.addWidget(shuffle_box)
|
||||
|
||||
layout.addSpacing(10)
|
||||
self._visualizer = VisualizerWidget(player)
|
||||
@@ -274,7 +288,7 @@ class TransportBar(QWidget):
|
||||
# artist/album line, centered, filling the zone above the seek row.
|
||||
self._now_playing = QFrame()
|
||||
self._now_playing.setObjectName("nowPlayingBox")
|
||||
self._now_playing.setStyleSheet(_NOWPLAYING_STYLE)
|
||||
self._apply_now_playing_bg()
|
||||
np = QVBoxLayout(self._now_playing)
|
||||
np.setContentsMargins(12, 6, 12, 6)
|
||||
np.setSpacing(2)
|
||||
@@ -315,8 +329,10 @@ class TransportBar(QWidget):
|
||||
# centered like the transport boxes — kept out of the seek row so it
|
||||
# doesn't drive the timeline row's height.
|
||||
self._bpm_button = BpmButton(player, manager)
|
||||
layout.addWidget(_box(self._bpm_button, margins=(8, 8, 8, 8), hug=True,
|
||||
height=CONTROL_HEIGHT))
|
||||
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(8, 8, 8, 8),
|
||||
hug=True, height=CONTROL_HEIGHT)
|
||||
self._boxes.append(bpm_box)
|
||||
layout.addWidget(bpm_box)
|
||||
|
||||
# Wiring
|
||||
self._prev_btn.clicked.connect(player.previous)
|
||||
@@ -332,12 +348,22 @@ class TransportBar(QWidget):
|
||||
player.duration_changed.connect(self._on_duration_changed)
|
||||
|
||||
def refresh_theme(self):
|
||||
"""Rebuild button glyphs after a highlight-color or font change."""
|
||||
"""Re-apply everything driven by Preferences: button glyphs (highlight
|
||||
color), the tunable grayscale button boxes, and the now-playing panel
|
||||
background + text color."""
|
||||
for button in (self._prev_btn, self._play_btn, self._next_btn,
|
||||
self._shuffle_btn):
|
||||
button.refresh_icons()
|
||||
box_bg = theme.gray(theme.gray_value(self._prefs, "color_button"))
|
||||
for box in self._boxes:
|
||||
box.setStyleSheet(_box_style(box_bg))
|
||||
self._apply_now_playing_bg()
|
||||
self._apply_now_playing_font()
|
||||
|
||||
def _apply_now_playing_bg(self):
|
||||
bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg"))
|
||||
self._now_playing.setStyleSheet(_nowplaying_style(bg))
|
||||
|
||||
def _on_volume_changed(self, value: int):
|
||||
"""Slider moved: update audio live, defer the (theme-reapplying) save."""
|
||||
self._player.set_volume(value / 100)
|
||||
@@ -348,16 +374,18 @@ class TransportBar(QWidget):
|
||||
|
||||
def _apply_now_playing_font(self):
|
||||
"""Set the now-playing labels to Century Gothic (or the user's chosen
|
||||
substitute), black text; title slightly larger than artist/album.
|
||||
Sizes track the app font so they follow the UI-scale preference."""
|
||||
substitute); title slightly larger than artist/album. Sizes track the
|
||||
app font so they follow the UI-scale preference; the text color is the
|
||||
user-tunable grayscale (black by default)."""
|
||||
family = theme.now_playing_font_family(self._prefs)
|
||||
base_pt = self.font().pointSize()
|
||||
text_color = theme.gray(theme.gray_value(self._prefs, "color_now_playing_text"))
|
||||
for label, delta in ((self._title_label, TITLE_PT_BUMP),
|
||||
(self._artist_label, 0)):
|
||||
font = QFont(family) if family else QFont(self.font())
|
||||
font.setPointSize(max(1, base_pt + delta))
|
||||
label.setFont(font)
|
||||
label.setStyleSheet("color: black;")
|
||||
label.setStyleSheet(f"color: {text_color.name()};")
|
||||
|
||||
# ---- player events ----
|
||||
|
||||
|
||||
+16
-1
@@ -9,8 +9,16 @@ DEFAULTS = {
|
||||
"ui_scale": "medium", # small | medium | large
|
||||
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
|
||||
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
|
||||
# default, "Family"=user-chosen substitute
|
||||
# default, "Family"=use app
|
||||
"volume": 0.8, # logical 0..1 master output volume
|
||||
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
|
||||
# current/default look for that surface, so nothing changes until the user
|
||||
# drags a slider in Preferences.
|
||||
"color_background": None, # outer chrome (QPalette.Window)
|
||||
"color_button": None, # rounded transport button boxes
|
||||
"color_now_playing_bg": None, # panel behind the now-playing text
|
||||
"color_now_playing_text": None, # the now-playing title/artist text
|
||||
"color_stripe": None, # alternating row stripes (QPalette.AlternateBase)
|
||||
"lastfm": {
|
||||
"api_key": "",
|
||||
"api_secret": "",
|
||||
@@ -49,6 +57,13 @@ class Preferences(QObject):
|
||||
self.save()
|
||||
self.changed.emit()
|
||||
|
||||
def set_live(self, key: str, value):
|
||||
"""Update a preference and notify listeners *without* writing to disk —
|
||||
for live drag-preview (e.g. the color sliders). Call save() to persist
|
||||
once the user stops dragging."""
|
||||
self._data[key] = value
|
||||
self.changed.emit()
|
||||
|
||||
def update_lastfm(self, **fields):
|
||||
self._data["lastfm"].update(fields)
|
||||
self.save()
|
||||
|
||||
+62
-5
@@ -34,6 +34,54 @@ UI_SCALES = {
|
||||
_base_palette = None
|
||||
|
||||
|
||||
# The five user-tunable grayscale surfaces. now-playing bg/text have fixed
|
||||
# defaults (white/black); the rest default to whatever the base palette uses,
|
||||
# so an untouched install looks exactly as it did before the feature existed.
|
||||
_PALETTE_GRAY_ROLE = {
|
||||
"color_background": QPalette.ColorRole.Window,
|
||||
"color_stripe": QPalette.ColorRole.AlternateBase,
|
||||
"color_button": QPalette.ColorRole.AlternateBase,
|
||||
}
|
||||
_FIXED_GRAY_DEFAULT = {
|
||||
"color_now_playing_bg": 255,
|
||||
"color_now_playing_text": 0,
|
||||
}
|
||||
|
||||
|
||||
def _base() -> QPalette:
|
||||
"""The app's original palette, captured once, used as the reference for
|
||||
highlight/grayscale overrides and for the sliders' 'current' defaults."""
|
||||
global _base_palette
|
||||
if _base_palette is None:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
_base_palette = QPalette(app.palette()) if app is not None else QPalette()
|
||||
return _base_palette
|
||||
|
||||
|
||||
def gray(value) -> QColor:
|
||||
"""A 0..255 lightness as an opaque gray QColor."""
|
||||
v = max(0, min(255, int(value)))
|
||||
return QColor(v, v, v)
|
||||
|
||||
|
||||
def default_gray(key: str) -> int:
|
||||
"""The slider's value when this surface hasn't been overridden — i.e. its
|
||||
current appearance, so showing the slider doesn't imply a change."""
|
||||
if key in _FIXED_GRAY_DEFAULT:
|
||||
return _FIXED_GRAY_DEFAULT[key]
|
||||
color = _base().color(QPalette.ColorGroup.Active, _PALETTE_GRAY_ROLE[key])
|
||||
return round(0.299 * color.red() + 0.587 * color.green()
|
||||
+ 0.114 * color.blue())
|
||||
|
||||
|
||||
def gray_value(prefs, key: str) -> int:
|
||||
"""The 0..255 value to show/apply for a surface: the saved override, or the
|
||||
current default when the user hasn't touched it."""
|
||||
saved = prefs.get(key)
|
||||
return default_gray(key) if saved is None else max(0, min(255, int(saved)))
|
||||
|
||||
|
||||
def highlight_color(prefs) -> QColor:
|
||||
return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"),
|
||||
HIGHLIGHT_COLORS["blue"]))
|
||||
@@ -44,9 +92,7 @@ def scale_metrics(prefs) -> dict:
|
||||
|
||||
|
||||
def apply_theme(app, prefs):
|
||||
global _base_palette
|
||||
if _base_palette is None:
|
||||
_base_palette = QPalette(app.palette())
|
||||
base = _base()
|
||||
|
||||
color = highlight_color(prefs)
|
||||
# Black text on light highlights, white on dark
|
||||
@@ -54,11 +100,22 @@ def apply_theme(app, prefs):
|
||||
+ 0.114 * color.blue()) / 255
|
||||
text = QColor("black") if luminance > 0.6 else QColor("white")
|
||||
|
||||
palette = QPalette(_base_palette)
|
||||
for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive):
|
||||
palette = QPalette(base)
|
||||
groups = (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive)
|
||||
for group in groups:
|
||||
palette.setColor(group, QPalette.ColorRole.Highlight, color)
|
||||
palette.setColor(group, QPalette.ColorRole.HighlightedText, text)
|
||||
palette.setColor(group, QPalette.ColorRole.Accent, color)
|
||||
# User grayscale overrides (None = leave the base color untouched). The
|
||||
# button-box color lives in transport.py, not the palette.
|
||||
background = prefs.get("color_background")
|
||||
if background is not None:
|
||||
for group in groups:
|
||||
palette.setColor(group, QPalette.ColorRole.Window, gray(background))
|
||||
stripe = prefs.get("color_stripe")
|
||||
if stripe is not None:
|
||||
for group in groups:
|
||||
palette.setColor(group, QPalette.ColorRole.AlternateBase, gray(stripe))
|
||||
app.setPalette(palette)
|
||||
|
||||
metrics = scale_metrics(prefs)
|
||||
|
||||
Reference in New Issue
Block a user