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:
2026-06-26 21:21:00 -04:00
parent 45bc8518a3
commit bb31e9e5cc
4 changed files with 174 additions and 5 deletions

View File

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

View File

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

View File

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