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:
@@ -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