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>
361 lines
13 KiB
Python
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())
|