diff --git a/CLAUDE.md b/CLAUDE.md index e384c0a..936dbb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 2eb11d1..1b8ed32 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.43.0" +__version__ = "0.44.0" diff --git a/lintunes/gui/info_dialog.py b/lintunes/gui/info_dialog.py index 2675105..e511540 100644 --- a/lintunes/gui/info_dialog.py +++ b/lintunes/gui/info_dialog.py @@ -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() diff --git a/lintunes/gui/laser_diagram.py b/lintunes/gui/laser_diagram.py new file mode 100644 index 0000000..7934cda --- /dev/null +++ b/lintunes/gui/laser_diagram.py @@ -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°" diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 38df4ff..43bc525 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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 diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 98f347f..09cbe36 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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 diff --git a/lintunes/player.py b/lintunes/player.py index 0308aa2..dc30b96 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -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) diff --git a/tests/test_round79.py b/tests/test_round79.py new file mode 100644 index 0000000..85e71fd --- /dev/null +++ b/tests/test_round79.py @@ -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())