From 8861e55f32b519ada9cb5b1e7e300d2e32eb5802 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 20:32:56 +0700 Subject: [PATCH] Add shuffle and repeat controls with end-of-playlist fix --- src/app.py | 71 ++++++++++++++++++++++++++++++++++++++--- src/config_loader.py | 4 +++ src/ui/library_tab.py | 7 ++++ src/ui/player_header.py | 36 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/app.py b/src/app.py index b82b952..7859c0b 100644 --- a/src/app.py +++ b/src/app.py @@ -6,6 +6,7 @@ Provides a GUI for converting, managing and transferring music to iPod Nano devi import os import sys +import random import logging from typing import Optional @@ -54,6 +55,9 @@ class MainWindow(QMainWindow): self._saved_playing: bool = False self._pending_seek_ms: int = 0 + self._shuffle_active: bool = False + self._repeat_mode: str = "no_repeat" + self._setup_player() self._setup_ui() self.library_tab.set_playlist_manager(self.playlist_manager) @@ -88,6 +92,8 @@ class MainWindow(QMainWindow): self.player_header.prev_requested.connect(self._on_prev) self.player_header.play_pause_requested.connect(self._on_play_pause) self.player_header.next_requested.connect(self._on_next) + self.player_header.shuffle_requested.connect(self._on_shuffle_toggle) + self.player_header.repeat_requested.connect(self._on_repeat_cycle) self.player_header.position_changed_by_user.connect(self._on_user_seek_frac) self.player_header.volume_changed.connect(self._on_volume_changed) main_layout.addWidget(self.player_header) @@ -239,6 +245,11 @@ class MainWindow(QMainWindow): self._saved_track_path = config.get("Playback", "last_track_path", fallback="") self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0) self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False) + self._shuffle_active = config.get_boolean("Playback", "shuffle_active", fallback=False) + self._repeat_mode = config.get("Playback", "repeat_mode", fallback="no_repeat") + + self.player_header.set_shuffle_active(self._shuffle_active) + self.player_header.set_repeat_mode(self._repeat_mode) self.audio_output.setVolume(self._saved_volume / 100.0) self.player_header.set_volume(self._saved_volume) @@ -247,6 +258,8 @@ class MainWindow(QMainWindow): config = self.config_loader self.settings_tab.save_settings(config) config.set("Playback", "last_volume", str(self.player_header.volume_slider.value())) + config.set("Playback", "shuffle_active", str(self._shuffle_active).lower()) + config.set("Playback", "repeat_mode", self._repeat_mode) if self.current_track_path and os.path.exists(self.current_track_path): config.set("Playback", "last_track_path", self.current_track_path) position = self.player.position() @@ -353,10 +366,41 @@ class MainWindow(QMainWindow): idx = max(0, self.library_tab.current_playback_index - 1) self.library_tab.play_track_at_index(idx) + def _get_next_index(self): + playable = self.library_tab.playable_row_indices() + if not playable: + return None + + current = self.library_tab.current_playback_index + + if self._shuffle_active: + candidates = [r for r in playable if r != current] + if not candidates: + candidates = playable + return random.choice(candidates) + + try: + pos = playable.index(current) + except ValueError: + pos = -1 + + if pos >= 0 and pos < len(playable) - 1: + return playable[pos + 1] + + if self._repeat_mode == "repeat_all" and playable: + return playable[0] + + return None + + def _stop_playback(self): + self.player.stop() + self.library_tab.on_playing_changed(False) + self.player_header.set_playing(False) + def _on_next(self): - rows = self.library_tab.library_table.rowCount() - idx = min(rows - 1, self.library_tab.current_playback_index + 1) - self.library_tab.play_track_at_index(idx) + idx = self._get_next_index() + if idx is not None: + self.library_tab.play_track_at_index(idx) def _seek_forward(self): if self.player.duration() <= 0: @@ -396,10 +440,29 @@ class MainWindow(QMainWindow): pos = self.player.position() self.player_header.set_position(pos, duration) + def _on_shuffle_toggle(self): + self._shuffle_active = not self._shuffle_active + self.player_header.set_shuffle_active(self._shuffle_active) + + def _on_repeat_cycle(self): + cycle = {"no_repeat": "repeat_all", "repeat_all": "repeat_one", "repeat_one": "no_repeat"} + self._repeat_mode = cycle[self._repeat_mode] + self.player_header.set_repeat_mode(self._repeat_mode) + def _on_media_status(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: self.library_tab.on_track_ended() - self._on_next() + + if self._repeat_mode == "repeat_one": + self.player.setPosition(0) + self.player.play() + return + + next_idx = self._get_next_index() + if next_idx is not None: + self.library_tab.play_track_at_index(next_idx) + else: + self._stop_playback() elif status == QMediaPlayer.MediaStatus.NoMedia: self.library_tab.on_playing_changed(False) self.player_header.reset_info() diff --git a/src/config_loader.py b/src/config_loader.py index 9975976..fe38b06 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -174,6 +174,10 @@ class ConfigLoader: self.config.set('Playback', 'last_volume', '80') if not self.config.has_option('Playback', 'last_playing'): self.config.set('Playback', 'last_playing', 'false') + if not self.config.has_option('Playback', 'shuffle_active'): + self.config.set('Playback', 'shuffle_active', 'false') + if not self.config.has_option('Playback', 'repeat_mode'): + self.config.set('Playback', 'repeat_mode', 'no_repeat') def get(self, section: str, option: str, fallback: Any = None) -> Any: """ diff --git a/src/ui/library_tab.py b/src/ui/library_tab.py index 7c1cdc3..36675f4 100644 --- a/src/ui/library_tab.py +++ b/src/ui/library_tab.py @@ -256,6 +256,13 @@ class LibraryTab(QWidget): layout.addLayout(toolbar) + def playable_row_indices(self) -> list[int]: + indices = [] + for row in range(self.library_table.rowCount()): + if not self.library_table.isRowHidden(row): + indices.append(row) + return indices + def play_track_at_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True): if index < 0 or index >= self.library_table.rowCount(): return diff --git a/src/ui/player_header.py b/src/ui/player_header.py index 45aa104..b7b2ee9 100644 --- a/src/ui/player_header.py +++ b/src/ui/player_header.py @@ -22,6 +22,8 @@ class PlayerHeader(QGroupBox): prev_requested = pyqtSignal() play_pause_requested = pyqtSignal() next_requested = pyqtSignal() + shuffle_requested = pyqtSignal() + repeat_requested = pyqtSignal() position_changed_by_user = pyqtSignal(int) volume_changed = pyqtSignal(int) @@ -52,9 +54,21 @@ class PlayerHeader(QGroupBox): self.next_btn.setToolTip("Next") self.next_btn.clicked.connect(self.next_requested) + self.shuffle_btn = QPushButton("\u21C4") + self.shuffle_btn.setFixedWidth(36) + self.shuffle_btn.setToolTip("Shuffle") + self.shuffle_btn.clicked.connect(self.shuffle_requested) + + self.repeat_btn = QPushButton("\u21BB") + self.repeat_btn.setFixedWidth(36) + self.repeat_btn.setToolTip("Repeat: Off") + self.repeat_btn.clicked.connect(self.repeat_requested) + + controls_row.addWidget(self.shuffle_btn) controls_row.addWidget(self.prev_btn) controls_row.addWidget(self.play_btn) controls_row.addWidget(self.next_btn) + controls_row.addWidget(self.repeat_btn) self.time_label_start = QLabel("0:00") self.time_label_start.setFixedWidth(40) @@ -102,6 +116,28 @@ class PlayerHeader(QGroupBox): now_playing_layout.addWidget(self.now_playing_label, stretch=1) layout.addLayout(now_playing_layout) + def set_shuffle_active(self, active: bool): + if active: + self.shuffle_btn.setStyleSheet("color: palette(highlight); font-weight: bold;") + self.shuffle_btn.setToolTip("Shuffle: On") + else: + self.shuffle_btn.setStyleSheet("") + self.shuffle_btn.setToolTip("Shuffle: Off") + + def set_repeat_mode(self, mode: str): + if mode == "repeat_all": + self.repeat_btn.setText("\u21BB") + self.repeat_btn.setStyleSheet("color: palette(highlight); font-weight: bold;") + self.repeat_btn.setToolTip("Repeat: All") + elif mode == "repeat_one": + self.repeat_btn.setText("\u21BB\u00B9") + self.repeat_btn.setStyleSheet("color: palette(highlight); font-weight: bold;") + self.repeat_btn.setToolTip("Repeat: One") + else: + self.repeat_btn.setText("\u21BB") + self.repeat_btn.setStyleSheet("") + self.repeat_btn.setToolTip("Repeat: Off") + def _on_slider_user_action(self): self.position_changed_by_user.emit(self.position_slider.value())