Add shuffle and repeat controls with end-of-playlist fix
This commit is contained in:
parent
aaaf354c9d
commit
8861e55f32
71
src/app.py
71
src/app.py
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user