Add volume slider to the transport bar
A slim horizontal master-volume slider in its own slot between the visualizer and the timeline (TASKS.md). Perceptual->linear gain so it ramps like iTunes; persists to preferences.json (debounced so it doesn't thrash the theme). Clicking jumps to the spot via a new ClickJumpSlider base extracted from SeekSlider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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": "",
|
||||
|
||||
104
tests/test_round10.py
Normal file
104
tests/test_round10.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user