diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index ebb8d45..5e57bf0 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -49,6 +49,14 @@ QFrame#nowPlayingBox { TITLE_PT_BUMP = 2 +def _clamp_volume(value) -> float: + """Coerce a stored preference to a logical 0..1 volume.""" + try: + return max(0.0, min(1.0, float(value))) + except (TypeError, ValueError): + return 0.8 + + class FlashButton(QToolButton): """Transport button: dark-gray glyph that shows the highlight color while pressed (a quick flash on click, sustained while held). A checkable @@ -101,9 +109,10 @@ def _box(*widgets, margins=(10, 3, 10, 3), hug=False, height=None) -> QFrame: return frame -class SeekSlider(QSlider): - """Timeline slider: clicking anywhere jumps the handle (and playhead) - straight to the clicked spot instead of paging; dragging scrubs.""" +class ClickJumpSlider(QSlider): + """Horizontal slider where clicking anywhere jumps the handle straight to + the clicked spot (instead of paging one step toward it); dragging tracks + the cursor. Shared by the timeline and the volume slider.""" def __init__(self, parent=None): super().__init__(Qt.Orientation.Horizontal, parent) @@ -113,7 +122,8 @@ class SeekSlider(QSlider): self.minimum(), self.maximum(), int(x), self.width()) def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton and self.maximum() > 0: + if (event.button() == Qt.MouseButton.LeftButton + and self.maximum() > self.minimum()): self.setSliderDown(True) # emits sliderPressed self.setSliderPosition(self._value_at(event.position().x())) event.accept() @@ -135,6 +145,11 @@ class SeekSlider(QSlider): super().mouseReleaseEvent(event) +class SeekSlider(ClickJumpSlider): + """Timeline slider: clicking anywhere jumps the handle (and playhead) + straight to the clicked spot instead of paging; dragging scrubs.""" + + class BpmButton(QPushButton): """Tap-tempo button: tap along to the music; when the cursor leaves, the shown bpm is saved to the playing track 3 seconds later.""" @@ -231,6 +246,27 @@ class TransportBar(QWidget): layout.addWidget(self._visualizer) layout.addSpacing(10) + # Master output volume: a slim horizontal slider in its own slot + # between the visualizer and the timeline. The HBox centers it + # vertically, so nothing stacks above or below it (iTunes-style). + self._volume_slider = ClickJumpSlider() + self._volume_slider.setRange(0, 100) + self._volume_slider.setFixedWidth(96) + self._volume_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._volume_slider.setToolTip("Volume") + initial_volume = _clamp_volume(self._prefs.get("volume", 0.8)) + self._volume_slider.setValue(round(initial_volume * 100)) + player.set_volume(initial_volume) + # Persisting volume re-applies the theme (Preferences.changed), so save + # only after the user stops sliding — audio still tracks every tick. + self._volume_save_timer = QTimer(self) + self._volume_save_timer.setSingleShot(True) + self._volume_save_timer.setInterval(400) + self._volume_save_timer.timeout.connect(self._save_volume) + self._volume_slider.valueChanged.connect(self._on_volume_changed) + layout.addWidget(self._volume_slider) + layout.addSpacing(10) + center = QVBoxLayout() center.setSpacing(0) @@ -302,6 +338,14 @@ class TransportBar(QWidget): button.refresh_icons() self._apply_now_playing_font() + def _on_volume_changed(self, value: int): + """Slider moved: update audio live, defer the (theme-reapplying) save.""" + self._player.set_volume(value / 100) + self._volume_save_timer.start() + + def _save_volume(self): + self._prefs.set("volume", self._volume_slider.value() / 100) + def _apply_now_playing_font(self): """Set the now-playing labels to Century Gothic (or the user's chosen substitute), black text; title slightly larger than artist/album. diff --git a/lintunes/player.py b/lintunes/player.py index e83de37..f5dac03 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -3,7 +3,7 @@ from pathlib import Path from PyQt6.QtCore import QObject, QUrl, pyqtSignal from PyQt6.QtMultimedia import ( - QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices) + QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices, QAudio) from lintunes.models import Track @@ -53,6 +53,7 @@ class Player(QObject): self._counted_finish_id: int | None = None self._audio = QAudioOutput(self) + self._volume = 1.0 # logical 0..1; what the volume slider shows self._media = QMediaPlayer(self) self._media.setAudioOutput(self._audio) # Tee of the decoded PCM, feeding the visualizer @@ -138,6 +139,25 @@ class Player(QObject): def duration_ms(self) -> int: return self._media.duration() + # ---- volume ---- + + def set_volume(self, level: float): + """Set output volume from a logical 0..1 value (0=silent, 1=full). + The slider scale is perceptual, so convert to the linear gain + QAudioOutput expects — that makes the knob feel iTunes-like rather + than jumping to "loud" in the first few percent.""" + level = max(0.0, min(1.0, float(level))) + self._volume = level + self._audio.setVolume(QAudio.convertVolume( + level, + QAudio.VolumeScale.LogarithmicVolumeScale, + QAudio.VolumeScale.LinearVolumeScale, + )) + + def volume(self) -> float: + """The logical 0..1 volume last set (what the slider shows).""" + return self._volume + # ---- transport ---- def toggle_play(self): diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 5152b87..51dbb82 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -10,6 +10,7 @@ DEFAULTS = { "highlight": "blue", # see theme.HIGHLIGHT_COLORS "now_playing_font": None, # None=prefer Century Gothic, ""=use app # default, "Family"=user-chosen substitute + "volume": 0.8, # logical 0..1 master output volume "lastfm": { "api_key": "", "api_secret": "", diff --git a/tests/test_round10.py b/tests/test_round10.py new file mode 100644 index 0000000..9d42e30 --- /dev/null +++ b/tests/test_round10.py @@ -0,0 +1,104 @@ +"""Round 10: master output volume slider in the transport bar. + +1. Player.set_volume / volume round-trip (logical 0..1, clamped). +2. TransportBar builds a volume slider initialized from the prefs value and + sets the player volume to match. +3. Moving the slider updates the player live and (after the debounce) persists + the new volume to preferences.json. +""" + +from unittest.mock import MagicMock, patch + +from lintunes.models import Library, Track +from lintunes.library_manager import LibraryManager +from lintunes.preferences import Preferences +from lintunes import player as player_module +from lintunes.player import Player +from lintunes.gui.transport import TransportBar, ClickJumpSlider + + +def _mock_player(): + """A real Player with the Qt multimedia backend stubbed out (it blocks on + init headless — see tests/test_player.py). The audio output becomes a mock, + so set_volume's setVolume call is a harmless no-op.""" + library = Library(tracks={1: Track(track_id=1, name="One")}) + with patch.multiple( + player_module, + QMediaPlayer=MagicMock(), + QAudioOutput=MagicMock(), + QAudioBufferOutput=MagicMock(), + QMediaDevices=MagicMock(), + ): + return Player(MagicMock(library=library)) + + +# -------------------------------------------------------------------------- +# 1. Player volume API +# -------------------------------------------------------------------------- + +class TestPlayerVolume: + def test_round_trip(self, qapp): + player = _mock_player() + player.set_volume(0.5) + assert player.volume() == 0.5 + # The linear gain handed to QAudioOutput is set (perceptual->linear). + player._audio.setVolume.assert_called() + + def test_clamps_out_of_range(self, qapp): + player = _mock_player() + player.set_volume(1.5) + assert player.volume() == 1.0 + player.set_volume(-0.3) + assert player.volume() == 0.0 + + +# -------------------------------------------------------------------------- +# 2 + 3. TransportBar volume slider +# -------------------------------------------------------------------------- + +class TestTransportVolumeSlider: + def _build(self, tmp_path, prefs): + player = _mock_player() + manager = LibraryManager( + Library(tracks={1: Track(track_id=1, name="One")}), tmp_path) + return TransportBar(player, manager, prefs), player + + def test_slider_initialized_from_prefs(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("volume", 0.3) + transport, player = self._build(tmp_path, prefs) + + assert transport._volume_slider.value() == 30 + assert player.volume() == 0.3 + + def test_moving_slider_updates_player_live(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + transport, player = self._build(tmp_path, prefs) + + transport._volume_slider.setValue(20) + + assert player.volume() == 0.2 + + def test_save_persists_to_disk(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + transport, player = self._build(tmp_path, prefs) + + transport._volume_slider.setValue(20) + # The debounce timer normally fires this; call it directly. + transport._save_volume() + + # A freshly loaded Preferences sees the persisted value. + assert Preferences(tmp_path).get("volume") == 0.2 + + def test_click_jumps_to_position(self, qapp, tmp_path): + """Click-to-jump: the slider lands on the clicked spot, not a step.""" + prefs = Preferences(tmp_path) + transport, player = self._build(tmp_path, prefs) + slider = transport._volume_slider + + assert isinstance(slider, ClickJumpSlider) + slider.resize(100, 20) + # A click at the far-left maps to the minimum, mid to ~middle. + assert slider._value_at(0) == 0 + assert 40 <= slider._value_at(50) <= 60 + assert slider._value_at(100) == 100