- The now-playing panel and the visualizer are debossed by exactly as much as the buttons are embossed: the same rims and outline, flipped (theme.deboss_qss / theme.paint_deboss), so things you press stand out and things that show you something sit in. - The bpm button's text is black, enabled or not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
20 KiB
Python
488 lines
20 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: the row of CONTROL_HEIGHT boxes and
|
|
# now-playing panel, the seek row hanging under the panel (~17px at medium
|
|
# scale), and the 3px margins. Any leftover sits below the seek row.
|
|
BAR_HEIGHT = 86
|
|
|
|
# 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
|
|
|
|
# Every control in the bar hangs from the same top edge as the now-playing
|
|
# panel (the seek row sits below the panel, not beside the boxes).
|
|
TOP = Qt.AlignmentFlag.AlignTop
|
|
|
|
def _box_style(bg: QColor, button: bool = False) -> str:
|
|
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
|
|
shared button face — emboss, outline, whole-box darkening on hover; the
|
|
three-tile play box gets the same face, and each tile darkens on its own
|
|
(see TILE_STYLE)."""
|
|
face = theme.button_face_qss("QFrame#transportBox", bg, radius=8)
|
|
if not button:
|
|
# Hover belongs to the tiles here, not the whole box.
|
|
face += (f"QFrame#transportBox:hover {{ background:"
|
|
f" {theme.emboss_gradient(bg)}; }}")
|
|
return face + TILE_STYLE
|
|
|
|
|
|
# Tool buttons inside a box: no light panel of their own. A tile in the play
|
|
# box darkens slightly under the cursor — the same "whole button, a little
|
|
# darker" as every other button — and a lone button leaves that to its box.
|
|
TILE_STYLE = """
|
|
QFrame#transportBox QToolButton, QFrame#transportBox QPushButton {
|
|
border: none; background: transparent; border-radius: 6px;
|
|
}
|
|
QFrame#transportBox QPushButton, QFrame#transportBox QPushButton:disabled {
|
|
color: black;
|
|
}
|
|
QFrame#transportBox[tiles="true"] QToolButton:hover {
|
|
background: rgba(0, 0, 0, 22);
|
|
}
|
|
QFrame#transportBox[tiles="true"] QToolButton:pressed {
|
|
background: rgba(0, 0, 0, 38);
|
|
}
|
|
"""
|
|
|
|
|
|
def _nowplaying_style(bg: QColor) -> str:
|
|
"""Debossed: pushed in as far as the buttons stand out."""
|
|
return theme.deboss_qss("QFrame#nowPlayingBox", bg, radius=8)
|
|
|
|
# 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")
|
|
# A box holding one control is that control: it darkens as a whole.
|
|
frame.setProperty("tiles", len(widgets) > 1)
|
|
frame.setAttribute(Qt.WidgetAttribute.WA_Hover)
|
|
frame.setStyleSheet(_box_style(bg, button=len(widgets) == 1))
|
|
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(74) # + the box's 3 px margins = its old footprint
|
|
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, alignment=TOP)
|
|
|
|
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, alignment=TOP)
|
|
|
|
layout.addSpacing(10)
|
|
self._visualizer = VisualizerWidget(player, prefs)
|
|
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
|
|
layout.addWidget(self._visualizer, alignment=TOP)
|
|
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)
|
|
# As tall as the boxes, so its groove (drawn centered) sits on the
|
|
# same midline as everything else in the row.
|
|
self._volume_slider.setFixedHeight(CONTROL_HEIGHT)
|
|
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, alignment=TOP)
|
|
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 is exactly as tall as the boxes and shares
|
|
# their top edge, so boxes, visualizer, panel and bpm read as one row;
|
|
# the seek row hangs underneath the panel and any slack goes below it.
|
|
self._now_playing.setFixedHeight(CONTROL_HEIGHT)
|
|
center.addWidget(self._now_playing)
|
|
center.addLayout(seek_row)
|
|
center.addStretch(1)
|
|
layout.addLayout(center, stretch=1)
|
|
|
|
# bpm tap button lives on the far right as its own box, top-aligned
|
|
# with the now-playing panel 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)
|
|
# The whole box is the tap target, so it darkens (and taps) as one.
|
|
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(3, 3, 3, 3),
|
|
hug=True, height=CONTROL_HEIGHT, split=True)
|
|
self._boxes.append(bpm_box)
|
|
layout.addWidget(bpm_box, alignment=TOP)
|
|
|
|
# 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, button=not box.property("tiles")))
|
|
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())
|