Files
lintunes/lintunes/gui/transport.py
T
trav d73aa3190d v0.5.1: one cast control instead of two
The cast glyph showed up both under the volume slider and inside the
visualizer, which was redundant. Dropped the volume-slider indicator
(gui/cast_indicator.py deleted) and made the visualizer panel the single
cast control — the volume slider goes back to sitting on its own, and
nothing shifts position when a session starts.

The panel's glyph is bigger (20 -> 40px) and always painted in the theme
highlight rather than following the brightness mode. Clicking it now stops
casting instead of cycling on/dim/off, which meant nothing when there are
no bars to dim; that click was the useful behavior the volume-slider icon
had, so it moves here rather than being lost.

transport_icon() takes a size and scales the painter, so the bigger glyph
is repainted crisply rather than being a blown-up 20px pixmap; size joins
the cache key. The cast/cast_connected glyph pair collapses into one,
since only the connected state was ever drawn.

450 tests pass.
2026-08-13 22:24:31 -04:00

459 lines
18 KiB
Python

import time
from PyQt6.QtWidgets import (
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
QPushButton, QSizePolicy, QStyle,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from lintunes.tap_tempo import TapTempo
from lintunes.gui.icons import transport_icon
from lintunes.gui.track_table import format_time
from lintunes.gui.visualizer import VisualizerWidget
from lintunes import theme
ICON_GRAY = QColor("#4A4A4A")
# Height floor for the top control panel. It must exceed the natural stacked
# height of title + artist + timeline (~69px at medium scale) so the leftover
# becomes vertical slack that the equal stretches split into matching borders
# above the title and below the artist. Bigger = more breathing room (and a
# taller bar); the text block stays centered either way.
BAR_HEIGHT = 84
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
# visualizer, so they line up. ~midway between their old heights: the stretchy
# boxes/visualizer used to fill the bar (~78px) while the hugging bpm box sat
# at ~46px. Tune to taste.
CONTROL_HEIGHT = 62
def _box_style(bg: QColor) -> str:
return f"""
QFrame#transportBox {{
border: 1px solid palette(mid);
border-radius: 8px;
background: {bg.name()};
}}
"""
def _nowplaying_style(bg: QColor) -> str:
return f"""
QFrame#nowPlayingBox {{
background: {bg.name()};
border-radius: 8px;
}}
"""
# Title sits this many points above the artist/album line, both following the
# UI-scale app font (see theme.UI_SCALES).
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
button (shuffle) keeps the highlight color while checked."""
def __init__(self, kind: str, parent=None):
super().__init__(parent)
self._kind = kind
self.setAutoRaise(True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.pressed.connect(lambda: self._apply_icon(active=True))
self.released.connect(lambda: self._apply_icon())
self.toggled.connect(lambda _: self._apply_icon())
self.refresh_icons()
def set_kind(self, kind: str):
if kind != self._kind:
self._kind = kind
self._apply_icon(active=self.isDown())
def refresh_icons(self):
self._apply_icon(active=self.isDown())
def _apply_icon(self, active: bool = False):
show_active = active or (self.isCheckable() and self.isChecked())
color = self.palette().highlight().color() if show_active else ICON_GRAY
self.setIcon(transport_icon(self._kind, color))
def _box(*widgets, bg: QColor, margins=(10, 3, 10, 3), hug=False,
height=None, split=False) -> QFrame:
"""A small rounded frame with its content centered inside.
With hug=True the frame stays at its size hint instead of stretching
to fill the row it sits in. height pins the frame's height (width is
left untouched), so several boxes can be lined up to the same height.
bg is the rounded-rectangle fill (a user-tunable grayscale).
With split=True the widgets tile the frame's interior in equal shares
instead of hugging their size hints in the middle — each widget becomes
a full-bleed tap target with its content centered in its share."""
frame = QFrame()
frame.setObjectName("transportBox")
frame.setStyleSheet(_box_style(bg))
inner = QHBoxLayout(frame)
inner.setContentsMargins(*margins)
if split:
inner.setSpacing(0)
for widget in widgets:
widget.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding)
inner.addWidget(widget, stretch=1)
else:
inner.addStretch()
for widget in widgets:
inner.addWidget(widget)
inner.addStretch()
if height is not None:
frame.setFixedHeight(height)
if hug:
frame.setSizePolicy(QSizePolicy.Policy.Maximum,
QSizePolicy.Policy.Maximum)
return frame
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)
def _value_at(self, x: float) -> int:
return QStyle.sliderValueFromPosition(
self.minimum(), self.maximum(), int(x), self.width())
def mousePressEvent(self, event):
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()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.isSliderDown():
self.setSliderPosition(self._value_at(event.position().x()))
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.isSliderDown():
self.setSliderDown(False) # emits sliderReleased
event.accept()
return
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."""
SAVE_DELAY_MS = 3000
def __init__(self, player, manager, parent=None):
super().__init__("bpm", parent)
self._player = player
self._manager = manager
self._tempo = TapTempo()
self._target_track_id: int | None = None
self.setFixedWidth(64)
self.setFlat(True) # flat text inside its box, like the transport icons
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setEnabled(False)
self.setToolTip("Tap along to set this track's BPM")
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(self.SAVE_DELAY_MS)
self._save_timer.timeout.connect(self._save_bpm)
self.clicked.connect(self._on_tap)
player.track_changed.connect(self._on_track_changed)
def _on_track_changed(self, track):
# Track switched: any in-progress tap series no longer applies
self._reset()
self.setEnabled(track is not None)
def _on_tap(self):
self._save_timer.stop()
track = self._player.current_track
if track is None:
return
if self._tempo.tap_count == 0:
self._target_track_id = track.track_id
bpm = self._tempo.tap(time.monotonic())
self.setText(str(bpm) if bpm is not None else "...")
def leaveEvent(self, event):
if self._tempo.tap_count >= 2 and self._tempo.bpm() is not None:
self._save_timer.start()
super().leaveEvent(event)
def _save_bpm(self):
bpm = self._tempo.bpm()
track = (self._manager.library.tracks.get(self._target_track_id)
if self._target_track_id is not None else None)
if bpm is not None and track is not None:
# Writes the bpm tag to the file and records it for undo.
self._manager.edit_track_fields(track.track_id, {"bpm": bpm})
self._reset()
def _reset(self):
self._save_timer.stop()
self._tempo.reset()
self._target_track_id = None
self.setText("bpm")
class TransportBar(QWidget):
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
now-playing text, seek slider, boxed bpm tap button (right)."""
play_clicked = pyqtSignal() # MainWindow decides what "play" means
def __init__(self, player, manager, prefs, cast=None, parent=None):
super().__init__(parent)
self._player = player
self._prefs = prefs
self._cast = cast
self._scrubbing = False
self._current_track = None
self.setMinimumHeight(BAR_HEIGHT)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 3, 8, 3)
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
# from the stripe color they used to share via palette(alternate-base).
self._boxes = []
box_bg = theme.gray(theme.gray_value(prefs, "color_button"))
self._prev_btn = FlashButton("previous")
self._play_btn = FlashButton("play")
self._next_btn = FlashButton("next")
# split=True turns each button into a full-bleed tap target tiling the
# bubble; the minimum widths keep the bubbles at their pre-split
# footprint (the centering stretches/margins used to pad them out).
for btn in (self._prev_btn, self._play_btn, self._next_btn):
btn.setMinimumWidth(36)
transport_box = _box(self._prev_btn, self._play_btn, self._next_btn,
bg=box_bg, margins=(3, 3, 3, 3),
height=CONTROL_HEIGHT, split=True)
self._boxes.append(transport_box)
layout.addWidget(transport_box)
self._shuffle_btn = FlashButton("shuffle")
self._shuffle_btn.setMinimumWidth(50)
self._shuffle_btn.setCheckable(True)
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
shuffle_box = _box(self._shuffle_btn, bg=box_bg, margins=(3, 3, 3, 3),
height=CONTROL_HEIGHT, split=True)
self._boxes.append(shuffle_box)
layout.addWidget(shuffle_box)
layout.addSpacing(10)
self._visualizer = VisualizerWidget(player, prefs)
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
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)
# White rounded now-playing panel: holds only the song title and the
# artist/album line, centered, filling the zone above the seek row.
self._now_playing = QFrame()
self._now_playing.setObjectName("nowPlayingBox")
self._apply_now_playing_bg()
np = QVBoxLayout(self._now_playing)
np.setContentsMargins(12, 6, 12, 6)
np.setSpacing(2)
np.addStretch(1)
self._title_label = QLabel("")
self._title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
np.addWidget(self._title_label)
self._artist_label = QLabel("")
self._artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
np.addWidget(self._artist_label)
np.addStretch(1)
self._apply_now_playing_font()
seek_row = QHBoxLayout()
self._elapsed_label = QLabel("")
self._elapsed_label.setFixedWidth(50)
self._remaining_label = QLabel("")
self._remaining_label.setFixedWidth(50)
self._remaining_label.setAlignment(Qt.AlignmentFlag.AlignRight)
self._slider = SeekSlider()
self._slider.setRange(0, 0)
self._slider.setFocusPolicy(Qt.FocusPolicy.NoFocus)
seek_row.addWidget(self._elapsed_label)
seek_row.addWidget(self._slider)
seek_row.addWidget(self._remaining_label)
# The now-playing panel fills the zone above the seek row; the seek row
# stays pinned to the bottom so the timeline sits low. Its height is
# just the slider + time labels, so the panel takes the rest.
center.addWidget(self._now_playing, stretch=1)
center.addLayout(seek_row)
layout.addLayout(center, stretch=1)
# bpm tap button lives on the far right as its own box, vertically
# centered like the transport boxes — kept out of the seek row so it
# doesn't drive the timeline row's height.
self._bpm_button = BpmButton(player, manager)
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(8, 8, 8, 8),
hug=True, height=CONTROL_HEIGHT)
self._boxes.append(bpm_box)
layout.addWidget(bpm_box)
# Wiring
self._prev_btn.clicked.connect(player.previous)
self._play_btn.clicked.connect(self.play_clicked)
self._next_btn.clicked.connect(player.next)
self._shuffle_btn.toggled.connect(player.set_shuffle)
self._slider.sliderPressed.connect(self._on_scrub_start)
self._slider.sliderReleased.connect(self._on_scrub_end)
self._slider.sliderMoved.connect(self._on_scrub_move)
player.track_changed.connect(self._on_track_changed)
player.playing_changed.connect(self._on_playing_changed)
player.position_changed.connect(self._on_position_changed)
player.duration_changed.connect(self._on_duration_changed)
if cast is not None:
# While casting, the visualizer panel *is* the cast indicator —
# there is no spectrum to show, so a click there stops casting.
self._visualizer.cast_stop_requested.connect(
lambda: cast.disconnect())
def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight
color), the tunable grayscale button boxes, and the now-playing panel
background + text color."""
for button in (self._prev_btn, self._play_btn, self._next_btn,
self._shuffle_btn):
button.refresh_icons()
box_bg = theme.gray(theme.gray_value(self._prefs, "color_button"))
for box in self._boxes:
box.setStyleSheet(_box_style(box_bg))
self._apply_now_playing_bg()
self._apply_now_playing_font()
self._visualizer.update() # dim-mode bar color may have changed
def _apply_now_playing_bg(self):
bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg"))
self._now_playing.setStyleSheet(_nowplaying_style(bg))
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); title slightly larger than artist/album. Sizes track the
app font so they follow the UI-scale preference; the text color is the
user-tunable grayscale (black by default)."""
family = theme.now_playing_font_family(self._prefs)
base_pt = self.font().pointSize()
text_color = theme.gray(theme.gray_value(self._prefs, "color_now_playing_text"))
for label, delta in ((self._title_label, TITLE_PT_BUMP),
(self._artist_label, 0)):
font = QFont(family) if family else QFont(self.font())
font.setPointSize(max(1, base_pt + delta))
label.setFont(font)
label.setStyleSheet(f"color: {text_color.name()};")
# ---- player events ----
def _on_track_changed(self, track):
self._current_track = track
if track is None:
self._title_label.setText("")
self._artist_label.setText("")
self._slider.setRange(0, 0)
self._elapsed_label.setText("")
self._remaining_label.setText("")
return
self._title_label.setText(track.name)
parts = [p for p in (track.artist, track.album) if p]
self._artist_label.setText(" — ".join(parts))
def _on_playing_changed(self, playing):
self._play_btn.set_kind("pause" if playing else "play")
def _on_position_changed(self, position):
if not self._scrubbing:
self._slider.setValue(position)
self._update_time_labels(position)
def _on_duration_changed(self, duration):
self._slider.setRange(0, max(0, duration))
def _update_time_labels(self, position):
duration = self._slider.maximum()
self._elapsed_label.setText(format_time(position) or "0:00")
if duration > 0:
self._remaining_label.setText("-" + (format_time(duration - position) or "0:00"))
# ---- scrubbing ----
def _on_scrub_start(self):
self._scrubbing = True
def _on_scrub_move(self, value):
self._update_time_labels(value)
def _on_scrub_end(self):
self._scrubbing = False
self._player.seek(self._slider.value())