Give the app icons, tooltips and a finished theme
There was no icon system at all. Transport buttons called QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark monochrome glyph -- painted onto the app's saturated red buttons at a fixed 36px with no text, that is the black square. Everything else called an icon was an emoji baked into en.json, which is tofu wherever the emoji font is missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument to the new helper; that is the whole fix. The SVGs are drawn here rather than vendored, which keeps a third-party licence out of the tree, and they live in the module rather than as asset files, which keeps them out of package-data and safe in a frozen build. Tooltips were the other half: three widgets in the entire application had one and nothing set an accessible name. Filling that in at the call sites would have meant editing well over a hundred of them, most in upstream-owned files. Instead one application-level event filter handles QEvent.Polish, which Qt sends to every widget once before it is shown -- so it also reaches dialogs built by upstream code, and survives the next merge. It maps placeholder emoji to icons, fills empty tooltips from the button text, and logs icon-only buttons that still have none so the gaps are findable. StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips and the horizontal scrollbar fell through to the platform style. EXTRA_QSS covers them, in a fork-owned module appended at the one application site. SmoothTabWidget names its frame "tabContent" with the comment "We draw border on content instead" -- that rule existed only inside two dialogs, and now exists for the main window too. Two corrections to rules that were already there: the pressed style changed the padding, shifting every label two pixels and clipping fixed-width icon buttons, and checkboxes were fully rounded, which reads as a radio button rather than an on/off toggle. Verified by screenshot on the real display: tab bar, buttons, combo carets and checkboxes all render as intended, and all 35 icons rasterise non-empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,36 @@ records.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Real icons.** The app had no icon system: transport buttons used the
|
||||||
|
platform style's dark monochrome glyphs painted on saturated red, and
|
||||||
|
everything else called an icon was an emoji baked into the English strings.
|
||||||
|
A drawn SVG set is now rendered through QtSvg and recoloured at load — Qt
|
||||||
|
stylesheets cannot recolour a `QIcon`, which is exactly why the old ones were
|
||||||
|
unreadable. Buttons across the Downloads tab gained icons too.
|
||||||
|
- **Tooltips and accessible names on every button.** Three widgets in the whole
|
||||||
|
application had a tooltip. An application-level polish filter now fills them
|
||||||
|
in as each button is first shown, and swaps placeholder emoji for icons —
|
||||||
|
which covers dialogs owned by upstream without editing them.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
|
||||||
|
inputs, buttons and tables and nothing else, so the main tab bar, combo
|
||||||
|
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||||
|
scrollbar were drawn by the platform — the tab bar with white text forced
|
||||||
|
onto system-coloured tabs, tooltips in the system's light style on a black
|
||||||
|
app. All of them now match. `QFrame#tabContent` finally has the rule the tab
|
||||||
|
widget's own comment promised.
|
||||||
|
- Checkboxes were round, which reads as a radio button — "one of these" rather
|
||||||
|
than "on or off". They are square with a tick.
|
||||||
|
- Clicking any button shifted its label two pixels down and right, and clipped
|
||||||
|
the artwork on fixed-width icon buttons: the pressed style changed the
|
||||||
|
padding. The background change alone reads as pressed.
|
||||||
|
- The window icon shipped only at 48px and was upscaled everywhere; the 256px
|
||||||
|
master now ships beside it. Its fallback was the platform's download arrow
|
||||||
|
standing in for the application's identity.
|
||||||
- **The player no longer crashes the app.** Qt destroys and recreates a
|
- **The player no longer crashes the app.** Qt destroys and recreates a
|
||||||
widget's OpenGL context whenever it moves to another top-level window, and
|
widget's OpenGL context whenever it moves to another top-level window, and
|
||||||
calls `initializeGL()` again. libmpv allows one render context per handle, so
|
calls `initializeGL()` again. libmpv allows one render context per handle, so
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ include = ["ytsage*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
ytsage = [
|
ytsage = [
|
||||||
"assets/Icon/icon.png",
|
"assets/Icon/icon.png",
|
||||||
|
"assets/Icon/icon-256.png",
|
||||||
"assets/sound/notification.mp3",
|
"assets/sound/notification.mp3",
|
||||||
"languages/*.json",
|
"languages/*.json",
|
||||||
]
|
]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -132,6 +132,7 @@ class BrowsePage(QWidget):
|
|||||||
self.url_input.setMinimumHeight(38)
|
self.url_input.setMinimumHeight(38)
|
||||||
bar.addWidget(self.url_input, stretch=1)
|
bar.addWidget(self.url_input, stretch=1)
|
||||||
self.open_btn = QPushButton(_("browse.open"))
|
self.open_btn = QPushButton(_("browse.open"))
|
||||||
|
self.open_btn.setToolTip(_("browse.open_tooltip"))
|
||||||
self.open_btn.setMinimumHeight(38)
|
self.open_btn.setMinimumHeight(38)
|
||||||
self.open_btn.clicked.connect(self._open_from_input)
|
self.open_btn.clicked.connect(self._open_from_input)
|
||||||
bar.addWidget(self.open_btn)
|
bar.addWidget(self.open_btn)
|
||||||
@@ -142,14 +143,17 @@ class BrowsePage(QWidget):
|
|||||||
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
||||||
header.addWidget(self.title_label, stretch=1)
|
header.addWidget(self.title_label, stretch=1)
|
||||||
self.subscribe_btn = QPushButton(_("browse.subscribe"))
|
self.subscribe_btn = QPushButton(_("browse.subscribe"))
|
||||||
|
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
|
||||||
self.subscribe_btn.setVisible(False)
|
self.subscribe_btn.setVisible(False)
|
||||||
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
|
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
|
||||||
header.addWidget(self.subscribe_btn)
|
header.addWidget(self.subscribe_btn)
|
||||||
self.playall_btn = QPushButton(_("browse.play_all"))
|
self.playall_btn = QPushButton(_("browse.play_all"))
|
||||||
|
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
|
||||||
self.playall_btn.setVisible(False)
|
self.playall_btn.setVisible(False)
|
||||||
self.playall_btn.clicked.connect(self._on_play_all)
|
self.playall_btn.clicked.connect(self._on_play_all)
|
||||||
header.addWidget(self.playall_btn)
|
header.addWidget(self.playall_btn)
|
||||||
self.download_btn = QPushButton(_("browse.download_playlist"))
|
self.download_btn = QPushButton(_("browse.download_playlist"))
|
||||||
|
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
|
||||||
self.download_btn.setVisible(False)
|
self.download_btn.setVisible(False)
|
||||||
self.download_btn.clicked.connect(self._on_download_playlist)
|
self.download_btn.clicked.connect(self._on_download_playlist)
|
||||||
header.addWidget(self.download_btn)
|
header.addWidget(self.download_btn)
|
||||||
|
|||||||
@@ -147,14 +147,17 @@ class VideoCard(QFrame):
|
|||||||
actions.setSpacing(6)
|
actions.setSpacing(6)
|
||||||
|
|
||||||
self.play_btn = QPushButton(_("cards.play"))
|
self.play_btn = QPushButton(_("cards.play"))
|
||||||
|
self.play_btn.setToolTip(_("cards.play_tooltip"))
|
||||||
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
|
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
|
||||||
actions.addWidget(self.play_btn)
|
actions.addWidget(self.play_btn)
|
||||||
|
|
||||||
self.queue_btn = QPushButton(_("cards.queue"))
|
self.queue_btn = QPushButton(_("cards.queue"))
|
||||||
|
self.queue_btn.setToolTip(_("cards.queue_tooltip"))
|
||||||
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
|
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
|
||||||
actions.addWidget(self.queue_btn)
|
actions.addWidget(self.queue_btn)
|
||||||
|
|
||||||
self.download_btn = QPushButton(_("cards.download"))
|
self.download_btn = QPushButton(_("cards.download"))
|
||||||
|
self.download_btn.setToolTip(_("cards.download_tooltip"))
|
||||||
self.download_btn.clicked.connect(self._emit_download)
|
self.download_btn.clicked.connect(self._emit_download)
|
||||||
actions.addWidget(self.download_btn)
|
actions.addWidget(self.download_btn)
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ class VideoCardGrid(QScrollArea):
|
|||||||
outer.addWidget(self._grid_widget)
|
outer.addWidget(self._grid_widget)
|
||||||
|
|
||||||
self.load_more_btn = QPushButton(_("cards.load_more"))
|
self.load_more_btn = QPushButton(_("cards.load_more"))
|
||||||
|
self.load_more_btn.setToolTip(_("cards.load_more_tooltip"))
|
||||||
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
|
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
|
||||||
self.load_more_btn.setVisible(False)
|
self.load_more_btn.setVisible(False)
|
||||||
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class FeedPage(QWidget):
|
|||||||
|
|
||||||
bar = QHBoxLayout()
|
bar = QHBoxLayout()
|
||||||
self.mode_combo = QComboBox()
|
self.mode_combo = QComboBox()
|
||||||
|
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
|
||||||
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
||||||
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
||||||
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
||||||
@@ -91,6 +92,7 @@ class FeedPage(QWidget):
|
|||||||
bar.addWidget(self.mode_combo)
|
bar.addWidget(self.mode_combo)
|
||||||
|
|
||||||
self.refresh_btn = QPushButton(_("feed.refresh"))
|
self.refresh_btn = QPushButton(_("feed.refresh"))
|
||||||
|
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
||||||
self.refresh_btn.clicked.connect(self.refresh)
|
self.refresh_btn.clicked.connect(self.refresh)
|
||||||
bar.addWidget(self.refresh_btn)
|
bar.addWidget(self.refresh_btn)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from .ytsage_gui_analysis import AnalysisMixin
|
|||||||
from .ytsage_smooth_tab_widget import SmoothTabWidget
|
from .ytsage_smooth_tab_widget import SmoothTabWidget
|
||||||
from ..utils.ytsage_constants import (
|
from ..utils.ytsage_constants import (
|
||||||
ICON_PATH,
|
ICON_PATH,
|
||||||
|
ICON_PATH_LARGE,
|
||||||
SOUND_PATH,
|
SOUND_PATH,
|
||||||
SUBPROCESS_CREATIONFLAGS,
|
SUBPROCESS_CREATIONFLAGS,
|
||||||
VIDEO_EXTENSIONS,
|
VIDEO_EXTENSIONS,
|
||||||
@@ -67,6 +68,9 @@ from ..utils.ytsage_config_manager import ConfigManager
|
|||||||
from ..utils.ytsage_localization import LocalizationManager, _
|
from ..utils.ytsage_localization import LocalizationManager, _
|
||||||
from ..utils.ytsage_history_manager import HistoryManager
|
from ..utils.ytsage_history_manager import HistoryManager
|
||||||
from .ytsage_stylesheet import StyleSheet
|
from .ytsage_stylesheet import StyleSheet
|
||||||
|
from .ytsage_theme import build_extra_qss
|
||||||
|
from . import ytsage_icons as icons
|
||||||
|
from . import ytsage_theme as theme
|
||||||
|
|
||||||
|
|
||||||
class UpdateCheckThread(QThread):
|
class UpdateCheckThread(QThread):
|
||||||
@@ -122,12 +126,18 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
|
|
||||||
self.version = APP_VERSION
|
self.version = APP_VERSION
|
||||||
load_saved_path(self)
|
load_saved_path(self)
|
||||||
# Load custom icon
|
# Load custom icon. Both sizes go in so the window manager and taskbar
|
||||||
if ICON_PATH.exists():
|
# pick rather than upscale the 48px one.
|
||||||
self.setWindowIcon(QIcon(str(ICON_PATH)))
|
app_icon = QIcon()
|
||||||
else:
|
for path in (ICON_PATH, ICON_PATH_LARGE):
|
||||||
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
|
if path.exists():
|
||||||
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
|
app_icon.addFile(str(path))
|
||||||
|
if app_icon.isNull():
|
||||||
|
logger.warning(f"Icon file not found at {ICON_PATH}. Using a drawn fallback.")
|
||||||
|
# A drawn icon beats the platform's download arrow standing in for
|
||||||
|
# the application's identity.
|
||||||
|
app_icon = icons.icon("play-circle", theme.ACCENT, 64)
|
||||||
|
self.setWindowIcon(app_icon)
|
||||||
self.signals = SignalManager()
|
self.signals = SignalManager()
|
||||||
self.download_paused = False
|
self.download_paused = False
|
||||||
self.current_download = None
|
self.current_download = None
|
||||||
@@ -184,7 +194,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
# Defer heavy start-up tasks to ensure UI renders immediately
|
# Defer heavy start-up tasks to ensure UI renders immediately
|
||||||
QTimer.singleShot(100, self._perform_startup_checks)
|
QTimer.singleShot(100, self._perform_startup_checks)
|
||||||
|
|
||||||
self.setStyleSheet(StyleSheet.MAIN)
|
# StyleSheet.MAIN is upstream's and covers the window, inputs, buttons
|
||||||
|
# and tables. EXTRA_QSS adds everything it never styled -- the tab bar,
|
||||||
|
# combos, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||||
|
# scrollbar -- and corrects two of its rules. Kept in a separate,
|
||||||
|
# fork-owned module so this stays a one-line change here.
|
||||||
|
self.setStyleSheet(StyleSheet.MAIN + build_extra_qss())
|
||||||
self.signals.update_progress.connect(self.update_progress_bar)
|
self.signals.update_progress.connect(self.update_progress_bar)
|
||||||
|
|
||||||
# After adding format buttons
|
# After adding format buttons
|
||||||
@@ -314,6 +329,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
|
|
||||||
# Analyze button with app's red theme
|
# Analyze button with app's red theme
|
||||||
self.analyze_button = QPushButton(_("buttons.analyze"))
|
self.analyze_button = QPushButton(_("buttons.analyze"))
|
||||||
|
self.analyze_button.setProperty("sageIcon", "search")
|
||||||
self.analyze_button.clicked.connect(self.analyze_url)
|
self.analyze_button.clicked.connect(self.analyze_url)
|
||||||
self.analyze_button.setEnabled(False) # Disabled until URL is entered
|
self.analyze_button.setEnabled(False) # Disabled until URL is entered
|
||||||
self.analyze_button.setMinimumHeight(42)
|
self.analyze_button.setMinimumHeight(42)
|
||||||
@@ -435,33 +451,41 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
|
|
||||||
# Replace the two separate buttons with a single Custom Options button
|
# Replace the two separate buttons with a single Custom Options button
|
||||||
self.custom_options_btn = QPushButton(_("buttons.custom_options"))
|
self.custom_options_btn = QPushButton(_("buttons.custom_options"))
|
||||||
|
self.custom_options_btn.setProperty("sageIcon", "settings")
|
||||||
self.custom_options_btn.clicked.connect(self.show_custom_options)
|
self.custom_options_btn.clicked.connect(self.show_custom_options)
|
||||||
|
|
||||||
self.about_btn = QPushButton(_("buttons.about"))
|
self.about_btn = QPushButton(_("buttons.about"))
|
||||||
|
self.about_btn.setProperty("sageIcon", "info")
|
||||||
self.about_btn.clicked.connect(self.show_about_dialog)
|
self.about_btn.clicked.connect(self.show_about_dialog)
|
||||||
|
|
||||||
self.history_btn = QPushButton(_("buttons.history"))
|
self.history_btn = QPushButton(_("buttons.history"))
|
||||||
|
self.history_btn.setProperty("sageIcon", "clock")
|
||||||
self.history_btn.clicked.connect(self.show_history_dialog)
|
self.history_btn.clicked.connect(self.show_history_dialog)
|
||||||
|
|
||||||
# Add new Time Range button
|
# Add new Time Range button
|
||||||
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
||||||
|
self.time_range_btn.setProperty("sageIcon", "scissors")
|
||||||
self.time_range_btn.clicked.connect(self.show_time_range_dialog)
|
self.time_range_btn.clicked.connect(self.show_time_range_dialog)
|
||||||
|
|
||||||
# --- Rename Path Button to Settings Button ---
|
# --- Rename Path Button to Settings Button ---
|
||||||
self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button
|
self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button
|
||||||
|
self.settings_button.setProperty("sageIcon", "settings")
|
||||||
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
|
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
|
||||||
self._update_settings_tooltip()
|
self._update_settings_tooltip()
|
||||||
# --- End Settings Button ---
|
# --- End Settings Button ---
|
||||||
|
|
||||||
self.download_btn = QPushButton(_("buttons.download"))
|
self.download_btn = QPushButton(_("buttons.download"))
|
||||||
|
self.download_btn.setProperty("sageIcon", "download")
|
||||||
self.download_btn.clicked.connect(self.start_download)
|
self.download_btn.clicked.connect(self.start_download)
|
||||||
|
|
||||||
# Add pause and cancel buttons
|
# Add pause and cancel buttons
|
||||||
self.pause_btn = QPushButton(_("buttons.pause"))
|
self.pause_btn = QPushButton(_("buttons.pause"))
|
||||||
|
self.pause_btn.setProperty("sageIcon", "pause")
|
||||||
self.pause_btn.clicked.connect(self.toggle_pause)
|
self.pause_btn.clicked.connect(self.toggle_pause)
|
||||||
self.pause_btn.setVisible(False) # Hidden initially
|
self.pause_btn.setVisible(False) # Hidden initially
|
||||||
|
|
||||||
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
||||||
|
self.cancel_btn.setProperty("sageIcon", "x")
|
||||||
self.cancel_btn.clicked.connect(self.cancel_download)
|
self.cancel_btn.clicked.connect(self.cancel_download)
|
||||||
self.cancel_btn.setVisible(False) # Hidden initially
|
self.cancel_btn.setVisible(False) # Hidden initially
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import ytsage_icons as icons
|
||||||
|
from . import ytsage_theme as theme
|
||||||
from ..core.ytsage_mpv import probe_player
|
from ..core.ytsage_mpv import probe_player
|
||||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
from ..utils.ytsage_config_manager import ConfigManager
|
from ..utils.ytsage_config_manager import ConfigManager
|
||||||
@@ -454,8 +456,9 @@ class PlayerPanel(QWidget):
|
|||||||
controls.setContentsMargins(6, 0, 6, 4)
|
controls.setContentsMargins(6, 0, 6, 4)
|
||||||
|
|
||||||
self.play_btn = QPushButton()
|
self.play_btn = QPushButton()
|
||||||
self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
|
self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT))
|
||||||
self.play_btn.setFixedWidth(36)
|
self.play_btn.setFixedWidth(36)
|
||||||
|
self.play_btn.setToolTip(_("player.play_pause"))
|
||||||
self.play_btn.clicked.connect(self.toggle_pause)
|
self.play_btn.clicked.connect(self.toggle_pause)
|
||||||
controls.addWidget(self.play_btn)
|
controls.addWidget(self.play_btn)
|
||||||
|
|
||||||
@@ -464,6 +467,7 @@ class PlayerPanel(QWidget):
|
|||||||
|
|
||||||
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
self.seek_slider.setRange(0, 1000)
|
self.seek_slider.setRange(0, 1000)
|
||||||
|
self.seek_slider.setToolTip(_("player.seek_tooltip"))
|
||||||
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
|
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
|
||||||
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
||||||
controls.addWidget(self.seek_slider, stretch=1)
|
controls.addWidget(self.seek_slider, stretch=1)
|
||||||
@@ -474,6 +478,7 @@ class PlayerPanel(QWidget):
|
|||||||
default_q = ConfigManager.get("player.default_quality")
|
default_q = ConfigManager.get("player.default_quality")
|
||||||
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
|
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
|
||||||
self.quality_combo.setCurrentIndex(idx)
|
self.quality_combo.setCurrentIndex(idx)
|
||||||
|
self.quality_combo.setToolTip(_("player.quality_tooltip"))
|
||||||
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
|
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
|
||||||
controls.addWidget(self.quality_combo)
|
controls.addWidget(self.quality_combo)
|
||||||
|
|
||||||
@@ -481,6 +486,7 @@ class PlayerPanel(QWidget):
|
|||||||
for s in SPEED_CHOICES:
|
for s in SPEED_CHOICES:
|
||||||
self.speed_combo.addItem(f"{s:g}x", s)
|
self.speed_combo.addItem(f"{s:g}x", s)
|
||||||
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
|
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
|
||||||
|
self.speed_combo.setToolTip(_("player.speed_tooltip"))
|
||||||
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
||||||
controls.addWidget(self.speed_combo)
|
controls.addWidget(self.speed_combo)
|
||||||
|
|
||||||
@@ -488,17 +494,20 @@ class PlayerPanel(QWidget):
|
|||||||
self.volume_slider.setRange(0, 100)
|
self.volume_slider.setRange(0, 100)
|
||||||
self.volume_slider.setFixedWidth(90)
|
self.volume_slider.setFixedWidth(90)
|
||||||
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
|
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
|
||||||
|
self.volume_slider.setToolTip(_("player.volume_tooltip"))
|
||||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||||
controls.addWidget(self.volume_slider)
|
controls.addWidget(self.volume_slider)
|
||||||
|
|
||||||
self.subs_btn = QPushButton(_("player.subtitles"))
|
self.subs_btn = QPushButton(_("player.subtitles"))
|
||||||
self.subs_btn.setCheckable(True)
|
self.subs_btn.setCheckable(True)
|
||||||
|
self.subs_btn.setToolTip(_("player.subtitles_tooltip"))
|
||||||
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
||||||
controls.addWidget(self.subs_btn)
|
controls.addWidget(self.subs_btn)
|
||||||
|
|
||||||
self.fullscreen_btn = QPushButton()
|
self.fullscreen_btn = QPushButton()
|
||||||
self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton))
|
self.fullscreen_btn.setIcon(icons.icon("maximize", theme.ICON_ON_ACCENT))
|
||||||
self.fullscreen_btn.setFixedWidth(36)
|
self.fullscreen_btn.setFixedWidth(36)
|
||||||
|
self.fullscreen_btn.setToolTip(_("player.fullscreen"))
|
||||||
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
||||||
controls.addWidget(self.fullscreen_btn)
|
controls.addWidget(self.fullscreen_btn)
|
||||||
|
|
||||||
@@ -619,8 +628,8 @@ class PlayerPanel(QWidget):
|
|||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def _on_paused_changed(self, paused: bool) -> None:
|
def _on_paused_changed(self, paused: bool) -> None:
|
||||||
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause
|
self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
|
||||||
self.play_btn.setIcon(self.style().standardIcon(icon))
|
self.play_btn.setToolTip(_("player.play") if paused else _("player.pause"))
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def _on_end_reached(self, reason: str) -> None:
|
def _on_end_reached(self, reason: str) -> None:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class SearchPage(QWidget):
|
|||||||
bar.addWidget(self.query_input, stretch=1)
|
bar.addWidget(self.query_input, stretch=1)
|
||||||
|
|
||||||
self.search_btn = QPushButton(_("search.button"))
|
self.search_btn = QPushButton(_("search.button"))
|
||||||
|
self.search_btn.setToolTip(_("search.button_tooltip"))
|
||||||
self.search_btn.clicked.connect(self.start_search)
|
self.search_btn.clicked.connect(self.start_search)
|
||||||
self.search_btn.setMinimumHeight(38)
|
self.search_btn.setMinimumHeight(38)
|
||||||
bar.addWidget(self.search_btn)
|
bar.addWidget(self.search_btn)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Icons
|
||||||
|
=====
|
||||||
|
|
||||||
|
The app had no icon system. Transport buttons used
|
||||||
|
`QStyle.standardIcon(SP_MediaPlay)`, which returns the platform style's dark
|
||||||
|
monochrome glyph -- painted onto the app's saturated red buttons, at a fixed
|
||||||
|
36px with no text and no tooltip, that reads as a black or empty square. On
|
||||||
|
styles that return a null icon for some standard pixmaps it *was* an empty
|
||||||
|
square. Everything else called an "icon" was an emoji baked into the English
|
||||||
|
translation file, which renders as tofu wherever the emoji font is missing.
|
||||||
|
|
||||||
|
Approach: a small set of hand-drawn SVGs rendered through QtSvg (part of
|
||||||
|
PySide6-Essentials, so no new dependency), recoloured at load. Qt stylesheets
|
||||||
|
cannot recolour a QIcon, which is the whole reason the old icons were
|
||||||
|
unreadable -- so the colour is an argument here.
|
||||||
|
|
||||||
|
The sources live in this module rather than as asset files on purpose: no
|
||||||
|
package-data to keep in sync, no path resolution, and nothing to go missing
|
||||||
|
from a wheel or a frozen build.
|
||||||
|
|
||||||
|
Drawing conventions: 24x24 viewBox, 2px round strokes, `%COLOR%` wherever the
|
||||||
|
colour goes. Shapes that must read as solid at 16px (the play triangle) carry
|
||||||
|
their own fill.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QByteArray, QRectF, Qt
|
||||||
|
from PySide6.QtGui import QIcon, QPainter, QPixmap
|
||||||
|
from PySide6.QtSvg import QSvgRenderer
|
||||||
|
|
||||||
|
from ..utils.ytsage_logger import logger
|
||||||
|
|
||||||
|
#: Default stroke colour. Overridden per call; kept in step with ytsage_theme.
|
||||||
|
DEFAULT_COLOR = "#e8eaed"
|
||||||
|
|
||||||
|
_SVG: Dict[str, str] = {
|
||||||
|
# --- transport -------------------------------------------------------
|
||||||
|
"play": '<polygon points="7 4 20 12 7 20" fill="%COLOR%" stroke-linejoin="round"/>',
|
||||||
|
"pause": '<rect x="6" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>'
|
||||||
|
'<rect x="14" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>',
|
||||||
|
"stop": '<rect x="5" y="5" width="14" height="14" rx="2" fill="%COLOR%"/>',
|
||||||
|
"skip-back": '<polygon points="19 5 9 12 19 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||||
|
'<line x1="6" y1="5" x2="6" y2="19"/>',
|
||||||
|
"skip-forward": '<polygon points="5 5 15 12 5 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||||
|
'<line x1="18" y1="5" x2="18" y2="19"/>',
|
||||||
|
"rewind-10": '<path d="M11 20a8 8 0 1 0-8-8"/><polyline points="3 8 3 12 7 12"/>'
|
||||||
|
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||||
|
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||||
|
"forward-10": '<path d="M13 20a8 8 0 1 1 8-8"/><polyline points="21 8 21 12 17 12"/>'
|
||||||
|
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||||
|
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||||
|
# --- audio -----------------------------------------------------------
|
||||||
|
"volume-high": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||||
|
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/>',
|
||||||
|
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||||
|
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
|
||||||
|
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||||
|
'<line x1="16" y1="9" x2="22" y2="15"/><line x1="22" y1="9" x2="16" y2="15"/>',
|
||||||
|
"captions": '<rect x="2" y="5" width="20" height="14" rx="3"/>'
|
||||||
|
'<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6"/><path d="M17.5 10.2a2.6 2.6 0 1 0 0 3.6"/>',
|
||||||
|
# --- window ----------------------------------------------------------
|
||||||
|
"maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>'
|
||||||
|
'<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
||||||
|
"minimize": '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>'
|
||||||
|
'<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
|
||||||
|
# --- tabs / navigation -----------------------------------------------
|
||||||
|
"rss": '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'
|
||||||
|
'<circle cx="5" cy="19" r="1.6" fill="%COLOR%" stroke="none"/>',
|
||||||
|
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/>',
|
||||||
|
"library": '<line x1="4" y1="4" x2="4" y2="20"/><line x1="9" y1="6" x2="9" y2="20"/>'
|
||||||
|
'<path d="M14 6.5l4.5 13"/>',
|
||||||
|
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
|
||||||
|
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||||
|
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||||
|
"user-check": '<path d="M15 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>'
|
||||||
|
'<polyline points="16 11 18 13 22 9"/>',
|
||||||
|
# --- actions ---------------------------------------------------------
|
||||||
|
"queue": '<line x1="3" y1="6" x2="16" y2="6"/><line x1="3" y1="12" x2="11" y2="12"/>'
|
||||||
|
'<line x1="3" y1="18" x2="11" y2="18"/><line x1="18" y1="9" x2="18" y2="15"/>'
|
||||||
|
'<line x1="21" y1="12" x2="15" y2="12"/>',
|
||||||
|
"refresh": '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><polyline points="21 3 21 9 15 9"/>',
|
||||||
|
"folder-open": '<path d="M4 20a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.6.8l1 1.4a2 2 0 0 0 1.6.8H18a2 2 0 0 1 2 2v1"/>'
|
||||||
|
'<path d="M4 20l2.2-7a2 2 0 0 1 1.9-1.4h12a1.6 1.6 0 0 1 1.55 2l-1.7 5.4A2 2 0 0 1 18 20z"/>',
|
||||||
|
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||||
|
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||||
|
"trash": '<polyline points="3 6 21 6"/>'
|
||||||
|
'<path d="M19 6v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>'
|
||||||
|
'<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>',
|
||||||
|
"settings": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>'
|
||||||
|
'<circle cx="10" cy="7" r="2.4" fill="%COLOR%" stroke="none"/>'
|
||||||
|
'<circle cx="15" cy="17" r="2.4" fill="%COLOR%" stroke="none"/>',
|
||||||
|
"info": '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16"/>'
|
||||||
|
'<circle cx="12" cy="7.8" r="1.1" fill="%COLOR%" stroke="none"/>',
|
||||||
|
"clock": '<circle cx="12" cy="12" r="9"/><polyline points="12 6.8 12 12 15.5 14"/>',
|
||||||
|
"scissors": '<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
|
||||||
|
'<line x1="20" y1="4" x2="8.1" y2="15.9"/><line x1="14.5" y1="14.5" x2="20" y2="20"/>'
|
||||||
|
'<line x1="8.1" y1="8.1" x2="12" y2="12"/>',
|
||||||
|
"external-link": '<path d="M14 3h7v7"/><line x1="10" y1="14" x2="21" y2="3"/>'
|
||||||
|
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||||
|
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
||||||
|
"chevron-right": '<polyline points="9 6 15 12 9 18"/>',
|
||||||
|
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/>'
|
||||||
|
'<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
||||||
|
"play-circle": '<circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5" fill="%COLOR%" stroke="none"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
_DOC = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" '
|
||||||
|
'fill="none" stroke="%COLOR%" stroke-width="2" stroke-linecap="round" '
|
||||||
|
'stroke-linejoin="round">%BODY%</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> list:
|
||||||
|
"""Every icon name this module can draw. Useful when adding call sites."""
|
||||||
|
return sorted(_SVG)
|
||||||
|
|
||||||
|
|
||||||
|
def _document(name: str, color: str) -> Optional[bytes]:
|
||||||
|
body = _SVG.get(name)
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
return _DOC.replace("%BODY%", body).replace("%COLOR%", color).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def pixmap(name: str, color: str = DEFAULT_COLOR, size: int = 20, dpr: float = 1.0) -> QPixmap:
|
||||||
|
"""One rendered pixmap. A missing name yields a transparent one, never an error."""
|
||||||
|
px = QPixmap(max(1, int(size * dpr)), max(1, int(size * dpr)))
|
||||||
|
px.setDevicePixelRatio(dpr)
|
||||||
|
px.fill(Qt.GlobalColor.transparent)
|
||||||
|
|
||||||
|
document = _document(name, color)
|
||||||
|
if document is None:
|
||||||
|
# A typo in a call site should show a gap, not take a dialog down.
|
||||||
|
logger.debug(f"Unknown icon name: {name!r}")
|
||||||
|
return px
|
||||||
|
|
||||||
|
renderer = QSvgRenderer(QByteArray(document))
|
||||||
|
if not renderer.isValid():
|
||||||
|
logger.debug(f"Icon {name!r} failed to parse")
|
||||||
|
return px
|
||||||
|
|
||||||
|
painter = QPainter(px)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
try:
|
||||||
|
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return px
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def icon(name: str, color: str = DEFAULT_COLOR, size: int = 20, disabled_color: str = "#6b7075") -> QIcon:
|
||||||
|
"""
|
||||||
|
A QIcon with its own Normal and Disabled artwork.
|
||||||
|
|
||||||
|
Both 1x and 2x are added so the icon stays sharp on a HiDPI screen without
|
||||||
|
Qt upscaling a small bitmap.
|
||||||
|
"""
|
||||||
|
result = QIcon()
|
||||||
|
for dpr in (1.0, 2.0):
|
||||||
|
result.addPixmap(pixmap(name, color, size, dpr), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
|
result.addPixmap(pixmap(name, disabled_color, size, dpr), QIcon.Mode.Disabled, QIcon.State.Off)
|
||||||
|
return result
|
||||||
@@ -224,20 +224,26 @@ class StyleSheet:
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
|
/* Square, not round: a fully-rounded indicator reads as a radio
|
||||||
|
button, i.e. "one of these", when these are independent
|
||||||
|
on/off options. */
|
||||||
QCheckBox::indicator {
|
QCheckBox::indicator {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
border-radius: 9px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QCheckBox::indicator:unchecked {
|
QCheckBox::indicator:unchecked {
|
||||||
border: 2px solid #666666;
|
border: 2px solid #666666;
|
||||||
background: #1d1e22;
|
background: #1d1e22;
|
||||||
border-radius: 9px;
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:unchecked:hover {
|
||||||
|
border-color: #9aa0a6;
|
||||||
}
|
}
|
||||||
QCheckBox::indicator:checked {
|
QCheckBox::indicator:checked {
|
||||||
border: 2px solid #c90000;
|
border: 2px solid #c90000;
|
||||||
background: #c90000;
|
background: #c90000;
|
||||||
border-radius: 9px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QCheckBox:disabled { color: #888888; }
|
QCheckBox:disabled { color: #888888; }
|
||||||
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Theme tokens and the stylesheet rules the app never had
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
`StyleSheet.MAIN` (upstream's, in ytsage_stylesheet.py) styles the window,
|
||||||
|
line edits, buttons, tables, progress bars and vertical scrollbars -- and
|
||||||
|
nothing else. Everything it misses falls through to `QWidget { background:
|
||||||
|
#15181b; color: #ffffff }` plus the platform style, which is why the app looked
|
||||||
|
half-finished: the main tab bar was drawn by Fusion from the system palette
|
||||||
|
with white text forced onto it, combo boxes and sliders were native, tooltips
|
||||||
|
came out in the system's light style on a black app, and a native light
|
||||||
|
horizontal scrollbar appeared under any wide view.
|
||||||
|
|
||||||
|
Notably `SmoothTabWidget` names its content frame `tabContent` with the comment
|
||||||
|
"We draw border on content instead" -- and the only `QFrame#tabContent` rules
|
||||||
|
in the codebase were inside two dialogs. The main window's tab bar got none of
|
||||||
|
it.
|
||||||
|
|
||||||
|
This module holds the colour tokens and `EXTRA_QSS`, appended to
|
||||||
|
`StyleSheet.MAIN` at the single application site. Keeping it separate means one
|
||||||
|
changed line in an upstream-owned file, so the next merge from YTSage does not
|
||||||
|
fight over it.
|
||||||
|
|
||||||
|
The two dialogs that define their own tab rules keep them. Unifying would cost
|
||||||
|
more in merge conflicts than the duplication does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..utils.ytsage_logger import logger
|
||||||
|
|
||||||
|
# --- colour tokens -------------------------------------------------------
|
||||||
|
# Names, not hexes, at the call sites. These match what StyleSheet.MAIN
|
||||||
|
# already uses so the two halves agree.
|
||||||
|
BG = "#15181b" # window
|
||||||
|
SURFACE = "#1b2021" # panels, inputs, list backgrounds
|
||||||
|
SURFACE_ALT = "#1d2124" # menus, tooltips, elevated surfaces
|
||||||
|
SURFACE_HOVER = "#252a2d"
|
||||||
|
BORDER = "#2a2d2e"
|
||||||
|
BORDER_STRONG = "#3d3d3d"
|
||||||
|
TEXT = "#ffffff"
|
||||||
|
TEXT_MUTED = "#9aa0a6"
|
||||||
|
TEXT_DISABLED = "#6b7075"
|
||||||
|
ACCENT = "#c90000"
|
||||||
|
ACCENT_HOVER = "#a50000"
|
||||||
|
ACCENT_PRESSED = "#800000"
|
||||||
|
FOCUS = "#ff6b6b"
|
||||||
|
|
||||||
|
#: Default icon colour: near-white, deliberately not pure #ffffff so it does
|
||||||
|
#: not out-glare the text beside it.
|
||||||
|
ICON = "#e8eaed"
|
||||||
|
#: Icons sitting on an accent-coloured button.
|
||||||
|
ICON_ON_ACCENT = "#ffffff"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ui_assets() -> dict:
|
||||||
|
"""
|
||||||
|
Write the few bitmaps Qt stylesheets can only reference as files.
|
||||||
|
|
||||||
|
QSS cannot draw a shape: `::down-arrow` and `::indicator` need an actual
|
||||||
|
`image:`. Everything else here is drawn with borders and radii, but a
|
||||||
|
caret and a tick are not expressible that way -- restyling a combo box
|
||||||
|
without supplying one leaves it with a blank square where its arrow was.
|
||||||
|
|
||||||
|
Rendered from the same SVG set as every other icon and cached next to the
|
||||||
|
thumbnails, so nothing extra ships and nothing is fetched.
|
||||||
|
"""
|
||||||
|
from . import ytsage_icons as icons
|
||||||
|
from ..utils.ytsage_constants import APP_DATA_DIR
|
||||||
|
|
||||||
|
out_dir = APP_DATA_DIR / "ui"
|
||||||
|
# Rendered larger than the box they are drawn into (see the QSS below):
|
||||||
|
# a 24-unit viewBox squeezed straight into 12px leaves a 1px stroke that
|
||||||
|
# all but disappears on this background.
|
||||||
|
wanted = {
|
||||||
|
"chevron": ("chevron-down", TEXT_MUTED, 14),
|
||||||
|
"chevron_disabled": ("chevron-down", TEXT_DISABLED, 14),
|
||||||
|
"check": ("check", TEXT, 12),
|
||||||
|
}
|
||||||
|
paths = {}
|
||||||
|
try:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for key, (name, color, size) in wanted.items():
|
||||||
|
target = out_dir / f"{key}.png"
|
||||||
|
if not target.exists():
|
||||||
|
# dpr 1.0 and the final pixel size: Qt draws a QSS `image:` at
|
||||||
|
# its natural size, so anything else is scaled or clipped.
|
||||||
|
icons.pixmap(name, color, size, 1.0).save(str(target), "PNG")
|
||||||
|
# QSS wants forward slashes even on Windows.
|
||||||
|
paths[key] = str(target).replace("\\", "/")
|
||||||
|
except Exception as e: # pragma: no cover - cosmetic only
|
||||||
|
logger.debug(f"Could not write UI assets, falling back to plain styling: {e}")
|
||||||
|
return {}
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def build_extra_qss() -> str:
|
||||||
|
"""EXTRA_QSS plus the rules that need generated images, when available."""
|
||||||
|
qss = EXTRA_QSS
|
||||||
|
assets = ensure_ui_assets()
|
||||||
|
if not assets:
|
||||||
|
return qss
|
||||||
|
return qss + f"""
|
||||||
|
QComboBox::down-arrow {{
|
||||||
|
image: url("{assets['chevron']}");
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
QComboBox::down-arrow:disabled {{ image: url("{assets['chevron_disabled']}"); }}
|
||||||
|
|
||||||
|
/* Upstream styles checkboxes as filled circles (border-radius: 9px), which
|
||||||
|
reads as a radio button -- a control that means "one of these" rather than
|
||||||
|
"on or off". Square them off and show an actual tick. Widget-level
|
||||||
|
stylesheets win over this one, so StyleSheet.CHECKBOX is corrected at
|
||||||
|
source; these rules cover every checkbox that does not use it. */
|
||||||
|
QCheckBox::indicator {{
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid {BORDER_STRONG};
|
||||||
|
background-color: {SURFACE};
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:hover {{ border-color: {TEXT_MUTED}; }}
|
||||||
|
QCheckBox::indicator:checked {{
|
||||||
|
border-color: {ACCENT};
|
||||||
|
background-color: {ACCENT};
|
||||||
|
image: url("{assets['check']}");
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:checked:hover {{ border-color: {FOCUS}; }}
|
||||||
|
QCheckBox::indicator:disabled {{ border-color: {BORDER}; background-color: {BG}; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
EXTRA_QSS = f"""
|
||||||
|
/* ---- main tab bar (SmoothTabWidget) --------------------------------- */
|
||||||
|
QTabBar {{
|
||||||
|
background-color: {BG};
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
QTabBar::tab {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {TEXT_MUTED};
|
||||||
|
padding: 9px 18px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
QTabBar::tab:hover:!selected {{
|
||||||
|
color: {TEXT};
|
||||||
|
background-color: {SURFACE};
|
||||||
|
}}
|
||||||
|
QTabBar::tab:selected {{
|
||||||
|
color: {TEXT};
|
||||||
|
background-color: {SURFACE};
|
||||||
|
border-bottom: 2px solid {ACCENT};
|
||||||
|
}}
|
||||||
|
QTabBar::tab:disabled {{
|
||||||
|
color: {TEXT_DISABLED};
|
||||||
|
}}
|
||||||
|
QFrame#tabContent {{
|
||||||
|
background-color: {BG};
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- combo boxes ---------------------------------------------------- */
|
||||||
|
QComboBox {{
|
||||||
|
background-color: {SURFACE};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
min-height: 18px;
|
||||||
|
}}
|
||||||
|
QComboBox:hover {{ border-color: {BORDER_STRONG}; }}
|
||||||
|
QComboBox:focus {{ border-color: {FOCUS}; }}
|
||||||
|
QComboBox:disabled {{ color: {TEXT_DISABLED}; background-color: {BG}; }}
|
||||||
|
QComboBox::drop-down {{
|
||||||
|
border: none;
|
||||||
|
width: 22px;
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: center right;
|
||||||
|
}}
|
||||||
|
/* ::down-arrow is deliberately not styled here -- QSS cannot draw a caret,
|
||||||
|
and setting only a border leaves a small square where the arrow was.
|
||||||
|
build_extra_qss() supplies a real image; without it Qt's native arrow is
|
||||||
|
the better fallback. */
|
||||||
|
QComboBox QAbstractItemView {{
|
||||||
|
background-color: {SURFACE_ALT};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
selection-background-color: {ACCENT};
|
||||||
|
selection-color: {TEXT};
|
||||||
|
outline: none;
|
||||||
|
padding: 2px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- lists ---------------------------------------------------------- */
|
||||||
|
QListWidget, QListView {{
|
||||||
|
background-color: {SURFACE};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
QListWidget::item, QListView::item {{
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}}
|
||||||
|
QListWidget::item:hover, QListView::item:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||||
|
QListWidget::item:selected, QListView::item:selected {{
|
||||||
|
background-color: {ACCENT};
|
||||||
|
color: {TEXT};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- menus ---------------------------------------------------------- */
|
||||||
|
QMenu {{
|
||||||
|
background-color: {SURFACE_ALT};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
padding: 4px;
|
||||||
|
}}
|
||||||
|
QMenu::item {{ padding: 6px 22px 6px 14px; border-radius: 3px; }}
|
||||||
|
QMenu::item:selected {{ background-color: {ACCENT}; }}
|
||||||
|
QMenu::item:disabled {{ color: {TEXT_DISABLED}; }}
|
||||||
|
QMenu::separator {{ height: 1px; background-color: {BORDER}; margin: 4px 6px; }}
|
||||||
|
|
||||||
|
/* ---- sliders (seek and volume were fully native) --------------------- */
|
||||||
|
QSlider::groove:horizontal {{
|
||||||
|
height: 4px;
|
||||||
|
background-color: {BORDER};
|
||||||
|
border-radius: 2px;
|
||||||
|
}}
|
||||||
|
QSlider::sub-page:horizontal {{
|
||||||
|
background-color: {ACCENT};
|
||||||
|
border-radius: 2px;
|
||||||
|
}}
|
||||||
|
QSlider::handle:horizontal {{
|
||||||
|
background-color: {TEXT};
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin: -5px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}}
|
||||||
|
QSlider::handle:horizontal:hover {{ background-color: {FOCUS}; }}
|
||||||
|
QSlider::handle:horizontal:disabled {{ background-color: {TEXT_DISABLED}; }}
|
||||||
|
QSlider::groove:vertical {{ width: 4px; background-color: {BORDER}; border-radius: 2px; }}
|
||||||
|
QSlider::handle:vertical {{
|
||||||
|
background-color: {TEXT}; height: 12px; margin: 0 -5px; border-radius: 6px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- splitters ------------------------------------------------------- */
|
||||||
|
QSplitter::handle {{ background-color: {BORDER}; }}
|
||||||
|
QSplitter::handle:horizontal {{ width: 4px; }}
|
||||||
|
QSplitter::handle:vertical {{ height: 4px; }}
|
||||||
|
QSplitter::handle:hover {{ background-color: {ACCENT}; }}
|
||||||
|
|
||||||
|
/* ---- tooltips (were rendering in the system's light style) ----------- */
|
||||||
|
QToolTip {{
|
||||||
|
background-color: {SURFACE_ALT};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- horizontal scrollbar (only the vertical one was styled) --------- */
|
||||||
|
QScrollBar:horizontal {{
|
||||||
|
background-color: {BG};
|
||||||
|
height: 12px;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:horizontal {{
|
||||||
|
background-color: {BORDER_STRONG};
|
||||||
|
min-width: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:horizontal:hover {{ background-color: {ACCENT}; }}
|
||||||
|
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||||
|
width: 0; border: none; background: none;
|
||||||
|
}}
|
||||||
|
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }}
|
||||||
|
|
||||||
|
/* ---- assorted controls left unstyled --------------------------------- */
|
||||||
|
QRadioButton, QCheckBox {{ color: {TEXT}; spacing: 7px; }}
|
||||||
|
QRadioButton:disabled, QCheckBox:disabled {{ color: {TEXT_DISABLED}; }}
|
||||||
|
QGroupBox {{
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
color: {TEXT};
|
||||||
|
}}
|
||||||
|
QGroupBox::title {{
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 10px;
|
||||||
|
padding: 0 5px;
|
||||||
|
color: {TEXT_MUTED};
|
||||||
|
}}
|
||||||
|
QSpinBox, QDoubleSpinBox {{
|
||||||
|
background-color: {SURFACE};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- corrections to rules that already exist ------------------------- */
|
||||||
|
/* Upstream's QPushButton:pressed changes the padding, which shifts every
|
||||||
|
label two pixels down-and-right on click and clips the artwork on the
|
||||||
|
fixed-width icon buttons. The background change alone reads as pressed. */
|
||||||
|
QPushButton:pressed {{
|
||||||
|
padding: 8px 15px;
|
||||||
|
}}
|
||||||
|
/* One consistent icon size everywhere, since icons are now real artwork. */
|
||||||
|
QPushButton, QToolButton {{
|
||||||
|
qproperty-iconSize: 18px 18px;
|
||||||
|
}}
|
||||||
|
QToolButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
color: {TEXT};
|
||||||
|
}}
|
||||||
|
QToolButton:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||||
|
QToolButton:pressed {{ background-color: {BORDER}; }}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Application-wide button polish
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Two problems, both spread across every file in the GUI:
|
||||||
|
|
||||||
|
- **Emoji standing in for icons.** `cards.download` is the single character
|
||||||
|
"⬇" on a button with no text and no tooltip; the queue's clear button is
|
||||||
|
"✕"; the downloads folder button is "📁". These depend entirely on a system
|
||||||
|
emoji font, and render as tofu boxes without one.
|
||||||
|
- **Almost nothing has a tooltip.** Three widgets in the whole application set
|
||||||
|
one, and nothing sets an accessible name -- so icon-only buttons are
|
||||||
|
unidentifiable to sighted users and invisible to a screen reader alike.
|
||||||
|
|
||||||
|
Fixing that at the call sites would mean editing well over a hundred of them,
|
||||||
|
most in files owned by upstream YTSage, which would conflict on the next merge.
|
||||||
|
|
||||||
|
Instead this installs one event filter on the QApplication and handles
|
||||||
|
`QEvent.Polish`, which Qt sends to every widget exactly once before it is first
|
||||||
|
shown -- including widgets inside dialogs built by upstream code, and including
|
||||||
|
any added later. One hook, whole tree, no call-site edits.
|
||||||
|
|
||||||
|
New code does not need the glyph map: give a button a `sageIcon` property and
|
||||||
|
this will resolve it.
|
||||||
|
|
||||||
|
btn.setProperty("sageIcon", "download")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QObject
|
||||||
|
from PySide6.QtWidgets import QAbstractButton, QApplication
|
||||||
|
|
||||||
|
from . import ytsage_icons as icons
|
||||||
|
from . import ytsage_theme as theme
|
||||||
|
from ..utils.ytsage_logger import logger
|
||||||
|
|
||||||
|
#: Emoji that are standing in for icons, and what they actually mean.
|
||||||
|
#: Matched as the whole label, or as a leading glyph followed by real text
|
||||||
|
#: ("▶ Play"), never mid-string.
|
||||||
|
GLYPH_ICONS: Dict[str, str] = {
|
||||||
|
"⬇": "download",
|
||||||
|
"▶": "play",
|
||||||
|
"⏸": "pause",
|
||||||
|
"⏹": "stop",
|
||||||
|
"✕": "x",
|
||||||
|
"✖": "x",
|
||||||
|
"×": "x",
|
||||||
|
"+": "plus",
|
||||||
|
"+": "plus",
|
||||||
|
"📁": "folder-open",
|
||||||
|
"📂": "folder-open",
|
||||||
|
"🔄": "refresh",
|
||||||
|
"🗑": "trash",
|
||||||
|
"⚙": "settings",
|
||||||
|
"🔍": "search",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Tooltips for buttons whose label is only artwork once the glyph is
|
||||||
|
#: replaced, so they do not end up with no description at all.
|
||||||
|
ICON_TOOLTIPS: Dict[str, str] = {
|
||||||
|
"download": "Download",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"stop": "Stop",
|
||||||
|
"x": "Clear",
|
||||||
|
"plus": "Add",
|
||||||
|
"folder-open": "Open folder",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"trash": "Delete",
|
||||||
|
"settings": "Settings",
|
||||||
|
"search": "Search",
|
||||||
|
"maximize": "Fullscreen",
|
||||||
|
"minimize": "Leave fullscreen",
|
||||||
|
"volume-mute": "Mute",
|
||||||
|
"skip-forward": "Next",
|
||||||
|
"skip-back": "Previous",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_accelerator(text: str) -> str:
|
||||||
|
""""&Yes" -> "Yes". Qt's mnemonic markers must not defeat matching."""
|
||||||
|
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&")
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonPolisher(QObject):
|
||||||
|
"""Turns placeholder glyphs into icons and fills in missing descriptions."""
|
||||||
|
|
||||||
|
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||||
|
if event.type() == QEvent.Type.Polish and isinstance(obj, QAbstractButton):
|
||||||
|
try:
|
||||||
|
self._polish_button(obj)
|
||||||
|
except Exception as e:
|
||||||
|
# Cosmetics must never break a dialog from opening.
|
||||||
|
logger.debug(f"Button polish skipped: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _polish_button(self, btn: QAbstractButton) -> None:
|
||||||
|
icon_name: Optional[str] = None
|
||||||
|
|
||||||
|
# 1. Explicit opt-in wins.
|
||||||
|
declared = btn.property("sageIcon")
|
||||||
|
if declared:
|
||||||
|
icon_name = str(declared)
|
||||||
|
|
||||||
|
raw_text = btn.text() or ""
|
||||||
|
text = _strip_accelerator(raw_text).strip()
|
||||||
|
|
||||||
|
# 2. Otherwise infer from a placeholder glyph. Buttons carrying a
|
||||||
|
# mnemonic are left alone: those are standard dialog buttons.
|
||||||
|
if icon_name is None and "&" not in raw_text and text:
|
||||||
|
first = text[0]
|
||||||
|
if text in GLYPH_ICONS:
|
||||||
|
icon_name = GLYPH_ICONS[text]
|
||||||
|
text = "" # the glyph *was* the whole label
|
||||||
|
elif first in GLYPH_ICONS and len(text) > 1 and text[1] in " \t":
|
||||||
|
icon_name = GLYPH_ICONS[first]
|
||||||
|
text = text[1:].strip()
|
||||||
|
|
||||||
|
if icon_name is not None and btn.icon().isNull():
|
||||||
|
# White reads on the accent-coloured buttons; the muted token
|
||||||
|
# would disappear into them.
|
||||||
|
btn.setIcon(icons.icon(icon_name, theme.ICON_ON_ACCENT))
|
||||||
|
if text != _strip_accelerator(raw_text).strip():
|
||||||
|
btn.setText(text)
|
||||||
|
|
||||||
|
# 3. Every button gets something to describe it.
|
||||||
|
label = _strip_accelerator(btn.text() or "").strip()
|
||||||
|
if not btn.toolTip():
|
||||||
|
if label:
|
||||||
|
btn.setToolTip(label)
|
||||||
|
elif icon_name and icon_name in ICON_TOOLTIPS:
|
||||||
|
btn.setToolTip(ICON_TOOLTIPS[icon_name])
|
||||||
|
elif not btn.icon().isNull():
|
||||||
|
# An icon-only button nobody has described. Name it so the
|
||||||
|
# gap is findable rather than silent.
|
||||||
|
logger.debug(f"Icon-only button without a tooltip: {btn.objectName() or btn!r}")
|
||||||
|
|
||||||
|
if not btn.accessibleName():
|
||||||
|
btn.setAccessibleName(label or btn.toolTip())
|
||||||
|
|
||||||
|
|
||||||
|
_polisher: Optional[ButtonPolisher] = None
|
||||||
|
|
||||||
|
|
||||||
|
def install(app: Optional[QApplication] = None) -> None:
|
||||||
|
"""Install once, before the main window is built."""
|
||||||
|
global _polisher
|
||||||
|
if _polisher is not None:
|
||||||
|
return
|
||||||
|
app = app or QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
logger.debug("UI polish not installed: no QApplication yet.")
|
||||||
|
return
|
||||||
|
_polisher = ButtonPolisher(app)
|
||||||
|
app.installEventFilter(_polisher)
|
||||||
|
logger.debug("Button polish filter installed.")
|
||||||
@@ -651,14 +651,25 @@
|
|||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality."
|
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
|
||||||
|
"play_pause": "Play / pause (Space)",
|
||||||
|
"fullscreen": "Fullscreen (F)",
|
||||||
|
"seek_tooltip": "Seek through the video",
|
||||||
|
"quality_tooltip": "Maximum playback resolution",
|
||||||
|
"speed_tooltip": "Playback speed",
|
||||||
|
"volume_tooltip": "Volume",
|
||||||
|
"subtitles_tooltip": "Toggle subtitles (C)"
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"play": "▶ Play",
|
"play": "▶ Play",
|
||||||
"queue": "+ Queue",
|
"queue": "+ Queue",
|
||||||
"download": "⬇",
|
"download": "⬇",
|
||||||
"load_more": "Load more",
|
"load_more": "Load more",
|
||||||
"empty": "Nothing here yet"
|
"empty": "Nothing here yet",
|
||||||
|
"play_tooltip": "Play this video now",
|
||||||
|
"queue_tooltip": "Add to the play queue",
|
||||||
|
"download_tooltip": "Open in Downloads with this video ready to analyse",
|
||||||
|
"load_more_tooltip": "Load the next page of results"
|
||||||
},
|
},
|
||||||
"main_tabs": {
|
"main_tabs": {
|
||||||
"watch": "Watch",
|
"watch": "Watch",
|
||||||
@@ -672,7 +683,8 @@
|
|||||||
"button": "Search",
|
"button": "Search",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"results_count": "{count} results",
|
"results_count": "{count} results",
|
||||||
"failed": "Search failed: {error}"
|
"failed": "Search failed: {error}",
|
||||||
|
"button_tooltip": "Search YouTube"
|
||||||
},
|
},
|
||||||
"watch": {
|
"watch": {
|
||||||
"clear_queue": "Clear queue"
|
"clear_queue": "Clear queue"
|
||||||
@@ -690,7 +702,12 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"entry_count": "{count} videos",
|
"entry_count": "{count} videos",
|
||||||
"failed": "Failed to load: {error}",
|
"failed": "Failed to load: {error}",
|
||||||
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search."
|
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
|
||||||
|
"open_tooltip": "Open this channel or playlist",
|
||||||
|
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
|
||||||
|
"unsubscribe_tooltip": "Stop following this channel",
|
||||||
|
"play_all_tooltip": "Queue everything loaded here",
|
||||||
|
"download_playlist_tooltip": "Open this playlist in Downloads"
|
||||||
},
|
},
|
||||||
"feed": {
|
"feed": {
|
||||||
"mode_local": "Local subscriptions",
|
"mode_local": "Local subscriptions",
|
||||||
@@ -705,6 +722,8 @@
|
|||||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||||
"subscribed": "Subscribed to {title}",
|
"subscribed": "Subscribed to {title}",
|
||||||
"unsubscribed": "Unsubscribed from {title}",
|
"unsubscribed": "Unsubscribed from {title}",
|
||||||
"open_channel": "Open channel"
|
"open_channel": "Open channel",
|
||||||
|
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
|
||||||
|
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,12 @@ def main():
|
|||||||
app.setApplicationName("SageTube")
|
app.setApplicationName("SageTube")
|
||||||
app.setDesktopFileName("sagetube")
|
app.setDesktopFileName("sagetube")
|
||||||
|
|
||||||
|
# Before the main window: this retro-fits icons, tooltips and
|
||||||
|
# accessible names onto every button as it is first polished.
|
||||||
|
from .gui.ytsage_ui_polish import install as install_ui_polish
|
||||||
|
|
||||||
|
install_ui_polish(app)
|
||||||
|
|
||||||
window = YTSageApp() # Instantiate the main application class
|
window = YTSageApp() # Instantiate the main application class
|
||||||
window.show()
|
window.show()
|
||||||
logger.info("Application window shown, entering main loop")
|
logger.info("Application window shown, entering main loop")
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ def get_asset_path(asset_relative_path: str) -> Path:
|
|||||||
|
|
||||||
# Assets Constants
|
# Assets Constants
|
||||||
ICON_PATH: Path = get_asset_path("assets/Icon/icon.png")
|
ICON_PATH: Path = get_asset_path("assets/Icon/icon.png")
|
||||||
|
# The 48px original is what the taskbar and window manager had to upscale.
|
||||||
|
ICON_PATH_LARGE: Path = get_asset_path("assets/Icon/icon-256.png")
|
||||||
SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
|
SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
|
||||||
|
|
||||||
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
|
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
|
||||||
|
|||||||
Reference in New Issue
Block a user