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())