Files
lintunes/lintunes/gui/transport.py
Trav f6d35fa594 Import existing LinTunes project
Snapshot of the existing codebase before working through the TASKS.md
backlog. Real library data (data/) and the iTunes import fixture
(itunes-test-library/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:12:01 -04:00

361 lines
13 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
_BOX_STYLE = """
QFrame#transportBox {
border: 1px solid palette(mid);
border-radius: 8px;
background: palette(alternate-base);
}
"""
_NOWPLAYING_STYLE = """
QFrame#nowPlayingBox {
background: white;
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
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, margins=(10, 3, 10, 3), hug=False, height=None) -> 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."""
frame = QFrame()
frame.setObjectName("transportBox")
frame.setStyleSheet(_BOX_STYLE)
inner = QHBoxLayout(frame)
inner.setContentsMargins(*margins)
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 SeekSlider(QSlider):
"""Timeline slider: clicking anywhere jumps the handle (and playhead)
straight to the clicked spot instead of paging; dragging scrubs."""
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() > 0:
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 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, parent=None):
super().__init__(parent)
self._player = player
self._prefs = prefs
self._scrubbing = False
self._current_track = None
self.setMinimumHeight(BAR_HEIGHT)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 3, 8, 3)
self._prev_btn = FlashButton("previous")
self._play_btn = FlashButton("play")
self._next_btn = FlashButton("next")
layout.addWidget(_box(self._prev_btn, self._play_btn, self._next_btn,
height=CONTROL_HEIGHT))
self._shuffle_btn = FlashButton("shuffle")
self._shuffle_btn.setCheckable(True)
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
layout.addWidget(_box(self._shuffle_btn, height=CONTROL_HEIGHT))
layout.addSpacing(10)
self._visualizer = VisualizerWidget(player)
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
layout.addWidget(self._visualizer)
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._now_playing.setStyleSheet(_NOWPLAYING_STYLE)
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)
layout.addWidget(_box(self._bpm_button, margins=(8, 8, 8, 8), hug=True,
height=CONTROL_HEIGHT))
# 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)
def refresh_theme(self):
"""Rebuild button glyphs after a highlight-color or font change."""
for button in (self._prev_btn, self._play_btn, self._next_btn,
self._shuffle_btn):
button.refresh_icons()
self._apply_now_playing_font()
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.
Sizes track the app font so they follow the UI-scale preference."""
family = theme.now_playing_font_family(self._prefs)
base_pt = self.font().pointSize()
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("color: black;")
# ---- 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())