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>
This commit is contained in:
360
lintunes/gui/transport.py
Normal file
360
lintunes/gui/transport.py
Normal file
@ -0,0 +1,360 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user