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
|
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):
|
class FlashButton(QToolButton):
|
||||||
"""Transport button: dark-gray glyph that shows the highlight color
|
"""Transport button: dark-gray glyph that shows the highlight color
|
||||||
while pressed (a quick flash on click, sustained while held). A checkable
|
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
|
return frame
|
||||||
|
|
||||||
|
|
||||||
class SeekSlider(QSlider):
|
class ClickJumpSlider(QSlider):
|
||||||
"""Timeline slider: clicking anywhere jumps the handle (and playhead)
|
"""Horizontal slider where clicking anywhere jumps the handle straight to
|
||||||
straight to the clicked spot instead of paging; dragging scrubs."""
|
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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(Qt.Orientation.Horizontal, parent)
|
super().__init__(Qt.Orientation.Horizontal, parent)
|
||||||
@ -113,7 +122,8 @@ class SeekSlider(QSlider):
|
|||||||
self.minimum(), self.maximum(), int(x), self.width())
|
self.minimum(), self.maximum(), int(x), self.width())
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
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.setSliderDown(True) # emits sliderPressed
|
||||||
self.setSliderPosition(self._value_at(event.position().x()))
|
self.setSliderPosition(self._value_at(event.position().x()))
|
||||||
event.accept()
|
event.accept()
|
||||||
@ -135,6 +145,11 @@ class SeekSlider(QSlider):
|
|||||||
super().mouseReleaseEvent(event)
|
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):
|
class BpmButton(QPushButton):
|
||||||
"""Tap-tempo button: tap along to the music; when the cursor leaves,
|
"""Tap-tempo button: tap along to the music; when the cursor leaves,
|
||||||
the shown bpm is saved to the playing track 3 seconds later."""
|
the shown bpm is saved to the playing track 3 seconds later."""
|
||||||
@ -231,6 +246,27 @@ class TransportBar(QWidget):
|
|||||||
layout.addWidget(self._visualizer)
|
layout.addWidget(self._visualizer)
|
||||||
layout.addSpacing(10)
|
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 = QVBoxLayout()
|
||||||
center.setSpacing(0)
|
center.setSpacing(0)
|
||||||
|
|
||||||
@ -302,6 +338,14 @@ class TransportBar(QWidget):
|
|||||||
button.refresh_icons()
|
button.refresh_icons()
|
||||||
self._apply_now_playing_font()
|
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):
|
def _apply_now_playing_font(self):
|
||||||
"""Set the now-playing labels to Century Gothic (or the user's chosen
|
"""Set the now-playing labels to Century Gothic (or the user's chosen
|
||||||
substitute), black text; title slightly larger than artist/album.
|
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.QtCore import QObject, QUrl, pyqtSignal
|
||||||
from PyQt6.QtMultimedia import (
|
from PyQt6.QtMultimedia import (
|
||||||
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices)
|
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices, QAudio)
|
||||||
|
|
||||||
from lintunes.models import Track
|
from lintunes.models import Track
|
||||||
|
|
||||||
@ -53,6 +53,7 @@ class Player(QObject):
|
|||||||
self._counted_finish_id: int | None = None
|
self._counted_finish_id: int | None = None
|
||||||
|
|
||||||
self._audio = QAudioOutput(self)
|
self._audio = QAudioOutput(self)
|
||||||
|
self._volume = 1.0 # logical 0..1; what the volume slider shows
|
||||||
self._media = QMediaPlayer(self)
|
self._media = QMediaPlayer(self)
|
||||||
self._media.setAudioOutput(self._audio)
|
self._media.setAudioOutput(self._audio)
|
||||||
# Tee of the decoded PCM, feeding the visualizer
|
# Tee of the decoded PCM, feeding the visualizer
|
||||||
@ -138,6 +139,25 @@ class Player(QObject):
|
|||||||
def duration_ms(self) -> int:
|
def duration_ms(self) -> int:
|
||||||
return self._media.duration()
|
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 ----
|
# ---- transport ----
|
||||||
|
|
||||||
def toggle_play(self):
|
def toggle_play(self):
|
||||||
|
|||||||
@ -10,6 +10,7 @@ DEFAULTS = {
|
|||||||
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
|
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
|
||||||
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
|
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
|
||||||
# default, "Family"=user-chosen substitute
|
# default, "Family"=user-chosen substitute
|
||||||
|
"volume": 0.8, # logical 0..1 master output volume
|
||||||
"lastfm": {
|
"lastfm": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_secret": "",
|
"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