1
0
mirror of https://github.com/darkzoul5/YoutubePlaylistSync.git synced 2026-07-03 04:23:59 +03:00

13 Commits

10 changed files with 314 additions and 35 deletions
+8 -2
View File
@@ -107,6 +107,12 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Write bundled version file
shell: bash
run: |
set -euo pipefail
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
- uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -124,14 +130,14 @@ jobs:
run: |
$ErrorActionPreference = "Stop"
$ws = "${{ github.workspace }}"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --add-data "$ws/version.txt;." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Build binary (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --add-data "${GITHUB_WORKSPACE}/version.txt:." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Stage package
shell: bash
@@ -1,5 +1,4 @@
name: update yt-dlp and open PR
name: update yt-dlp
on:
schedule:
- cron: "0 10 * * *"
@@ -77,7 +76,7 @@ jobs:
- name: Create or update pull request
if: steps.detect.outputs.needs_update == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
branch: chore/refresh-yt-dlp
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
+1 -1
View File
@@ -5,7 +5,7 @@
"retry_delay_seconds": 1.5,
"ui": {
"tray": {
"close_to_tray": true,
"close_to_tray": false,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import sys
from pathlib import Path
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(str(base))
return Path.cwd()
def _read_text(path: Path) -> str | None:
try:
if path.exists():
text = path.read_text(encoding="utf-8").strip()
return text or None
except Exception:
pass
return None
def get_app_version() -> str:
"""
Returns the packaged app version.
In release builds this reads from `version.txt` bundled into the EXE.
"""
candidates = [
Path("version.txt"),
_resource_base() / "version.txt",
]
for candidate in candidates:
text = _read_text(candidate)
if text:
return text
return "dev"
+122 -27
View File
@@ -15,6 +15,7 @@ from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage
from .pages.about import AboutPage
class MainWindow(QtWidgets.QMainWindow):
@@ -38,29 +39,37 @@ class MainWindow(QtWidgets.QMainWindow):
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar")
self._nav.setFixedWidth(220)
self._nav.setSpacing(2)
self._nav.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self._nav.setVerticalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
self._nav.model().rowsInserted.connect(self._update_sidebar_width)
self._nav.model().dataChanged.connect(self._update_sidebar_width)
self._nav.model().rowsRemoved.connect(self._update_sidebar_width)
self._stack = QtWidgets.QStackedWidget()
self._playlists_page = PlaylistManagerPage(self._settings)
self._queue_page = QueuePage()
self._logs_page = LogsPage()
self._settings_page = SettingsPage()
self._about_page = AboutPage()
self._pages: list[QtWidgets.QWidget] = [
self._playlists_page,
self._queue_page,
self._logs_page,
self._settings_page,
self._about_page,
]
for p in self._pages:
self._stack.addWidget(p)
for label in ("Playlists", "Queue", "Logs", "Settings"):
item = QtWidgets.QListWidgetItem(label)
item.setSizeHint(QtCore.QSize(200, 36))
self._nav.addItem(item)
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
self._add_sidebar_item(label)
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
self._nav.setCurrentRow(0)
@@ -93,6 +102,29 @@ class MainWindow(QtWidgets.QMainWindow):
self._refresh_queue_labels()
self._init_tray()
QtCore.QTimer.singleShot(0, self._update_sidebar_width)
def _add_sidebar_item(self, label: str) -> None:
item = QtWidgets.QListWidgetItem(label)
self._nav.addItem(item)
self._update_sidebar_width()
def _update_sidebar_width(self, *_args: object) -> None:
metrics = self._nav.fontMetrics()
max_text_width = 0
for row in range(self._nav.count()):
item = self._nav.item(row)
if item is None:
continue
max_text_width = max(max_text_width, metrics.horizontalAdvance(item.text()))
if max_text_width <= 0:
return
frame = self._nav.frameWidth() * 2
padding = 44
target_width = max_text_width + frame + padding
self._nav.setFixedWidth(max(120, min(220, target_width)))
def _tray_config(self) -> dict:
# Read from disk so toggles apply immediately (no restart required).
@@ -110,7 +142,7 @@ class MainWindow(QtWidgets.QMainWindow):
return {}
def _close_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("close_to_tray", True))
return bool(self._tray_config().get("close_to_tray", False))
def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False))
@@ -324,13 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
def _apply_style(self) -> None:
self.setStyleSheet(
"""
QMainWindow { background: #0f1115; color: #e6e6e6; }
QWidget { font-size: 13px; }
QMainWindow { background: #0f1218; color: #d7dce4; }
QWidget { font-size: 13px; color: #d7dce4; }
QWidget#playlistsPage,
QWidget#queuePage,
QWidget#logsPage,
QWidget#settingsPage,
QWidget#aboutPage {
background: #0f1218;
}
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
QLabel#cardTitle { font-size: 15px; font-weight: 600; color: #eef2f8; }
QLabel[muted="true"] { color: #9aa3b2; }
QLabel[link="true"] { color: #6c8bff; }
QLabel[link="true"]:hover { color: #8ea7ff; }
QListWidget#sidebar {
background: #0b0d11;
border-right: 1px solid #20242d;
background: #0d1015;
border-right: 1px solid #2a3140;
padding: 8px;
}
QListWidget#sidebar::item {
@@ -339,42 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
padding: 8px 10px;
}
QListWidget#sidebar::item:selected {
background: #1e2633;
background: #21304a;
color: #ffffff;
}
QTableWidget {
background: #0f1115;
gridline-color: #20242d;
border: 1px solid #20242d;
background: #171b22;
gridline-color: #2a3140;
border: 1px solid #2a3140;
}
QTableWidget::item {
padding: 6px 8px;
}
QPlainTextEdit {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 10px;
color: #d7dce4;
}
QScrollBar:vertical {
background: #0f1218;
width: 12px;
margin: 0px;
}
QScrollBar::handle:vertical {
background: #34465f;
min-height: 24px;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background: #456183;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical,
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical {
background: transparent;
border: none;
}
QGroupBox {
border: 1px solid #2a3140;
border-radius: 12px;
margin-top: 14px;
padding: 12px;
background: #171b22;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #e2e7ef;
background: #171b22;
}
QFrame#aboutCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 14px;
}
QHeaderView::section {
background: #0b0d11;
color: #cfd3da;
border: 1px solid #20242d;
background: #171b22;
color: #d7dce4;
border: 1px solid #2a3140;
padding: 6px;
}
QPushButton {
background: #1e2633;
border: 1px solid #2a3140;
background: #1e2631;
border: 1px solid #31405a;
padding: 6px 10px;
border-radius: 8px;
color: #e6e6e6;
color: #d7dce4;
}
QPushButton:hover { background: #243044; }
QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard {
background: #0b0d11;
border: 1px solid #20242d;
border-radius: 10px;
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
padding: 10px;
}
QLineEdit, QComboBox {
background: #0f1115;
border: 1px solid #20242d;
background: #11151c;
border: 1px solid #2a3140;
border-radius: 8px;
padding: 6px 8px;
color: #e6e6e6;
color: #d7dce4;
}
QLineEdit:focus, QComboBox:focus {
border: 1px solid #6c8bff;
}
"""
)
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from PySide6 import QtCore, QtGui, QtWidgets
from ...core.utils.version import get_app_version
class AboutPage(QtWidgets.QWidget):
REPO_URL = "https://github.com/darkzoul5/YoutubePlaylistSync"
ISSUES_URL = f"{REPO_URL}/issues"
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("aboutPage")
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(14)
title = QtWidgets.QLabel("About")
title.setObjectName("pageTitle")
layout.addWidget(title)
for card in (
self._hero_card(),
self._project_card(),
self._suggestions_card(),
):
layout.addWidget(card)
layout.addStretch(1)
def _card(self, title: str) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]:
card = QtWidgets.QFrame()
card.setObjectName("aboutCard")
layout = QtWidgets.QVBoxLayout(card)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.addWidget(self._card_title(title))
return card, layout
def _card_title(self, text: str) -> QtWidgets.QLabel:
label = QtWidgets.QLabel(text)
label.setObjectName("cardTitle")
return label
def _muted_label(self, text: str) -> QtWidgets.QLabel:
label = QtWidgets.QLabel(text)
label.setWordWrap(True)
label.setProperty("muted", True)
return label
def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton:
button = QtWidgets.QPushButton(text)
button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)))
return button
def _action_row(self, text: str, url: str) -> QtWidgets.QWidget:
row = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
layout.addWidget(self._link_button(text, url))
layout.addStretch(1)
return row
def _hero_card(self) -> QtWidgets.QFrame:
card, layout = self._card("About this project")
layout.insertWidget(
1,
self._muted_label(
"ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync."
),
)
layout.insertWidget(2, self._muted_label("This is a student project."))
return card
def _project_card(self) -> QtWidgets.QFrame:
card, layout = self._card("Project")
form = QtWidgets.QFormLayout()
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
form.setFormAlignment(
QtCore.Qt.AlignmentFlag.AlignTop | QtCore.Qt.AlignmentFlag.AlignLeft
)
form.setHorizontalSpacing(14)
form.setVerticalSpacing(10)
version_text = get_app_version()
version = f"v{version_text}" if version_text != "dev" else version_text
rows = [
("Author", self._muted_label("Dark_Zoul")),
("Version", self._muted_label(version)),
("Repository", self._action_row("Open", self.REPO_URL)),
("Issues", self._action_row("Open", self.ISSUES_URL)),
]
for label, widget in rows:
form.addRow(label, widget)
layout.addLayout(form)
return card
def _suggestions_card(self) -> QtWidgets.QFrame:
card, layout = self._card("Suggestions")
layout.addWidget(
self._muted_label(
"• Keep the app updated regularly so that YouTube extraction stays reliable."
)
)
layout.addStretch(1)
return card
+1
View File
@@ -10,6 +10,7 @@ from ..smooth_scroll import enable_smooth_scrolling
class LogsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("logsPage")
layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Logs")
title.setObjectName("pageTitle")
+26
View File
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
parent: QtWidgets.QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("playlistsPage")
self._settings = settings
self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {}
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
header.setObjectName("pageTitle")
self._list = QtWidgets.QListWidget()
self._list.setObjectName("playlistList")
# Selection-based UI is intentionally disabled; actions happen per-card.
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
self._list.setSpacing(8)
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._list.setWordWrap(True)
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
enable_smooth_scrolling(self._list)
self._list.setStyleSheet(
"""
QListWidget#playlistList {
background: #0f1218;
border: none;
}
QListWidget#playlistList::viewport {
background: #0f1218;
}
QListWidget#playlistList::item {
background: transparent;
}
"""
)
self._add_btn = QtWidgets.QPushButton("Add")
self._add_btn.clicked.connect(self._add_playlist)
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.setObjectName("playlistCard")
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(
"""
QFrame#playlistCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
}
"""
)
self._index = index
self._active = False
self._paused = False
+1
View File
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("queuePage")
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
self._pending_by_key: dict[tuple[str, str], dict] = {}
+3 -2
View File
@@ -11,6 +11,7 @@ from ..config_store import load_config, save_config
class SettingsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("settingsPage")
self._config_path: Path | None = None
self._config: dict[str, Any] = {}
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(True)
self._close_to_tray.setChecked(False)
tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox()
@@ -120,7 +121,7 @@ class SettingsPage(QtWidgets.QWidget):
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True)))
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))