Add shuffle and repeat controls with end-of-playlist fix

This commit is contained in:
Maksim Totmin 2026-06-01 20:32:56 +07:00
parent aaaf354c9d
commit 8861e55f32
4 changed files with 114 additions and 4 deletions

View File

@ -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,9 +366,40 @@ 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)
idx = self._get_next_index()
if idx is not None:
self.library_tab.play_track_at_index(idx)
def _seek_forward(self):
@ -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()

View File

@ -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:
"""

View File

@ -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

View File

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