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 os
import sys import sys
import random
import logging import logging
from typing import Optional from typing import Optional
@ -54,6 +55,9 @@ class MainWindow(QMainWindow):
self._saved_playing: bool = False self._saved_playing: bool = False
self._pending_seek_ms: int = 0 self._pending_seek_ms: int = 0
self._shuffle_active: bool = False
self._repeat_mode: str = "no_repeat"
self._setup_player() self._setup_player()
self._setup_ui() self._setup_ui()
self.library_tab.set_playlist_manager(self.playlist_manager) 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.prev_requested.connect(self._on_prev)
self.player_header.play_pause_requested.connect(self._on_play_pause) self.player_header.play_pause_requested.connect(self._on_play_pause)
self.player_header.next_requested.connect(self._on_next) 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.position_changed_by_user.connect(self._on_user_seek_frac)
self.player_header.volume_changed.connect(self._on_volume_changed) self.player_header.volume_changed.connect(self._on_volume_changed)
main_layout.addWidget(self.player_header) 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_track_path = config.get("Playback", "last_track_path", fallback="")
self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0) 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._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.audio_output.setVolume(self._saved_volume / 100.0)
self.player_header.set_volume(self._saved_volume) self.player_header.set_volume(self._saved_volume)
@ -247,6 +258,8 @@ class MainWindow(QMainWindow):
config = self.config_loader config = self.config_loader
self.settings_tab.save_settings(config) self.settings_tab.save_settings(config)
config.set("Playback", "last_volume", str(self.player_header.volume_slider.value())) 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): if self.current_track_path and os.path.exists(self.current_track_path):
config.set("Playback", "last_track_path", self.current_track_path) config.set("Playback", "last_track_path", self.current_track_path)
position = self.player.position() position = self.player.position()
@ -353,10 +366,41 @@ class MainWindow(QMainWindow):
idx = max(0, self.library_tab.current_playback_index - 1) idx = max(0, self.library_tab.current_playback_index - 1)
self.library_tab.play_track_at_index(idx) 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): def _on_next(self):
rows = self.library_tab.library_table.rowCount() idx = self._get_next_index()
idx = min(rows - 1, self.library_tab.current_playback_index + 1) if idx is not None:
self.library_tab.play_track_at_index(idx) self.library_tab.play_track_at_index(idx)
def _seek_forward(self): def _seek_forward(self):
if self.player.duration() <= 0: if self.player.duration() <= 0:
@ -396,10 +440,29 @@ class MainWindow(QMainWindow):
pos = self.player.position() pos = self.player.position()
self.player_header.set_position(pos, duration) 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): def _on_media_status(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia: if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.library_tab.on_track_ended() 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: elif status == QMediaPlayer.MediaStatus.NoMedia:
self.library_tab.on_playing_changed(False) self.library_tab.on_playing_changed(False)
self.player_header.reset_info() self.player_header.reset_info()

View File

@ -174,6 +174,10 @@ class ConfigLoader:
self.config.set('Playback', 'last_volume', '80') self.config.set('Playback', 'last_volume', '80')
if not self.config.has_option('Playback', 'last_playing'): if not self.config.has_option('Playback', 'last_playing'):
self.config.set('Playback', 'last_playing', 'false') 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: def get(self, section: str, option: str, fallback: Any = None) -> Any:
""" """

View File

@ -256,6 +256,13 @@ class LibraryTab(QWidget):
layout.addLayout(toolbar) 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): 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(): if index < 0 or index >= self.library_table.rowCount():
return return

View File

@ -22,6 +22,8 @@ class PlayerHeader(QGroupBox):
prev_requested = pyqtSignal() prev_requested = pyqtSignal()
play_pause_requested = pyqtSignal() play_pause_requested = pyqtSignal()
next_requested = pyqtSignal() next_requested = pyqtSignal()
shuffle_requested = pyqtSignal()
repeat_requested = pyqtSignal()
position_changed_by_user = pyqtSignal(int) position_changed_by_user = pyqtSignal(int)
volume_changed = pyqtSignal(int) volume_changed = pyqtSignal(int)
@ -52,9 +54,21 @@ class PlayerHeader(QGroupBox):
self.next_btn.setToolTip("Next") self.next_btn.setToolTip("Next")
self.next_btn.clicked.connect(self.next_requested) 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.prev_btn)
controls_row.addWidget(self.play_btn) controls_row.addWidget(self.play_btn)
controls_row.addWidget(self.next_btn) controls_row.addWidget(self.next_btn)
controls_row.addWidget(self.repeat_btn)
self.time_label_start = QLabel("0:00") self.time_label_start = QLabel("0:00")
self.time_label_start.setFixedWidth(40) self.time_label_start.setFixedWidth(40)
@ -102,6 +116,28 @@ class PlayerHeader(QGroupBox):
now_playing_layout.addWidget(self.now_playing_label, stretch=1) now_playing_layout.addWidget(self.now_playing_label, stretch=1)
layout.addLayout(now_playing_layout) 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): def _on_slider_user_action(self):
self.position_changed_by_user.emit(self.position_slider.value()) self.position_changed_by_user.emit(self.position_slider.value())