Redesign Library tab and add Download tab toggle

Library tab redesign:
- Merge 'Ready to Transfer' table and 'Source Files' list into one unified table
- Replace bottom buttons with top toolbar (Add Files, Convert, Transfer, Remove, Refresh, Search)
- Add search/filter bar to filter tracks by title
- Add context menu (right-click): Play, Convert/Transfer, Show in File Manager, Remove
- Source tracks shown in gray (dimmed), ready tracks in normal color
- Remove status column with emoji — clean iTunes-like layout

Settings:
- Add 'Show YouTube Download Tab' checkbox in Advanced section
- Download tab hidden by default, can be enabled in Settings
- Uses QTabWidget.setTabVisible() to toggle without destroying widgets
This commit is contained in:
Maksim Totmin 2026-05-31 16:35:13 +07:00
parent 885a401ad0
commit 8c576232b1

View File

@ -15,7 +15,7 @@ from PyQt6.QtWidgets import (
QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog,
QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem,
QMessageBox, QSpinBox, QGroupBox, QHeaderView,
QTableWidget, QTableWidgetItem, QSlider
QTableWidget, QTableWidgetItem, QSlider, QMenu
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
@ -219,8 +219,10 @@ class MainWindow(QMainWindow):
self.downloaded_tracks: List[TrackInfo] = []
self.library_ready: List[Dict] = []
self.library_source_files: List[str] = []
self.library_scanned_sources: List[Dict] = []
self.ipod_devices: List[Dict] = []
self.current_mount_point: Optional[str] = None
self.show_download_tab = False
self.player: Optional[QMediaPlayer] = None
self.audio_output: Optional[QAudioOutput] = None
@ -248,25 +250,28 @@ class MainWindow(QMainWindow):
main_widget = QWidget()
main_layout = QVBoxLayout(main_widget)
tab_widget = QTabWidget()
main_layout.addWidget(tab_widget)
self.tab_widget = QTabWidget()
main_layout.addWidget(self.tab_widget)
download_tab = QWidget()
tab_widget.addTab(download_tab, "Download")
self.tab_widget.addTab(download_tab, "Download")
self._setup_download_tab(download_tab)
self.download_tab_index = 0
library_tab = QWidget()
tab_widget.addTab(library_tab, "Library")
self.tab_widget.addTab(library_tab, "Library")
self._setup_library_tab(library_tab)
ipod_tab = QWidget()
tab_widget.addTab(ipod_tab, "iPod")
self.tab_widget.addTab(ipod_tab, "iPod")
self._setup_ipod_tab(ipod_tab)
settings_tab = QWidget()
tab_widget.addTab(settings_tab, "Settings")
self.tab_widget.addTab(settings_tab, "Settings")
self._setup_settings_tab(settings_tab)
self.tab_widget.setTabVisible(self.download_tab_index, self.show_download_tab)
self.statusBar().showMessage("Ready")
self.setCentralWidget(main_widget)
@ -384,30 +389,23 @@ class MainWindow(QMainWindow):
layout.addWidget(playback_group)
# Refresh button
refresh_row = QHBoxLayout()
refresh_btn = QPushButton("\u21BB Refresh Library")
refresh_btn.clicked.connect(self._on_refresh_library_clicked)
refresh_row.addStretch()
refresh_row.addWidget(refresh_btn)
layout.addLayout(refresh_row)
self._setup_library_toolbar(layout)
self.ready_label = QLabel("Ready to Transfer:")
layout.addWidget(self.ready_label)
self.library_ready_table = QTableWidget()
self.library_ready_table.setColumnCount(7)
self.library_ready_table.setHorizontalHeaderLabels(
self.library_table = QTableWidget()
self.library_table.setColumnCount(7)
self.library_table.setHorizontalHeaderLabels(
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"]
)
self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
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)
self.library_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
self.library_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.library_table.setAlternatingRowColors(True)
self.library_table.setSortingEnabled(True)
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)
header = self.library_ready_table.horizontalHeader()
header = self.library_table.horizontalHeader()
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
@ -416,32 +414,7 @@ class MainWindow(QMainWindow):
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
layout.addWidget(self.library_ready_table, stretch=2)
self.source_label = QLabel("Source Files — Need Conversion:")
layout.addWidget(self.source_label)
self.library_source_list = QListWidget()
self.library_source_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
layout.addWidget(self.library_source_list, stretch=1)
btn_row = QHBoxLayout()
self.library_add_btn = QPushButton("Add Files...")
self.library_add_btn.clicked.connect(self._on_library_add_files)
self.library_remove_btn = QPushButton("Remove Selected")
self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
btn_row.addWidget(self.library_add_btn)
btn_row.addWidget(self.library_remove_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.library_convert_btn = QPushButton("Convert Selected")
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 \u25B8")
self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked)
layout.addWidget(self.library_transfer_btn)
layout.addWidget(self.library_table, stretch=1)
self.library_progress = QProgressBar()
self.library_progress.setRange(0, 100)
@ -450,6 +423,44 @@ class MainWindow(QMainWindow):
self.library_status = QLabel("Ready")
layout.addWidget(self.library_status)
def _setup_library_toolbar(self, layout):
toolbar = QHBoxLayout()
toolbar.setSpacing(4)
self.library_add_btn = QPushButton("\u2795 Add Files...")
self.library_add_btn.clicked.connect(self._on_library_add_files)
toolbar.addWidget(self.library_add_btn)
self.library_convert_btn = QPushButton("\uD83D\uDD04 Convert")
self.library_convert_btn.setEnabled(False)
self.library_convert_btn.clicked.connect(self._on_library_convert_clicked)
toolbar.addWidget(self.library_convert_btn)
self.library_transfer_btn = QPushButton("\uD83D\uDCE4 Transfer")
self.library_transfer_btn.setEnabled(False)
self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked)
toolbar.addWidget(self.library_transfer_btn)
self.library_remove_btn = QPushButton("\uD83D\uDDD1\uFE0F Remove")
self.library_remove_btn.setEnabled(False)
self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
toolbar.addWidget(self.library_remove_btn)
refresh_btn = QPushButton("\uD83D\uDD04")
refresh_btn.setToolTip("Refresh Library")
refresh_btn.clicked.connect(self._on_refresh_library_clicked)
toolbar.addWidget(refresh_btn)
toolbar.addStretch()
self.library_search_input = QLineEdit()
self.library_search_input.setPlaceholderText("\uD83D\uDD0D Search Library...")
self.library_search_input.setFixedWidth(220)
self.library_search_input.textChanged.connect(self._on_search_text_changed)
toolbar.addWidget(self.library_search_input)
layout.addLayout(toolbar)
def _setup_ipod_tab(self, tab):
layout = QVBoxLayout(tab)
@ -565,6 +576,11 @@ class MainWindow(QMainWindow):
self.auto_detect_check.setChecked(True)
advanced_layout.addWidget(self.auto_detect_check)
self.show_download_check = QCheckBox("Show YouTube Download Tab")
self.show_download_check.setChecked(False)
self.show_download_check.toggled.connect(self._on_show_download_toggled)
advanced_layout.addWidget(self.show_download_check)
layout.addWidget(advanced_group)
layout.addStretch()
@ -577,6 +593,10 @@ class MainWindow(QMainWindow):
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(about_label)
def _on_show_download_toggled(self, checked: bool):
self.show_download_tab = checked
self.tab_widget.setTabVisible(self.download_tab_index, checked)
def _setup_player(self):
"""Initialize QMediaPlayer and QAudioOutput for playback."""
self.player = QMediaPlayer()
@ -590,12 +610,12 @@ class MainWindow(QMainWindow):
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():
if index < 0 or index >= self.library_table.rowCount():
return
data_item = self.library_ready_table.item(index, 0)
data_item = self.library_table.item(index, 0)
if data_item is None:
return
track_data = data_item.data(Qt.ItemDataRole.UserRole)
is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
if not track_data or not os.path.exists(track_data.get("path", "")):
return
@ -611,8 +631,8 @@ class MainWindow(QMainWindow):
)
# Highlight the current row
self.library_ready_table.clearSelection()
self.library_ready_table.selectRow(index)
self.library_table.clearSelection()
self.library_table.selectRow(index)
def _on_play_pause_clicked(self):
if self.player is None:
@ -624,7 +644,7 @@ class MainWindow(QMainWindow):
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()}
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:
@ -644,7 +664,7 @@ class MainWindow(QMainWindow):
def _on_next_clicked(self):
if self.player is None:
return
idx = min(self.library_ready_table.rowCount() - 1, self.current_playback_index + 1)
idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1)
self._play_track_from_index(idx)
def _on_slider_pressed(self):
@ -705,8 +725,8 @@ class MainWindow(QMainWindow):
output_dir = self.output_dir_input.text().strip()
if not output_dir or not os.path.isdir(output_dir):
self.library_ready.clear()
self.library_scanned_sources = []
self._refresh_library_ui()
self._refresh_source_ui([])
return
ready = []
@ -778,68 +798,33 @@ class MainWindow(QMainWindow):
ready = deduped_ready
self.library_ready = ready
self.library_scanned_sources = sources
self._refresh_library_ui()
self._refresh_source_ui(sources)
def _refresh_library_ui(self):
self.library_ready_table.setRowCount(0)
self.library_ready_table.setSortingEnabled(False)
for track in sorted(
self.library_ready,
key=lambda t: (t.get("track_num") or 0, t["artist"].lower(), t["title"].lower())
):
row = self.library_ready_table.rowCount()
self.library_ready_table.insertRow(row)
track_num_str = str(track["track_num"]) if track.get("track_num") else ""
cells = [
QTableWidgetItem(track_num_str),
QTableWidgetItem(track["artist"]),
QTableWidgetItem(track["title"]),
QTableWidgetItem(track.get("album", "") or ""),
QTableWidgetItem(self._format_duration(track["duration_s"])),
QTableWidgetItem(track.get("genre", "") or ""),
QTableWidgetItem(self._format_size(track["size"])),
]
cells[0].setData(Qt.ItemDataRole.UserRole, track)
for col, item in enumerate(cells):
self.library_ready_table.setItem(row, col, item)
self.library_ready_table.setSortingEnabled(True)
count = len(self.library_ready)
self.ready_label.setVisible(count > 0)
self.library_ready_table.setVisible(count > 0)
if count:
self.library_status.setText(f"{count} track(s) ready to transfer")
self.library_transfer_btn.setEnabled(True)
else:
self.library_status.setText("No tracks ready — convert source files or download")
self.library_transfer_btn.setEnabled(False)
def _refresh_source_ui(self, scanned_sources):
"""Build the Source Files list by combining scanned files + manual additions.
Deduplicates by path. All items get UserRole data for _get_selected_source_files().
"""
self.library_source_list.clear()
self.library_table.setSortingEnabled(False)
self.library_table.setRowCount(0)
seen_paths = set()
all_sources = []
all_tracks = []
for src in scanned_sources:
path = src.get("path", "")
# Add ready tracks
for t in self.library_ready:
path = t.get("path", "")
if path and path not in seen_paths and os.path.exists(path):
seen_paths.add(path)
all_sources.append(src)
all_tracks.append({**t, "_status": "ready"})
# Add scanned source files
for s in self.library_scanned_sources:
path = s.get("path", "")
if path and path not in seen_paths and os.path.exists(path):
seen_paths.add(path)
all_tracks.append({**s, "duration_s": 0, "genre": "", "_status": "source"})
# Add manually added source files
for fpath in self.library_source_files:
if fpath in seen_paths:
continue
if not os.path.exists(fpath):
if fpath in seen_paths or not os.path.exists(fpath):
continue
fname = os.path.basename(fpath)
size = os.path.getsize(fpath)
@ -851,65 +836,115 @@ class MainWindow(QMainWindow):
except Exception:
title = fname
artist = "Unknown"
src = {
"path": fpath,
"title": title,
"artist": artist,
"size": size,
}
all_sources.append(src)
seen_paths.add(fpath)
all_tracks.append({
"path": fpath, "title": title, "artist": artist,
"size": size, "duration_s": 0, "genre": "", "album": "",
"_status": "source",
})
for src in sorted(all_sources, key=lambda s: (s["artist"], s["title"])):
display = f"{src['artist']}{src['title']} {self._format_size(src['size'])}"
item = QListWidgetItem(display)
item.setData(Qt.ItemDataRole.UserRole, src)
self.library_source_list.addItem(item)
all_tracks.sort(key=lambda t: (t["artist"].lower(), t["title"].lower()))
count = len(all_sources)
self.source_label.setVisible(count > 0)
self.library_source_list.setVisible(count > 0)
self.library_convert_btn.setEnabled(count > 0)
for idx, track in enumerate(all_tracks):
row = self.library_table.rowCount()
self.library_table.insertRow(row)
is_ready = track["_status"] == "ready"
track_num_str = str(track.get("track_num") or "") if is_ready else ""
duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014"
cells = [
QTableWidgetItem(track_num_str),
QTableWidgetItem(track["artist"]),
QTableWidgetItem(track["title"]),
QTableWidgetItem(track.get("album", "") or ""),
QTableWidgetItem(duration_str),
QTableWidgetItem(track.get("genre", "") or ""),
QTableWidgetItem(self._format_size(track["size"])),
]
# Dim source tracks (gray text) like iTunes
if not is_ready:
gray = self.palette().color(self.palette().ColorRole.PlaceholderText)
for item in cells:
item.setForeground(gray)
# Store full track data in column 0
data_key = track.copy()
data_key.pop("_status", None)
cells[0].setData(Qt.ItemDataRole.UserRole, (is_ready, data_key))
for col, item in enumerate(cells):
self.library_table.setItem(row, col, item)
self.library_table.setSortingEnabled(True)
ready_count = sum(1 for t in all_tracks if t["_status"] == "ready")
source_count = len(all_tracks) - ready_count
has_ready = ready_count > 0
has_source = source_count > 0
self.library_transfer_btn.setEnabled(has_ready)
self.library_convert_btn.setEnabled(has_source)
self.library_remove_btn.setEnabled(len(all_tracks) > 0)
parts = []
if ready_count:
parts.append(f"{ready_count} ready")
if source_count:
parts.append(f"{source_count} need conversion")
status = f"{len(all_tracks)} track(s) \u2014 {', '.join(parts)}" if parts else "No tracks in library"
self.library_status.setText(status)
def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]:
selected_rows = set()
for item in self.library_ready_table.selectedItems():
for item in self.library_table.selectedItems():
selected_rows.add(item.row())
if not selected_rows:
selected_rows = set(range(self.library_ready_table.rowCount()))
selected_rows = set(range(self.library_table.rowCount()))
tracks = []
for row in sorted(selected_rows):
data_item = self.library_ready_table.item(row, 0)
data_item = self.library_table.item(row, 0)
if data_item is None:
continue
data = data_item.data(Qt.ItemDataRole.UserRole)
if data:
track_info = TrackInfo(
video_id=f"local_{hash(data['path'])}",
title=data['title'],
artist=data['artist'],
album=data.get("album", ""),
thumbnail_url="",
duration=data.get('duration_s', 0),
track_number=data.get('track_num'),
genre=data.get('genre'),
download_path=data['path'],
)
tracks.append((track_info, data['path']))
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
if not is_ready:
continue
track_info = TrackInfo(
video_id=f"local_{hash(data['path'])}",
title=data['title'],
artist=data['artist'],
album=data.get("album", ""),
thumbnail_url="",
duration=data.get('duration_s', 0),
track_number=data.get('track_num'),
genre=data.get('genre'),
download_path=data['path'],
)
tracks.append((track_info, data['path']))
return tracks
def _get_selected_source_files(self) -> List[str]:
selected = self.library_source_list.selectedItems()
if not selected:
selected = [self.library_source_list.item(i) for i in range(self.library_source_list.count())]
selected_rows = set()
for item in self.library_table.selectedItems():
selected_rows.add(item.row())
if not selected_rows:
selected_rows = set(range(self.library_table.rowCount()))
paths = []
for item in selected:
data = item.data(Qt.ItemDataRole.UserRole)
if data and os.path.exists(data.get("path", "")):
for row in sorted(selected_rows):
data_item = self.library_table.item(row, 0)
if data_item is None:
continue
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
if is_ready:
continue
if os.path.exists(data.get("path", "")):
paths.append(data["path"])
return paths
@ -938,26 +973,21 @@ class MainWindow(QMainWindow):
new_files.append(f)
if new_files:
self._refresh_source_ui([])
self._scan_library()
def _on_library_remove_selected(self):
"""Delete selected files from disk and clean up empty directories."""
files_to_delete = []
selected_rows = set()
for item in self.library_ready_table.selectedItems():
for item in self.library_table.selectedItems():
selected_rows.add(item.row())
for row in selected_rows:
data_item = self.library_ready_table.item(row, 0)
data_item = self.library_table.item(row, 0)
if data_item is None:
continue
data = data_item.data(Qt.ItemDataRole.UserRole)
if data and os.path.exists(data.get("path", "")):
files_to_delete.append(data)
for item in self.library_source_list.selectedItems():
data = item.data(Qt.ItemDataRole.UserRole)
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
if data and os.path.exists(data.get("path", "")):
files_to_delete.append(data)
@ -1002,6 +1032,57 @@ class MainWindow(QMainWindow):
self._scan_library()
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)")
def _on_search_text_changed(self, text: str):
for row in range(self.library_table.rowCount()):
item = self.library_table.item(row, 3)
match = not text or text.lower() in item.text().lower() if item else True
self.library_table.setRowHidden(row, not match)
def _on_library_table_context_menu(self, pos):
item = self.library_table.itemAt(pos)
if item is None:
return
row = item.row()
data_item = self.library_table.item(row, 0)
if data_item is None:
return
is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
menu = QMenu(self)
play_action = menu.addAction("\u25B6 Play")
play_action.triggered.connect(lambda: self._play_track_from_index(row))
if is_ready:
transfer_action = menu.addAction("\uD83D\uDCE4 Transfer to iPod")
transfer_action.triggered.connect(self._on_library_transfer_clicked)
else:
convert_action = menu.addAction("\uD83D\uDD04 Convert to iPod Format")
convert_action.triggered.connect(self._on_library_convert_clicked)
menu.addSeparator()
show_action = menu.addAction("\uD83D\uDCC2 Show in File Manager")
show_action.triggered.connect(lambda: self._on_show_in_file_manager(track_data))
remove_action = menu.addAction("\uD83D\uDDD1\uFE0F Remove from Library")
remove_action.triggered.connect(self._on_library_remove_selected)
menu.exec(self.library_table.viewport().mapToGlobal(pos))
def _on_show_in_file_manager(self, track_data):
path = track_data.get("path", "")
if not path:
return
import subprocess
platform = sys.platform
if platform == "win32":
subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
elif platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
def _on_download_clicked(self):
url = self.url_input.text().strip()
if not url: