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

16 Commits

13 changed files with 480 additions and 39 deletions
+8 -2
View File
@@ -107,6 +107,12 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$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 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
@@ -124,14 +130,14 @@ jobs:
run: | run: |
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ws = "${{ github.workspace }}" $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) - name: Build binary (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
shell: bash shell: bash
run: | run: |
set -euo pipefail 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 - name: Stage package
shell: bash shell: bash
@@ -1,5 +1,4 @@
name: update yt-dlp and open PR name: update yt-dlp
on: on:
schedule: schedule:
- cron: "0 10 * * *" - cron: "0 10 * * *"
@@ -77,7 +76,7 @@ jobs:
- name: Create or update pull request - name: Create or update pull request
if: steps.detect.outputs.needs_update == 'true' if: steps.detect.outputs.needs_update == 'true'
uses: peter-evans/create-pull-request@v7 uses: peter-evans/create-pull-request@v8
with: with:
branch: chore/refresh-yt-dlp branch: chore/refresh-yt-dlp
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}" commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
+3 -2
View File
@@ -11,7 +11,7 @@ Local-first YouTube playlist synchronization client.
## What's Included ## What's Included
- GUI (PySide6) playlist manager + sync runner - GUI (PySide6 Essentials) playlist manager + sync runner
- Scanner (yt-dlp extract-only), diff engine, filesystem scan - Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename, recycle deletions - Safe reordering via two-pass rename, recycle deletions
- Async download queue with simple retry (yt-dlp Python API) - Async download queue with simple retry (yt-dlp Python API)
@@ -30,6 +30,7 @@ Download the latest release from this repo's Releases page and pick one:
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled) - `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
## Configure ## Configure
Application uses a json config that canbe edited from UI or manually Application uses a json config that canbe edited from UI or manually
```json ```json
@@ -73,7 +74,7 @@ Queue / retry:
- Run `ytpl-sync.exe` (GUI). - Run `ytpl-sync.exe` (GUI).
## Tray ## Tray
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit. - The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
- Tray behavior settings (Settings page): - Tray behavior settings (Settings page):
- `close_to_tray`: close hides to tray (keeps running). - `close_to_tray`: close hides to tray (keeps running).
+1 -1
View File
@@ -5,7 +5,7 @@
"retry_delay_seconds": 1.5, "retry_delay_seconds": 1.5,
"ui": { "ui": {
"tray": { "tray": {
"close_to_tray": true, "close_to_tray": false,
"minimize_to_tray": false, "minimize_to_tray": false,
"start_minimized_to_tray": false "start_minimized_to_tray": false
} }
+161
View File
@@ -0,0 +1,161 @@
# MP3 Metadata Plan
## Subject Area
- Add MP3 tag writing for downloaded YouTube playlist items.
- Scope is limited to `.mp3` outputs produced by `audio` mode and the MP3 side of `both` mode.
- Metadata is sourced from YouTube/yt-dlp and embedded after audio extraction.
## Goal
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
- Keep the implementation reliable when optional fields are missing.
- Preserve successful downloads even when metadata embedding partially fails.
- Provide a setting to enable or disable MP3 metadata embedding.
## Required Metadata
- `title` ← video title
- `artist` ← uploader, fallback to channel
- `album` ← album name if present
- `tracknumber` ← playlist index
- `date` / `year` ← upload date
- `comment` ← source URL
- `genre` ← if available
- `album_art` ← thumbnail
## Configuration Requirement
- Add a setting to turn MP3 metadata embedding on or off.
- Default should be explicitly defined during implementation; recommended default is `enabled` for new configs.
- The setting should only affect `.mp3` metadata writing and should not change download selection, extraction, or `.mp4` handling.
## Current Constraints
- The current playlist scan keeps only a minimal item shape: title, video id, and playlist index.
- The scanner uses flat extraction, which is sufficient for diffing but not for full tag data.
- MP3 extraction currently transcodes audio but does not write ID3 metadata.
## Implementation Strategy
- Keep playlist diffing fast by retaining the current flat scan for remote playlist structure.
- Fetch full metadata only for items that are actually going to be downloaded or repaired.
- Write metadata only after MP3 extraction completes successfully.
- Treat metadata embedding as a post-processing step that can fail softly without discarding the MP3.
## Work Breakdown
### 1. Extend the metadata model
- Add optional fields to `PlaylistItem` for:
- uploader
- channel
- album
- upload_date
- genre
- thumbnail_url
- webpage_url
- Keep `artist` as a derived value instead of storing a separate field.
### 2. Fetch full per-video metadata
- Introduce a metadata fetch step for each item selected for download.
- Use yt-dlp per-video extraction to retrieve richer fields than the flat playlist entry provides.
- Prefer canonical values from the video page payload for upload date, uploader/channel, album, genre, thumbnail, and source URL.
### 3. Carry metadata through the download pipeline
- Ensure the enriched `PlaylistItem` reaches the download job and post-processing stage.
- Keep this propagation in-memory unless restart-safe metadata persistence becomes necessary later.
- Avoid changing unrelated sync behavior for video-only items.
- Carry the MP3 metadata enabled/disabled setting into the post-processing step.
### 4. Add an MP3 tag writer
- Add `mutagen` as the ID3 writing dependency.
- Implement a focused tagging component that maps `PlaylistItem` metadata into ID3 frames.
- Omit fields when the source value is missing instead of writing placeholders.
### 5. Map fields into ID3 tags
- `title` → video title
- `artist` → uploader, fallback to channel
- `album` → album if present
- `tracknumber` → playlist index
- `date/year` → parsed upload date
- `comment` → canonical source URL
- `genre` → genre if present
### 6. Embed album art
- Download the selected thumbnail for the video after the media download succeeds.
- Attach thumbnail data as embedded cover art when the image type is supported.
- Fail soft if thumbnail retrieval or embedding fails, and keep the MP3 intact.
### 7. Integrate into modes
- `audio` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- delete temporary/source MP4 if configured
- `both` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- keep MP4 unchanged
- `video` mode:
- no MP3 tagging path
### 8. Add configuration surface
- Add the new setting to the config model and default config output.
- Expose the setting in the GUI/settings surface if MP3 behavior is already user-configurable there.
- Keep the naming explicit, for example `write_mp3_metadata` or `embed_mp3_metadata`.
## Error Handling Rules
- If download fails, no tagging runs.
- If extraction fails, no tagging runs.
- If metadata embedding is disabled, skip the tagging step entirely.
- If tagging fails, mark the tag step as failed in logs/events but keep the MP3 file.
- If thumbnail embedding fails, continue with text metadata only.
- Missing `album` or `genre` is normal and should not be treated as an error.
## Testing Plan
- Unit test metadata mapping from yt-dlp info to the internal metadata model.
- Unit test ID3 writing against a temporary MP3 fixture.
- Unit test fallback behavior:
- uploader missing, channel present
- album missing
- genre missing
- thumbnail missing
- Integration test the audio post-processing path with tagging mocked.
- Integration test the both-mode MP3 path with tagging mocked.
## Documentation Updates
- Document that MP3 tags are written only for `.mp3` outputs.
- Document the new setting that enables or disables MP3 metadata embedding.
- Document the field fallback rules, especially artist and album behavior.
- Document that album art comes from the video thumbnail, not playlist artwork.
- Document that some YouTube items will not expose album or genre information.
## Dependency Decision
- Recommended library: `mutagen`
- Reason:
- direct ID3 support
- reliable field-level control
- suitable for embedding cover art
- avoids depending on ffmpeg metadata flags for all tag logic
## Delivery Order
- First: add config setting and defaults
- Second: extend metadata model and add full metadata fetch
- Third: add MP3 tag writer and field mapping
- Fourth: add thumbnail embedding
- Fifth: wire tagging into `audio` and `both`
- Sixth: add tests and docs
+2 -2
View File
@@ -12,8 +12,8 @@ license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"] keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"yt-dlp>=2026.3.17", "yt-dlp>=2026.6.9",
"PySide6", "PySide6_Essentials>=6.11.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ test = [
+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.queue import QueuePage
from .pages.logs import LogsPage from .pages.logs import LogsPage
from .pages.settings import SettingsPage from .pages.settings import SettingsPage
from .pages.about import AboutPage
class MainWindow(QtWidgets.QMainWindow): class MainWindow(QtWidgets.QMainWindow):
@@ -38,29 +39,37 @@ class MainWindow(QtWidgets.QMainWindow):
# Sidebar navigation # Sidebar navigation
self._nav = QtWidgets.QListWidget() self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar") self._nav.setObjectName("sidebar")
self._nav.setFixedWidth(220)
self._nav.setSpacing(2) 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.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._stack = QtWidgets.QStackedWidget()
self._playlists_page = PlaylistManagerPage(self._settings) self._playlists_page = PlaylistManagerPage(self._settings)
self._queue_page = QueuePage() self._queue_page = QueuePage()
self._logs_page = LogsPage() self._logs_page = LogsPage()
self._settings_page = SettingsPage() self._settings_page = SettingsPage()
self._about_page = AboutPage()
self._pages: list[QtWidgets.QWidget] = [ self._pages: list[QtWidgets.QWidget] = [
self._playlists_page, self._playlists_page,
self._queue_page, self._queue_page,
self._logs_page, self._logs_page,
self._settings_page, self._settings_page,
self._about_page,
] ]
for p in self._pages: for p in self._pages:
self._stack.addWidget(p) self._stack.addWidget(p)
for label in ("Playlists", "Queue", "Logs", "Settings"): for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
item = QtWidgets.QListWidgetItem(label) self._add_sidebar_item(label)
item.setSizeHint(QtCore.QSize(200, 36))
self._nav.addItem(item)
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex) self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
self._nav.setCurrentRow(0) self._nav.setCurrentRow(0)
@@ -93,6 +102,29 @@ class MainWindow(QtWidgets.QMainWindow):
self._refresh_queue_labels() self._refresh_queue_labels()
self._init_tray() 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: def _tray_config(self) -> dict:
# Read from disk so toggles apply immediately (no restart required). # Read from disk so toggles apply immediately (no restart required).
@@ -110,7 +142,7 @@ class MainWindow(QtWidgets.QMainWindow):
return {} return {}
def _close_to_tray_enabled(self) -> bool: 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: def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False)) return bool(self._tray_config().get("minimize_to_tray", False))
@@ -324,13 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
def _apply_style(self) -> None: def _apply_style(self) -> None:
self.setStyleSheet( self.setStyleSheet(
""" """
QMainWindow { background: #0f1115; color: #e6e6e6; } QMainWindow { background: #0f1218; color: #d7dce4; }
QWidget { font-size: 13px; } 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#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 { QListWidget#sidebar {
background: #0b0d11; background: #0d1015;
border-right: 1px solid #20242d; border-right: 1px solid #2a3140;
padding: 8px; padding: 8px;
} }
QListWidget#sidebar::item { QListWidget#sidebar::item {
@@ -339,42 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
padding: 8px 10px; padding: 8px 10px;
} }
QListWidget#sidebar::item:selected { QListWidget#sidebar::item:selected {
background: #1e2633; background: #21304a;
color: #ffffff; color: #ffffff;
} }
QTableWidget { QTableWidget {
background: #0f1115; background: #171b22;
gridline-color: #20242d; gridline-color: #2a3140;
border: 1px solid #20242d; 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 { QHeaderView::section {
background: #0b0d11; background: #171b22;
color: #cfd3da; color: #d7dce4;
border: 1px solid #20242d; border: 1px solid #2a3140;
padding: 6px; padding: 6px;
} }
QPushButton { QPushButton {
background: #1e2633; background: #1e2631;
border: 1px solid #2a3140; border: 1px solid #31405a;
padding: 6px 10px; padding: 6px 10px;
border-radius: 8px; border-radius: 8px;
color: #e6e6e6; color: #d7dce4;
} }
QPushButton:hover { background: #243044; } QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard { QFrame#playlistCard {
background: #0b0d11; background: #171b22;
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 10px; border-radius: 12px;
padding: 10px; padding: 10px;
} }
QLineEdit, QComboBox { QLineEdit, QComboBox {
background: #0f1115; background: #11151c;
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 8px; border-radius: 8px;
padding: 6px 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): class LogsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("logsPage")
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Logs") title = QtWidgets.QLabel("Logs")
title.setObjectName("pageTitle") title.setObjectName("pageTitle")
+26
View File
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
parent: QtWidgets.QWidget | None = None, parent: QtWidgets.QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("playlistsPage")
self._settings = settings self._settings = settings
self._config_path = getattr(settings, "path", None) self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
header.setObjectName("pageTitle") header.setObjectName("pageTitle")
self._list = QtWidgets.QListWidget() self._list = QtWidgets.QListWidget()
self._list.setObjectName("playlistList")
# Selection-based UI is intentionally disabled; actions happen per-card. # Selection-based UI is intentionally disabled; actions happen per-card.
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
self._list.setSpacing(8) self._list.setSpacing(8)
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._list.setWordWrap(True) self._list.setWordWrap(True)
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
enable_smooth_scrolling(self._list) 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 = QtWidgets.QPushButton("Add")
self._add_btn.clicked.connect(self._add_playlist) self._add_btn.clicked.connect(self._add_playlist)
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
super().__init__(parent) super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.setObjectName("playlistCard") 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._index = index
self._active = False self._active = False
self._paused = False self._paused = False
+1
View File
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("queuePage")
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves. # 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._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
self._pending_by_key: dict[tuple[str, str], dict] = {} 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): class SettingsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("settingsPage")
self._config_path: Path | None = None self._config_path: Path | None = None
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
tray_form = QtWidgets.QFormLayout() tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox() 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) tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox() self._minimize_to_tray = QtWidgets.QCheckBox()
@@ -120,7 +121,7 @@ class SettingsPage(QtWidgets.QWidget):
ui = ui if isinstance(ui, dict) else {} ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray") tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {} 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._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))) self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))