diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8a753d334..d633ce01b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,10 @@ on: branches: [ master ] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: NODE_VERSION: "22.x" diff --git a/.gitignore b/.gitignore index 674400d09b..5fc748cf3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules /dist /pack +package-lock.json .vscode/settings.json .idea diff --git a/README.md b/README.md index 6c4a29540d..287e54db4a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ +# Fork Information + +## Purpose of This Fork + +This fork adds back the **adblocker plugin** and implements a new **ytdlp-based downloader plugin** that provides more reliable downloading functionality compared to the previous youtube.js implementation. + +### Key Changes +- **Adblocker Plugin**: Restores ad-blocking capabilities using Cliqz adblocker +- **Downloader (ytdlp)**: New implementation using yt-dlp for robust video/audio downloads + +## Keeping Your Feature Branch Updated + +**To keep your feature branch updated with upstream changes:** +```bash +git checkout master +git fetch upstream +git merge upstream/master +git push origin master +git checkout feature/adblocker-and-downloader-plugins +git rebase master # Cleanly applies your changes on top of latest master +git push --force-with-lease origin feature/adblocker-and-downloader-plugins +``` + +**When ready to create a PR to upstream:** +1. Make sure your feature branch is rebased on latest upstream/master (steps above) +2. Push to your fork: `git push origin feature/adblocker-and-downloader-plugins` +3. Go to https://github.com/pear-devs/pear-desktop and create a PR from your fork's branch + +Using `rebase` keeps a clean, linear history which is preferred for PRs. + +--- +
Special thanks to:
diff --git a/package.json b/package.json index 7b7220f446..91a09d67a3 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "bgutils-js": "3.2.0", "butterchurn": "3.0.0-beta.5", "butterchurn-presets": "3.0.0-beta.4", + "chalk": "^5.6.2", "chinese-conv": "^4.0.0", "color": "5.0.3", "conf": "15.1.0", @@ -145,6 +146,7 @@ "@playwright/test": "1.58.2", "@stylistic/eslint-plugin": "5.7.1", "@total-typescript/ts-reset": "0.6.1", + "@types/chalk": "^2.2.4", "@types/electron-localshortcut": "3.1.3", "@types/howler": "2.2.12", "@types/html-to-text": "9.0.4", @@ -185,4 +187,4 @@ "unreleased": true, "output": "changelog.md" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704d5affb1..88121cda97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: butterchurn-presets: specifier: 3.0.0-beta.4 version: 3.0.0-beta.4 + chalk: + specifier: ^5.6.2 + version: 5.6.2 chinese-conv: specifier: ^4.0.0 version: 4.0.0 @@ -274,6 +277,9 @@ importers: '@total-typescript/ts-reset': specifier: 0.6.1 version: 0.6.1 + '@types/chalk': + specifier: ^2.2.4 + version: 2.2.4 '@types/electron-localshortcut': specifier: 3.1.3 version: 3.1.3 @@ -1262,6 +1268,10 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chalk@2.2.4': + resolution: {integrity: sha512-pb/QoGqtCpH2famSp72qEsXkNzcErlVmiXlQ/ww+5AddD8TmmYS7EWg5T20YiNCAiTgs8pMf2G8SJG5h/ER1ZQ==} + deprecated: This is a stub types definition. chalk provides its own type definitions, so you do not need this installed. + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1830,6 +1840,10 @@ packages: resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chinese-conv@4.0.0: resolution: {integrity: sha512-PVBMzvv6CtX1cubaDXfxYscIdbOAHPuY/E2vnfJIzOACX+xIW4NRKRlNsZVI2p5KxGsXyUp7tVHfvQlqZ4yx/w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5718,6 +5732,10 @@ snapshots: '@types/node': 25.2.0 '@types/responselike': 1.0.3 + '@types/chalk@2.2.4': + dependencies: + chalk: 5.6.2 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -6196,7 +6214,7 @@ snapshots: dependencies: ansi-align: 3.0.1 camelcase: 7.0.1 - chalk: 5.0.1 + chalk: 5.6.2 cli-boxes: 3.0.0 string-width: 5.1.2 type-fest: 2.19.0 @@ -6360,6 +6378,8 @@ snapshots: chalk@5.0.1: {} + chalk@5.6.2: {} + chinese-conv@4.0.0: {} chownr@3.0.0: {} diff --git a/src/i18n/resources/ar.json b/src/i18n/resources/ar.json index da91cbc2a6..ae232a6e9a 100644 --- a/src/i18n/resources/ar.json +++ b/src/i18n/resources/ar.json @@ -548,6 +548,10 @@ "button": "تنزيل" } }, + "downloader-ytdlp": { + "name": "أداة التنزيل (yt-dlp)", + "description": "يقوم بتنزيل ملفات MP3/مصدر الصوت مباشرة من الواجهة. نسخة من إضافة التنزيل تستخدم yt-dlp.exe. يمكنك تحديد مسار مخصص للملف التنفيذي وضبط سلسلة ملفات تعريف الارتباط (cookies) لتحسين نجاح التنزيلات عبر تضمين ملفات تعريف ارتباط صالحة." + }, "equalizer": { "description": "يضيف معادل صوتي للمشغل", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/bg.json b/src/i18n/resources/bg.json index dbf21bce51..10293c5a3e 100644 --- a/src/i18n/resources/bg.json +++ b/src/i18n/resources/bg.json @@ -548,6 +548,10 @@ "button": "Изтегляне" } }, + "downloader-ytdlp": { + "name": "Изтегляч (yt-dlp)", + "description": "Изтегля MP3 / източниково аудио директно от интерфейса. Версия на приставката за изтегляне, която използва yt-dlp.exe. Можете да зададете персонализиран път до изпълнимия файл и низ с бисквитки (cookies), за да повишите успеваемостта на изтеглянията, като добавите валидни бисквитки." + }, "equalizer": { "description": "Добавя еквалайзер към плеъра", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/bn.json b/src/i18n/resources/bn.json index 4b760d83fe..dd1bbbddd2 100644 --- a/src/i18n/resources/bn.json +++ b/src/i18n/resources/bn.json @@ -527,6 +527,10 @@ "button": "ডাউনলোড" } }, + "downloader-ytdlp": { + "name": "ডাউনলোডার (yt-dlp)", + "description": "ইন্টারফেস থেকে সরাসরি MP3 / উৎস অডিও ডাউনলোড করে. ডাউনলোডার প্লাগইনের একটি সংস্করণ যা yt-dlp.exe ব্যবহার করে। এক্সিকিউটেবলটির জন্য কাস্টম পাথ এবং বৈধ কুকি অন্তর্ভুক্ত করে ডাউনলোড সফলতার হার বাড়াতে একটি cookies string সেট করা যায়।" + }, "equalizer": { "description": "প্লেয়ারে একটি ইকুয়ালাইজার যোগ করে", "menu": { @@ -897,3 +901,4 @@ } } } + diff --git a/src/i18n/resources/ca.json b/src/i18n/resources/ca.json index e37e51f8dc..1a5a338609 100644 --- a/src/i18n/resources/ca.json +++ b/src/i18n/resources/ca.json @@ -565,6 +565,10 @@ "button": "Descarrega" } }, + "downloader-ytdlp": { + "name": "Descàrregues (yt-dlp)", + "description": "Descarrega el MP3 / àudio d'origen directament des de la interfície. Una versió del connector de descàrrega que utilitza yt-dlp.exe. Pots establir un camí personalitzat per a l'executable i una cadena de cookies per millorar l'èxit de les descàrregues incloent cookies vàlides." + }, "equalizer": { "description": "Afegeix un equalitzador al reproductor", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/cs.json b/src/i18n/resources/cs.json index 5d16999041..07c0e0426c 100644 --- a/src/i18n/resources/cs.json +++ b/src/i18n/resources/cs.json @@ -548,6 +548,10 @@ "button": "Stáhnout" } }, + "downloader-ytdlp": { + "name": "Stahovač (yt-dlp)", + "description": "Stahuje MP3 / source audio přímo z rozhraní. Verze pluginu stahování, která používá yt-dlp.exe. Můžete nastavit vlastní cestu ke spustitelnému souboru a řetězec cookies pro zlepšení úspěšnosti stahování zahrnutím platných cookies." + }, "equalizer": { "description": "Přidá do přehrávače ekvalizér", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/de.json b/src/i18n/resources/de.json index 89280076d4..2e808e10cb 100644 --- a/src/i18n/resources/de.json +++ b/src/i18n/resources/de.json @@ -577,6 +577,10 @@ "button": "Herunterladen" } }, + "downloader-ytdlp": { + "name": "Downloader (yt-dlp)", + "description": "Lädt MP3-/Original-Audio direkt von der Schnittstelle herunter. Eine Version des Downloader-Plugins, die yt-dlp.exe verwendet. Du kannst einen benutzerdefinierten Pfad zur ausführbaren Datei sowie einen Cookies-String festlegen, um erfolgreiche Downloads durch gültige Cookies zu verbessern." + }, "equalizer": { "description": "Fügt einen Equalizer zum Player hinzu", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/el.json b/src/i18n/resources/el.json index 3f68b8e4ad..05a71e6655 100644 --- a/src/i18n/resources/el.json +++ b/src/i18n/resources/el.json @@ -527,6 +527,10 @@ "button": "Λήψη" } }, + "downloader-ytdlp": { + "name": "Κατεβαστής (yt-dlp)", + "description": "Λήψεις MP3 / ήχου πηγής απευθείας από τη διεπαφή. Μια έκδοση του πρόσθετου λήψης που χρησιμοποιεί το yt-dlp.exe. Μπορείς να ορίσεις προσαρμοσμένη διαδρομή για το εκτελέσιμο και μια συμβολοσειρά cookies για να βελτιώσεις την επιτυχία των λήψεων συμπεριλαμβάνοντας έγκυρα cookies." + }, "equalizer": { "description": "Προσθέτει έναν ισοσταθμιστή στο πρόγραμμα αναπαραγωγής", "menu": { @@ -897,3 +901,4 @@ } } } + diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 763e854b82..2e1f6ab129 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -567,7 +567,11 @@ }, "download-playlist": "Download playlist", "presets": "Presets", - "skip-existing": "Skip existing files" + "skip-existing": "Skip existing files", + "yt-dlp-location-nice": "yt-dlp executable location", + "yt-dlp-location-title": "Set yt-dlp path", + "yt-dlp-location-label": "Path to yt-dlp executable", + "yt-dlp-location-saved": "yt-dlp path saved" }, "name": "Downloader", "renderer": { @@ -577,6 +581,10 @@ "button": "Download" } }, + "downloader-ytdlp": { + "name": "Downloader (yt-dlp)", + "description": "Downloads MP3 / source audio directly from the interface. A version of the downloader plugin that uses yt-dlp.exe. You can set a custom path for the executable and a cookies string to improve successful downloads by including valid cookies." + }, "equalizer": { "description": "Adds an equalizer to the player", "menu": { @@ -926,6 +934,38 @@ "description": "Control playback from your Windows taskbar", "name": "Taskbar Media Control" }, + "taskbar-widget": { + "description": "Embeds a mini player into the Windows taskbar with album art, song info, and playback controls", + "name": "Taskbar Widget", + "menu": { + "monitor": { + "label": "Display Monitor", + "primary": "Primary" + }, + "position": { + "label": "Position Offset", + "horizontal-offset": "Horizontal offset (px)", + "vertical-offset": "Vertical offset (px)" + }, + "background-blur": "Background Blur", + "blur-opacity": "Blur Opacity (0.1–1.0)", + "visualizer": { + "label": "Taskbar Visualizer", + "enabled": "Enable Taskbar Visualizer", + "position": { + "label": "Visualizer Position", + "left": "Left of Widget", + "right": "Right of Widget" + }, + "width": "Visualizer Width (40–300 px)", + "bar-count": "Bar Count (4–64)", + "centered-bars": "Centered Bars", + "show-baseline": "Show Baseline", + "audio-sensitivity": "Audio Sensitivity (0.01–1.0)", + "audio-peak-threshold": "Audio Peak Threshold (0.1–1.0)" + } + } + }, "touchbar": { "description": "Adds a TouchBar widget for macOS users", "name": "TouchBar" @@ -994,4 +1034,4 @@ "name": "Visualizer" } } -} +} \ No newline at end of file diff --git a/src/i18n/resources/es.json b/src/i18n/resources/es.json index 7820b4e466..4100b7c6ec 100644 --- a/src/i18n/resources/es.json +++ b/src/i18n/resources/es.json @@ -577,6 +577,10 @@ "button": "Descargar" } }, + "downloader-ytdlp": { + "name": "Gestor de descargas (yt-dlp)", + "description": "Descarga audio MP3 / fuente directamente desde la interfaz. Una versión del complemento de descargas que usa yt-dlp.exe. Puedes establecer una ruta personalizada para el ejecutable y una cadena de cookies para mejorar el éxito de las descargas incluyendo cookies válidas." + }, "equalizer": { "description": "Añade un ecualizador al reproductor", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/fa.json b/src/i18n/resources/fa.json index b88a398384..27aea6b6eb 100644 --- a/src/i18n/resources/fa.json +++ b/src/i18n/resources/fa.json @@ -564,6 +564,10 @@ "button": "دانلود" } }, + "downloader-ytdlp": { + "name": "دانلودر (yt-dlp)", + "description": "دانلود MP3 / صدای منبع به طور مستقیم از رابط. نسخه‌ای از افزونهٔ دانلود که از yt-dlp.exe استفاده می‌کند. می‌توانید مسیر سفارشی برای فایل اجرایی و یک رشتهٔ کوکی‌ها تنظیم کنید تا با افزودن کوکی‌های معتبر، موفقیت دانلودها بیشتر شود." + }, "equalizer": { "description": "اضافه کردن یک اکولایزر به پخش‌کننده", "menu": { @@ -964,3 +968,4 @@ } } } + diff --git a/src/i18n/resources/fi.json b/src/i18n/resources/fi.json index 9324b12b42..6037126713 100644 --- a/src/i18n/resources/fi.json +++ b/src/i18n/resources/fi.json @@ -527,6 +527,10 @@ "button": "Lataa" } }, + "downloader-ytdlp": { + "name": "Lataaja (yt-dlp)", + "description": "Lataa MP3- tai lähdetiedoston suoraan käyttöliittymästä. Latauslisäosan versio, joka käyttää yt-dlp.exe:tä. Voit asettaa suoritettavalle tiedostolle mukautetun polun sekä eväste-merkkijonon parantaaksesi latausten onnistumista lisäämällä kelvolliset evästeet." + }, "equalizer": { "description": "Lisää taajuuskorjaimen toistimeen", "menu": { @@ -777,3 +781,4 @@ } } } + diff --git a/src/i18n/resources/fil.json b/src/i18n/resources/fil.json index de26bf7d7c..17fc7625e8 100644 --- a/src/i18n/resources/fil.json +++ b/src/i18n/resources/fil.json @@ -536,6 +536,10 @@ "button": "I-download" } }, + "downloader-ytdlp": { + "name": "Taga-download (yt-dlp)", + "description": "Dina-download ang mga MP3 / source audio direkta mula sa interface. Isang bersyon ng plugin na pang-download na gumagamit ng yt-dlp.exe. Maaari kang magtakda ng custom na path para sa executable at isang cookies string para mas tumaas ang tagumpay ng pag-download sa pamamagitan ng paglalagay ng valid na cookies." + }, "equalizer": { "description": "Nagdaragdag ng equalizer sa player", "menu": { @@ -910,3 +914,4 @@ } } } + diff --git a/src/i18n/resources/fr.json b/src/i18n/resources/fr.json index f1eb9f970f..8e30d972f7 100644 --- a/src/i18n/resources/fr.json +++ b/src/i18n/resources/fr.json @@ -577,6 +577,10 @@ "button": "Télécharger" } }, + "downloader-ytdlp": { + "name": "Téléchargeur (yt-dlp)", + "description": "Télécharge les fichiers MP3/source audio directement depuis l'interface. Une version du plugin de téléchargement qui utilise yt-dlp.exe. Vous pouvez définir un chemin personnalisé vers l'exécutable ainsi qu'une chaîne de cookies afin d'améliorer la réussite des téléchargements en incluant des cookies valides." + }, "equalizer": { "description": "Ajoute un égaliseur au lecteur", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/hi.json b/src/i18n/resources/hi.json index 21830c5ff2..b2f76b2714 100644 --- a/src/i18n/resources/hi.json +++ b/src/i18n/resources/hi.json @@ -548,6 +548,10 @@ "button": "डाउनलोड" } }, + "downloader-ytdlp": { + "name": "डाउनलोडर (yt-dlp)", + "description": "इंटरफ़ेस से सीधे MP3 / स्रोत ऑडियो डाउनलोड करता है. डाउनलोडर प्लगइन का एक संस्करण जो yt-dlp.exe का उपयोग करता है। आप executable के लिए कस्टम पाथ और वैध कुकीज़ शामिल करके डाउनलोड की सफलता बढ़ाने हेतु एक cookies string सेट कर सकते हैं।" + }, "equalizer": { "description": "प्लेयर में एक एक्विलाइज़र जोड़ता है", "menu": { @@ -826,3 +830,4 @@ } } } + diff --git a/src/i18n/resources/hr.json b/src/i18n/resources/hr.json index fedb487389..e1a5d2f310 100644 --- a/src/i18n/resources/hr.json +++ b/src/i18n/resources/hr.json @@ -565,6 +565,10 @@ "button": "Preuzmi" } }, + "downloader-ytdlp": { + "name": "Preuzimatelj (yt-dlp)", + "description": "Preuzima MP3 / izvorni audiozapis izravno iz sučelja. Verzija dodatka za preuzimanje koja koristi yt-dlp.exe. Možete postaviti prilagođenu putanju do izvršne datoteke i niz kolačića (cookies) kako biste poboljšali uspješnost preuzimanja uključivanjem važećih kolačića." + }, "equalizer": { "description": "Dodaje equalizer reprodukciji", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/hu.json b/src/i18n/resources/hu.json index 50ad7104db..12c42275f5 100644 --- a/src/i18n/resources/hu.json +++ b/src/i18n/resources/hu.json @@ -545,6 +545,10 @@ "button": "Letöltés" } }, + "downloader-ytdlp": { + "name": "Letöltő (yt-dlp)", + "description": "MP3 / forrás hanganyag letöltése közvetlenül az interfészről. A letöltő bővítmény egy változata, amely a yt-dlp.exe-t használja. Beállítható egy egyéni útvonal a futtatható fájlhoz, valamint egy cookie-karakterlánc, amellyel érvényes cookie-k megadásával javítható a letöltések sikeressége." + }, "equalizer": { "description": "Hangszínszabályzót ad hozzá a zenelejátszóhoz", "menu": { @@ -909,3 +913,4 @@ } } } + diff --git a/src/i18n/resources/id.json b/src/i18n/resources/id.json index 4e682d42e9..83ba9c54ba 100644 --- a/src/i18n/resources/id.json +++ b/src/i18n/resources/id.json @@ -548,6 +548,10 @@ "button": "Unduh" } }, + "downloader-ytdlp": { + "name": "Pengunduh (yt-dlp)", + "description": "Unduh MP3 / sumber suara secara langsung via antarmuka. Versi dari plugin pengunduh yang menggunakan yt-dlp.exe. Anda dapat mengatur jalur khusus untuk executable dan sebuah string cookies untuk meningkatkan keberhasilan unduhan dengan menyertakan cookies yang valid." + }, "equalizer": { "description": "Menambahkan equalizer ke pemutar", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/is.json b/src/i18n/resources/is.json index 16c0f34944..8cfd4d320f 100644 --- a/src/i18n/resources/is.json +++ b/src/i18n/resources/is.json @@ -484,6 +484,10 @@ "button": "Sækja" } }, + "downloader-ytdlp": { + "name": "Niðurhalari (yt-dlp)", + "description": "Niðurhalar MP3 / upprunahljóði beint úr viðmótinu. Útgáfa af niðurhalsviðbótinni sem notar yt-dlp.exe. Þú getur stillt sérsniðna slóð að keyrsluskránni og cookies-streng til að auka líkur á árangursríkum niðurhölum með því að nota gild cookies." + }, "exponential-volume": { "description": "Gerir hljóðstyrkssleðann veldisvísis svo það er auðveldara að velja lægra hljóðstyrk.", "name": "Veldibundiðrúmmál" @@ -811,3 +815,4 @@ } } } + diff --git a/src/i18n/resources/it.json b/src/i18n/resources/it.json index 3bde5be387..372c9ac9a1 100644 --- a/src/i18n/resources/it.json +++ b/src/i18n/resources/it.json @@ -577,6 +577,10 @@ "button": "Scarica" } }, + "downloader-ytdlp": { + "name": "Downloader (yt-dlp)", + "description": "Download MP3 / sorgenti audio direttamente dall'interfaccia. Una versione del plugin di download che usa yt-dlp.exe. Puoi impostare un percorso personalizzato per l'eseguibile e una stringa di cookie per aumentare il successo dei download includendo cookie validi." + }, "equalizer": { "description": "Aggiunge un equalizzatore al player", "menu": { @@ -977,3 +981,4 @@ } } } + diff --git a/src/i18n/resources/ja.json b/src/i18n/resources/ja.json index 63e55f2655..3c5740a646 100644 --- a/src/i18n/resources/ja.json +++ b/src/i18n/resources/ja.json @@ -548,6 +548,10 @@ "button": "ダウンロード" } }, + "downloader-ytdlp": { + "name": "ダウンローダー (yt-dlp)", + "description": "UIから直にMP3・ソースオーディオをダウンロードします. yt-dlp.exe を使用するダウンローダープラグインの別版です。実行ファイルのカスタムパスや、有効な Cookie を含めてダウンロード成功率を高めるための Cookie 文字列を設定できます。" + }, "equalizer": { "description": "イコライザーを追加", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/ko.json b/src/i18n/resources/ko.json index 4c1c52a7ed..048131f453 100644 --- a/src/i18n/resources/ko.json +++ b/src/i18n/resources/ko.json @@ -577,6 +577,10 @@ "button": "다운로드" } }, + "downloader-ytdlp": { + "name": "다운로더 (yt-dlp)", + "description": "UI에서 직접 MP3/소스 오디오를 다운로드하세요. yt-dlp.exe를 사용하는 다운로더 플러그인 버전입니다. 실행 파일의 사용자 지정 경로와 유효한 쿠키를 포함해 다운로드 성공률을 높이기 위한 쿠키 문자열을 설정할 수 있습니다." + }, "equalizer": { "description": "플레이어에 이퀄라이저를 추가합니다", "menu": { @@ -977,3 +981,4 @@ } } } + diff --git a/src/i18n/resources/lt.json b/src/i18n/resources/lt.json index 840236065d..338144cde8 100644 --- a/src/i18n/resources/lt.json +++ b/src/i18n/resources/lt.json @@ -518,6 +518,10 @@ "button": "Atsisiųsti" } }, + "downloader-ytdlp": { + "name": "Atsiuntėjas (yt-dlp)", + "description": "Atsisiunčia MP3 / šaltinio garsą tiesiogiai iš sąsajos. Downloader įskiepio versija, naudojanti yt-dlp.exe. Galite nustatyti pasirinktą vykdomojo failo kelią ir slapukų (cookies) eilutę, kad įtraukus galiojančius slapukus būtų padidintas atsisiuntimų sėkmės procentas." + }, "equalizer": { "menu": { "presets": { @@ -774,3 +778,4 @@ } } } + diff --git a/src/i18n/resources/lv.json b/src/i18n/resources/lv.json index 8d7828ac1d..594a3478c9 100644 --- a/src/i18n/resources/lv.json +++ b/src/i18n/resources/lv.json @@ -547,6 +547,10 @@ "button": "Lejupielādēt" } }, + "downloader-ytdlp": { + "name": "Lejupielādētājs (yt-dlp)", + "description": "Lejupielādē MP3/avota audio tieši no saskarnes. Lejupielādes spraudņa versija, kas izmanto yt-dlp.exe. Varat iestatīt pielāgotu ceļu uz izpildāmo failu un sīkdatņu virkni, lai, iekļaujot derīgas sīkdatnes, uzlabotu lejupielāžu sekmību." + }, "equalizer": { "description": "Pievieno atskaņotājam ekvalaizeru", "menu": { @@ -624,3 +628,4 @@ } } } + diff --git a/src/i18n/resources/ms.json b/src/i18n/resources/ms.json index ae72cd6c45..910813699c 100644 --- a/src/i18n/resources/ms.json +++ b/src/i18n/resources/ms.json @@ -548,6 +548,10 @@ "button": "Memuat turun" } }, + "downloader-ytdlp": { + "name": "Pemuat turun (yt-dlp)", + "description": "Memuat turun audio MP3 / sumber terus dari antara muka. Versi pemalam muat turun yang menggunakan yt-dlp.exe. Anda boleh menetapkan laluan tersuai untuk fail boleh laksana dan rentetan cookies untuk meningkatkan kejayaan muat turun dengan menyertakan cookies yang sah." + }, "equalizer": { "description": "Menambahkan penyamaan kepada pemain", "menu": { @@ -683,3 +687,4 @@ } } } + diff --git a/src/i18n/resources/nb.json b/src/i18n/resources/nb.json index 59fc29f43f..d851470a0c 100644 --- a/src/i18n/resources/nb.json +++ b/src/i18n/resources/nb.json @@ -395,6 +395,10 @@ "button": "Last ned" } }, + "downloader-ytdlp": { + "name": "Nedlaster (yt-dlp)", + "description": "Laster ned MP3/kildelyd direkte fra grensesnittet. En versjon av nedlaster-tillegget som bruker yt-dlp.exe. Du kan angi en egendefinert sti til kjørbar fil og en cookies-streng for å øke sjansen for vellykkede nedlastinger ved å inkludere gyldige cookies." + }, "exponential-volume": { "description": "Gjør lydstyrkekontrollen eksponentiell, slik at det er enklere velge lavere lydstyrker.", "name": "Eksponentiell lydstyrke" @@ -589,3 +593,4 @@ } } } + diff --git a/src/i18n/resources/ne.json b/src/i18n/resources/ne.json index 6def4a1086..4472596880 100644 --- a/src/i18n/resources/ne.json +++ b/src/i18n/resources/ne.json @@ -527,6 +527,10 @@ "button": "डाउनलोड" } }, + "downloader-ytdlp": { + "name": "डाउनलोडर (yt-dlp)", + "description": "इन्टरफेसबाट सिधै MP3/स्रोत अडियो डाउनलोड गर. yt-dlp.exe प्रयोग गर्ने डाउनलोडर प्लगइनको एउटा संस्करण। executable को लागि कस्टम पाथ र वैध कुकीहरू समावेश गरेर डाउनलोड सफल बनाउन cookies string सेट गर्न सकिन्छ।" + }, "equalizer": { "description": "प्लेयरमा इक्वलाइजर थप्दछ", "menu": { @@ -892,3 +896,4 @@ } } } + diff --git a/src/i18n/resources/nl.json b/src/i18n/resources/nl.json index ec11969bf1..536b1f9aad 100644 --- a/src/i18n/resources/nl.json +++ b/src/i18n/resources/nl.json @@ -565,6 +565,10 @@ "button": "Download" } }, + "downloader-ytdlp": { + "name": "Downloader (yt-dlp)", + "description": "Download MP3 / bron-audio rechtstreeks vanuit de interface. Een versie van de downloader-plug-in die yt-dlp.exe gebruikt. Je kunt een aangepast pad naar het uitvoerbare bestand en een cookies-string instellen om downloads vaker te laten slagen door geldige cookies mee te geven." + }, "equalizer": { "description": "Voegt een equalizer toe aan de speler", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/pl.json b/src/i18n/resources/pl.json index 234eafbed5..fde559eb44 100644 --- a/src/i18n/resources/pl.json +++ b/src/i18n/resources/pl.json @@ -565,6 +565,10 @@ "button": "Pobierz" } }, + "downloader-ytdlp": { + "name": "Pobieranie (yt-dlp)", + "description": "Pobiera MP3/ źródło audio bezpośrednio z interfejsu. Wersja wtyczki pobierania, która korzysta z yt-dlp.exe. Możesz ustawić własną ścieżkę do pliku wykonywalnego oraz ciąg cookies, aby zwiększyć skuteczność pobierania poprzez dołączenie poprawnych cookies." + }, "equalizer": { "description": "Dodaje equalizer do odtwarzacza", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/pt-BR.json b/src/i18n/resources/pt-BR.json index a4c1deac2e..30bc7069df 100644 --- a/src/i18n/resources/pt-BR.json +++ b/src/i18n/resources/pt-BR.json @@ -577,6 +577,10 @@ "button": "Baixar" } }, + "downloader-ytdlp": { + "name": "Downloader (yt-dlp)", + "description": "Faça download do MP3 / fonte de áudio diretamente da interface. Uma versão do plugin de download que usa o yt-dlp.exe. Você pode definir um caminho personalizado para o executável e uma string de cookies para aumentar o sucesso dos downloads ao incluir cookies válidos." + }, "equalizer": { "description": "Adiciona um equalizador ao player", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/pt.json b/src/i18n/resources/pt.json index 2a306a7895..9ccc14baf1 100644 --- a/src/i18n/resources/pt.json +++ b/src/i18n/resources/pt.json @@ -565,6 +565,10 @@ "button": "Descarregar" } }, + "downloader-ytdlp": { + "name": "Descarregador (yt-dlp)", + "description": "Descarregar MP3/fonte de áudio diretamente da interface. Uma versão do plugin de descarregamento que usa o yt-dlp.exe. Pode definir um caminho personalizado para o executável e uma string de cookies para aumentar o sucesso das descargas ao incluir cookies válidos." + }, "equalizer": { "description": "Adiciona um equalizador ao reprodutor", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/ro.json b/src/i18n/resources/ro.json index 1ff06b9a26..3304064961 100644 --- a/src/i18n/resources/ro.json +++ b/src/i18n/resources/ro.json @@ -548,6 +548,10 @@ "button": "Descarcă" } }, + "downloader-ytdlp": { + "name": "Descărcător (yt-dlp)", + "description": "Descarcă MP3 / sursa audio direct din interfață. O versiune a pluginului de descărcare care folosește yt-dlp.exe. Puteți seta o cale personalizată către executabil și un șir de cookie-uri pentru a crește șansele de descărcare reușită prin includerea unor cookie-uri valide." + }, "equalizer": { "description": "Adauă un egalizator la player", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/ru.json b/src/i18n/resources/ru.json index 4ec675b846..c6e7e9f068 100644 --- a/src/i18n/resources/ru.json +++ b/src/i18n/resources/ru.json @@ -565,6 +565,10 @@ "button": "Скачать" } }, + "downloader-ytdlp": { + "name": "Загрузчик (yt-dlp)", + "description": "Скачивать MP3 / исходное аудио напрямую из интерфейса. Версия плагина загрузки, которая использует yt-dlp.exe. Вы можете указать собственный путь к исполняемому файлу и строку cookies, чтобы повысить успешность загрузок, добавив действительные cookies." + }, "equalizer": { "description": "Добавляет эквалайзер к плееру", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/sk.json b/src/i18n/resources/sk.json index 6bd5c4b1c8..655d231e97 100644 --- a/src/i18n/resources/sk.json +++ b/src/i18n/resources/sk.json @@ -560,6 +560,10 @@ "button": "Stiahnuť" } }, + "downloader-ytdlp": { + "name": "Sťahovač (yt-dlp)", + "description": "Sťahuje MP3 / zdrojový zvuk priamo z rozhrania. Verzia sťahovacieho pluginu, ktorá používa yt-dlp.exe. Môžete nastaviť vlastnú cestu k spustiteľnému súboru a reťazec cookies, aby ste zvýšili úspešnosť sťahovania zahrnutím platných cookies." + }, "equalizer": { "description": "Pridáva ekvalizér do prehrávača", "menu": { @@ -881,3 +885,4 @@ } } } + diff --git a/src/i18n/resources/sl.json b/src/i18n/resources/sl.json index ea8118de22..dff71dac54 100644 --- a/src/i18n/resources/sl.json +++ b/src/i18n/resources/sl.json @@ -484,6 +484,10 @@ "button": "Prenos" } }, + "downloader-ytdlp": { + "name": "Prenaševalec (yt-dlp)", + "description": "Prenese MP3 / izviren zvok direktno iz vmesnika. Različica vtičnika za prenos, ki uporablja yt-dlp.exe. Nastavite lahko prilagojeno pot do izvršljive datoteke in niz piškotkov, da z vključitvijo veljavnih piškotkov izboljšate uspešnost prenosov." + }, "exponential-volume": { "description": "Drsnik za glasnost naredi eksponenten, da bo lažje izbrati nižje glasnosti.", "name": "Eksponentna glasnost" @@ -551,3 +555,4 @@ } } } + diff --git a/src/i18n/resources/sr.json b/src/i18n/resources/sr.json index f331efcf3f..84a86e318c 100644 --- a/src/i18n/resources/sr.json +++ b/src/i18n/resources/sr.json @@ -565,6 +565,10 @@ "button": "Preuzimanje" } }, + "downloader-ytdlp": { + "name": "Servis za preuzimanje (yt-dlp)", + "description": "Preuzimanje MP3 / izvornog zvuka direktno sa interfejsa. Verzija dodatka za preuzimanje koja koristi yt-dlp.exe. Možete postaviti prilagođenu putanju do izvršne datoteke i cookies string kako biste povećali uspešnost preuzimanja uključivanjem važećih cookies." + }, "equalizer": { "description": "Dodaje ekvilajzer u muzički plejer", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/sv.json b/src/i18n/resources/sv.json index aa08632ae8..eb8fe89b61 100644 --- a/src/i18n/resources/sv.json +++ b/src/i18n/resources/sv.json @@ -565,6 +565,10 @@ "button": "Ladda ner" } }, + "downloader-ytdlp": { + "name": "Nedladdare (yt-dlp)", + "description": "Laddar ner MP3 / originalljud direkt från gränssnittet. En version av nedladdningspluginet som använder yt-dlp.exe. Du kan ange en anpassad sökväg till den körbara filen och en cookies-sträng för att förbättra chansen att nedladdningar lyckas genom att inkludera giltiga cookies." + }, "equalizer": { "description": "Lägger till en equalizer i spelaren", "menu": { @@ -965,3 +969,4 @@ } } } + diff --git a/src/i18n/resources/ta.json b/src/i18n/resources/ta.json index 19c2430d38..13fe81c1b4 100644 --- a/src/i18n/resources/ta.json +++ b/src/i18n/resources/ta.json @@ -548,6 +548,10 @@ "button": "பதிவிறக்கம்" } }, + "downloader-ytdlp": { + "name": "பதிவிறக்குபவர் (yt-dlp)", + "description": "எம்பி 3 / மூல ஆடியோவை இடைமுகத்திலிருந்து நேரடியாக பதிவிறக்குகிறது. yt-dlp.exe ஐ பயன்படுத்தும் பதிவிறக்கி ப்ளகினின் ஒரு பதிப்பு. இயக்கத்தக்க கோப்பிற்கான தனிப்பயன் பாதையும், செல்லுபடியாகும் குக்கீகளை சேர்த்து பதிவிறக்கங்கள் வெற்றிகரமாக முடிவடைய உதவும் cookies string-ஐயும் அமைக்கலாம்." + }, "equalizer": { "description": "பிளேயருக்கு ஒரு சமநிலையைச் சேர்க்கிறது", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/th.json b/src/i18n/resources/th.json index ec7f4be9a1..e482d7d0da 100644 --- a/src/i18n/resources/th.json +++ b/src/i18n/resources/th.json @@ -548,6 +548,10 @@ "button": "ดาวน์โหลด" } }, + "downloader-ytdlp": { + "name": "ตัวดาวน์โหลด (yt-dlp)", + "description": "ดาวน์โหลด MP3 / เสียงต้นฉบับโดยตรงจากอินเทอร์เฟซ. ปลั๊กอินดาวน์โหลดเวอร์ชันที่ใช้ yt-dlp.exe คุณสามารถตั้งค่าพาธของไฟล์ปฏิบัติการเองได้ และตั้งค่าสตริงคุกกี้เพื่อเพิ่มโอกาสในการดาวน์โหลดสำเร็จโดยการใส่คุกกี้ที่ถูกต้อง" + }, "equalizer": { "description": "เพิ่มอีควอไลเซอร์ให้ที่เล่นเพลง", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/tr.json b/src/i18n/resources/tr.json index 062c680d41..e41716aa36 100644 --- a/src/i18n/resources/tr.json +++ b/src/i18n/resources/tr.json @@ -577,6 +577,10 @@ "button": "İndir" } }, + "downloader-ytdlp": { + "name": "İndirici (yt-dlp)", + "description": "MP3 / kaynak sesini doğrudan arayüzden indir. yt-dlp.exe kullanan indirici eklentisinin bir sürümü. Çalıştırılabilir dosya için özel bir yol ve geçerli çerezleri ekleyerek indirmelerin başarılı olma ihtimalini artırmak için bir çerez dizesi ayarlayabilirsiniz." + }, "equalizer": { "description": "Oynatıcıya ekolayzer desteği ekler", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/uk.json b/src/i18n/resources/uk.json index bfa078f8d5..c997e9aa9f 100644 --- a/src/i18n/resources/uk.json +++ b/src/i18n/resources/uk.json @@ -548,6 +548,10 @@ "button": "Завантажити" } }, + "downloader-ytdlp": { + "name": "Завантажувач (yt-dlp)", + "description": "Завантажує MP3 / джерело аудіо безпосередньо з інтерфейсу. Версія плагіна завантаження, яка використовує yt-dlp.exe. Ви можете вказати власний шлях до виконуваного файла та рядок cookies, щоб підвищити успішність завантажень, додавши дійсні cookies." + }, "equalizer": { "description": "Додає еквалайзер до програвача", "menu": { @@ -948,3 +952,4 @@ } } } + diff --git a/src/i18n/resources/vi.json b/src/i18n/resources/vi.json index 948265c708..8579079650 100644 --- a/src/i18n/resources/vi.json +++ b/src/i18n/resources/vi.json @@ -577,6 +577,10 @@ "button": "Tải xuống" } }, + "downloader-ytdlp": { + "name": "Trình tải xuống (yt-dlp)", + "description": "Tải xuống MP3 / âm thanh nguồn trực tiếp từ giao diện. Phiên bản của plugin tải xuống sử dụng yt-dlp.exe. Bạn có thể đặt đường dẫn tùy chỉnh tới tệp thực thi và một chuỗi cookies để tăng khả năng tải xuống thành công bằng cách bao gồm cookies hợp lệ." + }, "equalizer": { "description": "Thêm bộ chỉnh âm để điều chỉnh âm thanh cho trình phát nhạc", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/zh-CN.json b/src/i18n/resources/zh-CN.json index 5f6368cce8..9daaa4eef5 100644 --- a/src/i18n/resources/zh-CN.json +++ b/src/i18n/resources/zh-CN.json @@ -577,6 +577,10 @@ "button": "下载" } }, + "downloader-ytdlp": { + "name": "下载器 (yt-dlp)", + "description": "在界面内直接下载 MP3 / 源音频. 使用 yt-dlp.exe 的下载器插件版本。你可以为可执行文件设置自定义路径,并设置 cookies 字符串,通过包含有效 cookies 来提高下载成功率。" + }, "equalizer": { "description": "为播放器添加均衡器", "menu": { @@ -995,3 +999,4 @@ } } } + diff --git a/src/i18n/resources/zh-TW.json b/src/i18n/resources/zh-TW.json index 0b4b4ce801..99b8898a17 100644 --- a/src/i18n/resources/zh-TW.json +++ b/src/i18n/resources/zh-TW.json @@ -577,6 +577,10 @@ "button": "下載" } }, + "downloader-ytdlp": { + "name": "歌曲下載 (yt-dlp)", + "description": "開啟應用程式內下載 MP3/原始音檔功能. 使用 yt-dlp.exe 的下載器外掛版本。你可以為可執行檔設定自訂路徑,並設定 cookies 字串,透過包含有效 cookies 來提高下載成功率。" + }, "equalizer": { "description": "為播放器加入等化器", "menu": { @@ -977,3 +981,4 @@ } } } + diff --git a/src/menu.ts b/src/menu.ts index c08f62710c..62b31aad8d 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -120,8 +120,17 @@ export const mainMenuTemplate = async ( ); const availablePlugins = Object.keys(await allPlugins()); + + // IDs that will be merged into a single "Downloader" parent menu + const downloaderIds = ['downloader', 'downloader-ytdlp'] as const; + const downloaderLabels: Record = { + 'downloader': 'youtube.js (built-in)', + 'downloader-ytdlp': 'ytdlp (external exe)', + }; + const pluginMenus = await Promise.all( availablePlugins + .filter((id) => !downloaderIds.includes(id as typeof downloaderIds[number])) .sort((a, b) => { const aPluginLabel = allPluginsStubs[a]?.name?.() ?? a; const bPluginLabel = allPluginsStubs[b]?.name?.() ?? b; @@ -150,6 +159,70 @@ export const mainMenuTemplate = async ( }), ); + // Build unified "Downloader" submenu from both downloader plugins + const downloaderSubmenus = await Promise.all( + downloaderIds.map(async (id) => { + const plugin = allPluginsStubs[id]; + if (!plugin) return null; + + const subLabel = downloaderLabels[id]; + const pluginDescription = plugin?.description?.() ?? undefined; + const isEnabled = await config.plugins.isEnabled(id); + const predefinedTemplate = menuResult.find((it) => it[0] === id); + + if (!isEnabled || !predefinedTemplate) { + // Plugin not enabled or has no custom menu — show just the Enabled checkbox + return { + label: subLabel, + toolTip: pluginDescription, + submenu: [ + await pluginEnabledMenu( + id, + t('main.menu.plugins.enabled'), + pluginDescription, + false, + true, + innerRefreshMenu, + ), + ], + } satisfies Electron.MenuItemConstructorOptions; + } + + // Plugin enabled with menu template + const template = (predefinedTemplate[1] as Electron.MenuItemConstructorOptions).submenu; + return { + label: subLabel, + toolTip: pluginDescription, + submenu: template, + } satisfies Electron.MenuItemConstructorOptions; + }), + ); + + const validDownloaderSubmenus: Electron.MenuItemConstructorOptions[] = downloaderSubmenus.filter( + (s): s is NonNullable => s !== null, + ); + + if (validDownloaderSubmenus.length > 0) { + // Insert unified Downloader menu in sorted position + const combinedDownloader: Electron.MenuItemConstructorOptions = { + label: t('plugins.downloader.name'), + submenu: validDownloaderSubmenus, + }; + // Find insertion index to keep alphabetical order + const insertIdx = pluginMenus.findIndex((item) => { + const itemLabel = + typeof item === 'object' && 'label' in item + ? (item as Electron.MenuItemConstructorOptions).label ?? '' + : ''; + return (itemLabel as string).localeCompare(t('plugins.downloader.name')) > 0; + }); + if (insertIdx === -1) { + pluginMenus.push(combinedDownloader); + } else { + pluginMenus.splice(insertIdx, 0, combinedDownloader); + } + } + const langResources = await languageResources(); const availableLanguages = Object.keys(langResources); diff --git a/src/plugins/adblocker/.gitignore b/src/plugins/adblocker/.gitignore new file mode 100644 index 0000000000..af053a2be1 --- /dev/null +++ b/src/plugins/adblocker/.gitignore @@ -0,0 +1 @@ +/ad-blocker-engine.bin diff --git a/src/plugins/adblocker/adSpeedup.ts b/src/plugins/adblocker/adSpeedup.ts new file mode 100644 index 0000000000..acbfd5ef8e --- /dev/null +++ b/src/plugins/adblocker/adSpeedup.ts @@ -0,0 +1,58 @@ +function skipAd(target: Element) { + const skipButton = target.querySelector( + 'button.ytp-ad-skip-button-modern', + ); + if (skipButton) { + skipButton.click(); + } +} + +function speedUpAndMute(player: Element, isAdShowing: boolean) { + const video = player.querySelector('video'); + if (!video) return; + if (isAdShowing) { + video.playbackRate = 16; + video.muted = true; + } else if (!isAdShowing) { + video.playbackRate = 1; + video.muted = false; + } +} + +export const loadAdSpeedup = () => { + const player = document.querySelector('#movie_player'); + if (!player) return; + + new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'class' + ) { + const target = mutation.target as HTMLElement; + + const isAdShowing = + target.classList.contains('ad-showing') || + target.classList.contains('ad-interrupting'); + speedUpAndMute(target, isAdShowing); + } + if ( + mutation.type === 'childList' && + mutation.addedNodes.length && + mutation.target instanceof HTMLElement + ) { + skipAd(mutation.target); + } + } + }).observe(player, { + attributes: true, + childList: true, + subtree: true, + }); + + const isAdShowing = + player.classList.contains('ad-showing') || + player.classList.contains('ad-interrupting'); + speedUpAndMute(player, isAdShowing); + skipAd(player); +}; diff --git a/src/plugins/adblocker/blocker.ts b/src/plugins/adblocker/blocker.ts new file mode 100644 index 0000000000..368adfe3d1 --- /dev/null +++ b/src/plugins/adblocker/blocker.ts @@ -0,0 +1,81 @@ +// Used for caching +import path from 'node:path'; +import fs, { promises } from 'node:fs'; + +import { ElectronBlocker } from '@ghostery/adblocker-electron'; +import { app, net } from 'electron'; + +const SOURCES = [ + 'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt', + // UBlock Origin + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/quick-fixes.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/unbreak.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2020.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2021.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2022.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2023.txt', + // Fanboy Annoyances + 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', + // AdGuard + 'https://filters.adtidy.org/extension/ublock/filters/122_optimized.txt', +]; + +let blocker: ElectronBlocker | undefined; + +export const loadAdBlockerEngine = async ( + session: Electron.Session | undefined = undefined, + cache: boolean = true, + additionalBlockLists: string[] = [], + disableDefaultLists: boolean | unknown[] = false, +) => { + // Only use cache if no additional blocklists are passed + const cacheDirectory = path.join(app.getPath('userData'), 'adblock_cache'); + if (!fs.existsSync(cacheDirectory)) { + fs.mkdirSync(cacheDirectory); + } + const cachingOptions = + cache && additionalBlockLists.length === 0 + ? { + path: path.join(cacheDirectory, 'adblocker-engine.bin'), + read: promises.readFile, + write: promises.writeFile, + } + : undefined; + const lists = [ + ...((disableDefaultLists && !Array.isArray(disableDefaultLists)) || + (Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) + ? [] + : SOURCES), + ...additionalBlockLists, + ]; + + try { + blocker = await ElectronBlocker.fromLists( + (url: string) => net.fetch(url), + lists, + { + enableCompression: true, + // When generating the engine for caching, do not load network filters + // So that enhancing the session works as expected + // Allowing to define multiple webRequest listeners + loadNetworkFilters: session !== undefined, + }, + cachingOptions, + ); + if (session) { + blocker.enableBlockingInSession(session); + } + } catch (error) { + console.error('Error loading adBlocker engine', error); + } +}; + +export const unloadAdBlockerEngine = (session: Electron.Session) => { + if (blocker) { + blocker.disableBlockingInSession(session); + } +}; + +export const isBlockerEnabled = (session: Electron.Session) => + blocker !== undefined && blocker.isBlockingEnabled(session); diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts new file mode 100644 index 0000000000..5cacba7c13 --- /dev/null +++ b/src/plugins/adblocker/index.ts @@ -0,0 +1,148 @@ +import { contextBridge, webFrame } from 'electron'; + +import { blockers } from './types'; +import { createPlugin } from '@/utils'; +import { + isBlockerEnabled, + loadAdBlockerEngine, + unloadAdBlockerEngine, +} from './blocker'; + +import { inject, isInjected } from './injectors/inject'; +import { loadAdSpeedup } from './adSpeedup'; + +import { t } from '@/i18n'; + +import type { BrowserWindow } from 'electron'; + +interface AdblockerConfig { + /** + * Whether to enable the adblocker. + * @default true + */ + enabled: boolean; + /** + * When enabled, the adblocker will cache the blocklists. + * @default true + */ + cache: boolean; + /** + * Which adblocker to use. + * @default blockers.InPlayer + */ + blocker: (typeof blockers)[keyof typeof blockers]; + /** + * Additional list of filters to use. + * @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"] + * @default [] + */ + additionalBlockLists: string[]; + /** + * Disable the default blocklists. + * @default false + */ + disableDefaultLists: boolean; +} + +export default createPlugin({ + name: () => t('plugins.adblocker.name'), + description: () => t('plugins.adblocker.description'), + restartNeeded: false, + config: { + enabled: true, + cache: true, + blocker: blockers.InPlayer, + additionalBlockLists: [], + disableDefaultLists: false, + } as AdblockerConfig, + menu: async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: t('plugins.adblocker.menu.blocker'), + submenu: Object.values(blockers).map((blocker) => ({ + label: blocker, + type: 'radio', + checked: (config.blocker || blockers.WithBlocklists) === blocker, + click() { + setConfig({ blocker }); + }, + })), + }, + ]; + }, + renderer: { + async onPlayerApiReady(_, { getConfig }) { + const config = await getConfig(); + if (config.blocker === blockers.AdSpeedup) { + loadAdSpeedup(); + } + }, + }, + backend: { + mainWindow: null as BrowserWindow | null, + async start({ getConfig, window }) { + const config = await getConfig(); + this.mainWindow = window; + + if (config.blocker === blockers.WithBlocklists) { + await loadAdBlockerEngine( + window.webContents.session, + config.cache, + config.additionalBlockLists, + config.disableDefaultLists, + ); + } + }, + stop({ window }) { + if (isBlockerEnabled(window.webContents.session)) { + unloadAdBlockerEngine(window.webContents.session); + } + }, + async onConfigChange(newConfig) { + if (this.mainWindow) { + if ( + newConfig.blocker === blockers.WithBlocklists && + !isBlockerEnabled(this.mainWindow.webContents.session) + ) { + await loadAdBlockerEngine( + this.mainWindow.webContents.session, + newConfig.cache, + newConfig.additionalBlockLists, + newConfig.disableDefaultLists, + ); + } + } + }, + }, + preload: { + // see #1478 + script: `const _prunerFn = window._pruner; + window._pruner = undefined; + JSON.parse = new Proxy(JSON.parse, { + apply() { + return _prunerFn(Reflect.apply(...arguments)); + }, + }); + Response.prototype.json = new Proxy(Response.prototype.json, { + apply() { + return Reflect.apply(...arguments).then((o) => _prunerFn(o)); + }, + }); 0`, + async start({ getConfig }) { + const config = await getConfig(); + + if (config.blocker === blockers.InPlayer && !isInjected()) { + inject(contextBridge); + await webFrame.executeJavaScript(this.script); + } + }, + async onConfigChange(newConfig) { + if (newConfig.blocker === blockers.InPlayer && !isInjected()) { + inject(contextBridge); + await webFrame.executeJavaScript(this.script); + } + }, + }, +}); diff --git a/src/plugins/adblocker/injectors/inject-cliqz-preload.ts b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts new file mode 100644 index 0000000000..27463f0bee --- /dev/null +++ b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts @@ -0,0 +1,3 @@ +export default async () => { + await import('@ghostery/adblocker-electron-preload'); +}; diff --git a/src/plugins/adblocker/injectors/inject.d.ts b/src/plugins/adblocker/injectors/inject.d.ts new file mode 100644 index 0000000000..10062acc3c --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.d.ts @@ -0,0 +1,5 @@ +import type { ContextBridge } from 'electron'; + +export const inject: (contextBridge: ContextBridge) => void; + +export const isInjected: () => boolean; diff --git a/src/plugins/adblocker/injectors/inject.js b/src/plugins/adblocker/injectors/inject.js new file mode 100644 index 0000000000..6e6219fe97 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.js @@ -0,0 +1,259 @@ +/* eslint-disable */ + +// Source: https://addons.mozilla.org/en-US/firefox/addon/adblock-for-youtube/ +// https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-US%2Ffirefox%2Faddon%2Fadblock-for-youtube%2F + +/* + Parts of this code is derived from set-constant.js: + https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 + */ + +let injected = false; + +export const isInjected = () => injected; + +/** + * @param {Electron.ContextBridge} contextBridge + * @returns {*} + */ +export const inject = (contextBridge) => { + injected = true; + { + const pruner = function (o) { + delete o.playerAds; + delete o.adPlacements; + delete o.adSlots; + // + if (o.playerResponse) { + delete o.playerResponse.playerAds; + delete o.playerResponse.adPlacements; + delete o.playerResponse.adSlots; + } + if (o.ytInitialPlayerResponse) { + delete o.ytInitialPlayerResponse.playerAds; + delete o.ytInitialPlayerResponse.adPlacements; + delete o.ytInitialPlayerResponse.adSlots; + } + + // + return o; + } + + contextBridge.exposeInMainWorld('_pruner', pruner); + } + + const chains = [ + { + chain: 'playerResponse.adPlacements', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.playerAds', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.adPlacements', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.adSlots', + cValue: 'undefined', + } + ]; + + chains.forEach(function ({ chain, cValue }) { + const thisScript = document.currentScript; + // + switch (cValue) { + case 'null': { + cValue = null; + break; + } + + case "''": { + cValue = ''; + break; + } + + case 'true': { + cValue = true; + break; + } + + case 'false': { + cValue = false; + break; + } + + case 'undefined': { + cValue = undefined; + break; + } + + case 'noopFunc': { + cValue = function () {}; + + break; + } + + case 'trueFunc': { + cValue = function () { + return true; + }; + + break; + } + + case 'falseFunc': { + cValue = function () { + return false; + }; + + break; + } + + default: { + if (/^\d+$/.test(cValue)) { + cValue = Number.parseFloat(cValue); + // + if (isNaN(cValue)) { + return; + } + + if (Math.abs(cValue) > 0x7f_ff) { + return; + } + } else { + return; + } + } + } + + // + let aborted = false; + const mustAbort = function (v) { + if (aborted) { + return true; + } + + aborted = + v !== undefined && + v !== null && + cValue !== undefined && + cValue !== null && + typeof v !== typeof cValue; + return aborted; + }; + + /* + Support multiple trappers for the same property: + https://github.com/uBlockOrigin/uBlock-issues/issues/156 + */ + + const trapProp = function (owner, prop, configurable, handler) { + if (handler.init(owner[prop]) === false) { + return; + } + + // + const odesc = Object.getOwnPropertyDescriptor(owner, prop); + let previousGetter; + let previousSetter; + if (odesc instanceof Object) { + if (odesc.configurable === false) { + return; + } + + if (odesc.get instanceof Function) { + previousGetter = odesc.get; + } + + if (odesc.set instanceof Function) { + previousSetter = odesc.set; + } + } + + // + Object.defineProperty(owner, prop, { + configurable, + get() { + if (previousGetter !== undefined) { + previousGetter(); + } + + // + return handler.getter(); + }, + set(a) { + if (previousSetter !== undefined) { + previousSetter(a); + } + + // + handler.setter(a); + }, + }); + }; + + const trapChain = function (owner, chain) { + const pos = chain.indexOf('.'); + if (pos === -1) { + trapProp(owner, chain, false, { + v: undefined, + getter() { + return document.currentScript === thisScript ? this.v : cValue; + }, + setter(a) { + if (mustAbort(a) === false) { + return; + } + + cValue = a; + }, + init(v) { + if (mustAbort(v)) { + return false; + } + + // + this.v = v; + return true; + }, + }); + // + return; + } + + // + const prop = chain.slice(0, pos); + const v = owner[prop]; + // + chain = chain.slice(pos + 1); + if (v instanceof Object || (typeof v === 'object' && v !== null)) { + trapChain(v, chain); + return; + } + + // + trapProp(owner, prop, true, { + v: undefined, + getter() { + return this.v; + }, + setter(a) { + this.v = a; + if (a instanceof Object) { + trapChain(a, chain); + } + }, + init(v) { + this.v = v; + return true; + }, + }); + }; + + // + trapChain(window, chain); + }); +}; diff --git a/src/plugins/adblocker/types/index.ts b/src/plugins/adblocker/types/index.ts new file mode 100644 index 0000000000..d96ce6647c --- /dev/null +++ b/src/plugins/adblocker/types/index.ts @@ -0,0 +1,5 @@ +export const blockers = { + WithBlocklists: 'With blocklists', + InPlayer: 'In player', + AdSpeedup: 'Ad speedup', +} as const; diff --git a/src/plugins/downloader-ytdlp/index.ts b/src/plugins/downloader-ytdlp/index.ts new file mode 100644 index 0000000000..71ec0b72af --- /dev/null +++ b/src/plugins/downloader-ytdlp/index.ts @@ -0,0 +1,65 @@ +import { DefaultPresetList, type Preset } from './types'; + +import style from './style.css?inline'; + +import { createPlugin } from '@/utils'; +import { onConfigChange, onMainLoad } from './main'; +import { onPlayerApiReady, onRendererLoad } from './renderer'; +import { onMenu } from './menu'; +import { t } from '@/i18n'; + +export type DownloaderPluginConfig = { + enabled: boolean; + downloadFolder?: string; + downloadOnFinish?: { + enabled: boolean; + seconds: number; + percent: number; + mode: 'percent' | 'seconds'; + folder?: string; + }; + selectedPreset: string; + customPresetSetting: Preset; + skipExisting: boolean; + playlistMaxItems?: number; + advanced?: { + ytDlpPath?: string; + cookie?: string; + }; +}; + +export const defaultConfig: DownloaderPluginConfig = { + enabled: false, + downloadFolder: undefined, + downloadOnFinish: { + enabled: false, + seconds: 20, + percent: 10, + mode: 'seconds', + folder: undefined, + }, + selectedPreset: 'mp3 (256kbps)', // Selected preset + customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets + skipExisting: false, + playlistMaxItems: undefined, + advanced: { + ytDlpPath: undefined, + }, +}; + +export default createPlugin({ + name: () => t('plugins.downloader-ytdlp.name'), + description: () => t('plugins.downloader-ytdlp.description'), + restartNeeded: true, + config: defaultConfig, + stylesheets: [style], + menu: onMenu, + backend: { + start: onMainLoad, + onConfigChange, + }, + renderer: { + start: onRendererLoad, + onPlayerApiReady, + }, +}); diff --git a/src/plugins/downloader-ytdlp/main/index.ts b/src/plugins/downloader-ytdlp/main/index.ts new file mode 100644 index 0000000000..c363d000c4 --- /dev/null +++ b/src/plugins/downloader-ytdlp/main/index.ts @@ -0,0 +1,887 @@ +import path from 'node:path'; +import { homedir } from 'node:os'; +import { existsSync, mkdirSync } from 'node:fs'; +import { spawn } from 'node:child_process'; + +import { app, type BrowserWindow, dialog, ipcMain } from 'electron'; +import is from 'electron-is'; + +import { getFolder, sendFeedback as sendFeedback_, setBadge } from './utils'; +import { + registerCallback, + type SongInfo, + SongInfoEvent, +} from '@/providers/song-info'; +import { t } from '@/i18n'; + +import type { DownloaderPluginConfig } from '../index'; +import type { BackendContext } from '@/types/contexts'; +import type { GetPlayerResponse } from '@/types/get-player-response'; + +// Helper to send OS notification if notifications plugin is enabled +async function sendOsNotification(title: string, body: string) { + try { + // First try to use Electron's built-in Notification API + const { Notification } = await import('electron'); + + if (Notification.isSupported()) { + const notification = new Notification({ + title, + body, + silent: false, + }); + notification.show(); + return true; + } + } catch (error) { + logToFrontend('warn', '⚠️ Could not send notification:', error); + } + return false; +} + +// Helper to clean URL and convert music.youtube.com to youtube.com for better yt-dlp compatibility +function cleanAndConvertUrl(url: string): string { + try { + const urlObj = new URL(url); + + // Convert music.youtube.com to youtube.com + if (urlObj.hostname === 'music.youtube.com') { + urlObj.hostname = 'youtube.com'; + } + + // Remove playlist parameters to prevent downloading entire playlists + const params = new URLSearchParams(urlObj.search); + if (params.has('list')) { + params.delete('list'); + } + if (params.has('index')) { + params.delete('index'); + } + + urlObj.search = params.toString(); + return urlObj.toString(); + } catch (error) { + console.warn('[Downloader] Failed to clean URL, using original:', error); + return url; + } +} + +// Cached yt-dlp path to reduce path checks and verbose logging +let cachedYtDlpPath: string | undefined; +let cachedConfig: DownloaderPluginConfig | null = null; + +// Helper function to log both to backend console and frontend +function logToFrontend( + level: 'info' | 'warn' | 'error', + message: string, + ...args: unknown[] +) { + const formattedMessage = + args.length > 0 ? `${message} ${args.join(' ')}` : message; + + console[level](`[Downloader] ${formattedMessage}`); + + // Also log to frontend for production debugging + try { + win?.webContents?.executeJavaScript( + `console.${level}('[Downloader] ${formattedMessage.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}');`, + ); + } catch { + // Ignore errors if window not available + } +} + +// Helper to find yt-dlp path, with caching to reduce verbose logging +function getYtDlpPath(customPath?: string): string | undefined { + // Check cache first to avoid redundant file system checks + if (cachedYtDlpPath && cachedConfig?.advanced?.ytDlpPath === customPath) { + return cachedYtDlpPath; + } + + const checkedPaths: string[] = []; + if (customPath) { + checkedPaths.push(`[custom] ${customPath}`); + if (existsSync(customPath)) { + // Only log yt-dlp path once when plugin loads, not for every download + if (!cachedYtDlpPath) { + logToFrontend('info', '🔧 Using custom yt-dlp path:', customPath); + } + cachedYtDlpPath = customPath; + return customPath; + } + } + + if (is.windows()) { + const candidates = [ + 'C:/yt-dlp.exe', + path.join(homedir(), 'Downloads', 'yt-dlp.exe'), + 'C:/utils/yt-dlp.exe', + ]; + for (const p of candidates) { + checkedPaths.push(p); + if (existsSync(p)) { + // Only log yt-dlp path once when plugin loads, not for every download + if (!cachedYtDlpPath) { + logToFrontend('info', '🔧 Found yt-dlp at:', p); + } + cachedYtDlpPath = p; + return p; + } + } + } else if (is.linux()) { + checkedPaths.push('/usr/bin/yt-dlp'); + if (existsSync('/usr/bin/yt-dlp')) { + // Only log yt-dlp path once when plugin loads, not for every download + if (!cachedYtDlpPath) { + logToFrontend('info', '🔧 Found yt-dlp at: /usr/bin/yt-dlp'); + } + cachedYtDlpPath = '/usr/bin/yt-dlp'; + return '/usr/bin/yt-dlp'; + } + } else if (is.macOS()) { + const macCandidates = ['/usr/local/bin/yt-dlp', '/opt/homebrew/bin/yt-dlp']; + for (const p of macCandidates) { + checkedPaths.push(p); + if (existsSync(p)) { + // Only log yt-dlp path once when plugin loads, not for every download + if (!cachedYtDlpPath) { + logToFrontend('info', '🔧 Found yt-dlp at:', p); + } + cachedYtDlpPath = p; + return p; + } + } + } + + logToFrontend('warn', '⚠️ yt-dlp not found. Paths checked:', checkedPaths); + cachedYtDlpPath = undefined; + return undefined; +} + +let win: BrowserWindow; +let playingUrl: string; +let config: DownloaderPluginConfig; + +const sendError = (error: Error, source?: string) => { + win.setProgressBar(-1); // Close progress bar + setBadge(0); // Close badge + sendFeedback_(win); // Reset feedback + + const songNameMessage = source ? `\nin ${source}` : ''; + const cause = error.cause + ? `\n\n${ + // eslint-disable-next-line @typescript-eslint/no-base-to-string,@typescript-eslint/restrict-template-expressions + error.cause instanceof Error ? error.cause.toString() : error.cause + }` + : ''; + const message = `${error.toString()}${songNameMessage}${cause}`; + + // Print full error to console for debugging + console.error('[Downloader] Error:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + console.trace(error); + + // Try to extract command info from error message + let commandInfo = ''; + const match = message.match(/Command: ([^\n]+)/); + if (match) { + commandInfo = `\n\nCommand attempted: ${match[1]}`; + } + + // Detect common yt-dlp errors and suggest workarounds + let userTip = ''; + if ( + /403 Forbidden|unable to download video data|age.restrict/i.test(message) + ) { + userTip = + '\n\nTip: This video may be age-restricted or region-blocked. Try logging in with cookies, or enable geo-bypass in yt-dlp settings.'; + } + + // Log error to frontend console for copy/paste + logToFrontend( + 'error', + '❌ Download failed:', + message + commandInfo + userTip, + ); + + // Send error to renderer for toast display (non-blocking) + try { + win.webContents.send('downloader-ytdlp-error-toast', { + message: message + commandInfo + userTip, + title: t('plugins.downloader.backend.dialog.error.title'), + }); + } catch (e) { + logToFrontend('warn', '⚠️ Could not send error toast:', e); + } + + // Optionally, show a non-blocking notification (OS toast) + sendOsNotification( + t('plugins.downloader.backend.dialog.error.title'), + message + userTip, + ).catch(() => {}); +}; + +export const onMainLoad = async ({ + window: _win, + getConfig, + ipc, +}: BackendContext) => { + win = _win; + config = await getConfig(); + + // Update cache when config changes + cachedConfig = config; + + ipc.handle('download-song-ytdlp', (url: string) => downloadSong(url)); + ipc.on('ytmd:video-src-changed', (data: GetPlayerResponse) => { + playingUrl = data.microformat.microformatDataRenderer.urlCanonical; + }); + ipc.handle('download-playlist-request-ytdlp', async (url: string) => + downloadPlaylist(url), + ); + + downloadSongOnFinishSetup({ ipc, getConfig }); +}; + +export const onConfigChange = (newConfig: DownloaderPluginConfig) => { + config = newConfig; + // Update cache when config changes + cachedConfig = newConfig; + + // Reset yt-dlp path cache if custom path changed + if (cachedConfig?.advanced?.ytDlpPath !== newConfig.advanced?.ytDlpPath) { + cachedYtDlpPath = undefined; + } +}; + +export async function downloadSong( + url: string, + playlistFolder: string | undefined = undefined, + trackId: string | undefined = undefined, + increasePlaylistProgress: (value: number) => void = () => {}, +) { + let resolvedName; + try { + await downloadSongUnsafe( + false, + url, + (name: string) => (resolvedName = name), + playlistFolder, + trackId, + increasePlaylistProgress, + ); + } catch (error: unknown) { + sendError(error as Error, resolvedName || url); + } +} + +export async function downloadSongFromId( + id: string, + playlistFolder: string | undefined = undefined, + trackId: string | undefined = undefined, + increasePlaylistProgress: (value: number) => void = () => {}, +) { + let resolvedName; + try { + await downloadSongUnsafe( + true, + id, + (name: string) => (resolvedName = name), + playlistFolder, + trackId, + increasePlaylistProgress, + ); + } catch (error: unknown) { + sendError(error as Error, resolvedName || id); + } +} + +function downloadSongOnFinishSetup({ + ipc, +}: Pick, 'ipc' | 'getConfig'>) { + let currentUrl: string | undefined; + let duration: number | undefined; + let time = 0; + + const defaultDownloadFolder = app.getPath('downloads'); + + registerCallback((songInfo: SongInfo, event) => { + if (event === SongInfoEvent.TimeChanged) { + const elapsedSeconds = songInfo.elapsedSeconds ?? 0; + if (elapsedSeconds > time) time = elapsedSeconds; + return; + } + if ( + !songInfo.isPaused && + songInfo.url !== currentUrl && + config.downloadOnFinish?.enabled + ) { + if (typeof currentUrl === 'string' && duration && duration > 0) { + if ( + config.downloadOnFinish.mode === 'seconds' && + duration - time <= config.downloadOnFinish.seconds + ) { + downloadSong( + currentUrl, + config.downloadOnFinish.folder ?? + config.downloadFolder ?? + defaultDownloadFolder, + ); + } else if ( + config.downloadOnFinish.mode === 'percent' && + time >= duration * (config.downloadOnFinish.percent / 100) + ) { + downloadSong( + currentUrl, + config.downloadOnFinish.folder ?? + config.downloadFolder ?? + defaultDownloadFolder, + ); + } + } + + currentUrl = songInfo.url; + duration = songInfo.songDuration; + time = 0; + } + }); + + ipcMain.on('ytmd:player-api-loaded', () => { + ipc.send('ytmd:setup-time-changed-listener'); + }); +} + +async function downloadSongUnsafe( + isId: boolean, + idOrUrl: string, + setName: (name: string) => void, + playlistFolder: string | undefined = undefined, + _trackId: string | undefined = undefined, + increasePlaylistProgress: (value: number) => void = () => {}, +) { + const sendFeedback = (message: unknown, progress?: number) => { + if (!playlistFolder) { + sendFeedback_(win, message); + if (progress && !isNaN(progress)) { + win.setProgressBar(progress); + } + } + }; + + sendFeedback(t('plugins.downloader.backend.feedback.downloading'), 0.1); + + let url: string; + if (isId) { + url = `https://youtube.com/watch?v=${idOrUrl}`; + } else { + // Clean up the URL and convert music.youtube.com to youtube.com + url = cleanAndConvertUrl(idOrUrl); + } + + const dir = + playlistFolder || config.downloadFolder || app.getPath('downloads'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Find yt-dlp path using cached value or search + const ytDlpPath = getYtDlpPath(config.advanced?.ytDlpPath); + if (!ytDlpPath) { + const allPaths = [ + config.advanced?.ytDlpPath + ? `[custom] ${config.advanced.ytDlpPath}` + : null, + ...(is.windows() + ? [ + 'C:/yt-dlp.exe', + path.join(homedir(), 'Downloads', 'yt-dlp.exe'), + 'C:/utils/yt-dlp.exe', + ] + : is.linux() + ? ['/usr/bin/yt-dlp'] + : is.macOS() + ? ['/usr/local/bin/yt-dlp', '/opt/homebrew/bin/yt-dlp'] + : []), + ].filter(Boolean); + + logToFrontend('error', '❌ yt-dlp not found. Paths checked:', allPaths); + throw new Error( + 'yt-dlp executable not found.\nPaths checked:\n' + + allPaths.join('\n') + + '\nPlease set the path in the Downloader plugin menu.', + ); + } + + // Output template: include artist only when available (avoids "NA - " prefix) + const outTemplate = `${dir}/%(artist&{} - |)s%(title)s.%(ext)s`; + + // Pre-resolve the expected filename so we can use it as fallback and for skip-existing check + let expectedFilePath: string | undefined; + try { + const infoArgs = ['--print', '%(artist&{} - |)s%(title)s.%(ext)s', url]; + const infoProcess = spawn(ytDlpPath, infoArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + + let infoOutput = ''; + infoProcess.stdout?.on('data', (data: Buffer) => { + infoOutput += data.toString(); + }); + + const infoExitCode = await new Promise((resolve) => { + infoProcess.on('close', resolve); + }); + + if (infoExitCode === 0) { + const expectedFilename = infoOutput + .trim() + .replace('.webm', '.mp3') + .replace('.m4a', '.mp3'); + expectedFilePath = path.join(dir, expectedFilename); + } + } catch (error) { + logToFrontend('warn', '⚠️ Could not pre-resolve expected filename:', error); + } + + // Check if file already exists when skipExisting is enabled + if (config.skipExisting && expectedFilePath && existsSync(expectedFilePath)) { + logToFrontend( + 'info', + '⏭️ File already exists, skipping:', + expectedFilePath, + ); + sendFeedback( + t('plugins.downloader.backend.feedback.file-already-exists'), + -1, + ); + setName(expectedFilePath); + return; // Skip download + } + + // Enhanced args with higher quality and metadata embedding (NO separate thumbnail downloads) + const args = [ + '-x', + '--audio-format', + 'mp3', + '--audio-quality', + '320K', // Higher bitrate + '--embed-thumbnail', // Embed album art into the MP3 + '--embed-metadata', // Embed ID3 tags + '--add-metadata', // Additional metadata + '--convert-thumbnails', + 'jpg', // Convert to standard format (for embedded only, no separate files) + + '-o', + outTemplate, + url, + ]; + + // Add skip existing flag if enabled + if (config.skipExisting) { + args.push('--no-overwrites'); + } + + // Enhanced colored logging with unicode icons + logToFrontend('info', '⬇️ Starting download...'); + logToFrontend('info', '🎵 URL:', url); + + setName(url); // We'll update this when we get the actual filename + + try { + const ytDlpProcess = spawn(ytDlpPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, // Only spawn once, no shell wrapping + }); + + let output = ''; + let errorOutput = ''; + let downloadedFile = ''; + + ytDlpProcess.stdout?.on('data', (data: Buffer) => { + const chunk = data.toString(); + output += chunk; + + // Parse for actual downloaded filename to show correct path in logs + const destinationMatch = chunk.match( + /\[ExtractAudio\] Destination: (.+)/, + ); + if (destinationMatch) { + downloadedFile = destinationMatch[1]; + logToFrontend('info', '📁 Saving to:', downloadedFile); + setName(downloadedFile); + } + + // Parse progress with multiple patterns for better compatibility + let progressMatch = chunk.match(/(\d+(?:\.\d+)?)%/); // Look for percentage with optional decimal + if (!progressMatch) { + progressMatch = output.match(/(\d+(?:\.\d+)?)%/); // Also check accumulated output + } + + if (progressMatch) { + const progress = parseFloat(progressMatch[1]) / 100; + if (!isNaN(progress) && progress > 0) { + logToFrontend('info', `📊 Progress: ${Math.floor(progress * 100)}%`); + sendFeedback( + t('plugins.downloader.backend.feedback.download-progress', { + percent: Math.floor(progress * 100), + }), + progress, + ); + increasePlaylistProgress(progress); + } + } + + // Detect conversion phase + if (chunk.includes('[ExtractAudio]') || chunk.includes('Converting')) { + sendFeedback(t('plugins.downloader.backend.feedback.converting')); + } + }); + + ytDlpProcess.stderr?.on('data', (data: Buffer) => { + errorOutput += data.toString(); + }); + + const exitCode = await new Promise((resolve) => { + ytDlpProcess.on('close', resolve); + }); + + if (exitCode !== 0) { + throw new Error( + `yt-dlp failed with exit code ${exitCode}: ${errorOutput}` + + `\nCommand: ${ytDlpPath} ${args.join(' ')}`, + ); + } + + sendFeedback(null, -1); + + // Try to determine the final file location more accurately + let finalFile = downloadedFile; + + // Check if yt-dlp reported the file already exists (no extraction happened) + const alreadyDownloaded = + output.includes('has already been downloaded') || + output.includes('already been recorded') || + (output.includes('[download]') && !output.includes('[ExtractAudio]')); + + if (!finalFile || finalFile === '') { + // Use the pre-resolved expected path instead of scanning the directory + if (expectedFilePath) { + finalFile = expectedFilePath; + if (alreadyDownloaded) { + logToFrontend('info', '⏭️ File already exists:', finalFile); + } else { + logToFrontend('info', '📁 Resolved output file:', finalFile); + } + } + } + + if (!finalFile) { + finalFile = 'Unknown location (check download folder)'; + } + + if (alreadyDownloaded) { + logToFrontend('info', '⏭️ File already existed, nothing new downloaded.'); + logToFrontend('info', '📂 Existing file:', finalFile); + sendOsNotification( + 'File already exists', + `Already downloaded: ${path.basename(finalFile)}`, + ).catch(() => {}); + } else { + logToFrontend('info', '✅ Download complete!'); + logToFrontend('info', '📂 File saved:', finalFile); + sendOsNotification( + 'Download complete!', + `Downloaded: ${path.basename(finalFile)}`, + ).catch(() => {}); + } + } catch (error) { + logToFrontend('error', '❌ Download failed:', error); + sendOsNotification('Download failed!', String(error)).catch(() => {}); + throw new Error(`Download failed: ${String(error)}`); + } +} + +export async function downloadPlaylist(givenUrl?: string | URL) { + logToFrontend('info', '🎵 Starting playlist download...'); + + try { + givenUrl = new URL(givenUrl ?? ''); + } catch { + givenUrl = new URL(win.webContents.getURL()); + } + + const playlistId = + getPlaylistID(givenUrl) || getPlaylistID(new URL(playingUrl)); + + if (!playlistId) { + logToFrontend('error', '❌ No playlist ID found'); + sendError( + new Error(t('plugins.downloader.backend.feedback.playlist-id-not-found')), + ); + return; + } + + logToFrontend('info', '🆔 Playlist ID:', playlistId); + + const sendFeedback = (message?: unknown, progress?: number) => { + sendFeedback_(win, message); + if (typeof progress === 'number' && !isNaN(progress)) { + win.setProgressBar(progress); + } + }; + + console.log( + t('plugins.downloader.backend.feedback.trying-to-get-playlist-id', { + playlistId, + }), + ); + sendFeedback(t('plugins.downloader.backend.feedback.getting-playlist-info')); + + const dir = getFolder(config.downloadFolder ?? ''); + const playlistUrl = `https://music.youtube.com/playlist?list=${playlistId}`; + + logToFrontend('info', '📁 Download directory:', dir); + logToFrontend('info', '🔗 Playlist URL:', playlistUrl); + + // Find yt-dlp path using cached and fallback logic + const ytDlpPath = getYtDlpPath(config.advanced?.ytDlpPath); + if (!ytDlpPath) { + const allPaths = [ + config.advanced?.ytDlpPath + ? `[custom] ${config.advanced.ytDlpPath}` + : null, + ...(is.windows() + ? [ + 'C:/yt-dlp.exe', + path.join(homedir(), 'Downloads', 'yt-dlp.exe'), + 'C:/utils/yt-dlp.exe', + ] + : is.linux() + ? ['/usr/bin/yt-dlp'] + : is.macOS() + ? ['/usr/local/bin/yt-dlp', '/opt/homebrew/bin/yt-dlp'] + : []), + ].filter(Boolean); + + logToFrontend( + 'error', + '❌ yt-dlp not found for playlist. Paths checked:', + allPaths, + ); + sendError( + new Error( + 'yt-dlp executable not found.\nPaths checked:\n' + + allPaths.join('\n') + + '\nPlease set the path in the Downloader plugin menu.', + ), + ); + return; + } + + const args = [ + '-x', + '--audio-format', + 'mp3', + '--audio-quality', + '320K', // Higher bitrate + '--embed-thumbnail', // Embed album art + '--embed-metadata', // Embed ID3 tags + '--add-metadata', // Additional metadata + '-o', + `${dir}/%(playlist_title)s/%(title)s.%(ext)s`, + playlistUrl, + ]; + + // Add skip existing flag if enabled + if (config.skipExisting) { + args.push('--no-overwrites'); + } + + try { + logToFrontend('info', '💬 Showing playlist download dialog...'); + + const dialogResult = await dialog.showMessageBox(win, { + type: 'info', + buttons: [ + t( + 'plugins.downloader.backend.dialog.start-download-playlist.buttons.ok', + ), + 'Cancel', + ], + title: t( + 'plugins.downloader.backend.dialog.start-download-playlist.title', + ), + message: t( + 'plugins.downloader.backend.dialog.start-download-playlist.message', + { + playlistTitle: playlistId, + }, + ), + detail: t( + 'plugins.downloader.backend.dialog.start-download-playlist.detail', + { + playlistSize: 'Unknown', + }, + ), + }); + + // Check if user cancelled + if (dialogResult.response !== 0) { + logToFrontend('info', '❌ User cancelled playlist download'); + sendFeedback('Download cancelled', -1); + return; + } + + logToFrontend('info', '🚀 Starting yt-dlp process...'); + win.setProgressBar(0.1); // Start with indefinite bar + + logToFrontend( + 'info', + '⚙️ yt-dlp command:', + `${ytDlpPath} ${args.join(' ')}`, + ); + + const ytDlpProcess = spawn(ytDlpPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, // Use shell: false for better stability + }); + + let output = ''; + let errorOutput = ''; + let lastProgressUpdate = 0; + + logToFrontend('info', '📊 yt-dlp process started, PID:', ytDlpProcess.pid); + + ytDlpProcess.stdout?.on('data', (data: Buffer) => { + const chunk = data.toString(); + output += chunk; + + // Log chunks for debugging (but limit the amount) + if (chunk.trim()) { + logToFrontend( + 'info', + '📥 yt-dlp output:', + chunk.trim().substring(0, 200), + ); + } + + // Parse progress with multiple patterns for better compatibility + let progressMatch = chunk.match(/(\d+(?:\.\d+)?)%/); + if (!progressMatch) { + progressMatch = output.match(/(\d+(?:\.\d+)?)%/); + } + + if (progressMatch) { + const progress = parseFloat(progressMatch[1]) / 100; + if ( + !isNaN(progress) && + progress > 0 && + progress !== lastProgressUpdate + ) { + lastProgressUpdate = progress; + logToFrontend( + 'info', + `📊 Playlist progress: ${Math.floor(progress * 100)}%`, + ); + sendFeedback( + `Downloading... ${Math.floor(progress * 100)}%`, + progress, + ); + } + } + + // Detect conversion step + if (chunk.includes('[ExtractAudio]') || chunk.includes('Converting')) { + logToFrontend('info', '🔄 Converting audio...'); + sendFeedback('Converting to mp3...'); + } + + // Detect download completion for individual tracks + if (chunk.includes('[ExtractAudio] Destination:')) { + const filename = chunk.match(/\[ExtractAudio\] Destination: (.+)/); + if (filename) { + logToFrontend( + 'info', + '✅ Track completed:', + path.basename(filename[1]), + ); + } + } + + // Detect download start for tracks + if (chunk.includes('[youtube]') || chunk.includes('[download]')) { + if (chunk.includes('Downloading webpage')) { + logToFrontend('info', '🌐 Fetching track info...'); + } + } + }); + + ytDlpProcess.stderr?.on('data', (data: Buffer) => { + const errorChunk = data.toString(); + errorOutput += errorChunk; + logToFrontend( + 'warn', + '⚠️ yt-dlp stderr:', + errorChunk.trim().substring(0, 200), + ); + }); + + ytDlpProcess.on('error', (error) => { + logToFrontend('error', '💥 yt-dlp process error:', error); + }); + + const exitCode = await new Promise((resolve) => { + ytDlpProcess.on('close', (code) => { + logToFrontend('info', '🏁 yt-dlp process closed with code:', code); + resolve(code || 0); + }); + }); + + if (exitCode !== 0) { + logToFrontend('error', '❌ yt-dlp failed with exit code:', exitCode); + logToFrontend('error', '📄 Error output:', errorOutput); + throw new Error( + `yt-dlp failed with exit code ${exitCode}: ${errorOutput}` + + `\nCommand: ${ytDlpPath} ${args.join(' ')}`, + ); + } + + logToFrontend('info', '🎉 Playlist download completed successfully!'); + sendFeedback('Download complete!', -1); + sendOsNotification('Download complete!', `Saved to: ${dir}`).catch( + () => {}, + ); + logToFrontend('info', '✅ Playlist download complete!', `Saved to: ${dir}`); + console.info( + t('plugins.downloader.backend.feedback.done', { + filePath: dir, + }), + ); + } catch (error: unknown) { + logToFrontend('error', '💥 Playlist download error:', error); + sendFeedback('Download failed!', -1); + sendOsNotification('Download failed!', String(error)).catch(() => {}); + sendError(error as Error); + } finally { + logToFrontend('info', '🧹 Cleaning up playlist download...'); + win.setProgressBar(-1); // Close progress bar + setBadge(0); // Close badge counter + sendFeedback(); // Clear feedback + } +} + +// Playlist radio modifier needs to be cut from playlist ID +const INVALID_PLAYLIST_MODIFIER = 'RDAMPL'; + +const getPlaylistID = (aURL?: URL): string | null | undefined => { + const result = + aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist'); + if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) { + return result.slice(INVALID_PLAYLIST_MODIFIER.length); + } + + return result; +}; diff --git a/src/plugins/downloader-ytdlp/main/utils.ts b/src/plugins/downloader-ytdlp/main/utils.ts new file mode 100644 index 0000000000..792f5cace3 --- /dev/null +++ b/src/plugins/downloader-ytdlp/main/utils.ts @@ -0,0 +1,30 @@ +import { app, type BrowserWindow } from 'electron'; +import is from 'electron-is'; + +export const getFolder = (customFolder?: string) => + customFolder ?? app.getPath('downloads'); + +export const sendFeedback = (win: BrowserWindow, message?: unknown) => { + win.webContents.send('downloader-ytdlp-feedback', message); +}; + +export const cropMaxWidth = (image: Electron.NativeImage) => { + const imageSize = image.getSize(); + // Standart YouTube artwork width with margins from both sides is 280 + 720 + 280 + if (imageSize.width === 1280 && imageSize.height === 720) { + return image.crop({ + x: 280, + y: 0, + width: 720, + height: 720, + }); + } + + return image; +}; + +export const setBadge = (n: number) => { + if (is.linux() || is.macOS()) { + app.setBadgeCount(n); + } +}; diff --git a/src/plugins/downloader-ytdlp/menu.ts b/src/plugins/downloader-ytdlp/menu.ts new file mode 100644 index 0000000000..f0c953adb7 --- /dev/null +++ b/src/plugins/downloader-ytdlp/menu.ts @@ -0,0 +1,248 @@ +import { dialog } from 'electron'; +import prompt from 'custom-electron-prompt'; +import { deepmerge } from 'deepmerge-ts'; + +import { downloadPlaylist } from './main'; +import { getFolder } from './main/utils'; +import { DefaultPresetList } from './types'; + +import { t } from '@/i18n'; + +import promptOptions from '@/providers/prompt-options'; + +import { type DownloaderPluginConfig, defaultConfig } from './index'; + +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + +export const onMenu = async ({ + getConfig, + setConfig, +}: MenuContext): Promise => { + const config = await getConfig(); + + return [ + { + label: t('plugins.downloader.menu.download-finish-settings.label'), + type: 'submenu', + submenu: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.enabled', + ), + type: 'checkbox', + checked: config.downloadOnFinish?.enabled ?? false, + click(item) { + setConfig({ + downloadOnFinish: { + ...deepmerge( + defaultConfig.downloadOnFinish, + config.downloadOnFinish, + ), + enabled: item.checked, + }, + }); + }, + }, + { + type: 'separator', + }, + { + label: t('plugins.downloader.menu.choose-download-folder'), + click() { + const result = dialog.showOpenDialogSync({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: getFolder( + config.downloadOnFinish?.folder ?? config.downloadFolder, + ), + }); + if (result) { + setConfig({ + downloadOnFinish: { + ...deepmerge( + defaultConfig.downloadOnFinish, + config.downloadOnFinish, + ), + folder: result[0], + }, + }); + } + }, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.mode', + ), + type: 'submenu', + submenu: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.seconds', + ), + type: 'radio', + checked: config.downloadOnFinish?.mode === 'seconds', + click() { + setConfig({ + downloadOnFinish: { + ...deepmerge( + defaultConfig.downloadOnFinish, + config.downloadOnFinish, + ), + mode: 'seconds', + }, + }); + }, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.percent', + ), + type: 'radio', + checked: config.downloadOnFinish?.mode === 'percent', + click() { + setConfig({ + downloadOnFinish: { + ...deepmerge( + defaultConfig.downloadOnFinish, + config.downloadOnFinish, + ), + mode: 'percent', + }, + }); + }, + }, + ], + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.advanced', + ), + async click() { + const res = await prompt({ + title: t( + 'plugins.downloader.menu.download-finish-settings.prompt.title', + ), + type: 'multiInput', + multiInputOptions: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.prompt.last-seconds', + ), + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '1', + }, + value: + config.downloadOnFinish?.seconds ?? + defaultConfig.downloadOnFinish!.seconds, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.prompt.last-percent', + ), + inputAttrs: { + type: 'number', + required: true, + min: '1', + max: '100', + step: '1', + }, + value: + config.downloadOnFinish?.percent ?? + defaultConfig.downloadOnFinish!.percent, + }, + ], + ...promptOptions(), + height: 240, + resizable: true, + }).catch(console.error); + + if (!res) { + return undefined; + } + + setConfig({ + downloadOnFinish: { + ...deepmerge( + defaultConfig.downloadOnFinish, + config.downloadOnFinish, + ), + seconds: Number(res[0]), + percent: Number(res[1]), + }, + }); + return; + }, + }, + ], + }, + + { + label: t('plugins.downloader.menu.download-playlist'), + click: () => downloadPlaylist(), + }, + { + label: t('plugins.downloader.menu.choose-download-folder'), + click() { + const result = dialog.showOpenDialogSync({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: getFolder(config.downloadFolder ?? ''), + }); + if (result) { + setConfig({ downloadFolder: result[0] }); + } // Else = user pressed cancel + }, + }, + { + label: t('plugins.downloader.menu.yt-dlp-location-nice'), + click: async () => { + const ytDlpPathValue = typeof config.advanced?.ytDlpPath === 'string' ? config.advanced.ytDlpPath : ''; + const promptRes = await prompt({ + title: t('plugins.downloader.menu.yt-dlp-location-title'), + label: t('plugins.downloader.menu.yt-dlp-location-label'), + value: ytDlpPathValue, + inputAttrs: { + type: 'text', + placeholder: 'C:/yt-dlp.exe or /usr/bin/yt-dlp', + }, + type: 'input', + ...promptOptions(), + }).catch(console.error); + if (typeof promptRes === 'string') { + setConfig({ + advanced: { + ...(config.advanced || {}), + ytDlpPath: promptRes, + }, + }); + dialog.showMessageBox({ + type: 'info', + message: t('plugins.downloader.menu.yt-dlp-location-saved'), + detail: promptRes, + }); + } + }, + }, + { + label: t('plugins.downloader.menu.presets'), + submenu: Object.keys(DefaultPresetList).map((preset) => ({ + label: preset, + type: 'radio', + checked: config.selectedPreset === preset, + click() { + setConfig({ selectedPreset: preset }); + }, + })), + }, + { + label: t('plugins.downloader.menu.skip-existing'), + type: 'checkbox', + checked: config.skipExisting, + click(item) { + setConfig({ skipExisting: item.checked }); + }, + }, + ]; +}; diff --git a/src/plugins/downloader-ytdlp/renderer.tsx b/src/plugins/downloader-ytdlp/renderer.tsx new file mode 100644 index 0000000000..4c795a156b --- /dev/null +++ b/src/plugins/downloader-ytdlp/renderer.tsx @@ -0,0 +1,177 @@ +import { createSignal, Show } from 'solid-js'; + +import { render } from 'solid-js/web'; + +import { defaultConfig } from '@/config/defaults'; +import { getSongMenu } from '@/providers/dom-elements'; +import { getSongInfo } from '@/providers/song-info-front'; +import { t } from '@/i18n'; +import { + isAlbumOrPlaylist, + isMusicOrVideoTrack, +} from '@/plugins/utils/renderer/check'; + +import { DownloadButton } from './templates/download'; + +import type { RendererContext } from '@/types/contexts'; +import type { DownloaderPluginConfig } from './index'; + +let download: () => void; + +// Toast notification state +const [toast, setToast] = createSignal<{ + message: string; + title?: string; +} | null>(null); +let toastTimeout: number | undefined; + +const [downloadButtonText, setDownloadButtonText] = createSignal(''); + +let buttonContainer: HTMLDivElement | null = null; + +const menuObserver = new MutationObserver(() => { + const menu = getSongMenu(); + + if ( + !menu || + menu.contains(buttonContainer) || + !(isMusicOrVideoTrack() || isAlbumOrPlaylist()) || + !buttonContainer + ) { + return; + } + + menu.prepend(buttonContainer); +}); + +export const onRendererLoad = ({ + ipc, +}: RendererContext) => { + download = () => { + const songMenu = getSongMenu(); + + let videoUrl = songMenu + ?.querySelector( + 'ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint', + ) + ?.getAttribute('href'); + + if (!videoUrl && songMenu) { + for (const it of songMenu.querySelectorAll( + 'ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint', + )) { + if (it.getAttribute('href')?.includes('podcast/')) { + videoUrl = it.getAttribute('href'); + break; + } + } + } + + if (videoUrl) { + if (videoUrl.startsWith('watch?')) { + videoUrl = defaultConfig.url + '/' + videoUrl; + } + + if (videoUrl.startsWith('podcast/')) { + videoUrl = + defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v='); + } + + if (videoUrl.includes('?playlist=')) { + ipc.invoke('download-playlist-request-ytdlp', videoUrl); + return; + } + } else { + videoUrl = getSongInfo().url || window.location.href; + } + + ipc.invoke('download-song-ytdlp', videoUrl); + }; + + ipc.on('downloader-ytdlp-feedback', (feedback: string) => { + const targetHtml = feedback || t('plugins.downloader.templates.button') + ' (ytdlp)'; + setDownloadButtonText(targetHtml); + }); + + // Listen for error toasts from backend + ipc.on( + 'downloader-ytdlp-error-toast', + (data: { message: string; title?: string }) => { + setToast(data); + if (toastTimeout) clearTimeout(toastTimeout); + toastTimeout = window.setTimeout(() => setToast(null), 10000); // Auto-hide after 10s + }, + ); +}; + +export const onPlayerApiReady = () => { + setDownloadButtonText(t('plugins.downloader.templates.button') + ' (ytdlp)'); + + buttonContainer = document.createElement('div'); + buttonContainer.classList.add( + 'style-scope', + 'menu-item', + 'ytmusic-menu-popup-renderer', + ); + buttonContainer.setAttribute('aria-disabled', 'false'); + buttonContainer.setAttribute('aria-selected', 'false'); + buttonContainer.setAttribute('role', 'option'); + buttonContainer.setAttribute('tabindex', '-1'); + + render( + () => , + buttonContainer, + ); + + menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { + childList: true, + subtree: true, + }); + + // Render toast container + let toastDiv = document.getElementById('ytmd-toast-container'); + if (!toastDiv) { + toastDiv = document.createElement('div'); + toastDiv.id = 'ytmd-toast-container'; + document.body.appendChild(toastDiv); + } + render( + () => ( + +
{ + navigator.clipboard.writeText(toast()?.message || ''); + setToast(null); + }} + style={{ + 'position': 'fixed', + 'bottom': '32px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'background': '#222', + 'color': '#fff', + 'padding': '16px 24px', + 'border-radius': '8px', + 'box-shadow': '0 2px 8px #0008', + 'z-index': '9999', + 'max-width': '80vw', + 'font-size': '15px', + 'cursor': 'pointer', + }} + title="Click to copy error message and dismiss" + > + {toast()?.title || 'Error'} +
+ {toast()?.message} +
+ Click to copy & dismiss +
+
+
+ ), + toastDiv, + ); +}; diff --git a/src/plugins/downloader-ytdlp/style.css b/src/plugins/downloader-ytdlp/style.css new file mode 100644 index 0000000000..052838ca1f --- /dev/null +++ b/src/plugins/downloader-ytdlp/style.css @@ -0,0 +1,21 @@ +.ytmd-menu-item { + display: var(--ytmusic-menu-item_-_display); + height: var(--ytmusic-menu-item_-_height); + align-items: var(--ytmusic-menu-item_-_align-items); + padding: var(--ytmusic-menu-item_-_padding); + cursor: pointer; +} + +.ytmd-menu-item > .yt-simple-endpoint:hover { + background-color: var(--ytmusic-menu-item-hover-background-color); +} + +.ytmd-menu-item { + flex: var(--ytmusic-menu-item-icon_-_flex); + margin: var(--ytmusic-menu-item-icon_-_margin); + fill: var(--ytmusic-menu-item-icon_-_fill); + stroke: var(--iron-icon-stroke-color, none); + width: var(--iron-icon-width, 24px); + height: var(--iron-icon-height, 24px); + animation: var(--iron-icon_-_animation); +} diff --git a/src/plugins/downloader-ytdlp/templates/download.tsx b/src/plugins/downloader-ytdlp/templates/download.tsx new file mode 100644 index 0000000000..3b9e6d1443 --- /dev/null +++ b/src/plugins/downloader-ytdlp/templates/download.tsx @@ -0,0 +1,44 @@ +export const DownloadButton = (props: { + onClick: () => void; + text: string; +}) => ( + +
+ + + + + + +
+
+ {props.text} +
+
+); diff --git a/src/plugins/downloader-ytdlp/types.ts b/src/plugins/downloader-ytdlp/types.ts new file mode 100644 index 0000000000..8f04f68ad0 --- /dev/null +++ b/src/plugins/downloader-ytdlp/types.ts @@ -0,0 +1,910 @@ +export interface Preset { + extension?: string | null; + ffmpegArgs: string[]; +} + +// Presets for FFmpeg +export const DefaultPresetList: Record = { + 'mp3 (256kbps)': { + extension: 'mp3', + ffmpegArgs: ['-b:a', '256k'], + }, + 'Source': { + extension: undefined, + ffmpegArgs: ['-acodec', 'copy'], + }, + 'Custom': { + extension: null, + ffmpegArgs: [], + }, +}; + +export interface YouTubeFormat { + itag: number; + container: string; + content: string; + resolution: string; + bitrate: string; + range: string; + vrOr3D: string; +} + +// converted from https://gist.github.com/sidneys/7095afe4da4ae58694d128b1034e01e2#file-youtube_format_code_itag_list-md +// and https://gist.github.com/MartinEesmaa/2f4b261cb90a47e9c41ba115a011a4aa +export const YoutubeFormatList: YouTubeFormat[] = [ + { + itag: 5, + container: 'flv', + content: 'audio/video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 6, + container: 'flv', + content: 'audio/video', + resolution: '270p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 17, + container: '3gp', + content: 'audio/video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 18, + container: 'mp4', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 22, + container: 'mp4', + content: 'audio/video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 34, + container: 'flv', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 35, + container: 'flv', + content: 'audio/video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 36, + container: '3gp', + content: 'audio/video', + resolution: '180p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 37, + container: 'mp4', + content: 'audio/video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 38, + container: 'mp4', + content: 'audio/video', + resolution: '3072p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 43, + container: 'webm', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 44, + container: 'webm', + content: 'audio/video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 45, + container: 'webm', + content: 'audio/video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 46, + container: 'webm', + content: 'audio/video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 82, + container: 'mp4', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 83, + container: 'mp4', + content: 'audio/video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 84, + container: 'mp4', + content: 'audio/video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 85, + container: 'mp4', + content: 'audio/video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 91, + container: 'hls', + content: 'audio/video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 92, + container: 'hls', + content: 'audio/video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 93, + container: 'hls', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 94, + container: 'hls', + content: 'audio/video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 95, + container: 'hls', + content: 'audio/video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 96, + container: 'hls', + content: 'audio/video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '-', + }, + { + itag: 100, + container: 'webm', + content: 'audio/video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 101, + container: 'webm', + content: 'audio/video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 102, + container: 'webm', + content: 'audio/video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '3D', + }, + { + itag: 132, + container: 'hls', + content: 'audio/video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 133, + container: 'mp4', + content: 'video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 134, + container: 'mp4', + content: 'video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 135, + container: 'mp4', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 136, + container: 'mp4', + content: 'video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 137, + container: 'mp4', + content: 'video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 138, + container: 'mp4', + content: 'video', + resolution: '2160p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 139, + container: 'm4a', + content: 'audio', + resolution: '-', + bitrate: '48k', + range: '-', + vrOr3D: '', + }, + { + itag: 140, + container: 'm4a', + content: 'audio', + resolution: '-', + bitrate: '128k', + range: '-', + vrOr3D: '', + }, + { + itag: 141, + container: 'm4a', + content: 'audio', + resolution: '-', + bitrate: '256k', + range: '-', + vrOr3D: '', + }, + { + itag: 151, + container: 'hls', + content: 'audio/video', + resolution: '72p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 160, + container: 'mp4', + content: 'video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 167, + container: 'webm', + content: 'video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 168, + container: 'webm', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 169, + container: 'webm', + content: 'video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 171, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '128k', + range: '-', + vrOr3D: '', + }, + { + itag: 218, + container: 'webm', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 219, + container: 'webm', + content: 'video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 242, + container: 'webm', + content: 'video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 243, + container: 'webm', + content: 'video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 244, + container: 'webm', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 245, + container: 'webm', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 246, + container: 'webm', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 247, + container: 'webm', + content: 'video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 248, + container: 'webm', + content: 'video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 249, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '50k', + range: '-', + vrOr3D: '', + }, + { + itag: 250, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '70k', + range: '-', + vrOr3D: '', + }, + { + itag: 251, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '160k', + range: '-', + vrOr3D: '', + }, + { + itag: 264, + container: 'mp4', + content: 'video', + resolution: '1440p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 266, + container: 'mp4', + content: 'video', + resolution: '2160p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 271, + container: 'webm', + content: 'video', + resolution: '1440p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 272, + container: 'webm', + content: 'video', + resolution: '4320p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 278, + container: 'webm', + content: 'video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 298, + container: 'mp4', + content: 'video', + resolution: '720p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 299, + container: 'mp4', + content: 'video', + resolution: '1080p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 302, + container: 'webm', + content: 'video', + resolution: '720p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 303, + container: 'webm', + content: 'video', + resolution: '1080p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 308, + container: 'webm', + content: 'video', + resolution: '1440p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 313, + container: 'webm', + content: 'video', + resolution: '2160p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 315, + container: 'webm', + content: 'video', + resolution: '2160p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 330, + container: 'webm', + content: 'video', + resolution: '144p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 331, + container: 'webm', + content: 'video', + resolution: '240p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 332, + container: 'webm', + content: 'video', + resolution: '360p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 333, + container: 'webm', + content: 'video', + resolution: '480p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 334, + container: 'webm', + content: 'video', + resolution: '720p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 335, + container: 'webm', + content: 'video', + resolution: '1080p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 336, + container: 'webm', + content: 'video', + resolution: '1440p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 337, + container: 'webm', + content: 'video', + resolution: '2160p60', + bitrate: '-', + range: 'hdr', + vrOr3D: '', + }, + { + itag: 272, + container: 'webm', + content: 'video', + resolution: '2880p/4320p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 399, + container: 'mp4', + content: 'video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 400, + container: 'mp4', + content: 'video', + resolution: '1440p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 401, + container: 'mp4', + content: 'video', + resolution: '2160p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 402, + container: 'mp4', + content: 'video', + resolution: '2880p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 571, + container: 'mp4', + content: 'video', + resolution: '3840p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 702, + container: 'mp4', + content: 'video', + resolution: '3840p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 571, + container: 'mp4', + content: 'video', + resolution: '3840p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 694, + container: 'mp4', + content: 'video', + resolution: '144p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 695, + container: 'mp4', + content: 'video', + resolution: '240p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 696, + container: 'mp4', + content: 'video', + resolution: '360p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 697, + container: 'mp4', + content: 'video', + resolution: '480p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 698, + container: 'mp4', + content: 'video', + resolution: '720p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 699, + container: 'mp4', + content: 'video', + resolution: '1080p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 700, + container: 'mp4', + content: 'video', + resolution: '1440p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 701, + container: 'mp4', + content: 'video', + resolution: '2160p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 702, + container: 'mp4', + content: 'video', + resolution: '3840p', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + // Audio formats + { + itag: 599, + container: 'mp4', + content: 'audio', + resolution: '-', + bitrate: '30k', + range: '-', + vrOr3D: '', + }, + { + itag: 600, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '35k', + range: '-', + vrOr3D: '', + }, + { + itag: 774, + container: 'webm', + content: 'audio', + resolution: '-', + bitrate: '256k', + range: '-', + vrOr3D: '', + }, + // Livestream formats + { + itag: 300, + container: 'ts', + content: 'audio/video', + resolution: '720p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, + { + itag: 301, + container: 'ts', + content: 'audio/video', + resolution: '1080p60', + bitrate: '-', + range: '-', + vrOr3D: '', + }, +]; diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index 33d58ce444..cfcc4a7653 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { app, type BrowserWindow, dialog, ipcMain } from 'electron'; +import { app, type BrowserWindow, ipcMain } from 'electron'; import { Innertube, UniversalCache, @@ -58,21 +58,24 @@ const ffmpeg = lazy(async () => ); const ffmpegMutex = new Mutex(); -Platform.shim.eval = async (data: Types.BuildScriptResult, env: Record) => { +Platform.shim.eval = async ( + data: Types.BuildScriptResult, + env: Record, +) => { const properties = []; - if(env.n) { - properties.push(`n: exportedVars.nFunction("${env.n}")`) + if (env.n) { + properties.push(`n: exportedVars.nFunction("${env.n}")`); } if (env.sig) { - properties.push(`sig: exportedVars.sigFunction("${env.sig}")`) + properties.push(`sig: exportedVars.sigFunction("${env.sig}")`); } const code = `${data.output}\nreturn { ${properties.join(', ')} }`; return new Function(code)(); -} +}; let yt: Innertube; let win: BrowserWindow; @@ -117,13 +120,16 @@ const sendError = (error: Error, source?: string) => { console.error(message); console.trace(error); - dialog.showMessageBox(win, { - type: 'info', - buttons: [t('plugins.downloader.backend.dialog.error.buttons.ok')], - title: t('plugins.downloader.backend.dialog.error.title'), - message: t('plugins.downloader.backend.dialog.error.message'), - detail: message, - }); + + // Send error to renderer for non-blocking toast display + try { + win.webContents.send('downloader-error-toast', { + message, + title: t('plugins.downloader.backend.dialog.error.title'), + }); + } catch (e) { + console.warn('Could not send error toast:', e); + } }; export const getCookieFromWindow = async (win: BrowserWindow) => { @@ -421,8 +427,7 @@ async function downloadSongUnsafe( let targetFileExtension: string; if (!presetSetting?.extension) { targetFileExtension = - VideoFormatList.find((it) => it.itag === format.itag)?.container ?? - 'mp3'; + VideoFormatList.find((it) => it.itag === format.itag)?.container ?? 'mp3'; } else { targetFileExtension = presetSetting?.extension ?? 'mp3'; } @@ -740,25 +745,22 @@ export async function downloadPlaylist(givenUrl?: string | URL) { mkdirSync(playlistFolder, { recursive: true }); } - dialog.showMessageBox(win, { - type: 'info', - buttons: [ - t('plugins.downloader.backend.dialog.start-download-playlist.buttons.ok'), - ], - title: t('plugins.downloader.backend.dialog.start-download-playlist.title'), - message: t( + // Non-blocking toast notification for playlist download start + try { + const playlistMessage = t( 'plugins.downloader.backend.dialog.start-download-playlist.message', - { - playlistTitle, - }, - ), - detail: t( + { playlistTitle }, + ) + ' ' + t( 'plugins.downloader.backend.dialog.start-download-playlist.detail', - { - playlistSize: items.length, - }, - ), - }); + { playlistSize: items.length }, + ); + win.webContents.send('downloader-error-toast', { + message: playlistMessage, + title: t('plugins.downloader.backend.dialog.start-download-playlist.title'), + }); + } catch (e) { + console.warn('Could not send playlist toast:', e); + } if (is.dev()) { console.log( diff --git a/src/plugins/downloader/renderer.tsx b/src/plugins/downloader/renderer.tsx index 533179e7cf..c04f572b0f 100644 --- a/src/plugins/downloader/renderer.tsx +++ b/src/plugins/downloader/renderer.tsx @@ -1,4 +1,4 @@ -import { createSignal } from 'solid-js'; +import { createSignal, Show } from 'solid-js'; import { render } from 'solid-js/web'; @@ -18,6 +18,13 @@ import type { DownloaderPluginConfig } from './index'; let download: () => void; +// Toast notification state +const [toast, setToast] = createSignal<{ + message: string; + title?: string; +} | null>(null); +let toastTimeout: number | undefined; + const [downloadButtonText, setDownloadButtonText] = createSignal(''); let buttonContainer: HTMLDivElement | null = null; @@ -85,6 +92,16 @@ export const onRendererLoad = ({ const targetHtml = feedback || t('plugins.downloader.templates.button'); setDownloadButtonText(targetHtml); }); + + // Listen for error/info toasts from backend + ipc.on( + 'downloader-error-toast', + (data: { message: string; title?: string }) => { + setToast(data); + if (toastTimeout) clearTimeout(toastTimeout); + toastTimeout = window.setTimeout(() => setToast(null), 10000); // Auto-hide after 10s + }, + ); }; export const onPlayerApiReady = () => { @@ -110,4 +127,51 @@ export const onPlayerApiReady = () => { childList: true, subtree: true, }); + + // Render toast container for non-blocking notifications + let toastDiv = document.getElementById('ytmd-downloader-toast-container'); + if (!toastDiv) { + toastDiv = document.createElement('div'); + toastDiv.id = 'ytmd-downloader-toast-container'; + document.body.appendChild(toastDiv); + } + render( + () => ( + +
{ + navigator.clipboard.writeText(toast()?.message || ''); + setToast(null); + }} + style={{ + 'position': 'fixed', + 'bottom': '32px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'background': '#222', + 'color': '#fff', + 'padding': '16px 24px', + 'border-radius': '8px', + 'box-shadow': '0 2px 8px #0008', + 'z-index': '9999', + 'max-width': '80vw', + 'font-size': '15px', + 'cursor': 'pointer', + }} + title="Click to copy message and dismiss" + > + {toast()?.title || 'Error'} +
+ {toast()?.message} +
+ Click to copy & dismiss +
+
+
+ ), + toastDiv, + ); }; diff --git a/src/plugins/taskbar-widget/index.ts b/src/plugins/taskbar-widget/index.ts new file mode 100644 index 0000000000..2407a891df --- /dev/null +++ b/src/plugins/taskbar-widget/index.ts @@ -0,0 +1,489 @@ +import prompt from 'custom-electron-prompt'; +import { screen } from 'electron'; + +import { createPlugin } from '@/utils'; +import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; +import { Platform } from '@/types/plugins'; + +import type { MenuContext } from '@/types/contexts'; + +export type VisualizerPosition = 'left' | 'right'; + +export type TaskbarWidgetPluginConfig = { + enabled: boolean; + monitorIndex: number; + offsetX: number; + offsetY: number; + backgroundBlur: boolean; + blurOpacity: number; + visualizer: { + enabled: boolean; + position: VisualizerPosition; + width: number; + barCount: number; + centeredBars: boolean; + showBaseline: boolean; + audioSensitivity: number; + audioPeakThreshold: number; + }; +}; + +let cleanupFn: (() => void) | null = null; +let updateConfigFn: ((config: TaskbarWidgetPluginConfig) => void) | null = null; + +export default createPlugin({ + name: () => t('plugins.taskbar-widget.name'), + description: () => t('plugins.taskbar-widget.description'), + restartNeeded: true, + platform: Platform.Windows, + config: { + enabled: false, + monitorIndex: 0, + offsetX: 0, + offsetY: 0, + backgroundBlur: false, + blurOpacity: 0.5, + visualizer: { + enabled: false, + position: 'left' as VisualizerPosition, + width: 84, + barCount: 64, + centeredBars: true, + showBaseline: true, + audioSensitivity: 0.01, + audioPeakThreshold: 1.0, + }, + } as TaskbarWidgetPluginConfig, + + menu: async ({ + getConfig, + setConfig, + window: win, + }: MenuContext) => { + const config = await getConfig(); + const displays = screen.getAllDisplays(); + + return [ + { + label: t('plugins.taskbar-widget.menu.monitor.label'), + submenu: displays.map((display, index) => ({ + label: + index === 0 + ? `${t('plugins.taskbar-widget.menu.monitor.primary')} (${display.bounds.width}x${display.bounds.height})` + : `${index + 1} (${display.bounds.width}x${display.bounds.height})`, + type: 'radio' as const, + checked: config.monitorIndex === index, + click() { + setConfig({ monitorIndex: index }); + }, + })), + }, + { + label: t('plugins.taskbar-widget.menu.position.label'), + click: async () => { + // Read config fresh each time so previously saved values are shown + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t('plugins.taskbar-widget.menu.position.label'), + type: 'multiInput', + multiInputOptions: [ + { + label: t( + 'plugins.taskbar-widget.menu.position.horizontal-offset', + ), + value: currentConfig.offsetX, + inputAttrs: { + type: 'number', + required: true, + step: '1', + }, + }, + { + label: t( + 'plugins.taskbar-widget.menu.position.vertical-offset', + ), + value: currentConfig.offsetY, + inputAttrs: { + type: 'number', + required: true, + step: '1', + }, + }, + ], + resizable: true, + height: 260, + ...promptOptions(), + }, + win, + ).catch(console.error); + + if (res) { + const newOffsetX = Number(res[0]); + const newOffsetY = Number(res[1]); + setConfig({ + offsetX: Number.isFinite(newOffsetX) ? newOffsetX : 0, + offsetY: Number.isFinite(newOffsetY) ? newOffsetY : 0, + }); + } + }, + }, + { + label: t('plugins.taskbar-widget.menu.background-blur'), + type: 'checkbox' as const, + checked: config.backgroundBlur, + click(item: Electron.MenuItem) { + setConfig({ backgroundBlur: item.checked }); + }, + }, + { + label: t('plugins.taskbar-widget.menu.blur-opacity'), + click: async () => { + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t('plugins.taskbar-widget.menu.blur-opacity'), + type: 'input', + value: String(currentConfig.blurOpacity), + inputAttrs: { + type: 'number', + required: true, + min: '0.1', + max: '1.0', + step: '0.05', + }, + resizable: true, + height: 200, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (res != null) { + const val = Math.max(0.1, Math.min(1.0, Number(res))); + if (Number.isFinite(val)) { + setConfig({ blurOpacity: val }); + } + } + }, + }, + { type: 'separator' as const }, + { + label: t('plugins.taskbar-widget.menu.visualizer.label'), + submenu: [ + { + label: t('plugins.taskbar-widget.menu.visualizer.enabled'), + type: 'checkbox' as const, + checked: config.visualizer.enabled, + click(item: Electron.MenuItem) { + setConfig({ + visualizer: { ...config.visualizer, enabled: item.checked }, + }); + }, + }, + { + label: t('plugins.taskbar-widget.menu.visualizer.position.label'), + submenu: [ + { + label: t( + 'plugins.taskbar-widget.menu.visualizer.position.left', + ), + type: 'radio' as const, + checked: config.visualizer.position === 'left', + click() { + setConfig({ + visualizer: { ...config.visualizer, position: 'left' }, + }); + }, + }, + { + label: t( + 'plugins.taskbar-widget.menu.visualizer.position.right', + ), + type: 'radio' as const, + checked: config.visualizer.position === 'right', + click() { + setConfig({ + visualizer: { ...config.visualizer, position: 'right' }, + }); + }, + }, + ], + }, + { + label: t('plugins.taskbar-widget.menu.visualizer.width'), + click: async () => { + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t('plugins.taskbar-widget.menu.visualizer.width'), + type: 'input', + value: String(currentConfig.visualizer.width), + inputAttrs: { + type: 'number', + required: true, + min: '40', + max: '300', + step: '1', + }, + resizable: true, + height: 200, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (res != null) { + const val = Math.max(40, Math.min(300, Number(res))); + if (Number.isFinite(val)) { + setConfig({ + visualizer: { + ...currentConfig.visualizer, + width: val, + }, + }); + } + } + }, + }, + { + label: t('plugins.taskbar-widget.menu.visualizer.bar-count'), + click: async () => { + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t('plugins.taskbar-widget.menu.visualizer.bar-count'), + type: 'input', + value: String(currentConfig.visualizer.barCount), + inputAttrs: { + type: 'number', + required: true, + min: '4', + max: '64', + step: '1', + }, + resizable: true, + height: 200, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (res != null) { + const count = Math.max(4, Math.min(64, Number(res))); + if (Number.isFinite(count)) { + setConfig({ + visualizer: { + ...currentConfig.visualizer, + barCount: count, + }, + }); + } + } + }, + }, + { + label: t('plugins.taskbar-widget.menu.visualizer.centered-bars'), + type: 'checkbox' as const, + checked: config.visualizer.centeredBars, + click(item: Electron.MenuItem) { + setConfig({ + visualizer: { + ...config.visualizer, + centeredBars: item.checked, + }, + }); + }, + }, + { + label: t('plugins.taskbar-widget.menu.visualizer.show-baseline'), + type: 'checkbox' as const, + checked: config.visualizer.showBaseline, + click(item: Electron.MenuItem) { + setConfig({ + visualizer: { + ...config.visualizer, + showBaseline: item.checked, + }, + }); + }, + }, + { + label: t( + 'plugins.taskbar-widget.menu.visualizer.audio-sensitivity', + ), + click: async () => { + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t( + 'plugins.taskbar-widget.menu.visualizer.audio-sensitivity', + ), + type: 'input', + value: String(currentConfig.visualizer.audioSensitivity), + inputAttrs: { + type: 'number', + required: true, + min: '0.01', + max: '1.0', + step: '0.05', + }, + resizable: true, + height: 200, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (res != null) { + const val = Math.max(0.01, Math.min(1.0, Number(res))); + if (Number.isFinite(val)) { + setConfig({ + visualizer: { + ...currentConfig.visualizer, + audioSensitivity: val, + }, + }); + } + } + }, + }, + { + label: t( + 'plugins.taskbar-widget.menu.visualizer.audio-peak-threshold', + ), + click: async () => { + const currentConfig = await getConfig(); + const res = await prompt( + { + title: t( + 'plugins.taskbar-widget.menu.visualizer.audio-peak-threshold', + ), + type: 'input', + value: String(currentConfig.visualizer.audioPeakThreshold), + inputAttrs: { + type: 'number', + required: true, + min: '0.1', + max: '1.0', + step: '0.05', + }, + resizable: true, + height: 200, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (res != null) { + const val = Math.max(0.1, Math.min(1.0, Number(res))); + if (Number.isFinite(val)) { + setConfig({ + visualizer: { + ...currentConfig.visualizer, + audioPeakThreshold: val, + }, + }); + } + } + }, + }, + ], + }, + ]; + }, + + renderer: { + audioContext: null as AudioContext | null, + audioSource: null as MediaElementAudioSourceNode | null, + analyser: null as AnalyserNode | null, + animationFrame: null as number | null, + ipcSend: null as ((channel: string, ...args: unknown[]) => void) | null, + + start({ ipc }) { + this.ipcSend = ipc.send; + }, + + onPlayerApiReady(_, { ipc }) { + document.addEventListener( + 'peard:audio-can-play', + (e: Event) => { + const detail = (e as CustomEvent).detail as { + audioContext: AudioContext; + audioSource: MediaElementAudioSourceNode; + }; + this.audioContext = detail.audioContext; + this.audioSource = detail.audioSource; + this.startAnalysis(ipc.send); + }, + { passive: true }, + ); + }, + + startAnalysis( + this: { + audioContext: AudioContext | null; + audioSource: MediaElementAudioSourceNode | null; + analyser: AnalyserNode | null; + animationFrame: number | null; + ipcSend: ((channel: string, ...args: unknown[]) => void) | null; + }, + send: (channel: string, ...args: unknown[]) => void, + ) { + if (!this.audioContext || !this.audioSource) return; + + // Clean up any previous analyser + if (this.animationFrame) { + clearInterval(this.animationFrame); + this.animationFrame = null; + } + + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = 1024; + this.analyser.smoothingTimeConstant = 0; + this.audioSource.connect(this.analyser); + + const dataArray = new Uint8Array(this.analyser.frequencyBinCount); + const analyserRef = this.analyser; + + // Use setInterval instead of requestAnimationFrame so that audio + // data continues to be captured and forwarded even when the main + // BrowserWindow is minimized or hidden (rAF pauses for hidden windows). + const intervalId = setInterval(() => { + analyserRef.getByteFrequencyData(dataArray); + send('taskbar-widget:audio-data', Array.from(dataArray)); + }, 33); // ~30 fps + + // Store the interval ID so we can clean it up later. + // We repurpose animationFrame to hold this (it's just a number ID). + this.animationFrame = intervalId as unknown as number; + }, + + stop() { + if (this.animationFrame) { + clearInterval(this.animationFrame); + this.animationFrame = null; + } + this.analyser = null; + this.audioContext = null; + this.audioSource = null; + }, + }, + + backend: { + async start({ window: mainWindow, getConfig }) { + const { createMiniPlayer, cleanup, updateConfig } = + await import('./main'); + const config = await getConfig(); + + await createMiniPlayer(mainWindow, config); + + cleanupFn = cleanup; + updateConfigFn = updateConfig; + }, + onConfigChange(newConfig) { + updateConfigFn?.(newConfig); + }, + stop() { + cleanupFn?.(); + cleanupFn = null; + updateConfigFn = null; + }, + }, +}); diff --git a/src/plugins/taskbar-widget/main.ts b/src/plugins/taskbar-widget/main.ts new file mode 100644 index 0000000000..66594553b9 --- /dev/null +++ b/src/plugins/taskbar-widget/main.ts @@ -0,0 +1,1413 @@ +import path from 'node:path'; +import fs from 'node:fs'; + +import { + app, + BrowserWindow, + ipcMain, + nativeImage, + net, + screen, +} from 'electron'; + +import { getSongControls } from '@/providers/song-controls'; +import { + registerCallback, + type SongInfo, + SongInfoEvent, +} from '@/providers/song-info'; + +import type { TaskbarWidgetPluginConfig, VisualizerPosition } from './index'; + +// Widget width limits – the actual width is driven by the renderer content +// via the 'taskbar-widget:resize' IPC channel. +const MAX_WIDGET_WIDTH = 350; +const MIN_WIDGET_WIDTH = 150; +// Default taskbar height on Windows 11 (used as fallback) +const DEFAULT_TASKBAR_HEIGHT = 48; +// Estimated width of the system tray area (hidden icons arrow, pinned +// tray icons, clock, action center) so the widget sits to their left. +// A generous default keeps the widget clear of pinned tray icons. +// Windows 11 only supports the bottom taskbar position. +const SYSTEM_TRAY_ESTIMATED_WIDTH = 450; +// How often (ms) to re-check and reposition the widget + reassert z-order. +// Handles auto-hide taskbar changes and z-index loss from window focus changes. +const REPOSITION_INTERVAL_MS = 100; +// Every FORCE_ZORDER_EVERY_N_TICKS repositions, the always-on-top flag is +// toggled off then back on (hidden behind an opacity:0 guard to prevent +// visible flicker). This forces Windows to re-evaluate the widget's +// position in the TOPMOST z-band. +const FORCE_ZORDER_EVERY_N_TICKS = 30; // ~3 s when REPOSITION_INTERVAL_MS=100 +// When the widget is hidden externally (e.g. Start menu opens), an aggressive +// recovery interval fires every HIDE_RECOVERY_INTERVAL_MS for up to +// HIDE_RECOVERY_DURATION_MS. This covers both fast transitions (clicking a +// pinned taskbar icon) and slower system overlay animations (Start menu). +const HIDE_RECOVERY_INTERVAL_MS = 100; +const HIDE_RECOVERY_DURATION_MS = 3000; + +let miniPlayerWin: BrowserWindow | null = null; +// Visualizer window – sits adjacent to the mini player widget. +let visualizerWin: BrowserWindow | null = null; +// Keep a reference to the main window so cleanup can remove event listeners. +let mainWindowRef: BrowserWindow | null = null; +let controlHandler: + | ((_: Electron.IpcMainEvent, command: string) => void) + | null = null; +let showWindowHandler: ((_: Electron.IpcMainEvent) => void) | null = null; +let resizeHandler: ((_: Electron.IpcMainEvent, width: number) => void) | null = + null; +let displayChangeHandler: (() => void) | null = null; +let repositionTimer: ReturnType | null = null; +let selectedMonitorIndex = 0; +let positionOffsetX = 0; +let positionOffsetY = 0; +let backgroundBlurEnabled = false; +let currentWidgetWidth = MIN_WIDGET_WIDTH; +// Visualizer configuration state +let visualizerEnabled = false; +let visualizerPosition: VisualizerPosition = 'left'; +let visualizerBarCount = 20; +let visualizerCenteredBars = true; +let visualizerShowBaseline = true; +let visualizerAudioSensitivity = 0.3; +let visualizerAudioPeakThreshold = 0.85; +let visualizerWidth = 84; +let blurOpacity = 0.5; +// IPC handler for audio data forwarding from renderer to visualizer +let audioDataHandler: + | ((_: Electron.IpcMainEvent, data: number[]) => void) + | null = null; +// Tracks whether the widget is supposed to be visible (a song is playing). +// Used to decide whether to recover from external hides. +let isShowing = false; +// Set before intentional close to suppress auto-recovery. +let intentionalClose = false; +// Cache last bounds to avoid unnecessary setBounds calls that cause flicker. +let lastBounds: { x: number; y: number; width: number; height: number } | null = + null; +// Persistent interval used to recover from external hides (Start menu, etc.). +let hideRecoveryInterval: ReturnType | null = null; +// Delayed recovery timers scheduled after main window blur events. +// The widget may be pushed behind the taskbar when shell overlays (Start menu, +// notification center) open. These timers fire recovery attempts at staggered +// intervals so the widget reappears after the overlay closes. +let blurRecoveryTimers: ReturnType[] = []; +// Cached imageSrc URL for dominant-color extraction to avoid re-fetching. +let lastColorUrl: string | null = null; +// Tick counter for the periodic reposition timer. +let repositionTickCount = 0; +// Handler references for main window blur/focus listeners so they can be +// cleaned up when the widget is destroyed. +let mainWindowBlurHandler: (() => void) | null = null; +let mainWindowFocusHandler: (() => void) | null = null; + +const getWidgetDir = () => { + const dir = path.join(app.getPath('userData'), 'taskbar-widget'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +}; + +const writePreloadScript = (): string => { + const preloadPath = path.join(getWidgetDir(), 'preload.js'); + // Written at runtime because the plugin system doesn't support bundling + // separate preload scripts for secondary windows + fs.writeFileSync( + preloadPath, + `const { contextBridge, ipcRenderer } = require('electron'); +const ALLOWED_SEND = ['taskbar-widget:control', 'taskbar-widget:resize', 'taskbar-widget:show-window', 'taskbar-widget:audio-data']; +const ALLOWED_RECEIVE = ['taskbar-widget:song-info', 'taskbar-widget:set-blur', 'taskbar-widget:set-blur-opacity', 'taskbar-widget:set-background-color', 'taskbar-widget:visualizer-config', 'taskbar-widget:audio-data']; +contextBridge.exposeInMainWorld('widgetIpc', { + send: (channel, ...args) => { + if (ALLOWED_SEND.includes(channel)) { + ipcRenderer.send(channel, ...args); + } + }, + on: (channel, listener) => { + if (ALLOWED_RECEIVE.includes(channel)) { + ipcRenderer.on(channel, (_event, ...args) => listener(...args)); + } + }, +}); +`, + ); + return preloadPath; +}; + +/** + * Get the target display for the widget. + * Falls back to the primary display if the requested index is out of range. + */ +const getTargetDisplay = () => { + const displays = screen.getAllDisplays(); + return displays[selectedMonitorIndex] ?? screen.getPrimaryDisplay(); +}; + +/** + * Detect the taskbar region by comparing display bounds with the work area. + * Returns the position and dimensions of the taskbar on the target display. + */ +const getTaskbarGeometry = () => { + const display = getTargetDisplay(); + const { bounds, workArea } = display; + + // The taskbar occupies the gap between the full screen bounds + // and the usable work area (bottom taskbar is the Windows 11 default) + const taskbarHeight = + bounds.height - workArea.height - (workArea.y - bounds.y); + const taskbarY = workArea.y + workArea.height; + + return { + taskbarHeight: taskbarHeight > 0 ? taskbarHeight : DEFAULT_TASKBAR_HEIGHT, + taskbarY: + taskbarHeight > 0 + ? taskbarY + : bounds.y + bounds.height - DEFAULT_TASKBAR_HEIGHT, + screenWidth: bounds.width, + screenX: bounds.x, + }; +}; + +/** + * Calculate the widget window position so it sits on the taskbar surface, + * to the left of the notification / system tray area. + * User-configured offsets are applied on top of the computed position. + */ +const getWidgetBounds = () => { + const { taskbarHeight, taskbarY, screenWidth, screenX } = + getTaskbarGeometry(); + + return { + x: + screenX + + screenWidth - + currentWidgetWidth - + SYSTEM_TRAY_ESTIMATED_WIDTH + + positionOffsetX, + y: taskbarY + positionOffsetY, + width: currentWidgetWidth, + height: taskbarHeight, + }; +}; + +const getMiniPlayerHTML = (widgetHeight: number): string => { + // Scale UI elements relative to taskbar height + const albumSize = Math.max(widgetHeight - 16, 24); + const titleFontSize = widgetHeight >= 48 ? 13 : 11; + const artistFontSize = widgetHeight >= 48 ? 11 : 10; + const btnSize = widgetHeight >= 48 ? 24 : 22; + const iconSize = widgetHeight >= 48 ? 14 : 13; + const playIconSize = widgetHeight >= 48 ? 18 : 15; + const containerPadding = widgetHeight >= 48 ? '4px 6px' : '2px 4px'; + const blurPadding = + widgetHeight >= 48 ? '4px 8px 4px 4px' : '3px 6px 3px 3px'; + // Max width of the title/artist block before text is truncated with ellipsis + const infoMaxWidth = 160; + + return ` + + + + + + + +
No song playing
+ + +`; +}; + +const writeHtmlFile = (widgetHeight: number): string => { + const htmlPath = path.join(getWidgetDir(), 'index.html'); + fs.writeFileSync(htmlPath, getMiniPlayerHTML(widgetHeight)); + return htmlPath; +}; + +/** + * Generate the HTML for the audio visualizer window. + * Renders vertical bars on a canvas that react to audio frequency data. + */ +const getVisualizerHTML = (widgetHeight: number): string => { + return ` + + + + + + + + + +`; +}; + +const writeVisualizerHtmlFile = (widgetHeight: number): string => { + const htmlPath = path.join(getWidgetDir(), 'visualizer.html'); + fs.writeFileSync(htmlPath, getVisualizerHTML(widgetHeight)); + return htmlPath; +}; + +/** + * Calculate the bounds for the visualizer window, positioned adjacent + * to the mini player widget. + */ +const getVisualizerBounds = () => { + const widgetBounds = getWidgetBounds(); + const vizWidth = visualizerWidth; + + return { + x: + visualizerPosition === 'left' + ? widgetBounds.x - vizWidth - 4 + : widgetBounds.x + widgetBounds.width + 4, + y: widgetBounds.y, + width: vizWidth, + height: widgetBounds.height, + }; +}; + +/** Create or destroy the visualizer window based on config. */ +const ensureVisualizerWindow = async (preloadPath: string) => { + if (visualizerEnabled && isShowing) { + if (!visualizerWin || visualizerWin.isDestroyed()) { + const bounds = getVisualizerBounds(); + const htmlPath = writeVisualizerHtmlFile(bounds.height); + + visualizerWin = new BrowserWindow({ + ...bounds, + frame: false, + transparent: true, + skipTaskbar: true, + resizable: false, + movable: false, + focusable: false, + show: false, + type: 'toolbar', + webPreferences: { + contextIsolation: true, + preload: preloadPath, + }, + }); + + visualizerWin.setAlwaysOnTop(true, 'screen-saver'); + await visualizerWin.loadFile(htmlPath); + visualizerWin.setIgnoreMouseEvents(true, { forward: true }); + visualizerWin.showInactive(); + + // Forward hide/minimize recovery + visualizerWin.on('hide', () => { + if ( + isShowing && + !intentionalClose && + visualizerWin && + !visualizerWin.isDestroyed() + ) { + visualizerWin.showInactive(); + visualizerWin.setAlwaysOnTop(true, 'screen-saver'); + } + }); + visualizerWin.on('minimize', () => { + if ( + isShowing && + !intentionalClose && + visualizerWin && + !visualizerWin.isDestroyed() + ) { + visualizerWin.restore(); + } + }); + } else { + // Update position + const bounds = getVisualizerBounds(); + visualizerWin.setBounds(bounds); + } + } else if (visualizerWin && !visualizerWin.isDestroyed()) { + visualizerWin.close(); + visualizerWin = null; + } +}; + +/** Reposition the visualizer window alongside the main widget. */ +const repositionVisualizer = () => { + if (!visualizerWin || visualizerWin.isDestroyed()) return; + const bounds = getVisualizerBounds(); + visualizerWin.setBounds(bounds); + if (isShowing && !intentionalClose) { + visualizerWin.moveTop(); + } +}; + +/** Send current visualizer config to the visualizer renderer. */ +const sendVisualizerConfig = () => { + if (!visualizerWin || visualizerWin.isDestroyed()) return; + visualizerWin.webContents.send('taskbar-widget:visualizer-config', { + barCount: visualizerBarCount, + centeredBars: visualizerCenteredBars, + showBaseline: visualizerShowBaseline, + audioSensitivity: visualizerAudioSensitivity, + audioPeakThreshold: visualizerAudioPeakThreshold, + }); +}; + +/** + * Extract the dominant color from an album art URL using Electron's + * nativeImage API. Runs entirely in the main process so there are no + * CORS issues. Returns `null` when extraction fails for any reason. + */ +const extractDominantColor = async ( + imageUrl: string, +): Promise<{ r: number; g: number; b: number } | null> => { + try { + const response = await net.fetch(imageUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + const image = nativeImage.createFromBuffer(buffer); + if (image.isEmpty()) return null; + + // Scale down for fast sampling + const small = image.resize({ width: 16, height: 16 }); + const bitmap = small.toBitmap(); // BGRA on Windows + + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (let i = 0; i < bitmap.length; i += 4) { + const blue = bitmap[i]; + const green = bitmap[i + 1]; + const red = bitmap[i + 2]; + const brightness = (red + green + blue) / 3; + // Skip very dark / very bright pixels for a more representative color + if (brightness > 30 && brightness < 220) { + r += red; + g += green; + b += blue; + count++; + } + } + + if (count === 0) return null; + + let avgR = Math.round(r / count); + let avgG = Math.round(g / count); + let avgB = Math.round(b / count); + + // Cap brightness so white text stays readable on the semi-transparent bg + const avgBrightness = (avgR + avgG + avgB) / 3; + if (avgBrightness > 150) { + const factor = 150 / avgBrightness; + avgR = Math.round(avgR * factor); + avgG = Math.round(avgG * factor); + avgB = Math.round(avgB * factor); + } + + return { r: avgR, g: avgG, b: avgB }; + } catch { + return null; + } +}; + +/** Cancel any pending blur-recovery timeouts. */ +const clearBlurRecoveryTimers = () => { + for (const timer of blurRecoveryTimers) clearTimeout(timer); + blurRecoveryTimers = []; +}; + +/** + * Schedule staggered recovery attempts after a main-window blur event. + * The widget may be pushed behind the taskbar when shell overlays open + * (Start menu, notification centre, etc.). These delayed attempts ensure + * recovery even when the overlay is slow to close and no further Electron + * events fire. + */ +const scheduleBlurRecovery = () => { + clearBlurRecoveryTimers(); + const delays = [300, 800, 1500, 3000]; + for (const delay of delays) { + blurRecoveryTimers.push( + setTimeout(() => { + if (isShowing && !intentionalClose) recoverVisibility(); + }, delay), + ); + } +}; + +/** + * Recover visibility if the widget was hidden, minimized, or pushed behind + * the taskbar by a system overlay (Start menu, shell flyouts, etc.). + * + * The z-order toggle (off → on) forces Windows to re-evaluate the TOPMOST + * z-band. To prevent a visible flash the window opacity is set to 0 before + * the toggle and restored to 1 immediately after. Because the Electron + * calls are synchronous the compositor sees only the final state. + */ +const recoverVisibility = () => { + if ( + !isShowing || + intentionalClose || + !miniPlayerWin || + miniPlayerWin.isDestroyed() + ) { + return; + } + + if (miniPlayerWin.isMinimized()) { + miniPlayerWin.restore(); + } + + if (!miniPlayerWin.isVisible()) { + miniPlayerWin.showInactive(); + miniPlayerWin.setAlwaysOnTop(true, 'screen-saver'); + miniPlayerWin.moveTop(); + return; + } + + // Hide briefly during z-order toggle to prevent visible flash. + try { + miniPlayerWin.setOpacity(0); + miniPlayerWin.setAlwaysOnTop(false); + miniPlayerWin.setAlwaysOnTop(true, 'screen-saver'); + miniPlayerWin.moveTop(); + } finally { + if (miniPlayerWin && !miniPlayerWin.isDestroyed()) { + miniPlayerWin.setOpacity(1); + } + } +}; + +/** + * Reposition the widget and periodically reassert z-order. + * Called on display changes and periodically to handle auto-hide taskbar + * and z-index loss from window focus changes. + * + * Every {@link FORCE_ZORDER_EVERY_N_TICKS} ticks the always-on-top flag is + * toggled off then on (wrapped in an opacity guard to prevent visible + * flicker) to force the OS to re-evaluate the TOPMOST z-band. On + * intermediate ticks only {@link BrowserWindow.moveTop moveTop} is called + * to minimise overhead. + */ +const repositionWidget = () => { + if (!miniPlayerWin || miniPlayerWin.isDestroyed()) return; + + repositionTickCount++; + + const bounds = getWidgetBounds(); + + // Only call setBounds when the position/size actually changed to avoid + // unnecessary window manipulation that can cause flickering or broken + // rendering on some systems. + if ( + !lastBounds || + lastBounds.x !== bounds.x || + lastBounds.y !== bounds.y || + lastBounds.width !== bounds.width || + lastBounds.height !== bounds.height + ) { + miniPlayerWin.setBounds(bounds); + lastBounds = bounds; + } + + if (isShowing && !intentionalClose) { + // Periodically force a full z-order toggle so the widget can recover + // even when no Electron events fire (e.g. after the Start menu closes + // and focus stays on the taskbar). The opacity guard in + // recoverVisibility() prevents visible stutter. + if (repositionTickCount % FORCE_ZORDER_EVERY_N_TICKS === 0) { + recoverVisibility(); + } else if (miniPlayerWin.isVisible()) { + miniPlayerWin.moveTop(); + } + } + + // Keep the visualizer in sync with the widget position + repositionVisualizer(); +}; + +export const createMiniPlayer = async ( + mainWindow: BrowserWindow, + config: TaskbarWidgetPluginConfig, +) => { + const { playPause, next, previous } = getSongControls(mainWindow); + mainWindowRef = mainWindow; + + // Reset state from any previous session + intentionalClose = false; + isShowing = false; + currentWidgetWidth = MIN_WIDGET_WIDTH; + lastBounds = null; + lastColorUrl = null; + repositionTickCount = 0; + clearBlurRecoveryTimers(); + + selectedMonitorIndex = config.monitorIndex; + positionOffsetX = config.offsetX; + positionOffsetY = config.offsetY; + backgroundBlurEnabled = config.backgroundBlur; + visualizerEnabled = config.visualizer.enabled; + visualizerPosition = config.visualizer.position; + visualizerBarCount = config.visualizer.barCount; + visualizerCenteredBars = config.visualizer.centeredBars; + visualizerShowBaseline = config.visualizer.showBaseline; + visualizerAudioSensitivity = config.visualizer.audioSensitivity; + visualizerAudioPeakThreshold = config.visualizer.audioPeakThreshold; + visualizerWidth = config.visualizer.width ?? 84; + blurOpacity = config.blurOpacity ?? 0.5; + + // Disable background throttling on the main window when the visualizer + // is enabled. Chromium aggressively throttles setInterval in minimized + // or hidden BrowserWindows (~1 s), which starves the audio analysis + // loop running in the renderer. This keeps data flowing at full speed. + if (visualizerEnabled && !mainWindow.isDestroyed()) { + mainWindow.webContents.setBackgroundThrottling(false); + } + + const preloadPath = writePreloadScript(); + const { x, y, width, height } = getWidgetBounds(); + const htmlPath = writeHtmlFile(height); + + miniPlayerWin = new BrowserWindow({ + width, + height, + x, + y, + frame: false, + transparent: true, + skipTaskbar: true, + resizable: false, + movable: false, + focusable: false, + show: false, + // 'toolbar' type prevents third-party window managers (e.g. DisplayFusion) + // from attaching overlays such as "move to next monitor" buttons + type: 'toolbar', + webPreferences: { + contextIsolation: true, + preload: preloadPath, + }, + }); + + // Use 'screen-saver' z-level so the widget renders above the taskbar + miniPlayerWin.setAlwaysOnTop(true, 'screen-saver'); + + await miniPlayerWin.loadFile(htmlPath); + + // Apply initial blur setting + if (backgroundBlurEnabled) { + miniPlayerWin.webContents.send('taskbar-widget:set-blur', true); + } + + // Apply initial blur opacity + miniPlayerWin.webContents.send( + 'taskbar-widget:set-blur-opacity', + blurOpacity, + ); + + // Make the window click-through until we have a song to display. + // This prevents an invisible (transparent) window from blocking + // taskbar clicks on the system tray arrow, pinned icons, etc. + miniPlayerWin.setIgnoreMouseEvents(true, { forward: true }); + + // Immediately recover if the widget is hidden externally (e.g. by + // taskbar interactions, Start menu opening, or window management tools). + // A persistent interval keeps retrying for HIDE_RECOVERY_DURATION_MS so + // recovery succeeds even after slower system overlay animations finish. + miniPlayerWin.on('hide', () => { + if (!isShowing || intentionalClose) return; + if (hideRecoveryInterval) clearInterval(hideRecoveryInterval); + recoverVisibility(); + let elapsed = 0; + hideRecoveryInterval = setInterval(() => { + elapsed += HIDE_RECOVERY_INTERVAL_MS; + if ( + elapsed >= HIDE_RECOVERY_DURATION_MS || + !isShowing || + intentionalClose + ) { + if (hideRecoveryInterval) { + clearInterval(hideRecoveryInterval); + hideRecoveryInterval = null; + } + return; + } + recoverVisibility(); + }, HIDE_RECOVERY_INTERVAL_MS); + }); + + // Also recover immediately from any minimize event (the Start menu + // may minimize overlay windows on some configurations). + miniPlayerWin.on('minimize', () => { + if (!isShowing || intentionalClose) return; + recoverVisibility(); + }); + + // Re-assert always-on-top if something steals z-order. + miniPlayerWin.on('always-on-top-changed', (_event, isAlwaysOnTop) => { + if ( + !isAlwaysOnTop && + isShowing && + !intentionalClose && + miniPlayerWin && + !miniPlayerWin.isDestroyed() + ) { + miniPlayerWin.setAlwaysOnTop(true, 'screen-saver'); + miniPlayerWin.moveTop(); + } + }); + + // Reposition when display configuration changes (resolution, DPI, etc.) + displayChangeHandler = () => repositionWidget(); + screen.on('display-metrics-changed', displayChangeHandler); + + // When the main window loses focus the user may have clicked the taskbar, + // the Start menu, or another shell overlay. Schedule staggered recovery + // attempts so the widget reappears after the overlay closes – even if no + // further Electron events fire (e.g. focus stays on the taskbar). + mainWindowBlurHandler = () => { + if (isShowing && !intentionalClose) scheduleBlurRecovery(); + }; + mainWindow.on('blur', mainWindowBlurHandler); + + // When the main window regains focus, immediately ensure the widget is + // on top (handles the case where the user switches back from another app). + mainWindowFocusHandler = () => { + if (isShowing && !intentionalClose) recoverVisibility(); + }; + mainWindow.on('focus', mainWindowFocusHandler); + + // Periodically reposition and reassert z-order so the widget adapts to + // auto-hide taskbar state changes and recovers from z-index loss. + repositionTimer = setInterval( + () => repositionWidget(), + REPOSITION_INTERVAL_MS, + ); + + // Handle control commands from the mini player + controlHandler = (_, command: string) => { + switch (command) { + case 'previous': { + previous(); + break; + } + + case 'playPause': { + playPause(); + break; + } + + case 'next': { + next(); + break; + } + } + }; + + ipcMain.on('taskbar-widget:control', controlHandler); + + // Clicking on the widget (outside buttons) brings the main window to front + showWindowHandler = () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }; + + ipcMain.on('taskbar-widget:show-window', showWindowHandler); + + // Handle dynamic resize requests from the renderer + resizeHandler = (_, width: number) => { + if (!miniPlayerWin || miniPlayerWin.isDestroyed()) return; + // Add a small buffer for sub-pixel rounding + const clamped = Math.max( + MIN_WIDGET_WIDTH, + Math.min(Math.ceil(width) + 2, MAX_WIDGET_WIDTH), + ); + if (clamped !== currentWidgetWidth) { + currentWidgetWidth = clamped; + repositionWidget(); + } + }; + + ipcMain.on('taskbar-widget:resize', resizeHandler); + + // Forward audio frequency data from the renderer to the visualizer window + audioDataHandler = (_, data: number[]) => { + if (visualizerWin && !visualizerWin.isDestroyed()) { + visualizerWin.webContents.send('taskbar-widget:audio-data', data); + } + }; + ipcMain.on('taskbar-widget:audio-data', audioDataHandler); + + // Send song info to the mini player + const sendSongInfo = (songInfo: SongInfo) => { + if (!miniPlayerWin || miniPlayerWin.isDestroyed()) return; + + // Strip the artist prefix from the title if present. + // YouTube Music often formats titles as "Artist - Song Name" but + // we already show the artist separately below the title. + let displayTitle = songInfo.title; + if (songInfo.artist && displayTitle) { + const prefix = songInfo.artist + ' - '; + if (displayTitle.startsWith(prefix)) { + displayTitle = displayTitle.slice(prefix.length); + } + + // Also handle reversed "Song - Artist" format + const suffix = ' - ' + songInfo.artist; + if (displayTitle.endsWith(suffix)) { + displayTitle = displayTitle.slice(0, -suffix.length); + } + } + + // Extract year from uploadDate (format "YYYY-MM-DD") if available + let uploadYear = ''; + if (songInfo.uploadDate && songInfo.uploadDate.length >= 4) { + uploadYear = songInfo.uploadDate.slice(0, 4); + } + + miniPlayerWin.webContents.send('taskbar-widget:song-info', { + title: displayTitle, + artist: songInfo.artist, + imageSrc: songInfo.imageSrc, + isPaused: songInfo.isPaused, + year: uploadYear, + }); + + // Extract dominant color from album art for the dynamic blur background. + // Only re-extract when the image URL changes. + if (songInfo.imageSrc && songInfo.imageSrc !== lastColorUrl) { + lastColorUrl = songInfo.imageSrc; + extractDominantColor(songInfo.imageSrc).then((color) => { + if (color && miniPlayerWin && !miniPlayerWin.isDestroyed()) { + miniPlayerWin.webContents.send( + 'taskbar-widget:set-background-color', + color, + ); + // Also forward the color to the visualizer for bar tinting + if (visualizerWin && !visualizerWin.isDestroyed()) { + visualizerWin.webContents.send( + 'taskbar-widget:set-background-color', + color, + ); + } + } + }); + } + + // Show the mini player once we have a song + if (songInfo.title && !miniPlayerWin.isVisible()) { + isShowing = true; + miniPlayerWin.setIgnoreMouseEvents(false); + miniPlayerWin.showInactive(); + // Also create/show visualizer if enabled + ensureVisualizerWindow(preloadPath); + } + }; + + registerCallback((songInfo, event) => { + if (event !== SongInfoEvent.TimeChanged) { + sendSongInfo(songInfo); + } + }); + + // Clean up when main window is closed + mainWindow.on('closed', () => { + cleanup(); + }); +}; + +/** + * Live-update configuration without recreating the window. + * Called from the plugin's onConfigChange handler. + */ +export const updateConfig = (newConfig: TaskbarWidgetPluginConfig) => { + positionOffsetX = newConfig.offsetX; + positionOffsetY = newConfig.offsetY; + backgroundBlurEnabled = newConfig.backgroundBlur; + blurOpacity = newConfig.blurOpacity ?? 0.5; + const wasVisualizerEnabled = visualizerEnabled; + visualizerEnabled = newConfig.visualizer.enabled; + visualizerPosition = newConfig.visualizer.position; + visualizerWidth = newConfig.visualizer.width ?? 84; + visualizerBarCount = newConfig.visualizer.barCount; + visualizerCenteredBars = newConfig.visualizer.centeredBars; + visualizerShowBaseline = newConfig.visualizer.showBaseline; + visualizerAudioSensitivity = newConfig.visualizer.audioSensitivity; + visualizerAudioPeakThreshold = newConfig.visualizer.audioPeakThreshold; + lastBounds = null; // Force reposition on next tick + + // Toggle background throttling based on visualizer state + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + if (visualizerEnabled && !wasVisualizerEnabled) { + mainWindowRef.webContents.setBackgroundThrottling(false); + } else if (!visualizerEnabled && wasVisualizerEnabled) { + mainWindowRef.webContents.setBackgroundThrottling(true); + } + } + + if (miniPlayerWin && !miniPlayerWin.isDestroyed()) { + repositionWidget(); + miniPlayerWin.webContents.send( + 'taskbar-widget:set-blur', + newConfig.backgroundBlur, + ); + miniPlayerWin.webContents.send( + 'taskbar-widget:set-blur-opacity', + blurOpacity, + ); + } + + // Create, update, or destroy visualizer as needed + const preloadPath = path.join(getWidgetDir(), 'preload.js'); + ensureVisualizerWindow(preloadPath); + sendVisualizerConfig(); +}; + +export const cleanup = () => { + intentionalClose = true; + isShowing = false; + + // Restore normal background throttling + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + mainWindowRef.webContents.setBackgroundThrottling(true); + } + + clearBlurRecoveryTimers(); + + if (hideRecoveryInterval) { + clearInterval(hideRecoveryInterval); + hideRecoveryInterval = null; + } + + if (controlHandler) { + ipcMain.removeListener('taskbar-widget:control', controlHandler); + controlHandler = null; + } + + if (showWindowHandler) { + ipcMain.removeListener('taskbar-widget:show-window', showWindowHandler); + showWindowHandler = null; + } + + if (resizeHandler) { + ipcMain.removeListener('taskbar-widget:resize', resizeHandler); + resizeHandler = null; + } + + if (audioDataHandler) { + ipcMain.removeListener('taskbar-widget:audio-data', audioDataHandler); + audioDataHandler = null; + } + + if (displayChangeHandler) { + screen.removeListener('display-metrics-changed', displayChangeHandler); + displayChangeHandler = null; + } + + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + if (mainWindowBlurHandler) { + mainWindowRef.removeListener('blur', mainWindowBlurHandler); + } + if (mainWindowFocusHandler) { + mainWindowRef.removeListener('focus', mainWindowFocusHandler); + } + } + mainWindowBlurHandler = null; + mainWindowFocusHandler = null; + mainWindowRef = null; + + if (repositionTimer) { + clearInterval(repositionTimer); + repositionTimer = null; + } + + if (visualizerWin && !visualizerWin.isDestroyed()) { + visualizerWin.close(); + } + visualizerWin = null; + + if (miniPlayerWin && !miniPlayerWin.isDestroyed()) { + miniPlayerWin.close(); + } + + miniPlayerWin = null; +};