v0.44.0: per-track volume, laser rig diagram, bolder Preferences headings

Get Info has a Volume slider (−100%…+100%): iTunes' own per-song volume
adjustment, which the library already imported but never used. It scales
the volume slider while that song plays; a boost can't pass full volume.
Works on one song or several (Mixed when they differ), and undoes.

Get Info's Previous/Next buttons are gone: since the combined multi-song
editor they were always disabled (and blank in some themes).

The laser settings draw the rig — speaker and mirror, laser, beam, and the
screen at its distance and angles — as a small textbook line drawing that
labels distance, angles and dot size in place of the sliders' numbers.

Preferences section headings are bold and 2 px larger than the text.
This commit is contained in:
2026-10-10 01:53:07 -07:00
parent 599972fa1a
commit 8339e0404f
8 changed files with 600 additions and 39 deletions
+11
View File
@@ -139,6 +139,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
without recursing. Widgets react to its signals (`playlists_changed`, without recursing. Widgets react to its signals (`playlists_changed`,
`playlist_content_changed(pid)`, `track_updated(id)`, `track_fields_edited`). `playlist_content_changed(pid)`, `track_updated(id)`, `track_fields_edited`).
- **Per-track volume** (Round 79): `Track.volume_adjustment` is iTunes'
"Volume Adjustment", imported as-is (±255 = ±100%) and edited in Get Info.
`player.volume_with_adjustment` scales the slider's *perceptual* level by
it: −100% is silence, and a boost can't pass full. `Player` applies it
through `_output_volume()` everywhere it pushes volume to a sink, before
each load, and on `refresh_volume()` (MainWindow calls that when the
playing track is edited). `Player.volume()` stays the bare slider value.
- **`lintunes/player.py`** — `Player(QObject)` walks a queue through a swappable - **`lintunes/player.py`** — `Player(QObject)` walks a queue through a swappable
**`PlaybackSink`**. Player owns the queue, shuffle walk, per-track start/stop **`PlaybackSink`**. Player owns the queue, shuffle walk, per-track start/stop
times and the play-count/scrobble bookkeeping; a sink owns only "make this file times and the play-count/scrobble bookkeeping; a sink owns only "make this file
@@ -232,6 +240,9 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
"Custom" whenever the settings match no preset. A setting added later "Custom" whenever the settings match no preset. A setting added later
must join `STYLE_KEYS` and register a setter, or presets silently skip it must join `STYLE_KEYS` and register a setter, or presets silently skip it
(`test_every_style_key_has_a_control` pins that). (`test_every_style_key_has_a_control` pins that).
The laser page draws the rig (`gui/laser_diagram.py`, textbook line art
through the renderer's own `_rotation`); it labels distance, angles and
dot size, so those sliders show no number.
The highlight swatches can be repainted with a color picker: The highlight swatches can be repainted with a color picker:
`highlight_palette` overrides a slot, and `theme.highlight_color` reads `highlight_palette` overrides a slot, and `theme.highlight_color` reads
through `theme.highlight_palette`, so nothing else needs to know. through `theme.highlight_palette`, so nothing else needs to know.
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.43.0" __version__ = "0.44.0"
+60 -23
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
QSlider,
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox, QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
QApplication, QMenu, QFileDialog, QWidget, QApplication, QMenu, QFileDialog, QWidget,
) )
@@ -11,6 +12,7 @@ from PyQt6.QtGui import QPixmap, QImage, QKeySequence, QShortcut
from lintunes import tagging from lintunes import tagging
from lintunes.gui.art_ops import embed_artwork from lintunes.gui.art_ops import embed_artwork
from lintunes.player import VOLUME_ADJUSTMENT_MAX
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -288,8 +290,9 @@ class _TimeField(QLineEdit):
class InfoDialog(QDialog): class InfoDialog(QDialog):
"""iTunes-style Get Info: edits library fields and the file's real tags. """iTunes-style Get Info: edits library fields and the file's real tags.
Single track: Previous/Next walk a multi-selection one at a time, and the Single track: custom start/stop times and the file's path, besides the
Options block exposes custom start/stop times. Multiple tracks: a combined fields every mode has (incl. Volume, iTunes' per-song volume adjustment).
Multiple tracks: a combined
editor where fields shared by every selection are editable and applied to editor where fields shared by every selection are editable and applied to
all, while fields that differ show "Mixed" and are only written if touched. all, while fields that differ show "Mixed" and are only written if touched.
""" """
@@ -372,35 +375,44 @@ class InfoDialog(QDialog):
form.addRow("", self._start_row) form.addRow("", self._start_row)
form.addRow("", self._stop_row) form.addRow("", self._stop_row)
# Volume adjustment: iTunes' own (imported as-is), ±255 = ±100%.
volume_row = QHBoxLayout()
self._volume = QSlider(Qt.Orientation.Horizontal)
self._volume.setRange(-100, 100)
self._volume.setPageStep(10)
self._volume.setTickPosition(QSlider.TickPosition.TicksBelow)
self._volume.setTickInterval(50)
self._volume.setToolTip(
"Make this song quieter or louder than the rest. A boost can't "
"go past full volume, so it only helps while the main volume "
"isn't all the way up.")
self._volume_label = QLabel()
self._volume_label.setMinimumWidth(QLabel("Mixed").sizeHint().width() + 8)
self._volume.valueChanged.connect(self._on_volume_moved)
volume_row.addWidget(QLabel("−100%"))
volume_row.addWidget(self._volume, stretch=1)
volume_row.addWidget(QLabel("+100%"))
volume_row.addSpacing(6)
volume_row.addWidget(self._volume_label)
form.addRow("Volume:", volume_row)
self._file_label = QLabel() self._file_label = QLabel()
self._file_label.setStyleSheet("color: gray; font-size: 10px;") self._file_label.setStyleSheet("color: gray; font-size: 10px;")
self._file_label.setWordWrap(True) self._file_label.setWordWrap(True)
layout.addWidget(self._file_label) layout.addWidget(self._file_label)
nav_row = QHBoxLayout()
self._prev_btn = QPushButton("Previous")
self._next_btn = QPushButton("Next")
nav_row.addWidget(self._prev_btn)
nav_row.addWidget(self._next_btn)
nav_row.addStretch()
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel) | QDialogButtonBox.StandardButton.Cancel)
nav_row.addWidget(buttons) layout.addWidget(buttons)
layout.addLayout(nav_row)
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
self._next_btn.clicked.connect(lambda: self._navigate(1))
buttons.accepted.connect(self._on_ok) buttons.accepted.connect(self._on_ok)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
if self._multi: if self._multi:
# Batch editing: no single file, no per-track times navigation. # Batch editing: no single file, no per-track times.
# Artwork stays: a pasted cover goes into every selected track. # Artwork stays: a pasted cover goes into every selected track.
self._start_row.setVisible(False) self._start_row.setVisible(False)
self._stop_row.setVisible(False) self._stop_row.setVisible(False)
self._file_label.setVisible(False) self._file_label.setVisible(False)
self._prev_btn.setVisible(False)
self._next_btn.setVisible(False)
self._load_multi() self._load_multi()
else: else:
self._load_current() self._load_current()
@@ -475,8 +487,7 @@ class InfoDialog(QDialog):
self._stop_time.setEnabled(track.stop_time > 0) self._stop_time.setEnabled(track.stop_time > 0)
self._stop_time.set_ms(track.stop_time) self._stop_time.set_ms(track.stop_time)
self._file_label.setText(track.location or "") self._file_label.setText(track.location or "")
self._prev_btn.setEnabled(self._index > 0) self._set_volume_slider(track.volume_adjustment or 0)
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
self._loading = False self._loading = False
def _load_multi(self): def _load_multi(self):
@@ -509,6 +520,9 @@ class InfoDialog(QDialog):
self._comments.setPlainText("") self._comments.setPlainText("")
else: else:
self._comments.setPlainText(comments or "") self._comments.setPlainText(comments or "")
volume = self._common_value("volume_adjustment")
self._set_volume_slider(0 if volume is _MIXED else (volume or 0),
mixed=volume is _MIXED)
self._loading = False self._loading = False
def _collect_fields(self) -> dict: def _collect_fields(self) -> dict:
@@ -523,6 +537,7 @@ class InfoDialog(QDialog):
if self._start_check.isChecked() else 0) if self._start_check.isChecked() else 0)
fields["stop_time"] = (self._stop_time.ms() fields["stop_time"] = (self._stop_time.ms()
if self._stop_check.isChecked() else 0) if self._stop_check.isChecked() else 0)
fields["volume_adjustment"] = self._volume_adjustment()
return fields return fields
def _validate_times(self, track) -> str | None: def _validate_times(self, track) -> str | None:
@@ -574,6 +589,8 @@ class InfoDialog(QDialog):
fields["compilation"] = self._compilation.isChecked() fields["compilation"] = self._compilation.isChecked()
if "comments" in self._touched: if "comments" in self._touched:
fields["comments"] = self._comments.toPlainText().strip() fields["comments"] = self._comments.toPlainText().strip()
if "volume_adjustment" in self._touched:
fields["volume_adjustment"] = self._volume_adjustment()
if self._staged_art is not None: if self._staged_art is not None:
tracks = [self._manager.library.tracks[tid] tracks = [self._manager.library.tracks[tid]
for tid in self._track_ids] for tid in self._track_ids]
@@ -595,11 +612,31 @@ class InfoDialog(QDialog):
self._manager.edit_tracks_fields(self._track_ids, fields) self._manager.edit_tracks_fields(self._track_ids, fields)
return True return True
def _navigate(self, delta: int): # ---- volume ----
if not self._save_current():
return def _set_volume_slider(self, adjustment: int, mixed: bool = False):
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1)) self._volume.setValue(round(100 * adjustment / VOLUME_ADJUSTMENT_MAX))
self._load_current() self._show_volume(mixed)
def _volume_adjustment(self) -> int:
"""The slider in iTunes' unit. A value the slider shows unchanged
keeps its exact stored number (±1 % is 2–3 units)."""
position = self._volume.value()
if not self._multi:
stored = self._current_track().volume_adjustment or 0
if round(100 * stored / VOLUME_ADJUSTMENT_MAX) == position:
return stored
return round(position * VOLUME_ADJUSTMENT_MAX / 100)
def _on_volume_moved(self, _value):
if not self._loading:
self._mark_touched("volume_adjustment")
self._show_volume(mixed=False)
def _show_volume(self, mixed: bool):
value = self._volume.value()
self._volume_label.setText(
"Mixed" if mixed else "None" if value == 0 else f"{value:+d}%")
def _on_ok(self): def _on_ok(self):
saved = self._save_multi() if self._multi else self._save_current() saved = self._save_multi() if self._multi else self._save_current()
+222
View File
@@ -0,0 +1,222 @@
"""A small line drawing of the laser rig as the settings describe it — the
speaker with its mirror, the laser, the beam, and the screen at its distance
and angles — in the manner of an old electronics textbook: thin dark-gray
lines, a dimension line with arrowheads, small italic serif labels.
Schematic, not to scale: the distance moves the screen along the beam and
the angles turn it (through the same rotation the visualizer projects
with), but sizes are chosen to read, not measured.
"""
import math
import numpy as np
from PyQt6.QtWidgets import QWidget, QSizePolicy
from PyQt6.QtCore import Qt, QPointF, QRectF, QSize
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QPolygonF, QFontMetricsF
from lintunes.gui import visualizer_styles
# Oblique projection: the screen's width (across the beam) recedes up and to
# the right at this share of its true length — a cabinet drawing.
DEPTH = 0.5
DEPTH_ANGLE = math.radians(35)
INK = 0.72 # how far the line color is from the background to the text
class LaserDiagram(QWidget):
def __init__(self, config=None, parent=None):
super().__init__(parent)
self._config = config or visualizer_styles.settings(None)
self.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding)
self.setMinimumSize(300, 150)
self.setToolTip("The rig your settings describe (not to scale)")
def sizeHint(self):
return QSize(420, 160)
def set_config(self, config: dict):
self._config = config
self.update()
# ---- drawing helpers ----
def _ink(self) -> QColor:
text = self.palette().windowText().color()
back = self.palette().window().color()
return QColor(*(round(b + (t - b) * INK) for t, b in (
(text.red(), back.red()), (text.green(), back.green()),
(text.blue(), back.blue()))))
def _label_font(self, caps=False) -> QFont:
font = QFont(self.font())
font.setFamilies(["Times New Roman", "Liberation Serif", "DejaVu Serif"])
font.setStyleHint(QFont.StyleHint.Serif)
font.setItalic(not caps)
font.setPointSizeF(max(7.0, font.pointSizeF() - (1.5 if caps else 0.5)))
if caps:
font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 112)
return font
@staticmethod
def _arrowhead(painter, tip: QPointF, toward: QPointF, size=6.0):
angle = math.atan2(tip.y() - toward.y(), tip.x() - toward.x())
wing = math.radians(18)
painter.drawPolygon(QPolygonF([
tip,
QPointF(tip.x() - size * math.cos(angle - wing),
tip.y() - size * math.sin(angle - wing)),
QPointF(tip.x() - size * math.cos(angle + wing),
tip.y() - size * math.sin(angle + wing))]))
def screen_corners(self, center: QPointF, half_height: float,
pitch=None, yaw=None, roll=None) -> list:
"""The screen's outline, projected: its own right/up axes turned by
the angles, then the across-the-beam axis drawn receding."""
c = self._config
rot = visualizer_styles._rotation(
c["visualizer_laser_pitch"] if pitch is None else pitch,
c["visualizer_laser_yaw"] if yaw is None else yaw,
c["visualizer_laser_roll"] if roll is None else roll)
half_width = half_height * 1.6
corners = []
for u, v in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
x, y, z = rot @ np.array([u * half_width, v * half_height, 0.0])
corners.append(QPointF(
center.x() + z + x * DEPTH * math.cos(DEPTH_ANGLE),
center.y() - y - x * DEPTH * math.sin(DEPTH_ANGLE)))
return corners
# ---- the drawing ----
def paintEvent(self, event):
c = self._config
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
ink = self._ink()
thin = QPen(ink, 1.0)
thin.setCapStyle(Qt.PenCapStyle.RoundCap)
thin.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
dashed = QPen(ink, 0.8, Qt.PenStyle.DashLine)
italic, caps = self._label_font(), self._label_font(caps=True)
w, h = self.width(), self.height()
axis_y = h * 0.42
mirror = QPointF(62, axis_y)
half = min(h * 0.24, 34) # the screen's half-height
angle_labels_w = QFontMetricsF(italic).horizontalAdvance("pitch −60°") + 14
near_x = mirror.x() + 105
far_x = max(near_x + 40, w - angle_labels_w - half * 1.0)
lo, hi = visualizer_styles.LIMITS["visualizer_laser_distance"]
distance = c["visualizer_laser_distance"]
screen = QPointF(near_x + (distance - lo) / (hi - lo) * (far_x - near_x),
axis_y)
# The laser, below and ahead of the speaker, aimed up at the mirror.
laser_at = QPointF(mirror.x() + 62, axis_y + 30)
incoming = math.atan2(laser_at.y() - mirror.y(), laser_at.x() - mirror.x())
painter.save()
painter.translate(laser_at)
painter.rotate(math.degrees(incoming))
painter.setPen(thin)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(QRectF(0, -5, 34, 10))
painter.drawLine(QPointF(34, -3), QPointF(38, -3)) # a cable stub
painter.restore()
painter.setFont(caps)
painter.drawText(QPointF(laser_at.x() + 2, laser_at.y() + 26), "LASER")
# The speaker: its axis halfway between the laser and the screen, so
# the mirror on its membrane sends the beam straight along the axis.
normal = incoming / 2
painter.save()
painter.translate(mirror)
painter.rotate(math.degrees(normal))
painter.setPen(thin)
cone = QPolygonF([QPointF(0, -26), QPointF(-22, -9), QPointF(-22, 9),
QPointF(0, 26)])
painter.drawPolyline(cone)
painter.drawLine(QPointF(0, -26), QPointF(0, 26)) # the membrane
painter.drawRect(QRectF(-34, -10, 12, 20)) # the magnet
painter.drawLine(QPointF(-3, -27), QPointF(-3, 27)) # the frame lip
mirror_pen = QPen(ink, 3.0)
painter.setPen(mirror_pen)
painter.drawLine(QPointF(1.5, -6), QPointF(1.5, 6)) # the mirror
painter.restore()
painter.setPen(thin)
painter.setFont(caps)
painter.drawText(QPointF(mirror.x() - 52, axis_y + 46), "SPEAKER")
# The beam: in from the laser, out to the screen.
painter.drawLine(laser_at, mirror)
painter.setBrush(ink)
self._arrowhead(painter, QPointF((laser_at.x() + mirror.x()) / 2,
(laser_at.y() + mirror.y()) / 2), laser_at)
corners = self.screen_corners(screen, half)
painter.drawLine(mirror, screen)
mid = QPointF((mirror.x() + screen.x()) / 2, axis_y)
self._arrowhead(painter, mid, mirror)
painter.setBrush(Qt.BrushStyle.NoBrush)
# Where a screen facing the beam squarely would be, if this one isn't.
turned = any(c[k] for k in ("visualizer_laser_pitch",
"visualizer_laser_yaw", "visualizer_laser_roll"))
if turned:
painter.setPen(dashed)
painter.drawPolygon(QPolygonF(self.screen_corners(screen, half, 0, 0, 0)))
painter.setPen(thin)
painter.drawPolygon(QPolygonF(corners))
painter.setFont(caps)
top = min(p.y() for p in corners)
painter.drawText(QPointF(screen.x() - 22, max(10.0, top - 6)), "SCREEN")
# The dot, where the beam lands, at its size.
radius = max(1.0, min(9.0, c["visualizer_laser_dot"] / 2))
painter.setBrush(ink)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(screen, radius, radius)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(thin)
right = max(p.x() for p in corners)
lead_from = QPointF(screen.x() + radius + 1, screen.y() + radius + 1)
lead_to = QPointF(right + 10, axis_y + half * 0.9)
painter.drawLine(lead_from, lead_to)
painter.setFont(italic)
dot = c["visualizer_laser_dot"]
dot_text = f"dot {dot:g} px" + ("" if c["visualizer_laser_head"] else
" (at rest)")
painter.drawText(QPointF(lead_to.x() + 3, lead_to.y() + 4), dot_text)
# The angles, beside the screen.
lines = [f"pitch {_deg(c['visualizer_laser_pitch'])}",
f"yaw {_deg(c['visualizer_laser_yaw'])}",
f"roll {_deg(c['visualizer_laser_roll'])}"]
metrics = QFontMetricsF(italic)
y = axis_y - half - 2
for line in lines:
painter.drawText(QPointF(right + 10, y), line)
y += metrics.height()
# The distance: a dimension line under it all, mirror to screen.
dim_y = h - 12
painter.setPen(thin)
for x in (mirror.x(), screen.x()):
painter.drawLine(QPointF(x, dim_y - 6), QPointF(x, dim_y + 4))
left, right_end = QPointF(mirror.x(), dim_y), QPointF(screen.x(), dim_y)
text = f"D = {distance:.1f} m"
text_w = metrics.horizontalAdvance(text) + 10
centre = (left.x() + right_end.x()) / 2
painter.drawLine(left, QPointF(centre - text_w / 2, dim_y))
painter.drawLine(QPointF(centre + text_w / 2, dim_y), right_end)
painter.setBrush(ink)
self._arrowhead(painter, left, right_end, 5)
self._arrowhead(painter, right_end, left, 5)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawText(QPointF(centre - text_w / 2 + 5, dim_y + 4), text)
painter.end()
def _deg(value) -> str:
value = round(value)
return f"{value:+d}°".replace("-", "−") if value else "0°"
+1
View File
@@ -2030,6 +2030,7 @@ class MainWindow(QMainWindow):
current = self.player.current_track current = self.player.current_track
if current is not None and current.track_id == track_id: if current is not None and current.track_id == track_id:
self._sidebar.art.refresh() self._sidebar.art.refresh()
self.player.refresh_volume() # its volume adjustment may have moved
def _update_now_playing_icon(self, _playing=None): def _update_now_playing_icon(self, _playing=None):
track = self.player.current_track track = self.player.current_track
+49 -11
View File
@@ -6,15 +6,16 @@ from PyQt6.QtWidgets import (
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter, QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton, QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
QColorDialog, QMenu, QDoubleSpinBox, QComboBox, QInputDialog, QColorDialog, QMenu, QDoubleSpinBox, QComboBox, QInputDialog,
QMessageBox, QMessageBox, QApplication,
) )
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor, QFontInfo
from lintunes import theme from lintunes import theme
from lintunes.config import load_config, save_config from lintunes.config import load_config, save_config
from lintunes.gui import visualizer_styles from lintunes.gui import visualizer_styles
from lintunes.gui.range_slider import RangeSlider from lintunes.gui.range_slider import RangeSlider
from lintunes.gui.laser_diagram import LaserDiagram
from lintunes.gui import visualizer_presets as presets from lintunes.gui import visualizer_presets as presets
from lintunes.gui import sounds from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS from lintunes.theme import HIGHLIGHT_COLORS
@@ -37,6 +38,7 @@ _COLOR_ROWS = [
_VALUE_WIDTH = 56 _VALUE_WIDTH = 56
HEADING_EXTRA_PX = 2 # section headings: this much over the text
_EDIT_PRESETS = "\0edit" # the preset drop-down's last item _EDIT_PRESETS = "\0edit" # the preset drop-down's last item
_SPEAKER_KEYS = ("visualizer_laser_low_hz", "visualizer_laser_high_hz") _SPEAKER_KEYS = ("visualizer_laser_low_hz", "visualizer_laser_high_hz")
@@ -108,6 +110,7 @@ class PreferencesDialog(QDialog):
self._color_save_timer.setInterval(400) self._color_save_timer.setInterval(400)
self._color_save_timer.timeout.connect(self._prefs.save) self._color_save_timer.timeout.connect(self._prefs.save)
self._headings = []
general = [self._build_sound_group(), self._build_wrapped_group()] general = [self._build_sound_group(), self._build_wrapped_group()]
if self._music_folder is not None: if self._music_folder is not None:
general.insert(0, self._build_music_group()) general.insert(0, self._build_music_group())
@@ -122,6 +125,8 @@ class PreferencesDialog(QDialog):
self._build_acoustid_group()])): self._build_acoustid_group()])):
self._add_page(title, groups) self._add_page(title, groups)
self.pages_list.currentRowChanged.connect(self._show_page) self.pages_list.currentRowChanged.connect(self._show_page)
self._style_headings()
self._prefs.changed.connect(self._style_headings)
splitter = QSplitter(Qt.Orientation.Horizontal) splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self.pages_list) splitter.addWidget(self.pages_list)
@@ -160,6 +165,13 @@ class PreferencesDialog(QDialog):
body = QWidget() body = QWidget()
column = QVBoxLayout(body) column = QVBoxLayout(body)
for group in groups: for group in groups:
# The heading is a label of its own above the box, not the box's
# title: a font set on a QGroupBox spreads to everything in it.
header = QLabel(group.title())
header.setObjectName("prefs-heading")
group.setTitle("")
self._headings.append(header)
column.addWidget(header)
column.addWidget(group) column.addWidget(group)
column.addStretch() column.addStretch()
scroll = QScrollArea() scroll = QScrollArea()
@@ -174,6 +186,16 @@ class PreferencesDialog(QDialog):
self.pages.addWidget(scroll) self.pages.addWidget(scroll)
self.pages_list.addItem(title) self.pages_list.addItem(title)
def _style_headings(self):
"""Section headings: bold, 2 px larger than the text. Re-run when
the UI size changes, since the app font moves with it."""
size = QFontInfo(QApplication.font()).pixelSize() + HEADING_EXTRA_PX
for header in self._headings:
font = QFont(QApplication.font())
font.setPixelSize(size)
font.setBold(True)
header.setFont(font)
def _show_page(self, row: int): def _show_page(self, row: int):
self.pages.setCurrentIndex(row) self.pages.setCurrentIndex(row)
PreferencesDialog._last_page = row PreferencesDialog._last_page = row
@@ -491,23 +513,35 @@ class PreferencesDialog(QDialog):
scale=20, fmt=_fade_text, scale=20, fmt=_fade_text,
tip="How long past sweeps of the beam take to fade — 0 is none") tip="How long past sweeps of the beam take to fade — 0 is none")
self._speaker = self._speaker_range(form, config) self._speaker = self._speaker_range(form, config)
self._width_box = self._width_spinbox(form, config) # The look controls on the left, and beside them a drawing of the
# rig the settings describe — it labels the distance, the angles
# and the dot, so those sliders carry no number of their own.
looks = self._viz_form(None)
self._width_box = self._width_spinbox(looks, config)
self._glow = self._viz_check( self._glow = self._viz_check(
form, "Glow", "visualizer_laser_glow", config, looks, "Glow", "visualizer_laser_glow", config,
tip="The haze around the beam") tip="The haze around the beam")
self._grid = self._viz_check( self._grid = self._viz_check(
form, "Oscilloscope grid", "visualizer_laser_grid", config, looks, "Oscilloscope grid", "visualizer_laser_grid", config,
tip="A graticule of gray dots painted under the beam") tip="A graticule of gray dots painted under the beam")
self._head = self._viz_check( self._head = self._viz_check(
form, "Dot at the tip of the beam", "visualizer_laser_head", looks, "Dot at the tip of the beam", "visualizer_laser_head",
config, tip="When the music is silent the beam rests as a dot " config, tip="When the music is silent the beam rests as a dot "
"in the middle either way") "in the middle either way")
self._diagram = LaserDiagram(config)
self._prefs.changed.connect(lambda: self._diagram.set_config(
visualizer_styles.settings(self._prefs)))
beside = QHBoxLayout()
beside.addLayout(looks)
beside.addSpacing(12)
beside.addWidget(self._diagram, stretch=1)
form.addRow(beside)
self._dot_slider, _ = self._viz_slider( self._dot_slider, _ = self._viz_slider(
form, "Dot size:", "visualizer_laser_dot", config, scale=2, form, "Dot size:", "visualizer_laser_dot", config, scale=2,
fmt=lambda v: f"{v:g} px") show_value=False)
self._distance_slider, _ = self._viz_slider( self._distance_slider, _ = self._viz_slider(
form, "Screen distance:", "visualizer_laser_distance", config, form, "Screen distance:", "visualizer_laser_distance", config,
scale=10, fmt=lambda v: f"{v:.1f} m", scale=10, show_value=False,
tip="From the mirror to the middle of the screen") tip="From the mirror to the middle of the screen")
degrees = lambda v: f"{v:+.0f}°" if v else "0°" degrees = lambda v: f"{v:+.0f}°" if v else "0°"
self._angle_sliders = {} self._angle_sliders = {}
@@ -521,7 +555,8 @@ class PreferencesDialog(QDialog):
("visualizer_laser_roll", "Screen roll:", ("visualizer_laser_roll", "Screen roll:",
"Spin the screen in its own plane — turns the figure")): "Spin the screen in its own plane — turns the figure")):
self._angle_sliders[key], _ = self._viz_slider( self._angle_sliders[key], _ = self._viz_slider(
form, label, key, config, fmt=degrees, tip=tip) form, label, key, config, fmt=degrees, tip=tip,
show_value=False)
square = QPushButton("Face Screen Square") square = QPushButton("Face Screen Square")
square.clicked.connect( square.clicked.connect(
lambda: [s.setValue(0) for s in self._angle_sliders.values()]) lambda: [s.setValue(0) for s in self._angle_sliders.values()])
@@ -542,7 +577,7 @@ class PreferencesDialog(QDialog):
@staticmethod @staticmethod
def _viz_form(page) -> QFormLayout: def _viz_form(page) -> QFormLayout:
form = QFormLayout(page) form = QFormLayout(page) if page is not None else QFormLayout()
form.setContentsMargins(0, 0, 0, 0) form.setContentsMargins(0, 0, 0, 0)
# Sliders run the full width of the window. # Sliders run the full width of the window.
form.setFieldGrowthPolicy( form.setFieldGrowthPolicy(
@@ -745,7 +780,7 @@ class PreferencesDialog(QDialog):
return box return box
def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str, def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str,
tip=""): tip="", show_value=True):
"""A slider over ``visualizer_styles.LIMITS[key]`` that previews live """A slider over ``visualizer_styles.LIMITS[key]`` that previews live
and saves once the drag settles, with its value beside it.""" and saves once the drag settles, with its value beside it."""
lo, hi = visualizer_styles.LIMITS[key] lo, hi = visualizer_styles.LIMITS[key]
@@ -765,8 +800,11 @@ class PreferencesDialog(QDialog):
slider.valueChanged.connect(changed) slider.valueChanged.connect(changed)
row = QHBoxLayout() row = QHBoxLayout()
row.addWidget(slider, stretch=1) row.addWidget(slider, stretch=1)
if show_value:
row.addSpacing(8) row.addSpacing(8)
row.addWidget(text) row.addWidget(text)
else:
text.hide() # shown elsewhere (the laser diagram)
form.addRow(label, row) form.addRow(label, row)
self._viz_setters[key] = lambda value: slider.setValue(round(value * scale)) self._viz_setters[key] = lambda value: slider.setValue(round(value * scale))
return slider, text return slider, text
+30 -2
View File
@@ -64,6 +64,21 @@ def make_shuffle_order(count: int, start_index: int) -> list[int]:
return order return order
VOLUME_ADJUSTMENT_MAX = 255 # iTunes' unit: ±255 is its slider's ±100%
def volume_with_adjustment(level: float, adjustment: int) -> float:
"""A track's volume adjustment (Get Info → Volume, iTunes' "Volume
Adjustment", imported as-is) applied to the logical 0..1 volume.
It scales the slider's own perceptual level: −100% is silence, +50% is
the slider half again as far up. It can't go past full — no output can —
so a boost only helps while the main volume is below the top, exactly
like iTunes'."""
share = max(-1.0, min(1.0, adjustment / VOLUME_ADJUSTMENT_MAX))
return max(0.0, min(1.0, level * (1.0 + share)))
class PlaybackSink(QObject): class PlaybackSink(QObject):
"""One audio output engine sitting behind Player. """One audio output engine sitting behind Player.
@@ -509,7 +524,7 @@ class Player(QObject):
self._sink = new_sink self._sink = new_sink
self._connect_sink(self._sink) self._connect_sink(self._sink)
# Whatever the slider last showed applies to the new output too. # Whatever the slider last showed applies to the new output too.
self._sink.set_volume(self._volume) self._sink.set_volume(self._output_volume())
if track is not None: if track is not None:
if self._sink.can_play(track): if self._sink.can_play(track):
@@ -628,7 +643,18 @@ class Player(QObject):
meaning the same thing whether audio is coming out of this machine or meaning the same thing whether audio is coming out of this machine or
a Chromecast.""" a Chromecast."""
self._volume = max(0.0, min(1.0, float(level))) self._volume = max(0.0, min(1.0, float(level)))
self._sink.set_volume(self._volume) self._sink.set_volume(self._output_volume())
def refresh_volume(self):
"""Re-apply the volume — after the playing track's own volume
adjustment was edited."""
self._sink.set_volume(self._output_volume())
def _output_volume(self) -> float:
"""The slider's volume with the playing track's adjustment on it."""
return volume_with_adjustment(
self._volume,
getattr(self._current_track, "volume_adjustment", 0) or 0)
def volume(self) -> float: def volume(self) -> float:
"""The logical 0..1 volume last set (what the slider shows).""" """The logical 0..1 volume last set (what the slider shows)."""
@@ -755,6 +781,8 @@ class Player(QObject):
else: else:
self._stop_at_ms = 0 self._stop_at_ms = 0
self._counted_finish_id = None self._counted_finish_id = None
# Before the load, so a quieter track never starts loud.
self._sink.set_volume(self._output_volume())
self._sink.load( self._sink.load(
track, autoplay, track, autoplay,
track.start_time if track.start_time > 0 else 0) track.start_time if track.start_time > 0 else 0)
+224
View File
@@ -0,0 +1,224 @@
"""Round 79: per-track volume, Get Info tidy-up, Preferences headings, and
the laser rig diagram.
1. A track's volume adjustment (iTunes' "Volume Adjustment", already
imported) scales the output while that track plays.
2. Get Info edits it — one track or several — and has no dead
Previous/Next buttons.
3. Preferences section headings are bold and 2 px larger than the text.
4. The laser page draws the rig, labelling distance, angles and dot size
in place of those sliders' own numbers.
"""
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QFontInfo
from PyQt6.QtWidgets import QApplication, QLabel, QPushButton
from lintunes.player import volume_with_adjustment, VOLUME_ADJUSTMENT_MAX
from lintunes.preferences import Preferences
from tests.test_round29 import _player, _local_track, _FakeSink
# --------------------------------------------------------------------------
# 1. The player
# --------------------------------------------------------------------------
class TestVolumeAdjustment:
def test_none_changes_nothing(self):
assert volume_with_adjustment(0.6, 0) == 0.6
def test_minus_100_is_silence_and_plus_100_doubles(self):
assert volume_with_adjustment(0.6, -VOLUME_ADJUSTMENT_MAX) == 0.0
assert volume_with_adjustment(0.3, VOLUME_ADJUSTMENT_MAX) == 0.6
def test_never_past_full(self):
assert volume_with_adjustment(0.8, VOLUME_ADJUSTMENT_MAX) == 1.0
def test_out_of_range_values_are_clamped(self):
assert volume_with_adjustment(0.5, -10_000) == 0.0
assert volume_with_adjustment(0.5, 10_000) == 1.0
def test_applied_when_the_track_loads(self, qapp, tmp_path):
quiet = _local_track(tmp_path, 1, volume_adjustment=-128)
plain = _local_track(tmp_path, 2)
player = _player(qapp, [quiet, plain])
sink = _FakeSink()
player.set_sink(sink)
player.set_volume(0.8)
player.play_queue([1, 2], 0)
assert sink.volume == pytest.approx(0.8 * (1 - 128 / 255))
assert player.volume() == 0.8 # the slider is untouched
player.next()
assert sink.volume == pytest.approx(0.8)
def test_moving_the_slider_keeps_the_adjustment(self, qapp, tmp_path):
track = _local_track(tmp_path, 1, volume_adjustment=255)
player = _player(qapp, [track])
sink = _FakeSink()
player.set_sink(sink)
player.play_queue([1], 0)
player.set_volume(0.25)
assert sink.volume == pytest.approx(0.5)
def test_an_edit_while_playing_applies_on_refresh(self, qapp, tmp_path):
track = _local_track(tmp_path, 1)
player = _player(qapp, [track])
sink = _FakeSink()
player.set_sink(sink)
player.set_volume(0.6)
player.play_queue([1], 0)
track.volume_adjustment = -255
player.refresh_volume()
assert sink.volume == 0.0
# --------------------------------------------------------------------------
# 2. Get Info
# --------------------------------------------------------------------------
@pytest.fixture
def manager(tmp_path):
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track
library = Library()
for tid, adj in ((1, 0), (2, 51), (3, 51)):
library.tracks[tid] = Track(track_id=tid, name=f"Song {tid}",
volume_adjustment=adj)
return LibraryManager(library, tmp_path)
def _info(manager, ids):
from lintunes.gui.info_dialog import InfoDialog
return InfoDialog(manager, ids)
class TestInfoDialog:
def test_no_previous_or_next(self, qapp, manager):
for ids in ([1], [1, 2]):
texts = {b.text() for b in _info(manager, ids).findChildren(QPushButton)}
assert not texts & {"Previous", "Next"}
def test_shows_the_tracks_adjustment(self, qapp, manager):
dialog = _info(manager, [2])
assert dialog._volume.value() == 20 # 51/255
assert dialog._volume_label.text() == "+20%"
assert _info(manager, [1])._volume_label.text() == "None"
def test_saving_one_track(self, qapp, manager):
dialog = _info(manager, [1])
dialog._volume.setValue(-50)
dialog._on_ok()
assert manager.library.tracks[1].volume_adjustment == -128
def test_an_untouched_value_keeps_its_exact_number(self, qapp, manager):
manager.library.tracks[1].volume_adjustment = 37 # an iTunes value
dialog = _info(manager, [1])
dialog._on_ok()
assert manager.library.tracks[1].volume_adjustment == 37
def test_the_edit_is_undoable(self, qapp, manager):
dialog = _info(manager, [1])
dialog._volume.setValue(100)
dialog._on_ok()
manager.undo_stack.undo()
assert manager.library.tracks[1].volume_adjustment == 0
def test_several_tracks_mixed_untouched(self, qapp, manager):
dialog = _info(manager, [1, 2])
assert dialog._volume_label.text() == "Mixed"
dialog._on_ok()
assert manager.library.tracks[1].volume_adjustment == 0
assert manager.library.tracks[2].volume_adjustment == 51
def test_several_tracks_shared_value(self, qapp, manager):
assert _info(manager, [2, 3])._volume_label.text() == "+20%"
def test_several_tracks_touched(self, qapp, manager):
dialog = _info(manager, [1, 2])
dialog._volume.setValue(-100)
dialog._on_ok()
assert manager.library.tracks[1].volume_adjustment == -255
assert manager.library.tracks[2].volume_adjustment == -255
# --------------------------------------------------------------------------
# 3 & 4. Preferences
# --------------------------------------------------------------------------
class _LastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
def _prefs_dialog(prefs):
from lintunes.gui.preferences_dialog import PreferencesDialog
return PreferencesDialog(prefs, _LastFm())
class TestHeadings:
def test_bold_and_two_px_larger(self, qapp, tmp_path):
dialog = _prefs_dialog(Preferences(tmp_path))
text_px = QFontInfo(QApplication.font()).pixelSize()
headings = dialog.findChildren(QLabel, "prefs-heading")
assert {h.text() for h in headings} >= {"Interface", "Visualizer",
"Colors", "Last.fm"}
for heading in headings:
assert heading.font().bold()
assert heading.font().pixelSize() == text_px + 2
def test_the_boxes_contents_stay_plain(self, qapp, tmp_path):
dialog = _prefs_dialog(Preferences(tmp_path))
assert not dialog._glow.font().bold()
class TestLaserDiagram:
def test_the_dialog_has_one_and_it_follows_the_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = _prefs_dialog(prefs)
dialog._angle_sliders["visualizer_laser_pitch"].setValue(-25)
dialog._distance_slider.setValue(16)
config = dialog._diagram._config
assert config["visualizer_laser_pitch"] == -25
assert config["visualizer_laser_distance"] == 1.6
def test_labelled_sliders_lost_their_numbers(self, qapp, tmp_path):
dialog = _prefs_dialog(Preferences(tmp_path))
page = dialog._viz_pages.widget(2)
dialog._viz_pages.setCurrentIndex(2)
dialog.show()
visible = {label.text() for label in page.findChildren(QLabel)
if label.isVisible()}
assert "3.5 m" not in visible and "0°" not in visible
assert "3 px" not in visible
dialog.close()
def test_paints_at_any_setting(self, qapp):
from lintunes.gui import visualizer_styles as vs
from lintunes.gui.laser_diagram import LaserDiagram
for overrides in ({}, {"visualizer_laser_pitch": 60,
"visualizer_laser_yaw": -60,
"visualizer_laser_roll": 180,
"visualizer_laser_distance": 1.0,
"visualizer_laser_dot": 16}):
config = vs.settings(None)
config.update(overrides)
diagram = LaserDiagram(config)
diagram.resize(420, 160)
assert not diagram.grab().isNull()
def test_the_screen_turns_with_the_angles(self, qapp):
from PyQt6.QtCore import QPointF
from lintunes.gui import visualizer_styles as vs
from lintunes.gui.laser_diagram import LaserDiagram
diagram = LaserDiagram(vs.settings(None))
square = diagram.screen_corners(QPointF(0, 0), 30)
pitched = diagram.screen_corners(QPointF(0, 0), 30, pitch=40,
yaw=0, roll=0)
# Tipping the top away moves the top edge along the beam.
assert pitched[2].x() != pytest.approx(square[2].x())
# A square screen's vertical edges are vertical.
assert square[1].x() == pytest.approx(square[2].x())