Add iTunes-style playback bar and move Output Directory to Settings
- Add QMediaPlayer + QAudioOutput for playback within the app - Playback bar at top of Library tab with: - Transport controls: Prev, Play/Pause, Next - Seekable progress slider with current/total time labels - Volume slider - Now playing label showing Artist - Title - Double-click table row to start playback - Playback auto-advances to next track on end - Move Output Directory setting from Library tab to Settings tab - Output Directory now shown in a dedicated 'Library' group in Settings - Add _setup_player(), _play_track_from_index(), _format_time_ms() - Add all playback handlers for position, duration, status changes
This commit is contained in:
parent
7477cfba66
commit
a1881df910
226
src/main.py
226
src/main.py
@ -15,9 +15,10 @@ from PyQt6.QtWidgets import (
|
||||
QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog,
|
||||
QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QSpinBox, QGroupBox, QHeaderView,
|
||||
QTableWidget, QTableWidgetItem
|
||||
QTableWidget, QTableWidgetItem, QSlider
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
|
||||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||
|
||||
from youtube_downloader import YouTubeDownloader, TrackInfo
|
||||
from audio_converter import AudioConverter
|
||||
@ -221,7 +222,13 @@ class MainWindow(QMainWindow):
|
||||
self.ipod_devices: List[Dict] = []
|
||||
self.current_mount_point: Optional[str] = None
|
||||
|
||||
self.player: Optional[QMediaPlayer] = None
|
||||
self.audio_output: Optional[QAudioOutput] = None
|
||||
self.current_playback_index: int = -1
|
||||
self._is_playing = False
|
||||
|
||||
self._setup_ui()
|
||||
self._setup_player()
|
||||
self._scan_library()
|
||||
self._on_refresh_devices_clicked()
|
||||
|
||||
@ -310,19 +317,80 @@ class MainWindow(QMainWindow):
|
||||
def _setup_library_tab(self, tab):
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
output_layout = QHBoxLayout()
|
||||
output_label = QLabel("Output Directory:")
|
||||
self.output_dir_input = QLineEdit()
|
||||
self.output_dir_input.setText(os.path.join(os.path.expanduser("~"), "Music", "iPod"))
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn.clicked.connect(self._on_browse_output_clicked)
|
||||
refresh_btn = QPushButton("Refresh")
|
||||
# === Playback controls bar ===
|
||||
playback_group = QGroupBox()
|
||||
playback_layout = QVBoxLayout(playback_group)
|
||||
playback_layout.setContentsMargins(8, 6, 8, 6)
|
||||
|
||||
# Top row: transport buttons + progress + time + volume
|
||||
controls_row = QHBoxLayout()
|
||||
controls_row.setSpacing(8)
|
||||
|
||||
self.prev_btn = QPushButton("\u23EE")
|
||||
self.prev_btn.setFixedWidth(36)
|
||||
self.prev_btn.clicked.connect(self._on_prev_clicked)
|
||||
|
||||
self.play_btn = QPushButton("\u25B6")
|
||||
self.play_btn.setFixedWidth(44)
|
||||
self.play_btn.clicked.connect(self._on_play_pause_clicked)
|
||||
|
||||
self.next_btn = QPushButton("\u23ED")
|
||||
self.next_btn.setFixedWidth(36)
|
||||
self.next_btn.clicked.connect(self._on_next_clicked)
|
||||
|
||||
controls_row.addWidget(self.prev_btn)
|
||||
controls_row.addWidget(self.play_btn)
|
||||
controls_row.addWidget(self.next_btn)
|
||||
|
||||
self.time_label_start = QLabel("0:00")
|
||||
self.time_label_start.setFixedWidth(40)
|
||||
self.time_label_start.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self.position_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.position_slider.setMinimum(0)
|
||||
self.position_slider.setMaximum(1000)
|
||||
self.position_slider.setValue(0)
|
||||
self.position_slider.sliderPressed.connect(self._on_slider_pressed)
|
||||
self.position_slider.sliderReleased.connect(self._on_slider_released)
|
||||
|
||||
self.time_label_end = QLabel("0:00")
|
||||
self.time_label_end.setFixedWidth(40)
|
||||
self.time_label_end.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
controls_row.addWidget(self.time_label_start)
|
||||
controls_row.addWidget(self.position_slider, stretch=1)
|
||||
controls_row.addWidget(self.time_label_end)
|
||||
|
||||
vol_icon = QLabel("\U0001F50A")
|
||||
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.volume_slider.setFixedWidth(80)
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setValue(80)
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
|
||||
controls_row.addWidget(vol_icon)
|
||||
controls_row.addWidget(self.volume_slider)
|
||||
playback_layout.addLayout(controls_row)
|
||||
|
||||
# Bottom row: now playing info
|
||||
self.now_playing_label = QLabel("Select a track to play")
|
||||
self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;")
|
||||
now_playing_layout = QHBoxLayout()
|
||||
now_playing_layout.addStretch()
|
||||
now_playing_layout.addWidget(self.now_playing_label)
|
||||
now_playing_layout.addStretch()
|
||||
playback_layout.addLayout(now_playing_layout)
|
||||
|
||||
layout.addWidget(playback_group)
|
||||
|
||||
# Refresh button
|
||||
refresh_row = QHBoxLayout()
|
||||
refresh_btn = QPushButton("\u21BB Refresh Library")
|
||||
refresh_btn.clicked.connect(self._on_refresh_library_clicked)
|
||||
output_layout.addWidget(output_label)
|
||||
output_layout.addWidget(self.output_dir_input)
|
||||
output_layout.addWidget(browse_btn)
|
||||
output_layout.addWidget(refresh_btn)
|
||||
layout.addLayout(output_layout)
|
||||
refresh_row.addStretch()
|
||||
refresh_row.addWidget(refresh_btn)
|
||||
layout.addLayout(refresh_row)
|
||||
|
||||
self.ready_label = QLabel("Ready to Transfer:")
|
||||
layout.addWidget(self.ready_label)
|
||||
@ -337,6 +405,7 @@ class MainWindow(QMainWindow):
|
||||
self.library_ready_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.library_ready_table.setAlternatingRowColors(True)
|
||||
self.library_ready_table.setSortingEnabled(True)
|
||||
self.library_ready_table.cellDoubleClicked.connect(self._on_table_double_clicked)
|
||||
|
||||
header = self.library_ready_table.horizontalHeader()
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
@ -369,7 +438,7 @@ class MainWindow(QMainWindow):
|
||||
self.library_convert_btn.clicked.connect(self._on_library_convert_clicked)
|
||||
layout.addWidget(self.library_convert_btn)
|
||||
|
||||
self.library_transfer_btn = QPushButton("Transfer Selected to iPod ▸")
|
||||
self.library_transfer_btn = QPushButton("Transfer Selected to iPod \u25B8")
|
||||
self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked)
|
||||
layout.addWidget(self.library_transfer_btn)
|
||||
|
||||
@ -433,6 +502,21 @@ class MainWindow(QMainWindow):
|
||||
def _setup_settings_tab(self, tab):
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
library_group = QGroupBox("Library")
|
||||
library_layout = QVBoxLayout(library_group)
|
||||
|
||||
output_layout = QHBoxLayout()
|
||||
output_label = QLabel("Output Directory:")
|
||||
self.output_dir_input = QLineEdit()
|
||||
self.output_dir_input.setText(os.path.join(os.path.expanduser("~"), "Music", "iPod"))
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn.clicked.connect(self._on_browse_output_clicked)
|
||||
output_layout.addWidget(output_label)
|
||||
output_layout.addWidget(self.output_dir_input)
|
||||
output_layout.addWidget(browse_btn)
|
||||
library_layout.addLayout(output_layout)
|
||||
layout.addWidget(library_group)
|
||||
|
||||
video_group = QGroupBox("Video Support")
|
||||
video_layout = QVBoxLayout(video_group)
|
||||
|
||||
@ -488,6 +572,118 @@ class MainWindow(QMainWindow):
|
||||
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(about_label)
|
||||
|
||||
def _setup_player(self):
|
||||
"""Initialize QMediaPlayer and QAudioOutput for playback."""
|
||||
self.player = QMediaPlayer()
|
||||
self.audio_output = QAudioOutput()
|
||||
self.audio_output.setVolume(0.8)
|
||||
self.player.setAudioOutput(self.audio_output)
|
||||
|
||||
self.player.positionChanged.connect(self._on_media_position_changed)
|
||||
self.player.durationChanged.connect(self._on_media_duration_changed)
|
||||
self.player.mediaStatusChanged.connect(self._on_media_status_changed)
|
||||
|
||||
def _play_track_from_index(self, index: int):
|
||||
"""Start playing a track from the library table by row index."""
|
||||
if index < 0 or index >= self.library_ready_table.rowCount():
|
||||
return
|
||||
data_item = self.library_ready_table.item(index, 0)
|
||||
if data_item is None:
|
||||
return
|
||||
track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||
if not track_data or not os.path.exists(track_data.get("path", "")):
|
||||
return
|
||||
|
||||
self.current_playback_index = index
|
||||
self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
|
||||
self.player.play()
|
||||
self._is_playing = True
|
||||
self.play_btn.setText("\u23F8")
|
||||
|
||||
self.now_playing_label.setText(f"{track_data['artist']} \u2014 {track_data['title']}")
|
||||
self.now_playing_label.setStyleSheet(
|
||||
"color: palette(text); font-weight: bold;"
|
||||
)
|
||||
|
||||
# Highlight the current row
|
||||
self.library_ready_table.clearSelection()
|
||||
self.library_ready_table.selectRow(index)
|
||||
|
||||
def _on_play_pause_clicked(self):
|
||||
if self.player is None:
|
||||
return
|
||||
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||||
self.player.pause()
|
||||
self._is_playing = False
|
||||
self.play_btn.setText("\u25B6")
|
||||
else:
|
||||
if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState:
|
||||
# Nothing playing yet — play first selected row or row 0
|
||||
selected_rows = {item.row() for item in self.library_ready_table.selectedItems()}
|
||||
start = min(selected_rows) if selected_rows else 0
|
||||
self._play_track_from_index(start)
|
||||
else:
|
||||
self.player.play()
|
||||
self._is_playing = True
|
||||
self.play_btn.setText("\u23F8")
|
||||
|
||||
def _on_prev_clicked(self):
|
||||
if self.player is None:
|
||||
return
|
||||
if self.player.position() > 3000:
|
||||
self.player.setPosition(0)
|
||||
return
|
||||
idx = max(0, self.current_playback_index - 1)
|
||||
self._play_track_from_index(idx)
|
||||
|
||||
def _on_next_clicked(self):
|
||||
if self.player is None:
|
||||
return
|
||||
idx = min(self.library_ready_table.rowCount() - 1, self.current_playback_index + 1)
|
||||
self._play_track_from_index(idx)
|
||||
|
||||
def _on_slider_pressed(self):
|
||||
if self.player:
|
||||
self.player.setPosition(
|
||||
int(self.position_slider.value() / 1000.0 * self.player.duration())
|
||||
)
|
||||
|
||||
def _on_slider_released(self):
|
||||
pass
|
||||
|
||||
def _on_volume_changed(self, value):
|
||||
if self.audio_output:
|
||||
self.audio_output.setVolume(value / 100.0)
|
||||
|
||||
def _on_media_position_changed(self, position: int):
|
||||
if self.position_slider is None or self.player is None:
|
||||
return
|
||||
duration = self.player.duration()
|
||||
if duration > 0 and not self.position_slider.isSliderDown():
|
||||
self.position_slider.setValue(int(position / duration * 1000))
|
||||
self.time_label_start.setText(self._format_time_ms(position))
|
||||
|
||||
def _on_media_duration_changed(self, duration: int):
|
||||
self.time_label_end.setText(self._format_time_ms(duration))
|
||||
if self.position_slider and not self.position_slider.isSliderDown():
|
||||
self.position_slider.setValue(0)
|
||||
|
||||
def _on_media_status_changed(self, status):
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self._on_next_clicked()
|
||||
elif status == QMediaPlayer.MediaStatus.NoMedia:
|
||||
self.play_btn.setText("\u25B6")
|
||||
self._is_playing = False
|
||||
|
||||
def _on_table_double_clicked(self, row: int):
|
||||
self._play_track_from_index(row)
|
||||
|
||||
@staticmethod
|
||||
def _format_time_ms(ms: int) -> str:
|
||||
total_seconds = ms // 1000
|
||||
m, s = divmod(total_seconds, 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
if seconds <= 0:
|
||||
return "??:??"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user