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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
This commit is contained in:
@@ -183,8 +183,17 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
replaced audio.js + jQuery (abandoned since 2012; jQuery was only ever glue —
|
||||
audio.js never used it). `player-graphics.gif` must ship **byte-identical**:
|
||||
it's an animated GIF whose loading frame is a spinner, not a flat sprite
|
||||
sheet. Registered in `setup.py` via `package_data`; loaded with
|
||||
`importlib.resources` so an installed copy finds it.
|
||||
sheet — which is why Round 41 recolors its pale glyphs to black with
|
||||
`filter: brightness(0)` in CSS rather than editing the file. The one
|
||||
user-chosen color (link/row hover backgrounds + the progress fill) travels as
|
||||
a `:root { --accent }` custom property declared in `index.html`, so
|
||||
`player.css` can read it while staying a verbatim `shutil.copyfile` — the page
|
||||
is still the only rendered template. `exporter.normalize_accent` is a hard
|
||||
gate, not politeness: the value lands raw inside a `<style>` block and
|
||||
`string.Template` escapes nothing. The accent is **not** persisted, so an
|
||||
untouched export still renders the original `#8c764a`. Templates are
|
||||
registered in `setup.py` via `package_data`; loaded with
|
||||
`importlib.resources` so an installed copy finds them.
|
||||
|
||||
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
|
||||
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.10.1"
|
||||
__version__ = "0.11.0"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 <br> 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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]))
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
## Done
|
||||
|
||||
### Round 41 (2026-08-22) — Web mixes pick their own color (v0.11.0)
|
||||
|
||||
Every exported mix came out in the same 2010 gold-and-brown skin: a `#c7b563`
|
||||
player bar, `#ccc` progress fill, and a hard-coded `#8c764a` behind every hover.
|
||||
Now the hover backgrounds and the progress fill are one color the user picks in
|
||||
the export dialog, and the rest of the bar is deliberately colorless so that
|
||||
choice is the only color in it.
|
||||
|
||||
- [x] **One accent, chosen per export.** A swatch button in `WebMixDialog`
|
||||
opening `QColorDialog`, plus Reset. Deliberately *not* persisted — it
|
||||
opens on `exporter.DEFAULT_ACCENT` (`#8c764a`) every time, so an export
|
||||
nobody touches still looks exactly like the mixes already online.
|
||||
`exporter.normalize_accent` is the injection gate, not a nicety: the value
|
||||
is substituted raw into the page's `<style>` block and `string.Template`
|
||||
escapes nothing, so anything that isn't `#rgb`/`#rrggbb` falls back to the
|
||||
default.
|
||||
- [x] **Delivered as a CSS custom property**, `:root { --accent }` declared in
|
||||
`index.html`, which `player.css` reads as `var(--accent, #8c764a)`. That is
|
||||
what lets the stylesheet stay in the verbatim `shutil.copyfile` loop —
|
||||
`index.html` is still the only thing rendered.
|
||||
- [x] **Hover text flips black or white** against the accent
|
||||
(`exporter.contrast_text`, the same luminance rule `theme.apply_theme`
|
||||
uses). The old page hard-coded white, which vanished the moment anyone
|
||||
picked a pale color.
|
||||
- [x] **The player bar is fixed light gray with black text**: `#eee` behind the
|
||||
transport, `#000` for both elapsed and duration, a gray buffered segment,
|
||||
and a dark box-shadow in place of the near-white one that was invisible on
|
||||
the white page it always sat on.
|
||||
- [x] **Black glyphs without touching the GIF.** The sprite's play/pause are
|
||||
pale lavender and the spinner is yellow — drawn for the dark gold bar and
|
||||
invisible on light gray. `filter: brightness(0)` zeroes every channel and
|
||||
leaves alpha alone, so they paint solid black and the loading frame still
|
||||
animates. `player-graphics.gif` still ships byte-identical.
|
||||
- [x] `theme.swatch_icon()` — `preferences_dialog`'s private `_swatch` promoted
|
||||
so the export dialog isn't a second copy of it.
|
||||
|
||||
Verified in Chrome against two generated mixes: a dark teal (`#0f8a7e`) accent
|
||||
gives white hover text, a pale yellow (`#f2e96b`) gives black, and both paint
|
||||
the progress fill while the bar around it stays gray with a visible black
|
||||
play/pause.
|
||||
|
||||
### Round 39 (2026-08-20) — Playlist merges stop losing your track order (v0.9.1)
|
||||
|
||||
The playlist half of the Round 36 rework, and what actually scrambled
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Round 41: an accent color for the web mix.
|
||||
|
||||
The export dialog gained one color, and the player bar lost its gold. What is
|
||||
load-bearing here:
|
||||
|
||||
* the accent reaches the page as a `:root` custom property, so `player.css`
|
||||
can read it while still being copied byte-for-byte rather than rendered;
|
||||
* a color the user never touched still renders `#8c764a` — the mixes already
|
||||
online must keep looking the way they do;
|
||||
* the value lands raw inside a `<style>` block, so anything that isn't a hex
|
||||
color is rejected outright rather than escaped;
|
||||
* hover text flips black/white against the accent, because the old page
|
||||
hard-coded white and a pale accent made it unreadable.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.export import exporter
|
||||
from lintunes.export.exporter import (DEFAULT_ACCENT, ExportWorker,
|
||||
contrast_text, normalize_accent,
|
||||
plan_export)
|
||||
from lintunes.models import Track
|
||||
|
||||
|
||||
def _track(tid, name, artist, path):
|
||||
return Track(track_id=tid, name=name, artist=artist, location=str(path),
|
||||
kind="MPEG audio file", total_time=180_000)
|
||||
|
||||
|
||||
def _audio(tmp_path, filename):
|
||||
path = tmp_path / "local" / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x" * 100)
|
||||
return path
|
||||
|
||||
|
||||
def _export(tmp_path, **details):
|
||||
"""A one-track web mix, with `details` applied to the plan."""
|
||||
tracks = [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]
|
||||
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
for key, value in details.items():
|
||||
setattr(plan, key, value)
|
||||
worker = ExportWorker(plan)
|
||||
failed = []
|
||||
worker.failed.connect(failed.append)
|
||||
worker._run()
|
||||
assert not failed, failed
|
||||
return tmp_path / "out" / "My Mix"
|
||||
|
||||
|
||||
class TestNormalizeAccent:
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("#8C764A", "#8c764a"),
|
||||
("#abc", "#abc"),
|
||||
(" #3584e4 ", "#3584e4"),
|
||||
])
|
||||
def test_hex_colors_are_accepted_and_lowercased(self, value, expected):
|
||||
assert normalize_accent(value) == expected
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"red", "#xyz", "#12345", "", None, 0x8c764a,
|
||||
"#fff; } body { display: none; ", # the injection attempt
|
||||
"url(https://example.com/beacon.png)",
|
||||
])
|
||||
def test_anything_else_falls_back_to_the_default(self, value):
|
||||
assert normalize_accent(value) == DEFAULT_ACCENT
|
||||
|
||||
def test_a_rejected_value_never_reaches_the_page(self, tmp_path):
|
||||
dest = _export(tmp_path, accent="#fff; } body { display: none; ")
|
||||
html = (dest / "index.html").read_text()
|
||||
assert "display: none" not in html
|
||||
assert "--accent: #8c764a" in html
|
||||
|
||||
|
||||
class TestContrastText:
|
||||
@pytest.mark.parametrize("accent", ["#ffffff", "#f5f5a0", "#7fd1ae"])
|
||||
def test_light_accents_get_black_text(self, accent):
|
||||
assert contrast_text(accent) == "#000"
|
||||
|
||||
@pytest.mark.parametrize("accent", ["#000000", "#8c764a", "#3584e4"])
|
||||
def test_dark_accents_get_white_text(self, accent):
|
||||
assert contrast_text(accent) == "#fff"
|
||||
|
||||
def test_shorthand_hex_is_expanded_not_misread(self):
|
||||
assert contrast_text("#fff") == contrast_text("#ffffff")
|
||||
assert contrast_text("#000") == contrast_text("#000000")
|
||||
|
||||
|
||||
class TestAccentInThePage:
|
||||
def test_default_is_the_colour_the_old_mixes_used(self, tmp_path):
|
||||
html = (_export(tmp_path) / "index.html").read_text()
|
||||
assert "--accent: #8c764a" in html
|
||||
assert "--accent-text: #fff" in html
|
||||
|
||||
def test_chosen_accent_is_substituted(self, tmp_path):
|
||||
html = (_export(tmp_path, accent="#3584E4") / "index.html").read_text()
|
||||
assert "--accent: #3584e4" in html
|
||||
|
||||
def test_no_placeholder_survives(self, tmp_path):
|
||||
html = (_export(tmp_path, accent="#3584e4") / "index.html").read_text()
|
||||
for placeholder in ("$accent", "$accent_text", "$title", "$tracklist"):
|
||||
assert placeholder not in html
|
||||
|
||||
def test_hover_rules_read_the_variable_not_a_hard_coded_brown(self, tmp_path):
|
||||
html = (_export(tmp_path, accent="#3584e4") / "index.html").read_text()
|
||||
assert html.count("background-color: var(--accent)") == 2 # links + rows
|
||||
assert html.count("color: var(--accent-text)") == 2
|
||||
# The old brown only survives as the variable's value.
|
||||
assert html.count("#8c764a") == 0
|
||||
|
||||
def test_hover_text_flips_with_the_accent(self, tmp_path):
|
||||
pale = (_export(tmp_path / "pale", accent="#f5f5a0")
|
||||
/ "index.html").read_text()
|
||||
assert "--accent-text: #000" in pale
|
||||
dark = (_export(tmp_path / "dark", accent="#220d02")
|
||||
/ "index.html").read_text()
|
||||
assert "--accent-text: #fff" in dark
|
||||
|
||||
|
||||
class TestPlayerBarChrome:
|
||||
def test_css_reads_the_variable_so_it_can_ship_verbatim(self, tmp_path):
|
||||
dest = _export(tmp_path, accent="#3584e4")
|
||||
css = (dest / "player.css").read_text()
|
||||
assert "background: var(--accent, #8c764a);" in css
|
||||
# Copied, not rendered: identical to the template on disk.
|
||||
assert css == (exporter.templates() / "player.css").read_text()
|
||||
|
||||
def test_bar_is_light_gray_with_black_text(self, tmp_path):
|
||||
css = (_export(tmp_path) / "player.css").read_text()
|
||||
assert "background: #eee;" in css
|
||||
# The old gold survives only in the header comment explaining it.
|
||||
assert "background: #c7b563;" not in css
|
||||
assert "color: #ddd;" not in css # the old time text
|
||||
assert "color: #666666;" not in css # the old elapsed text
|
||||
|
||||
def test_sprite_glyphs_are_forced_black(self, tmp_path):
|
||||
css = (_export(tmp_path) / "player.css").read_text()
|
||||
assert "filter: brightness(0);" in css
|
||||
|
||||
def test_graphics_gif_still_ships_byte_identical(self, tmp_path):
|
||||
dest = _export(tmp_path)
|
||||
shipped = (dest / "player-graphics.gif").read_bytes()
|
||||
assert shipped == (exporter.templates() / "player-graphics.gif").read_bytes()
|
||||
assert b"NETSCAPE" in shipped # still the animated spinner
|
||||
|
||||
|
||||
class TestDialog:
|
||||
def test_values_carry_the_accent(self, qapp):
|
||||
from lintunes.gui.export_dialog import WebMixDialog
|
||||
dialog = WebMixDialog("My Mix")
|
||||
assert dialog.values()["accent"] == DEFAULT_ACCENT
|
||||
dialog._set_accent("#3584E4")
|
||||
assert dialog.values()["accent"] == "#3584e4"
|
||||
|
||||
def test_the_button_shows_the_colour_it_holds(self, qapp):
|
||||
from lintunes.gui.export_dialog import WebMixDialog
|
||||
dialog = WebMixDialog("My Mix")
|
||||
dialog._set_accent("#3584e4")
|
||||
assert dialog._accent_button.text() == "#3584e4"
|
||||
assert not dialog._accent_button.icon().isNull()
|
||||
|
||||
def test_a_junk_colour_cannot_be_held(self, qapp):
|
||||
from lintunes.gui.export_dialog import WebMixDialog
|
||||
dialog = WebMixDialog("My Mix")
|
||||
dialog._set_accent("nonsense")
|
||||
assert dialog.values()["accent"] == DEFAULT_ACCENT
|
||||
Reference in New Issue
Block a user