feat: iTunes-style sidebar + global player header + album grid
- New: sidebar_widget.py — iTunes-like sidebar with library/playlist/nav sections, GTK palette - New: player_header.py — global player header (cover, now playing, controls) above splitter - New: playlist_manager.py — playlist CRUD with JSON persistence - Refactor: moved QMediaPlayer from LibraryTab to MainWindow for clean architecture - Refactor: LibraryTab — removed all player code, added play_requested signal - Feat: album grid (IconMode) — grouped by album name, rounded covers 8px, Spotify-like - Feat: NumericTableItem — numeric sort for track # column (1,2,3...10,11 not 1,10,2) - Feat: track_num animation fixed — finds row by path, not index (no freeze on sort) - Feat: album_artist tag extracted and stored in TrackInfo - Feat: state persistence — view_mode/filtered_album saved to config.ini on close - Feat: Ctrl+B toggle sidebar - Fix: sidebar splitter handle colour from GTK palette
This commit is contained in:
+301
-287
@@ -14,11 +14,11 @@ from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
|
||||
QProgressBar, QFileDialog, QMessageBox, QGroupBox, QHeaderView,
|
||||
QTableWidget, QTableWidgetItem, QSlider, QMenu, QStyle, QProxyStyle
|
||||
QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog,
|
||||
QStackedWidget, QListWidget, QListWidgetItem, QListView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QUrl, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QColor
|
||||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath
|
||||
|
||||
from track_info import TrackInfo
|
||||
from metadata_handler import MetadataHandler
|
||||
@@ -41,19 +41,20 @@ EQ_FRAMES = [
|
||||
"\u2585\u2586\u2583", "\u2586\u2584\u2581",
|
||||
]
|
||||
|
||||
|
||||
class JumpStyle(QProxyStyle):
|
||||
def styleHint(self, hint, opt=None, widget=None, returnData=None):
|
||||
if hint == QStyle.StyleHint.SH_Slider_AbsoluteSetButtons:
|
||||
return Qt.MouseButton.LeftButton.value
|
||||
return super().styleHint(hint, opt, widget, returnData)
|
||||
class NumericTableItem(QTableWidgetItem):
|
||||
def __lt__(self, other):
|
||||
try:
|
||||
return int(self.text()) < int(other.text())
|
||||
except ValueError:
|
||||
return super().__lt__(other)
|
||||
|
||||
|
||||
class LibraryTab(QWidget):
|
||||
"""Library tab widget with playback, conversion and transfer."""
|
||||
"""Library tab widget with library table, filtering, and transfer workflows."""
|
||||
|
||||
transfer_finished = pyqtSignal()
|
||||
tab_switch_requested = pyqtSignal(int)
|
||||
play_requested = pyqtSignal(dict, int, bool)
|
||||
|
||||
def __init__(self, config_loader: ConfigLoader, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -63,19 +64,16 @@ class LibraryTab(QWidget):
|
||||
self.library_source_files: List[str] = []
|
||||
self.library_scanned_sources: List[Dict] = []
|
||||
|
||||
self.player: Optional[QMediaPlayer] = None
|
||||
self.audio_output: Optional[QAudioOutput] = None
|
||||
self.current_playback_index: int = -1
|
||||
self._current_track_path: str = ""
|
||||
self._eq_active: bool = False
|
||||
self._eq_timer: Optional[QTimer] = None
|
||||
self._eq_frame: int = 0
|
||||
self._eq_running: bool = False
|
||||
self._is_playing = False
|
||||
self._saved_volume: int = 80
|
||||
self._saved_track_path: str = ""
|
||||
self._saved_position_ms: int = 0
|
||||
self._saved_playing: bool = False
|
||||
self._pending_seek_ms: int = 0
|
||||
self._halfway_passed: set[int] = set()
|
||||
self._current_view_mode: str = "table"
|
||||
self._current_filtered_album: str = ""
|
||||
self._current_playlist_id: str = ""
|
||||
|
||||
self._library_toolbar_buttons: list = []
|
||||
|
||||
@@ -84,7 +82,9 @@ class LibraryTab(QWidget):
|
||||
self._current_mount_point: Optional[str] = None
|
||||
|
||||
self._setup_ui()
|
||||
self._setup_player()
|
||||
self._eq_timer = QTimer()
|
||||
self._eq_timer.setInterval(80)
|
||||
self._eq_timer.timeout.connect(self._tick_eq_animation)
|
||||
|
||||
def set_mount_point(self, mount_point: Optional[str]):
|
||||
self._current_mount_point = mount_point
|
||||
@@ -92,76 +92,6 @@ class LibraryTab(QWidget):
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
playback_group = QGroupBox()
|
||||
playback_layout = QVBoxLayout(playback_group)
|
||||
playback_layout.setContentsMargins(8, 6, 8, 6)
|
||||
|
||||
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.setStyle(JumpStyle(self.position_slider.style()))
|
||||
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.sliderMoved.connect(self._on_slider_pressed)
|
||||
self.position_slider.sliderReleased.connect(self._on_slider_pressed)
|
||||
|
||||
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)
|
||||
|
||||
now_playing_layout = QHBoxLayout()
|
||||
self.cover_label = QLabel()
|
||||
self.cover_label.setFixedSize(60, 60)
|
||||
self.cover_label.setScaledContents(True)
|
||||
self.cover_label.setStyleSheet("background: palette(window); border-radius: 4px;")
|
||||
now_playing_layout.addWidget(self.cover_label)
|
||||
|
||||
self.now_playing_label = QLabel("Select a track to play")
|
||||
self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;")
|
||||
now_playing_layout.addWidget(self.now_playing_label, stretch=1)
|
||||
playback_layout.addLayout(now_playing_layout)
|
||||
|
||||
layout.addWidget(playback_group)
|
||||
|
||||
self._setup_library_toolbar(layout)
|
||||
|
||||
self.library_table = QTableWidget()
|
||||
@@ -177,6 +107,7 @@ class LibraryTab(QWidget):
|
||||
self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked)
|
||||
self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.library_table.customContextMenuRequested.connect(self._on_library_table_context_menu)
|
||||
self.library_table.horizontalHeader().sortIndicatorChanged.connect(self._on_sort_changed)
|
||||
|
||||
header = self.library_table.horizontalHeader()
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
@@ -188,7 +119,28 @@ class LibraryTab(QWidget):
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
layout.addWidget(self.library_table, stretch=1)
|
||||
self.view_stack = QStackedWidget()
|
||||
|
||||
self.library_table.setParent(self.view_stack)
|
||||
self.view_stack.addWidget(self.library_table)
|
||||
|
||||
self.album_grid = QListWidget()
|
||||
self.album_grid.setViewMode(QListView.ViewMode.IconMode)
|
||||
self.album_grid.setIconSize(QSize(140, 140))
|
||||
self.album_grid.setGridSize(QSize(170, 210))
|
||||
self.album_grid.setWordWrap(True)
|
||||
self.album_grid.setResizeMode(QListWidget.ResizeMode.Adjust)
|
||||
self.album_grid.setSpacing(8)
|
||||
self.album_grid.setMovement(QListWidget.Movement.Static)
|
||||
self.album_grid.setStyleSheet("QListWidget { padding: 24px 12px 12px 12px; }")
|
||||
self.album_grid.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
|
||||
self.album_grid.setFlow(QListWidget.Flow.LeftToRight)
|
||||
self.album_grid.setWrapping(True)
|
||||
self.album_grid.itemClicked.connect(self._on_album_clicked)
|
||||
self.view_stack.addWidget(self.album_grid)
|
||||
|
||||
self.view_stack.setCurrentIndex(0)
|
||||
layout.addWidget(self.view_stack, stretch=1)
|
||||
|
||||
self.library_progress = QProgressBar()
|
||||
self.library_progress.setRange(0, 100)
|
||||
@@ -258,21 +210,7 @@ class LibraryTab(QWidget):
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
def _setup_player(self):
|
||||
self.player = QMediaPlayer()
|
||||
self.audio_output = QAudioOutput()
|
||||
self.audio_output.setVolume(self._saved_volume / 100.0)
|
||||
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)
|
||||
|
||||
self._eq_timer = QTimer()
|
||||
self._eq_timer.setInterval(80)
|
||||
self._eq_timer.timeout.connect(self._tick_eq_animation)
|
||||
|
||||
def _play_track_from_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():
|
||||
return
|
||||
data_item = self.library_table.item(index, 0)
|
||||
@@ -287,98 +225,34 @@ class LibraryTab(QWidget):
|
||||
self._halfway_passed.discard(old_index)
|
||||
|
||||
self.current_playback_index = index
|
||||
self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
|
||||
if auto_play:
|
||||
self.player.play()
|
||||
self._is_playing = True
|
||||
self.play_btn.setText("\u23F8")
|
||||
else:
|
||||
self._is_playing = False
|
||||
self.play_btn.setText("\u25B6")
|
||||
|
||||
self.now_playing_label.setText(f"{track_data['artist']} \u2014 {track_data['title']}")
|
||||
self.now_playing_label.setStyleSheet(
|
||||
"color: palette(text); font-weight: bold;"
|
||||
)
|
||||
|
||||
cover_path = track_data.get("cover_path")
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
pixmap = QPixmap(cover_path)
|
||||
self.cover_label.setPixmap(pixmap)
|
||||
self.cover_label.setToolTip(track_data.get("album", ""))
|
||||
else:
|
||||
artist = track_data.get("artist", "")
|
||||
album = track_data.get("album", "")
|
||||
cached = cover_cache.get(artist, album) if artist else None
|
||||
if cached and os.path.exists(cached):
|
||||
pixmap = QPixmap(cached)
|
||||
self.cover_label.setPixmap(pixmap)
|
||||
self.cover_label.setToolTip(album)
|
||||
else:
|
||||
self.cover_label.clear()
|
||||
self.cover_label.setToolTip("")
|
||||
self._current_track_path = track_data["path"]
|
||||
|
||||
self.library_table.clearSelection()
|
||||
self.library_table.selectRow(index)
|
||||
|
||||
self._set_playing_row_bg(index)
|
||||
|
||||
if auto_play:
|
||||
self._start_eq_animation()
|
||||
|
||||
if start_position_ms > 0:
|
||||
self._pending_seek_ms = start_position_ms
|
||||
else:
|
||||
self._pending_seek_ms = 0
|
||||
self.play_requested.emit(track_data, start_position_ms, auto_play)
|
||||
|
||||
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")
|
||||
def on_position_changed(self, position_ms: int, duration_ms: int):
|
||||
if duration_ms > 0 and position_ms >= duration_ms * 0.5:
|
||||
self._halfway_passed.add(self.current_playback_index)
|
||||
|
||||
def on_track_ended(self):
|
||||
self._increment_play_count()
|
||||
self._stop_eq_animation()
|
||||
|
||||
def on_track_paused(self):
|
||||
self._stop_eq_animation()
|
||||
|
||||
def on_playing_changed(self, is_playing: bool):
|
||||
self._eq_active = is_playing
|
||||
if is_playing:
|
||||
self._start_eq_animation()
|
||||
else:
|
||||
self._stop_eq_animation()
|
||||
else:
|
||||
if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState:
|
||||
selected_rows = {item.row() for item in self.library_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")
|
||||
self._start_eq_animation()
|
||||
|
||||
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_table.rowCount() - 1, self.current_playback_index + 1)
|
||||
self._play_track_from_index(idx)
|
||||
|
||||
def _seek_forward(self):
|
||||
if self.player is None or self.player.duration() <= 0:
|
||||
return
|
||||
new_pos = min(self.player.duration(), self.player.position() + 5000)
|
||||
self.player.setPosition(new_pos)
|
||||
|
||||
def _seek_backward(self):
|
||||
if self.player is None or self.player.duration() <= 0:
|
||||
return
|
||||
new_pos = max(0, self.player.position() - 5000)
|
||||
self.player.setPosition(new_pos)
|
||||
|
||||
def _adjust_volume(self, delta: int):
|
||||
new_val = max(0, min(100, self.volume_slider.value() + delta))
|
||||
self.volume_slider.setValue(new_val)
|
||||
|
||||
def _focus_search(self):
|
||||
self.library_search_input.setFocus()
|
||||
@@ -390,50 +264,6 @@ class LibraryTab(QWidget):
|
||||
def _delete_selected_current(self):
|
||||
self._on_library_remove_selected()
|
||||
|
||||
def _on_slider_pressed(self):
|
||||
if self.player:
|
||||
duration = self.player.duration()
|
||||
new_pos = int(self.position_slider.value() / 1000.0 * duration)
|
||||
new_pos = max(0, min(duration - 1, new_pos))
|
||||
self.player.setPosition(new_pos)
|
||||
|
||||
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))
|
||||
if duration > 0 and position >= duration * 0.5:
|
||||
self._halfway_passed.add(self.current_playback_index)
|
||||
|
||||
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._increment_play_count()
|
||||
self._stop_eq_animation()
|
||||
self._on_next_clicked()
|
||||
elif status == QMediaPlayer.MediaStatus.NoMedia:
|
||||
self._halfway_passed.discard(self.current_playback_index)
|
||||
self._stop_eq_animation()
|
||||
self.play_btn.setText("\u25B6")
|
||||
self._is_playing = False
|
||||
elif status == QMediaPlayer.MediaStatus.LoadedMedia:
|
||||
if self._pending_seek_ms > 0:
|
||||
self.player.setPosition(self._pending_seek_ms)
|
||||
self._pending_seek_ms = 0
|
||||
|
||||
def _increment_play_count(self):
|
||||
idx = self.current_playback_index
|
||||
if idx not in self._halfway_passed:
|
||||
@@ -475,8 +305,8 @@ class LibraryTab(QWidget):
|
||||
return
|
||||
self._eq_timer.stop()
|
||||
self._eq_running = False
|
||||
idx = self.current_playback_index
|
||||
if idx >= 0 and idx < self.library_table.rowCount():
|
||||
idx = self._find_row_by_path(self._current_track_path) if self._current_track_path else -1
|
||||
if idx >= 0:
|
||||
item = self.library_table.item(idx, 0)
|
||||
if item:
|
||||
is_ready, data = item.data(Qt.ItemDataRole.UserRole)
|
||||
@@ -485,8 +315,10 @@ class LibraryTab(QWidget):
|
||||
item.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||
|
||||
def _tick_eq_animation(self):
|
||||
idx = self.current_playback_index
|
||||
if idx < 0 or idx >= self.library_table.rowCount():
|
||||
if not self._current_track_path:
|
||||
return
|
||||
idx = self._find_row_by_path(self._current_track_path)
|
||||
if idx < 0:
|
||||
return
|
||||
item = self.library_table.item(idx, 0)
|
||||
if item is None:
|
||||
@@ -531,14 +363,27 @@ class LibraryTab(QWidget):
|
||||
item0.setText(str(track_num) if (is_ready and track_num) else "")
|
||||
item0.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||
|
||||
def _on_table_double_clicked(self, row: int):
|
||||
self._play_track_from_index(row)
|
||||
def _find_row_by_path(self, path: str) -> int:
|
||||
if not path:
|
||||
return -1
|
||||
for row in range(self.library_table.rowCount()):
|
||||
item = self.library_table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
_, data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if data and data.get("path", "") == path:
|
||||
return row
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def _format_time_ms(ms: int) -> str:
|
||||
total_seconds = ms // 1000
|
||||
m, s = divmod(total_seconds, 60)
|
||||
return f"{m}:{s:02d}"
|
||||
def _on_sort_changed(self, _column: int, _order: int):
|
||||
if self._current_track_path:
|
||||
idx = self._find_row_by_path(self._current_track_path)
|
||||
if idx >= 0:
|
||||
self.current_playback_index = idx
|
||||
self._set_playing_row_bg(idx)
|
||||
|
||||
def _on_table_double_clicked(self, row: int):
|
||||
self.play_track_at_index(row)
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
if seconds <= 0:
|
||||
@@ -615,18 +460,6 @@ class LibraryTab(QWidget):
|
||||
self.library_scanned_sources = sources
|
||||
self._refresh_library_ui()
|
||||
|
||||
def _resume_playback_if_saved(self):
|
||||
if not self._saved_track_path or not os.path.exists(self._saved_track_path):
|
||||
return
|
||||
for row in range(self.library_table.rowCount()):
|
||||
data_item = self.library_table.item(row, 0)
|
||||
if data_item is None:
|
||||
continue
|
||||
_is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||
if track_data and track_data.get("path", "") == self._saved_track_path:
|
||||
self._play_track_from_index(row, start_position_ms=self._saved_position_ms, auto_play=self._saved_playing)
|
||||
return
|
||||
|
||||
def _extract_ready_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]:
|
||||
size = os.path.getsize(fpath)
|
||||
cover_path = None
|
||||
@@ -639,6 +472,7 @@ class LibraryTab(QWidget):
|
||||
genre = info.genre if info else ""
|
||||
track_num = info.track_number if info else 0
|
||||
cover_path = info.cover_path if info else None
|
||||
album_artist = info.album_artist if info else ""
|
||||
except Exception:
|
||||
duration = 0
|
||||
artist = "Unknown"
|
||||
@@ -646,10 +480,12 @@ class LibraryTab(QWidget):
|
||||
album = ""
|
||||
genre = ""
|
||||
track_num = 0
|
||||
album_artist = ""
|
||||
return {
|
||||
"path": fpath, "title": title, "artist": artist,
|
||||
"album": album, "duration_s": duration, "size": size,
|
||||
"genre": genre, "track_num": track_num, "cover_path": cover_path,
|
||||
"album_artist": album_artist,
|
||||
}
|
||||
|
||||
def _extract_source_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]:
|
||||
@@ -731,7 +567,7 @@ class LibraryTab(QWidget):
|
||||
duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014"
|
||||
|
||||
cells = [
|
||||
QTableWidgetItem(track_num_str),
|
||||
NumericTableItem(track_num_str),
|
||||
QTableWidgetItem(track["artist"]),
|
||||
QTableWidgetItem(track["title"]),
|
||||
QTableWidgetItem(track.get("album", "") or ""),
|
||||
@@ -777,14 +613,13 @@ class LibraryTab(QWidget):
|
||||
self.library_status.setText(status)
|
||||
|
||||
self._restore_playing_row()
|
||||
self._build_album_grid()
|
||||
|
||||
def _restore_playing_row(self):
|
||||
idx = self.current_playback_index
|
||||
if idx < 0:
|
||||
return
|
||||
track_path = ""
|
||||
if self.player and self.player.source().isValid():
|
||||
track_path = self.player.source().toLocalFile()
|
||||
track_path = self._current_track_path
|
||||
if not track_path and idx < self.library_table.rowCount():
|
||||
item = self.library_table.item(idx, 0)
|
||||
if item:
|
||||
@@ -804,7 +639,7 @@ class LibraryTab(QWidget):
|
||||
if data and data.get("path") == track_path:
|
||||
self.current_playback_index = row
|
||||
self._set_playing_row_bg(row)
|
||||
if self._is_playing:
|
||||
if self._eq_active:
|
||||
self._start_eq_animation()
|
||||
return
|
||||
|
||||
@@ -1027,7 +862,7 @@ class LibraryTab(QWidget):
|
||||
menu = QMenu(self)
|
||||
|
||||
play_action = menu.addAction("\u25B6 Play")
|
||||
play_action.triggered.connect(lambda: self._play_track_from_index(row))
|
||||
play_action.triggered.connect(lambda: self.play_track_at_index(row))
|
||||
|
||||
if is_ready and self._current_mount_point is not None:
|
||||
sync_action = menu.addAction("\uD83D\uDD04 Sync Metadata to iPod")
|
||||
@@ -1038,6 +873,20 @@ class LibraryTab(QWidget):
|
||||
convert_action = menu.addAction("\uD83D\uDD04 Convert to iPod Format")
|
||||
convert_action.triggered.connect(self._on_library_convert_clicked)
|
||||
|
||||
add_pl_menu = menu.addMenu("Add to Playlist")
|
||||
playlist_manager = getattr(self, "_playlist_manager", None)
|
||||
if playlist_manager is not None:
|
||||
for pl in playlist_manager.get_all():
|
||||
pl_action = add_pl_menu.addAction(f"\U0001F4CB {pl.name}")
|
||||
pl_action.triggered.connect(
|
||||
lambda checked, pid=pl.id: self._add_selected_to_playlist(pid)
|
||||
)
|
||||
add_pl_menu.addSeparator()
|
||||
new_pl_action = add_pl_menu.addAction("\u2795 New Playlist...")
|
||||
new_pl_action.triggered.connect(self._add_selected_to_new_playlist)
|
||||
else:
|
||||
add_pl_menu.setEnabled(False)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
edit_action = menu.addAction("\u270F\uFE0F Edit Metadata")
|
||||
@@ -1051,6 +900,38 @@ class LibraryTab(QWidget):
|
||||
|
||||
menu.exec(self.library_table.viewport().mapToGlobal(pos))
|
||||
|
||||
def _add_selected_to_playlist(self, playlist_id: str):
|
||||
paths = self._get_selected_track_paths()
|
||||
if not paths:
|
||||
return
|
||||
playlist_manager = getattr(self, "_playlist_manager", None)
|
||||
if playlist_manager:
|
||||
playlist_manager.add_tracks(playlist_id, paths)
|
||||
|
||||
def _add_selected_to_new_playlist(self):
|
||||
from PyQt6.QtWidgets import QInputDialog
|
||||
name, ok = QInputDialog.getText(self, "New Playlist", "Playlist name:")
|
||||
if not (ok and name.strip()):
|
||||
return
|
||||
name = name.strip()
|
||||
playlist_manager = getattr(self, "_playlist_manager", None)
|
||||
if playlist_manager is None:
|
||||
return
|
||||
paths = self._get_selected_track_paths()
|
||||
playlist_id = playlist_manager.create(name)
|
||||
if paths:
|
||||
playlist_manager.add_tracks(playlist_id, paths)
|
||||
self._notify_sidebar_playlist_created(playlist_id, name)
|
||||
|
||||
def _notify_sidebar_playlist_created(self, playlist_id: str, name: str):
|
||||
parent = self.parent()
|
||||
while parent is not None:
|
||||
sidebar = getattr(parent, "sidebar", None)
|
||||
if sidebar is not None:
|
||||
sidebar.add_playlist_item(playlist_id, name)
|
||||
break
|
||||
parent = parent.parent()
|
||||
|
||||
def _on_show_in_file_manager(self, track_data):
|
||||
path = track_data.get("path", "")
|
||||
if not path:
|
||||
@@ -1261,31 +1142,6 @@ class LibraryTab(QWidget):
|
||||
self.worker_thread.deleteLater()
|
||||
self.worker_thread = None
|
||||
|
||||
def load_player_settings(self, config):
|
||||
self._saved_volume = config.get_int("Playback", "last_volume", fallback=80)
|
||||
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)
|
||||
|
||||
def save_player_settings(self, config):
|
||||
config.set("Playback", "last_volume", str(self.volume_slider.value()))
|
||||
if self.current_playback_index >= 0 and self.player.source().isValid():
|
||||
track_path = self.player.source().toLocalFile()
|
||||
if track_path and os.path.exists(track_path):
|
||||
config.set("Playback", "last_track_path", track_path)
|
||||
position = self.player.position()
|
||||
duration = self.player.duration()
|
||||
if duration > 0 and position > duration - 2000:
|
||||
position = 0
|
||||
config.set("Playback", "last_position_ms", str(position))
|
||||
config.set("Playback", "last_playing", str(self._is_playing).lower())
|
||||
else:
|
||||
config.set("Playback", "last_track_path", "")
|
||||
config.set("Playback", "last_position_ms", "0")
|
||||
else:
|
||||
config.set("Playback", "last_track_path", "")
|
||||
config.set("Playback", "last_position_ms", "0")
|
||||
|
||||
def on_device_mounted(self, mount_point: str):
|
||||
self._current_mount_point = mount_point
|
||||
self.library_progress.setVisible(True)
|
||||
@@ -1298,6 +1154,153 @@ class LibraryTab(QWidget):
|
||||
self.library_progress.setVisible(False)
|
||||
self.library_status.setVisible(False)
|
||||
|
||||
def set_playlist_manager(self, playlist_manager):
|
||||
self._playlist_manager = playlist_manager
|
||||
|
||||
def _get_selected_track_paths(self) -> list:
|
||||
paths = []
|
||||
for item in self.library_table.selectedItems():
|
||||
data_item = self.library_table.item(item.row(), 0)
|
||||
if data_item is None:
|
||||
continue
|
||||
_is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||
if track_data and track_data.get("path"):
|
||||
paths.append(track_data["path"])
|
||||
return list(set(paths))
|
||||
|
||||
def apply_filter(self, kind: str, value):
|
||||
if kind == "playlist":
|
||||
self._apply_playlist_filter(value)
|
||||
|
||||
def set_view_mode(self, mode: str):
|
||||
"""mode: 'table', 'recent', 'artists', 'albums', 'songs'"""
|
||||
self._current_view_mode = mode
|
||||
self._current_filtered_album = ""
|
||||
self._current_playlist_id = ""
|
||||
self.view_stack.setCurrentIndex(0)
|
||||
self._unhide_all_rows()
|
||||
|
||||
if mode == "albums":
|
||||
self.view_stack.setCurrentIndex(1)
|
||||
self.album_grid.clearSelection()
|
||||
elif mode == "artists":
|
||||
self.library_table.sortItems(1, Qt.SortOrder.AscendingOrder)
|
||||
elif mode == "songs":
|
||||
self.library_table.sortItems(2, Qt.SortOrder.AscendingOrder)
|
||||
elif mode == "recent":
|
||||
cutoff = int(time.time()) - (30 * 86400)
|
||||
cache = get_library_cache()
|
||||
for row in range(self.library_table.rowCount()):
|
||||
data_item = self.library_table.item(row, 0)
|
||||
if data_item is None:
|
||||
continue
|
||||
_is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||
path = track_data.get("path", "") if track_data else ""
|
||||
entry = cache.get(path) if path else None
|
||||
visible = False
|
||||
if entry and isinstance(entry, dict):
|
||||
added = entry.get("_mtime", 0) or entry.get("added_at", 0) or 0
|
||||
visible = added >= cutoff
|
||||
self.library_table.setRowHidden(row, not visible)
|
||||
|
||||
def clear_filter(self):
|
||||
self._unhide_all_rows()
|
||||
|
||||
def _unhide_all_rows(self):
|
||||
for row in range(self.library_table.rowCount()):
|
||||
self.library_table.setRowHidden(row, False)
|
||||
|
||||
def _round_pixmap(self, source: QPixmap, radius: int) -> QPixmap:
|
||||
size = min(source.width(), source.height())
|
||||
result = QPixmap(size, size)
|
||||
result.fill(Qt.GlobalColor.transparent)
|
||||
p = QPainter(result)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(0, 0, size, size, radius, radius)
|
||||
p.setClipPath(path)
|
||||
p.drawPixmap(0, 0, source.scaled(size, size, Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation))
|
||||
p.end()
|
||||
return result
|
||||
|
||||
def _build_album_grid(self):
|
||||
self.album_grid.clear()
|
||||
albums = {}
|
||||
for track in self.library_ready:
|
||||
album = track.get("album", "") or "Unknown Album"
|
||||
key = album.lower()
|
||||
if key not in albums:
|
||||
albums[key] = (album, track.get("cover_path", ""))
|
||||
for track in self.library_ready:
|
||||
album = track.get("album", "") or "Unknown Album"
|
||||
key = album.lower()
|
||||
if key in albums and not albums[key][1]:
|
||||
albums[key] = (album, track.get("cover_path", ""))
|
||||
|
||||
for album, cover_path in albums.values():
|
||||
pixmap = None
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
pixmap = QPixmap(cover_path)
|
||||
if pixmap is None or pixmap.isNull():
|
||||
for track in self.library_ready:
|
||||
t_album = track.get("album", "") or "Unknown Album"
|
||||
if t_album.lower() == album.lower() and track.get("cover_path"):
|
||||
cached = cover_cache.get(
|
||||
track.get("album_artist") or track.get("artist", ""),
|
||||
album,
|
||||
)
|
||||
if cached and os.path.exists(cached):
|
||||
pixmap = QPixmap(cached)
|
||||
break
|
||||
if pixmap is None or pixmap.isNull():
|
||||
pixmap = QPixmap(140, 140)
|
||||
clr = self.palette().color(self.palette().ColorRole.Midlight)
|
||||
pixmap.fill(clr)
|
||||
|
||||
scaled = pixmap.scaled(140, 140, Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
rounded = self._round_pixmap(scaled, 8)
|
||||
icon = QIcon(rounded)
|
||||
item = QListWidgetItem(icon, album)
|
||||
item.setData(Qt.ItemDataRole.UserRole, album)
|
||||
self.album_grid.addItem(item)
|
||||
|
||||
def _on_album_clicked(self, item: QListWidgetItem):
|
||||
album = item.data(Qt.ItemDataRole.UserRole)
|
||||
self.album_grid.clearSelection()
|
||||
self.filter_by_album(album)
|
||||
|
||||
def filter_by_album(self, album: str):
|
||||
if not album:
|
||||
return
|
||||
self._current_filtered_album = album
|
||||
self._current_view_mode = "albums"
|
||||
self.view_stack.setCurrentIndex(0)
|
||||
album_lower = album.lower()
|
||||
for row in range(self.library_table.rowCount()):
|
||||
t_item = self.library_table.item(row, 0)
|
||||
if t_item is None:
|
||||
self.library_table.setRowHidden(row, False)
|
||||
continue
|
||||
_is_ready, track_data = t_item.data(Qt.ItemDataRole.UserRole)
|
||||
if not track_data:
|
||||
self.library_table.setRowHidden(row, True)
|
||||
continue
|
||||
t_album = (track_data.get("album") or "").lower()
|
||||
self.library_table.setRowHidden(row, t_album != album_lower)
|
||||
|
||||
def _apply_playlist_filter(self, track_paths):
|
||||
path_set = set(os.path.normpath(p) for p in track_paths)
|
||||
for row in range(self.library_table.rowCount()):
|
||||
data_item = self.library_table.item(row, 0)
|
||||
if data_item is None:
|
||||
self.library_table.setRowHidden(row, False)
|
||||
continue
|
||||
_is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||
row_path = os.path.normpath(track_data.get("path", "")) if track_data else ""
|
||||
self.library_table.setRowHidden(row, row_path not in path_set)
|
||||
|
||||
def set_buttons_visible(self, visible: bool):
|
||||
for btn in self._library_toolbar_buttons:
|
||||
btn.setVisible(visible)
|
||||
@@ -1310,3 +1313,14 @@ class LibraryTab(QWidget):
|
||||
|
||||
def get_convert_quality(self):
|
||||
return self.config_loader.get_int("General", "quality", fallback=256)
|
||||
|
||||
def set_current_playlist_id(self, playlist_id: str):
|
||||
self._current_playlist_id = playlist_id
|
||||
self._current_view_mode = "playlist"
|
||||
|
||||
def get_state(self) -> dict:
|
||||
return {
|
||||
"view_mode": self._current_view_mode,
|
||||
"filtered_album": self._current_filtered_album,
|
||||
"filtered_playlist_id": self._current_playlist_id,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user