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:
Maksim Totmin
2026-06-01 14:46:58 +07:00
parent 4c703db802
commit 199fb43f04
8 changed files with 1177 additions and 316 deletions
+301 -287
View File
@@ -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,
}