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.
This commit is contained in:
2026-06-26 21:21:00 -04:00
parent 8a07e280b9
commit 6a9027fb9b
4 changed files with 174 additions and 5 deletions
+48 -4
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.
+21 -1
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):
+1
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": "",