v0.11.0: web mixes pick their own color

The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.

The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.

The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.

Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.

Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
This commit is contained in:
2026-08-22 15:14:36 -04:00
parent dd823eae0d
commit ce7fe8abed
11 changed files with 351 additions and 33 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.10.1"
__version__ = "0.11.0"
+40
View File
@@ -17,6 +17,7 @@ Two invariants worth keeping:
import html
import os
import re
import shutil
import string
import threading
@@ -44,6 +45,41 @@ DEFAULT_DESCRIPTION = (
"and <br> work."
)
# The one color a web mix lets you choose: link/tracklist hover backgrounds and
# the progress fill. The default is the brown the hand-made mixes used, so an
# untouched export comes out looking exactly as it always has. Everything else
# in the player bar is deliberately fixed (light gray, black text) — see
# templates/player.css.
DEFAULT_ACCENT = "#8c764a"
_HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
def normalize_accent(value) -> str:
"""A `#rgb`/`#rrggbb` string, lowercased, or DEFAULT_ACCENT.
This is the injection gate, not a nicety: the result is substituted raw
into the page's <style> block, and string.Template escapes nothing.
"""
if isinstance(value, str) and _HEX_COLOR.match(value.strip()):
return value.strip().lower()
return DEFAULT_ACCENT
def contrast_text(accent: str) -> str:
"""Black or white, whichever reads on `accent`.
Same rule the app's own theme uses for highlighted text
(`theme.apply_theme`) — the old page hard-coded white, which disappears
the moment someone picks a pale accent.
"""
hex_digits = normalize_accent(accent).lstrip("#")
if len(hex_digits) == 3:
hex_digits = "".join(c * 2 for c in hex_digits)
r, g, b = (int(hex_digits[i:i + 2], 16) for i in (0, 2, 4))
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return "#000" if luminance > 0.6 else "#fff"
def templates():
"""The template directory, working from a checkout or an installed wheel."""
@@ -71,6 +107,7 @@ class ExportPlan:
title: str = ""
description: str = ""
image: Path | None = None # None = ship the gray placeholder
accent: str = DEFAULT_ACCENT # hover backgrounds + the progress fill
items: list = field(default_factory=list)
skipped: list = field(default_factory=list) # (display, reason)
bytes_to_copy: int = 0
@@ -181,12 +218,15 @@ def build_index_html(plan: ExportPlan, image_name: str | None) -> str:
f' width="400"><br>')
else:
image_tag = ""
accent = normalize_accent(plan.accent)
tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8"))
return tmpl.substitute(
title=html.escape(plan.title or plan.playlist_name),
description=plan.description,
tracklist=build_tracklist(plan.items),
image_tag=image_tag,
accent=accent,
accent_text=contrast_text(accent),
)
+8 -2
View File
@@ -5,11 +5,17 @@
<title>$title</title>
<meta content="width=device-width, initial-scale=0.6" name="viewport">
<style>
/* The mix's accent color, chosen in the export dialog. player.css reads
it too — that is how the progress fill gets colored without the
stylesheet having to be templated. --accent-text is black or white,
picked at export time for whichever reads on --accent. */
:root { --accent: $accent; --accent-text: $accent_text; }
body { color: #666; font-family: sans-serif; line-height: 1.4; font-size: .9em;}
h1 { color: #444; font-size: 1.2em; padding: 14px 2px 12px; margin: 0px; }
h1 em { font-style: normal; color: #999; }
a { color: #220d02; text-decoration: none; }
a:hover { color: white; background-color:#8c764a;}
a:hover { color: var(--accent-text); background-color: var(--accent); }
* {
box-sizing: border-box;
@@ -24,7 +30,7 @@
ol { padding: 0px; margin: 0px; list-style: decimal-leading-zero inside; color: #ccc; width: 400px; border-top: 1px solid #ccc; font-size: 0.9em; }
ol li { position: relative; margin: 0px; padding: 1px 2px 1px; border-bottom: 1px solid #ccc; cursor: pointer; }
ol li a { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; }
ol li a:hover { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; color: white; background-color:#8c764a;}
ol li a:hover { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; color: var(--accent-text); background-color: var(--accent); }
li.playing { color: #aaa; }
li.playing a { color: #000; }
li.playing:before { content: "\131b8"; width: 14px; height: 14px; padding: 3px; line-height: 14px; margin: 0px; position: absolute; left: -40px; top: 9px; color: #000; font-size: 2em; }
+33 -14
View File
@@ -12,10 +12,16 @@
* - `background-image: -moz-linear-gradient(…)` — Firefox removed the
* prefixed form years ago.
*
* What actually rendered was the flat #c7b563 bar underneath, which is what
* this file states plainly. The state classes are scoped to `.audiojs`
* (upstream wrote them bare) because the tracklist uses `li.playing` too, and
* a bare `.playing .pause` rule is a collision waiting to happen.
* What actually rendered was the flat #c7b563 bar underneath. Since Round 41
* that gold chrome is gone: the bar is a fixed very light gray with black text
* and black glyphs, and the *only* color in it is the progress fill, which
* reads `--accent` — the color chosen in the export dialog and declared on
* `:root` by index.html. Keeping the knob a custom property is what lets this
* file stay a verbatim copy rather than another rendered template.
*
* The state classes are scoped to `.audiojs` (upstream wrote them bare)
* because the tracklist uses `li.playing` too, and a bare `.playing .pause`
* rule is a collision waiting to happen.
*/
.audiojs audio { position: absolute; left: -1px; }
@@ -23,11 +29,14 @@
.audiojs {
width: 250px;
height: 36px;
background: #c7b563;
background: #eee;
color: #000;
overflow: hidden;
font-family: monospace;
font-size: 12px;
box-shadow: 1px 1px 8px rgba(240, 240, 240, 0.3);
/* Upstream's shadow was near-white — invisible against the white page it
always sat on. A faint dark one actually gives the bar an edge. */
box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.15);
}
.audiojs .play-pause {
@@ -45,10 +54,10 @@
position: relative;
float: left;
width: 75px;
background: #5a5a5a;
background: #d4d4d4;
height: 14px;
margin: 10px;
border-top: 1px solid #3f3f3f;
border-top: 1px solid #c2c2c2;
border-left: 0px;
border-bottom: 0px;
overflow: hidden;
@@ -58,14 +67,14 @@
.audiojs .progress {
position: absolute; top: 0px; left: 0px;
height: 14px; width: 0px;
background: #ccc;
background: var(--accent, #8c764a);
z-index: 1;
}
.audiojs .loaded {
position: absolute; top: 0px; left: 0px;
height: 14px; width: 0px;
background: #c7b563;
background: #c4c4c4;
}
.audiojs .time {
@@ -74,9 +83,9 @@
line-height: 36px;
margin: 0px 0px 0px 6px;
padding: 0px 6px 0px 12px;
color: #ddd;
color: #000;
}
.audiojs .time em { padding: 0px 2px 0px 0px; color: #666666; font-style: normal; }
.audiojs .time em { padding: 0px 2px 0px 0px; color: #000; font-style: normal; }
.audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; }
.audiojs .error-message {
@@ -88,11 +97,11 @@
overflow: hidden;
line-height: 36px;
white-space: nowrap;
color: #fff;
color: #000;
text-overflow: ellipsis;
}
.audiojs .error-message a {
color: #eee;
color: #000;
text-decoration: none;
padding-bottom: 1px;
border-bottom: 1px solid #999;
@@ -107,6 +116,16 @@
.audiojs .error { background: url("./player-graphics.gif") -2px -61px no-repeat; }
.audiojs .pause { background: url("./player-graphics.gif") -2px -91px no-repeat; }
/* The sprite's glyphs are pale lavender and yellow — drawn for the old dark
* gold bar, and all but invisible on a light gray one. brightness(0) drives
* every channel to zero while leaving alpha alone, so they paint solid black
* and the loading frame still animates. Recoloring here rather than in the
* file is what keeps the GIF byte-identical. */
.audiojs .play,
.audiojs .pause,
.audiojs .loading,
.audiojs .error { filter: brightness(0); }
.audiojs.playing .play,
.audiojs.playing .loading,
.audiojs.playing .error { display: none; }
+40 -5
View File
@@ -1,8 +1,8 @@
"""The two dialogs behind File → Export Playlist…
`ExportKindDialog` asks folder-or-website; `WebMixDialog` collects the bits of
a web mix that can't be inferred — title, blurb, hero image. Per-track liner
notes are deliberately absent: the hand-made mixes carry a numbered paragraph
a web mix that can't be inferred — title, blurb, hero image, accent color.
Per-track liner notes are deliberately absent: the hand-made mixes carry a numbered paragraph
per track, which is far too much to type into a form, so the generated
index.html leaves a commented-out block in the right place instead.
"""
@@ -10,10 +10,13 @@ index.html leaves a commented-out block in the right place instead.
from pathlib import Path
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QPlainTextEdit, QPushButton, QRadioButton, QVBoxLayout)
QColorDialog, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QRadioButton,
QVBoxLayout)
from lintunes import theme
from lintunes.export import exporter
_IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.gif *.webp)"
@@ -64,7 +67,12 @@ class ExportKindDialog(QDialog):
class WebMixDialog(QDialog):
"""Title, description and hero image for a web mix."""
"""Title, description, hero image and accent color for a web mix.
The accent isn't remembered between exports: it opens on
``exporter.DEFAULT_ACCENT`` every time, so an export nobody touched comes
out looking exactly like the mixes already online.
"""
def __init__(self, playlist_name: str, parent=None):
super().__init__(parent)
@@ -95,9 +103,24 @@ class WebMixDialog(QDialog):
clear.clicked.connect(lambda: self._image.clear())
image_row.addWidget(clear)
form.addRow("Image:", image_row)
color_row = QHBoxLayout()
self._accent = exporter.DEFAULT_ACCENT
self._accent_button = QPushButton()
self._accent_button.clicked.connect(self._choose_accent)
color_row.addWidget(self._accent_button)
reset = QPushButton("Reset")
reset.clicked.connect(
lambda: self._set_accent(exporter.DEFAULT_ACCENT))
color_row.addWidget(reset)
color_row.addStretch()
form.addRow("Accent:", color_row)
self._set_accent(self._accent)
layout.addLayout(form)
note = QLabel(
"The accent colors the hover background on links and tracklist "
"rows, and the player's progress bar. "
"The description is plain HTML — links and &lt;br&gt; work. "
"Per-track liner notes aren't asked for here; index.html has a "
"commented-out block in the right spot for them.")
@@ -120,10 +143,22 @@ class WebMixDialog(QDialog):
if name:
self._image.setText(name)
def _set_accent(self, color_hex: str):
self._accent = exporter.normalize_accent(color_hex)
self._accent_button.setIcon(theme.swatch_icon(self._accent))
self._accent_button.setText(self._accent)
def _choose_accent(self):
color = QColorDialog.getColor(
QColor(self._accent), self, "Accent Color")
if color.isValid():
self._set_accent(color.name())
def values(self) -> dict:
text = self._image.text().strip()
return {
"title": self._title.text().strip(),
"description": self._description.toPlainText().strip(),
"image": Path(text) if text else None,
"accent": self._accent,
}
+1
View File
@@ -579,6 +579,7 @@ class MainWindow(QMainWindow):
plan.title = details["title"] or playlist.name
plan.description = details["description"]
plan.image = details["image"]
plan.accent = details["accent"]
if not self._confirm_export(plan):
return
+2 -8
View File
@@ -4,7 +4,7 @@ from PyQt6.QtWidgets import (
QFormLayout, QFontComboBox,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPixmap, QColor, QIcon, QFont
from PyQt6.QtGui import QFont
from lintunes import theme
from lintunes.theme import HIGHLIGHT_COLORS
@@ -23,12 +23,6 @@ _COLOR_ROWS = [
]
def _swatch(color_hex: str) -> QIcon:
pixmap = QPixmap(18, 18)
pixmap.fill(QColor(color_hex))
return QIcon(pixmap)
class PreferencesDialog(QDialog):
"""App preferences. Applies immediately — there is no OK/Cancel.
@@ -98,7 +92,7 @@ class PreferencesDialog(QDialog):
self._color_group = QButtonGroup(self)
for name, hex_value in HIGHLIGHT_COLORS.items():
radio = QRadioButton(name)
radio.setIcon(_swatch(hex_value))
radio.setIcon(theme.swatch_icon(hex_value))
radio.setChecked(self._prefs.get("highlight") == name)
radio.toggled.connect(
lambda checked, n=name: checked and self._prefs.set("highlight", n))
+8 -1
View File
@@ -5,7 +5,7 @@ slider's filled groove, the visualizer bars) reads QPalette.Highlight, so
recoloring is a palette swap. UI scale adjusts the app font, scrollbar
dimensions, and (via MainWindow.apply_ui_metrics) table row heights.
"""
from PyQt6.QtGui import QPalette, QColor, QFontDatabase
from PyQt6.QtGui import QPalette, QColor, QFontDatabase, QIcon, QPixmap
# The font the now-playing panel wants by default. Not bundled (it's a
@@ -107,6 +107,13 @@ def gray_value(prefs, key: str) -> int:
return default_gray(key) if saved is None else max(0, min(255, int(saved)))
def swatch_icon(color_hex: str, size: int = 18) -> QIcon:
"""A flat square of `color_hex` — the icon on a color-choosing control."""
pixmap = QPixmap(size, size)
pixmap.fill(QColor(color_hex))
return QIcon(pixmap)
def highlight_color(prefs) -> QColor:
return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"),
HIGHLIGHT_COLORS["blue"]))