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:
@@ -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`,
|
||||
`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
|
||||
**`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
|
||||
@@ -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
|
||||
must join `STYLE_KEYS` and register a setter, or presets silently skip it
|
||||
(`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:
|
||||
`highlight_palette` overrides a slot, and `theme.highlight_color` reads
|
||||
through `theme.highlight_palette`, so nothing else needs to know.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.43.0"
|
||||
__version__ = "0.44.0"
|
||||
|
||||
+60
-23
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
|
||||
QSlider,
|
||||
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
|
||||
QApplication, QMenu, QFileDialog, QWidget,
|
||||
)
|
||||
@@ -11,6 +12,7 @@ from PyQt6.QtGui import QPixmap, QImage, QKeySequence, QShortcut
|
||||
|
||||
from lintunes import tagging
|
||||
from lintunes.gui.art_ops import embed_artwork
|
||||
from lintunes.player import VOLUME_ADJUSTMENT_MAX
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -288,8 +290,9 @@ class _TimeField(QLineEdit):
|
||||
class InfoDialog(QDialog):
|
||||
"""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
|
||||
Options block exposes custom start/stop times. Multiple tracks: a combined
|
||||
Single track: custom start/stop times and the file's path, besides the
|
||||
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
|
||||
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._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.setStyleSheet("color: gray; font-size: 10px;")
|
||||
self._file_label.setWordWrap(True)
|
||||
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
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
nav_row.addWidget(buttons)
|
||||
layout.addLayout(nav_row)
|
||||
|
||||
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
|
||||
self._next_btn.clicked.connect(lambda: self._navigate(1))
|
||||
layout.addWidget(buttons)
|
||||
buttons.accepted.connect(self._on_ok)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
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.
|
||||
self._start_row.setVisible(False)
|
||||
self._stop_row.setVisible(False)
|
||||
self._file_label.setVisible(False)
|
||||
self._prev_btn.setVisible(False)
|
||||
self._next_btn.setVisible(False)
|
||||
self._load_multi()
|
||||
else:
|
||||
self._load_current()
|
||||
@@ -475,8 +487,7 @@ class InfoDialog(QDialog):
|
||||
self._stop_time.setEnabled(track.stop_time > 0)
|
||||
self._stop_time.set_ms(track.stop_time)
|
||||
self._file_label.setText(track.location or "")
|
||||
self._prev_btn.setEnabled(self._index > 0)
|
||||
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
|
||||
self._set_volume_slider(track.volume_adjustment or 0)
|
||||
self._loading = False
|
||||
|
||||
def _load_multi(self):
|
||||
@@ -509,6 +520,9 @@ class InfoDialog(QDialog):
|
||||
self._comments.setPlainText("")
|
||||
else:
|
||||
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
|
||||
|
||||
def _collect_fields(self) -> dict:
|
||||
@@ -523,6 +537,7 @@ class InfoDialog(QDialog):
|
||||
if self._start_check.isChecked() else 0)
|
||||
fields["stop_time"] = (self._stop_time.ms()
|
||||
if self._stop_check.isChecked() else 0)
|
||||
fields["volume_adjustment"] = self._volume_adjustment()
|
||||
return fields
|
||||
|
||||
def _validate_times(self, track) -> str | None:
|
||||
@@ -574,6 +589,8 @@ class InfoDialog(QDialog):
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
if "comments" in self._touched:
|
||||
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:
|
||||
tracks = [self._manager.library.tracks[tid]
|
||||
for tid in self._track_ids]
|
||||
@@ -595,11 +612,31 @@ class InfoDialog(QDialog):
|
||||
self._manager.edit_tracks_fields(self._track_ids, fields)
|
||||
return True
|
||||
|
||||
def _navigate(self, delta: int):
|
||||
if not self._save_current():
|
||||
return
|
||||
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1))
|
||||
self._load_current()
|
||||
# ---- volume ----
|
||||
|
||||
def _set_volume_slider(self, adjustment: int, mixed: bool = False):
|
||||
self._volume.setValue(round(100 * adjustment / VOLUME_ADJUSTMENT_MAX))
|
||||
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):
|
||||
saved = self._save_multi() if self._multi else self._save_current()
|
||||
|
||||
@@ -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°"
|
||||
@@ -2030,6 +2030,7 @@ class MainWindow(QMainWindow):
|
||||
current = self.player.current_track
|
||||
if current is not None and current.track_id == track_id:
|
||||
self._sidebar.art.refresh()
|
||||
self.player.refresh_volume() # its volume adjustment may have moved
|
||||
|
||||
def _update_now_playing_icon(self, _playing=None):
|
||||
track = self.player.current_track
|
||||
|
||||
@@ -6,15 +6,16 @@ from PyQt6.QtWidgets import (
|
||||
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
||||
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
||||
QColorDialog, QMenu, QDoubleSpinBox, QComboBox, QInputDialog,
|
||||
QMessageBox,
|
||||
QMessageBox, QApplication,
|
||||
)
|
||||
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.config import load_config, save_config
|
||||
from lintunes.gui import visualizer_styles
|
||||
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 sounds
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
@@ -37,6 +38,7 @@ _COLOR_ROWS = [
|
||||
_VALUE_WIDTH = 56
|
||||
|
||||
|
||||
HEADING_EXTRA_PX = 2 # section headings: this much over the text
|
||||
_EDIT_PRESETS = "\0edit" # the preset drop-down's last item
|
||||
_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.timeout.connect(self._prefs.save)
|
||||
|
||||
self._headings = []
|
||||
general = [self._build_sound_group(), self._build_wrapped_group()]
|
||||
if self._music_folder is not None:
|
||||
general.insert(0, self._build_music_group())
|
||||
@@ -122,6 +125,8 @@ class PreferencesDialog(QDialog):
|
||||
self._build_acoustid_group()])):
|
||||
self._add_page(title, groups)
|
||||
self.pages_list.currentRowChanged.connect(self._show_page)
|
||||
self._style_headings()
|
||||
self._prefs.changed.connect(self._style_headings)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.addWidget(self.pages_list)
|
||||
@@ -160,6 +165,13 @@ class PreferencesDialog(QDialog):
|
||||
body = QWidget()
|
||||
column = QVBoxLayout(body)
|
||||
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.addStretch()
|
||||
scroll = QScrollArea()
|
||||
@@ -174,6 +186,16 @@ class PreferencesDialog(QDialog):
|
||||
self.pages.addWidget(scroll)
|
||||
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):
|
||||
self.pages.setCurrentIndex(row)
|
||||
PreferencesDialog._last_page = row
|
||||
@@ -491,23 +513,35 @@ class PreferencesDialog(QDialog):
|
||||
scale=20, fmt=_fade_text,
|
||||
tip="How long past sweeps of the beam take to fade — 0 is none")
|
||||
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(
|
||||
form, "Glow", "visualizer_laser_glow", config,
|
||||
looks, "Glow", "visualizer_laser_glow", config,
|
||||
tip="The haze around the beam")
|
||||
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")
|
||||
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 "
|
||||
"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(
|
||||
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(
|
||||
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")
|
||||
degrees = lambda v: f"{v:+.0f}°" if v else "0°"
|
||||
self._angle_sliders = {}
|
||||
@@ -521,7 +555,8 @@ class PreferencesDialog(QDialog):
|
||||
("visualizer_laser_roll", "Screen roll:",
|
||||
"Spin the screen in its own plane — turns the figure")):
|
||||
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.clicked.connect(
|
||||
lambda: [s.setValue(0) for s in self._angle_sliders.values()])
|
||||
@@ -542,7 +577,7 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
@staticmethod
|
||||
def _viz_form(page) -> QFormLayout:
|
||||
form = QFormLayout(page)
|
||||
form = QFormLayout(page) if page is not None else QFormLayout()
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
# Sliders run the full width of the window.
|
||||
form.setFieldGrowthPolicy(
|
||||
@@ -745,7 +780,7 @@ class PreferencesDialog(QDialog):
|
||||
return box
|
||||
|
||||
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
|
||||
and saves once the drag settles, with its value beside it."""
|
||||
lo, hi = visualizer_styles.LIMITS[key]
|
||||
@@ -765,8 +800,11 @@ class PreferencesDialog(QDialog):
|
||||
slider.valueChanged.connect(changed)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(slider, stretch=1)
|
||||
row.addSpacing(8)
|
||||
row.addWidget(text)
|
||||
if show_value:
|
||||
row.addSpacing(8)
|
||||
row.addWidget(text)
|
||||
else:
|
||||
text.hide() # shown elsewhere (the laser diagram)
|
||||
form.addRow(label, row)
|
||||
self._viz_setters[key] = lambda value: slider.setValue(round(value * scale))
|
||||
return slider, text
|
||||
|
||||
+30
-2
@@ -64,6 +64,21 @@ def make_shuffle_order(count: int, start_index: int) -> list[int]:
|
||||
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):
|
||||
"""One audio output engine sitting behind Player.
|
||||
|
||||
@@ -509,7 +524,7 @@ class Player(QObject):
|
||||
self._sink = new_sink
|
||||
self._connect_sink(self._sink)
|
||||
# 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 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
|
||||
a Chromecast."""
|
||||
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:
|
||||
"""The logical 0..1 volume last set (what the slider shows)."""
|
||||
@@ -755,6 +781,8 @@ class Player(QObject):
|
||||
else:
|
||||
self._stop_at_ms = 0
|
||||
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(
|
||||
track, autoplay,
|
||||
track.start_time if track.start_time > 0 else 0)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user