mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
Merge branch 'master' into master
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -13,3 +13,5 @@ electron-builder.yml
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
.vite-inspect
|
||||
|
||||
.DS_Store
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Read this in other languages: [🇰🇷](./docs/readme/README-ko.md), [🇫🇷](./docs/readme/README-fr.md), [🇮🇸](./docs/readme/README-is.md), [🇨🇱 🇪🇸](./docs/readme/README-es.md), [🇷🇺](./docs/readme/README-ru.md), [🇭🇺](./docs/readme/README-hu.md), [🇧🇷](./docs/readme/README-pt.md), [🇯🇵](./docs/readme/README-ja.md)
|
||||
Read this in other languages: [🇰🇷](./docs/readme/README-ko.md), [🇫🇷](./docs/readme/README-fr.md), [🇮🇸](./docs/readme/README-is.md), [🇨🇱 🇪🇸](./docs/readme/README-es.md), [🇷🇺](./docs/readme/README-ru.md), [🇺🇦](./docs/readme/README-uk.md), [🇭🇺](./docs/readme/README-hu.md), [🇧🇷](./docs/readme/README-pt.md), [🇯🇵](./docs/readme/README-ja.md)
|
||||
|
||||
**Electron wrapper around YouTube Music featuring:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Lee esto en otros idiomas: [🏴 Inglés](../../README.md), [🇰🇷 Coreano](./README-ko.md), [🇫🇷 Francés](./README-fr.md), [🇮🇸 Islandés](./README-is.md), [🇪🇸 Español](./README-es.md), [🇷🇺 Ruso](./README-ru.md), [🇧🇷 Portugués](./README-pt.md), [🇯🇵 Japonés](./README-ja.md)
|
||||
Lee esto en otros idiomas: [🏴 Inglés](../../README.md), [🇰🇷 Coreano](./README-ko.md), [🇫🇷 Francés](./README-fr.md), [🇮🇸 Islandés](./README-is.md), [🇪🇸 Español](./README-es.md), [🇷🇺 Ruso](./README-ru.md), [🇺🇦 Ucraniano](./README-uk.md), [🇧🇷 Portugués](./README-pt.md), [🇯🇵 Japonés](./README-ja.md)
|
||||
|
||||
**Electron wrapper de YouTube Music con las siguientes características:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Lisez ceci dans d'autres langues: [🏴 Anglais](../../README.md), [🇰🇷 Coréen](./README-ko.md), [🇫🇷 Français](./README-fr.md), [🇮🇸 Islandais](./README-is.md), [🇪🇸 Espagnol](./README-es.md), [🇷🇺 Russe](./README-ru.md), [🇧🇷 Portugais](./README-pt.md), [🇯🇵 Japonais](./README-ja.md)
|
||||
Lisez ceci dans d'autres langues: [🏴 Anglais](../../README.md), [🇰🇷 Coréen](./README-ko.md), [🇫🇷 Français](./README-fr.md), [🇮🇸 Islandais](./README-is.md), [🇪🇸 Espagnol](./README-es.md), [🇷🇺 Russe](./README-ru.md), [🇺🇦 Ukrainien](./README-uk.md), [🇧🇷 Portugais](./README-pt.md), [🇯🇵 Japonais](./README-ja.md)
|
||||
|
||||
**Enveloppe Electron autour de YouTube Music offrant :**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Olvasd el más nyelveken: [🏴 Angol](../../README.md), [🇰🇷 Korea](./README-ko.md), [🇫🇷 Francia](./README-fr.md), [🇮🇸 Izland](./README-is.md), [🇪🇸 Spanyol](./README-es.md), [🇷🇺 Orosz](./README-ru.md), [🇧🇷 Portugál](./README-pt.md), [🇯🇵 Japán](./README-ja.md)
|
||||
Olvasd el más nyelveken: [🏴 Angol](../../README.md), [🇰🇷 Korea](./README-ko.md), [🇫🇷 Francia](./README-fr.md), [🇮🇸 Izland](./README-is.md), [🇪🇸 Spanyol](./README-es.md), [🇷🇺 Orosz](./README-ru.md), [🇺🇦 Ukrán](./README-uk.md), [🇧🇷 Portugál](./README-pt.md), [🇯🇵 Japán](./README-ja.md)
|
||||
|
||||
**Electron keretrendszerre épülő alkalmazás a YouTube Music számára, amely a következőket kínálja:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Lestu þetta á öðrum tungumálum: [🏴 Ensku](../../README.md), [🇰🇷 Kóreska](./README-ko.md), [🇫🇷 Franska](./README-fr.md), [🇮🇸 Íslenskur](./README-is.md), [🇪🇸 Spænska](./README-es.md), [🇷🇺 Rússneska](./README-ru.md), [🇧🇷 Portúgalska](./README-pt.md), [🇯🇵 Japanska](./README-ja.md)
|
||||
Lestu þetta á öðrum tungumálum: [🏴 Ensku](../../README.md), [🇰🇷 Kóreska](./README-ko.md), [🇫🇷 Franska](./README-fr.md), [🇮🇸 Íslenskur](./README-is.md), [🇪🇸 Spænska](./README-es.md), [🇷🇺 Rússneska](./README-ru.md), [🇺🇦 Úkraínska](./README-uk.md), [🇧🇷 Portúgalska](./README-pt.md), [🇯🇵 Japanska](./README-ja.md)
|
||||
|
||||
**Electron umbúðir utan um YouTube Tónlist sem inniheldur:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
他の言語で読む: [🏴 英語](../../README.md), [🇰🇷 韓国語](./README-ko.md), [🇫🇷 フランス語](./README-fr.md), [🇮🇸 アイスランド語](./README-is.md), [🇪🇸 スペイン語](./README-es.md), [🇷🇺 ロシア語](./README-ru.md)
|
||||
他の言語で読む: [🏴 英語](../../README.md), [🇰🇷 韓国語](./README-ko.md), [🇫🇷 フランス語](./README-fr.md), [🇮🇸 アイスランド語](./README-is.md), [🇪🇸 スペイン語](./README-es.md), [🇷🇺 ロシア語](./README-ru.md), [🇺🇦 ウクライナ語](./README-uk.md)
|
||||
|
||||
**YouTube MusicのElectronラッパーには以下の機能があります:**
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
다른 언어로 읽어보세요: [🏴 영어](../../README.md), [🇰🇷 한국인](./README-ko.md), [🇫🇷 프랑스 국민](./README-fr.md), [🇮🇸 아이슬란드어](./README-is.md), [🇪🇸 스페인 사람](./README-es.md), [🇷🇺 러시아인](./README-ru.md), [🇧🇷 포르투갈어](./README-pt.md), [🇯🇵 일본어](./README-ja.md)
|
||||
다른 언어로 읽어보세요: [🏴 영어](../../README.md), [🇰🇷 한국인](./README-ko.md), [🇫🇷 프랑스 국민](./README-fr.md), [🇮🇸 아이슬란드어](./README-is.md), [🇪🇸 스페인 사람](./README-es.md), [🇷🇺 러시아인](./README-ru.md), [🇺🇦 우크라이나어](./README-uk.md), [🇧🇷 포르투갈어](./README-pt.md), [🇯🇵 일본어](./README-ja.md)
|
||||
|
||||
**유튜브 뮤직의 Electron 래퍼; 기능:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Leia em outros idiomas: [🏴 Inglês](../../README.md), [🇰🇷 Coreano](./README-ko.md), [🇫🇷 Francês](./README-fr.md), [🇮🇸 Islandês](./README-is.md), [🇪🇸 Espanhol](./README-es.md), [🇷🇺 Russo](./README-ru.md), [🇧🇷 Português](./README-pt.md)
|
||||
Leia em outros idiomas: [🏴 Inglês](../../README.md), [🇰🇷 Coreano](./README-ko.md), [🇫🇷 Francês](./README-fr.md), [🇮🇸 Islandês](./README-is.md), [🇪🇸 Espanhol](./README-es.md), [🇷🇺 Russo](./README-ru.md), [🇺🇦 Ucraniano](./README-uk.md), [🇧🇷 Português](./README-pt.md)
|
||||
|
||||
**Wrapper do Electron para o YouTube Music com os seguintes recursos:**
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Прочтите это на других языках: [🏴 Английский](../../README.md), [🇰🇷 корейский](./README-ko.md), [🇫🇷 Французский](./README-fr.md), [🇮🇸 исландский](./README-is.md), [🇪🇸 испанский](./README-es.md), [🇷🇺 Русский](./README-ru.md), [🇧🇷 Португальский](./README-pt.md)
|
||||
Прочтите это на других языках: [🏴 Английский](../../README.md), [🇰🇷 корейский](./README-ko.md), [🇫🇷 Французский](./README-fr.md), [🇮🇸 исландский](./README-is.md), [🇪🇸 испанский](./README-es.md), [🇷🇺 Русский](./README-ru.md), [🇺🇦 Украинский](./README-uk.md), [🇧🇷 Португальский](./README-pt.md)
|
||||
|
||||
**Клиент для YouTube Music основанный на Electron с поддержкой:**
|
||||
|
||||
|
||||
372
docs/readme/README-uk.md
Normal file
372
docs/readme/README-uk.md
Normal file
@ -0,0 +1,372 @@
|
||||
<div align="center">
|
||||
|
||||
# YouTube Music
|
||||
|
||||
[](https://github.com/th-ch/youtube-music/releases/)
|
||||
[](https://github.com/th-ch/youtube-music/blob/master/license)
|
||||
[](https://github.com/th-ch/youtube-music/blob/master/eslint.config.mjs)
|
||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
||||
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/th-ch/youtube-music/releases/latest">
|
||||
<img src="../../web/youtube-music.svg" width="400" height="100" alt="YouTube Music SVG">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Прочитайте це іншими мовами: [🏴 Англійська](../../README.md), [🇰🇷 Корейська](./README-ko.md), [🇫🇷 Французька](./README-fr.md), [🇮🇸 Ісландська](./README-is.md), [🇪🇸 Іспанська](./README-es.md), [🇷🇺 Російська](./README-ru.md), [🇺🇦 Українська](./README-uk.md), [🇭🇺 Угорська](./README-hu.md), [🇧🇷 Португальська](./README-pt.md), [🇯🇵 Японська](./README-ja.md)
|
||||
|
||||
**Клієнт YouTube Music на основі Electron, що має:**
|
||||
|
||||
- Нативний вигляд і функціонал, що має на меті зберегти оригінальний інтерфейс
|
||||
- Фреймворк для користувацьких плагінів: змінюйте YouTube Music відповідно до ваших потреб (стиль, вміст, функції), вмикайте/вимикайте плагіни одним клацанням миші
|
||||
|
||||
## Демонстраційне зображення
|
||||
|
||||
| Екран плеєра (колірна тема альбому та режим Ambient) |
|
||||
|:---------------------------------------------------------------------------------------------------------:|
|
||||
||
|
||||
|
||||
## Зміст
|
||||
|
||||
- [Можливості](#Можливості)
|
||||
- [Доступні плагіни](#Доступні-плагіни)
|
||||
- [Переклад](#Переклад)
|
||||
- [Завантажити](#Завантажити)
|
||||
- [Arch Linux](#arch-linux)
|
||||
- [MacOS](#macos)
|
||||
- [Windows](#windows)
|
||||
- [Як встановити без підключення до Інтернету? (у Windows)](#Встановлення-без-підключення-до-Інтернету-у-Windows)
|
||||
- [Теми](#Теми)
|
||||
- [Розробка](#Розробка)
|
||||
- [Створіть власні плагіни](#Створіть-власні-плагіни)
|
||||
- [Створення плагіна](#Створення-плагіна)
|
||||
- [Поширені випадки використання](#Поширені-випадки-використання)
|
||||
- [Збірка](#Збірка)
|
||||
- [Попередній перегляд для producción](#Попередній-перегляд-для-production)
|
||||
- [Тести](#Тести)
|
||||
- [Ліцензія](#Ліцензія)
|
||||
- [Поширені запитання](#Поширені-запитання)
|
||||
|
||||
## Можливості:
|
||||
|
||||
- **Автоматичне підтвердження під час паузи** (Завжди ввімкнено): вимикає спливаюче вікно ["Продовжити перегляд?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png),
|
||||
яке призупиняє музику через певний час
|
||||
|
||||
- Та багато іншого...
|
||||
|
||||
## Доступні плагіни:
|
||||
|
||||
- **Блокувальник реклами**: Блокує всю рекламу та відстеження «з коробки»
|
||||
|
||||
- **Дії з альбомом**: Додає кнопки «Скасувати "Не подобається"», «Не подобається», «Подобається» та «Скасувати "Подобається"», щоб застосувати це до всіх пісень у списку відтворення або альбомі
|
||||
|
||||
- **Колірна тема альбому**: Застосовує динамічну тему та візуальні ефекти на основі колірної палітри альбому
|
||||
|
||||
- **Режим Ambient**: Застосовує ефект освітлення, проектуючи м'які кольори з відео на фон екрана
|
||||
|
||||
- **Аудіокомпресор**: Застосовує компресію до аудіо (знижує гучність найгучніших частин сигналу та підвищує гучність найтихіших частин)
|
||||
|
||||
- **Розмиття панелі навігації**: робить панель навігації прозорою та розмитою
|
||||
|
||||
- **Обхід вікових обмежень**: обходить перевірку віку YouTube
|
||||
|
||||
- **Вибір субтитрів**: Увімкнути субтитри
|
||||
|
||||
- **Компактна бічна панель**: Завжди встановлювати бічну панель у компактному режимі
|
||||
|
||||
- **Плавний перехід**: Плавний перехід між піснями
|
||||
|
||||
- **Вимкнути автопрогравання**: Кожна пісня починається в режимі "пауза"
|
||||
|
||||
- **[Discord](https://discord.com/) Rich Presence**: Покажіть друзям, що ви слухаєте, за допомогою [Rich Presence](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)
|
||||
|
||||
- **Завантажувач**: завантажує MP3 [безпосередньо з інтерфейсу](https://user-images.githubusercontent.com/61631665/129977677-83a7d067-c192-45e1-98ae-b5a4927393be.png) [(youtube-dl)](https://github.com/ytdl-org/youtube-dl)
|
||||
|
||||
- **Експоненціальна гучність**: Робить повзунок гучності [експоненціальним](https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/), щоб було легше вибирати нижчу гучність
|
||||
|
||||
- **Меню в програмі**: [надає панелям модного, темного вигляду](https://user-images.githubusercontent.com/78568641/112215894-923dbf00-8c29-11eb-95c3-3ce15db27eca.png)
|
||||
|
||||
> (дивіться [цей пост](https://github.com/th-ch/youtube-music/issues/410#issuecomment-952060709), якщо у вас виникли проблеми з доступом до меню після ввімкнення цього плагіна та опції приховування меню)
|
||||
|
||||
- **Скробблер**: Додає підтримку скробблінгу для [Last.fm](https://www.last.fm/) та [ListenBrainz](https://listenbrainz.org/)
|
||||
|
||||
- **Lumia Stream**: Додає підтримку [Lumia Stream](https://lumiastream.com/)
|
||||
|
||||
- **Тексти пісень Genius**: Додає підтримку текстів для більшості пісень
|
||||
|
||||
- **Музика разом**: Поділіться списком відтворення з іншими. Коли хост відтворює пісню, всі інші чутимуть ту саму пісню
|
||||
|
||||
- **Навігація**: Стрілки навігації «Вперед»/«Назад» безпосередньо інтегровані в інтерфейс, як у вашому улюбленому браузері
|
||||
|
||||
- **Без входу в Google**: Видаляє кнопки та посилання для входу в Google з інтерфейсу
|
||||
|
||||
- **Сповіщення**: Відображає сповіщення, коли починає грати пісня ([інтерактивні сповіщення](https://user-images.githubusercontent.com/78568641/114102651-63ce0e00-98d0-11eb-9dfe-c5a02bb54f9c.png) доступні у Windows)
|
||||
|
||||
- **Картинка в картинці**: дозволяє перемикати програму в режим «картинка в картинці»
|
||||
|
||||
- **Швидкість відтворення**: Слухайте швидко, слухайте повільно! [Додає повзунок, який контролює швидкість пісні](https://user-images.githubusercontent.com/61631665/129976003-e55db5ba-bf42-448c-a059-26a009775e68.png)
|
||||
|
||||
- **Точна гучність**: Точно керуйте гучністю за допомогою коліщатка миші/гарячих клавіш, з власним HUD та настроюваними кроками гучності
|
||||
|
||||
- **Гарячі клавіші (та MPRIS)**: Дозволяє встановлювати глобальні гарячі клавіші для відтворення (відтворення/пауза/наступна/попередня) + вимкнути [екранне меню медіа](https://user-images.githubusercontent.com/84923831/128601225-afa38c1f-dea8-4209-9f72-0f84c1dd8b54.png) шляхом перевизначення медіаклавіш + увімкнути Ctrl/CMD + F для пошуку + увімкнути підтримку mpris у Linux для медіаклавіш + [власні гарячі клавіші](https://github.com/Araxeus/youtube-music/blob/1e591d6a3df98449bcda6e63baab249b28026148/providers/song-controls.js#L13-L50) для [досвідчених користувачів](https://github.com/th-ch/youtube-music/issues/106#issuecomment-952156902)
|
||||
|
||||
- **Пропускати пісні, що не сподобалися**: Пропускає пісні, які вам не сподобалися
|
||||
|
||||
- **Пропускати тишу**: Автоматично пропускати тихі фрагменти
|
||||
|
||||
- [**SponsorBlock**](https://github.com/ajayyy/SponsorBlock): Автоматично пропускає немузичні частини, такі як інтро/аутро, або частини музичних відео, де пісня не грає
|
||||
|
||||
- **Керування медіа на панелі завдань**: Керуйте відтворенням з [панелі завдань Windows](https://user-images.githubusercontent.com/78568641/111916130-24a35e80-8a82-11eb-80c8-5021c1aa27f4.png)
|
||||
|
||||
- **TouchBar**: Власний макет TouchBar для macOS
|
||||
|
||||
- **Tuna OBS**: Інтеграція з плагіном [Tuna](https://obsproject.com/forum/resources/tuna.843/) для [OBS](https://obsproject.com/)
|
||||
|
||||
- **Зміна якості відео**: Дозволяє змінювати якість відео за допомогою [кнопки](https://user-images.githubusercontent.com/78568641/138574366-70324a5e-2d64-4f6a-acdd-dc2a2b9cecc5.png) на відеопрогравачі
|
||||
|
||||
- **Перемикач відео**: Додає [кнопку](https://user-images.githubusercontent.com/28893833/173663950-63e6610e-a532-49b7-9afa-54cb57ddfc15.png) для перемикання між режимом відео/пісні. Також може опціонально видалити всю вкладку відео
|
||||
|
||||
- **Візуалізатор**: Різні музичні візуалізатори
|
||||
|
||||
- **Синхронізовані тексти**: Надає синхронізовані тексти пісень, використовуючи такі джерела, як [LRClib](https://lrclib.net).
|
||||
|
||||
## Переклад
|
||||
|
||||
Ви можете допомогти з перекладом на [Hosted Weblate](https://hosted.weblate.org/projects/youtube-music/).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/youtube-music/">
|
||||
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/multi-auto.svg" alt="статус перекладу" />
|
||||
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/287x66-black.png" alt="статус перекладу 2" />
|
||||
</a>
|
||||
|
||||
## Завантажити
|
||||
|
||||
Ви можете переглянути [останній реліз](https://github.com/th-ch/youtube-music/releases/latest), щоб швидко знайти найновішу версію.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
Встановіть пакет [`youtube-music-bin`](https://aur.archlinux.org/packages/youtube-music-bin) з AUR. Інструкції щодо встановлення з AUR можна знайти на цій [сторінці вікі](https://wiki.archlinux.org/index.php/Arch_User_Repository#Installing_packages).
|
||||
|
||||
### macOS
|
||||
|
||||
Ви можете встановити програму за допомогою Homebrew (дивіться [визначення cask](https://github.com/th-ch/homebrew-youtube-music)):
|
||||
|
||||
```bash
|
||||
brew install th-ch/youtube-music/youtube-music
|
||||
```
|
||||
|
||||
Якщо ви встановлюєте програму вручну та отримуєте помилку "is damaged and can’t be opened.", запустіть у Терміналі таку команду:
|
||||
|
||||
```bash
|
||||
/usr/bin/xattr -cr /Applications/YouTube\ Music.app
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Ви можете використовувати [менеджер пакунків Scoop](https://scoop.sh) для встановлення пакунка `youtube-music` з [`extras` bucket](https://github.com/ScoopInstaller/Extras).
|
||||
|
||||
```bash
|
||||
scoop bucket add extras
|
||||
scoop install extras/youtube-music
|
||||
```
|
||||
|
||||
Крім того, ви можете використовувати [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), офіційний менеджер пакунків командного рядка Windows 11, для встановлення пакунка `th-ch.YouTubeMusic`.
|
||||
|
||||
*Примітка: Microsoft Defender SmartScreen може блокувати встановлення, оскільки воно від "невідомого видавця". Це також стосується ручного встановлення під час спроби запустити виконуваний файл (.exe) після ручного завантаження тут, на GitHub (той самий файл).*
|
||||
|
||||
```bash
|
||||
winget install th-ch.YouTubeMusic
|
||||
```
|
||||
|
||||
#### Як встановити без підключення до Інтернету? (у Windows)
|
||||
|
||||
- Завантажте файл `*.nsis.7z` для _архітектури вашого пристрою_ зі [сторінки релізів](https://github.com/th-ch/youtube-music/releases/latest).
|
||||
- `x64` для 64-розрядної Windows
|
||||
- `ia32` для 32-розрядної Windows
|
||||
- `arm64` для ARM64 Windows
|
||||
- Завантажте інсталятор зі сторінки релізів. (`*-Setup.exe`)
|
||||
- Розмістіть їх в **одному каталозі**.
|
||||
- Запустіть інсталятор.
|
||||
|
||||
## Теми
|
||||
|
||||
Ви можете завантажити файли CSS, щоб змінити вигляд програми (Опції > Візуальні налаштування > Теми).
|
||||
|
||||
Деякі попередньо визначені теми доступні за адресою https://github.com/kerichdev/themes-for-ytmdesktop-player.
|
||||
|
||||
## Розробка
|
||||
|
||||
```bash
|
||||
git clone https://github.com/th-ch/youtube-music
|
||||
cd youtube-music
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Створіть власні плагіни
|
||||
|
||||
Використовуючи плагіни, ви можете:
|
||||
|
||||
- маніпулювати програмою - `BrowserWindow` з Electron передається обробнику плагінів
|
||||
- змінювати фронтенд, маніпулюючи HTML/CSS
|
||||
|
||||
### Створення плагіна
|
||||
|
||||
Створіть теку в `src/plugins/НАЗВА_ВАШОГО_ПЛАГІНА`:
|
||||
|
||||
- `index.ts`: основний файл плагіна
|
||||
```typescript
|
||||
import style from './style.css?inline'; // імпортувати стиль як вбудований
|
||||
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Назва плагіна',
|
||||
restartNeeded: true, // якщо значення true, ytmusic покаже діалогове вікно перезапуску
|
||||
config: {
|
||||
enabled: false,
|
||||
}, // ваша власна конфігурація
|
||||
stylesheets: [style], // ваш власний стиль,
|
||||
menu: async ({ getConfig, setConfig }) => {
|
||||
// Усі методи *Config є обгорнутими Promise<T>
|
||||
const config = await getConfig();
|
||||
return [
|
||||
{
|
||||
label: 'меню',
|
||||
submenu: [1, 2, 3].map((value) => ({
|
||||
label: `значення ${value}`,
|
||||
type: 'radio',
|
||||
checked: config.value === value,
|
||||
click() {
|
||||
setConfig({ value });
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
},
|
||||
backend: {
|
||||
start({ window, ipc }) {
|
||||
window.maximize();
|
||||
|
||||
// ви можете спілкуватися з плагіном рендерера
|
||||
ipc.handle('some-event', () => {
|
||||
return 'hello';
|
||||
});
|
||||
},
|
||||
// викликається при зміні конфігурації
|
||||
onConfigChange(newConfig) { /* ... */ },
|
||||
// викликається при вимкненні плагіна
|
||||
stop(context) { /* ... */ },
|
||||
},
|
||||
renderer: {
|
||||
async start(context) {
|
||||
console.log(await context.ipc.invoke('some-event'));
|
||||
},
|
||||
// Хук, доступний лише для рендерера
|
||||
onPlayerApiReady(api: YoutubePlayer, context: RendererContext) {
|
||||
// легко встановити конфігурацію плагіна
|
||||
context.setConfig({ myConfig: api.getVolume() });
|
||||
},
|
||||
onConfigChange(newConfig) { /* ... */ },
|
||||
stop(_context) { /* ... */ },
|
||||
},
|
||||
preload: {
|
||||
async start({ getConfig }) {
|
||||
const config = await getConfig();
|
||||
},
|
||||
onConfigChange(newConfig) {},
|
||||
stop(_context) {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Поширені випадки використання
|
||||
|
||||
- ін'єкція власного CSS: створіть файл `style.css` у тій самій теці, а потім:
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
import style from './style.css?inline'; // імпортувати стиль як вбудований
|
||||
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Назва плагіна',
|
||||
restartNeeded: true, // якщо значення true, ytmusic покаже діалогове вікно перезапуску
|
||||
config: {
|
||||
enabled: false,
|
||||
}, // ваша власна конфігурація
|
||||
stylesheets: [style], // ваш власний стиль
|
||||
renderer() {} // визначити хук рендерера
|
||||
});
|
||||
```
|
||||
|
||||
- Якщо ви хочете змінити HTML:
|
||||
|
||||
```typescript
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Назва плагіна',
|
||||
restartNeeded: true, // якщо значення true, ytmusic покаже діалогове вікно перезапуску
|
||||
config: {
|
||||
enabled: false,
|
||||
}, // ваша власна конфігурація
|
||||
renderer() {
|
||||
// Видалити кнопку входу
|
||||
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
||||
} // визначити хук рендерера
|
||||
});
|
||||
```
|
||||
|
||||
- зв'язок між фронтендом та бекендом: можна здійснити за допомогою модуля ipcMain з Electron. Дивіться файл `index.ts` та
|
||||
приклад у плагіні `sponsorblock`.
|
||||
|
||||
## Збірка
|
||||
|
||||
1. Клонуйте репозиторій
|
||||
2. Дотримуйтесь [цієї інструкції](https://pnpm.io/installation), щоб встановити `pnpm`
|
||||
3. Запустіть `pnpm install --frozen-lockfile` для встановлення залежностей
|
||||
4. Запустіть `pnpm build:OS`
|
||||
|
||||
- `pnpm dist:win` - Windows
|
||||
- `pnpm dist:linux` - Linux (amd64)
|
||||
- `pnpm dist:linux:deb-arm64` - Linux (arm64 для Debian)
|
||||
- `pnpm dist:linux:rpm-arm64` - Linux (arm64 для Fedora)
|
||||
- `pnpm dist:mac` - macOS (amd64)
|
||||
- `pnpm dist:mac:arm64` - macOS (arm64)
|
||||
|
||||
Збирає програму для macOS, Linux та Windows,
|
||||
використовуючи [electron-builder](https://github.com/electron-userland/electron-builder).
|
||||
|
||||
## Попередній перегляд для production
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Тести
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Використовує [Playwright](https://playwright.dev/) для тестування програми.
|
||||
|
||||
## Ліцензія
|
||||
|
||||
MIT © [th-ch](https://github.com/th-ch/youtube-music)
|
||||
|
||||
## Поширені запитання
|
||||
|
||||
### Чому меню програми не відображається?
|
||||
|
||||
Якщо опція `Приховати меню` увімкнена - ви можете показати меню клавішею <kbd>Alt</kbd> (або <kbd>\`</kbd> [зворотний апостроф], якщо використовуєте плагін "Меню в програмі")
|
||||
49
package.json
49
package.json
@ -222,12 +222,12 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "6.3.3",
|
||||
"vite": "6.3.5",
|
||||
"node-gyp": "11.2.0",
|
||||
"xml2js": "0.6.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"@electron/universal": "2.0.2",
|
||||
"@babel/runtime": "7.27.0"
|
||||
"@electron/universal": "2.0.3",
|
||||
"@babel/runtime": "7.27.1"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"vudio@2.1.1": "patches/vudio@2.1.1.patch",
|
||||
@ -241,14 +241,14 @@
|
||||
"@electron/remote": "2.1.2",
|
||||
"@ffmpeg.wasm/core-mt": "0.12.0",
|
||||
"@ffmpeg.wasm/main": "0.12.0",
|
||||
"@floating-ui/dom": "1.6.13",
|
||||
"@floating-ui/dom": "1.7.0",
|
||||
"@foobar404/wave": "2.0.5",
|
||||
"@ghostery/adblocker-electron": "2.5.1",
|
||||
"@ghostery/adblocker-electron-preload": "2.5.1",
|
||||
"@ghostery/adblocker-electron": "2.5.2",
|
||||
"@ghostery/adblocker-electron-preload": "2.5.2",
|
||||
"@hono/node-server": "1.14.1",
|
||||
"@hono/swagger-ui": "0.5.1",
|
||||
"@hono/zod-openapi": "0.19.5",
|
||||
"@hono/zod-validator": "0.4.3",
|
||||
"@hono/zod-openapi": "0.19.6",
|
||||
"@hono/zod-validator": "0.5.0",
|
||||
"@jellybrick/dbus-next": "0.10.3",
|
||||
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||
"@jellybrick/mpris-service": "2.1.5",
|
||||
@ -275,11 +275,11 @@
|
||||
"fast-equals": "5.2.2",
|
||||
"filenamify": "6.0.0",
|
||||
"hanja": "1.1.4",
|
||||
"happy-dom": "17.4.4",
|
||||
"hono": "4.7.7",
|
||||
"happy-dom": "17.4.7",
|
||||
"hono": "4.7.9",
|
||||
"howler": "2.2.4",
|
||||
"html-to-text": "9.0.5",
|
||||
"i18next": "25.0.1",
|
||||
"i18next": "25.1.2",
|
||||
"jimp": "1.6.0",
|
||||
"keyboardevent-from-electron-accelerator": "2.0.0",
|
||||
"keyboardevents-areequal": "0.2.2",
|
||||
@ -295,18 +295,19 @@
|
||||
"semver": "7.7.1",
|
||||
"serve": "14.2.4",
|
||||
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
|
||||
"socks": "2.8.4",
|
||||
"solid-floating-ui": "0.3.1",
|
||||
"solid-js": "1.9.5",
|
||||
"solid-js": "1.9.6",
|
||||
"solid-styled-components": "0.28.5",
|
||||
"solid-transition-group": "0.3.0",
|
||||
"ts-morph": "25.0.1",
|
||||
"vudio": "2.1.1",
|
||||
"x11": "2.3.0",
|
||||
"youtubei.js": "13.4.0",
|
||||
"zod": "3.24.3"
|
||||
"zod": "3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.25.1",
|
||||
"@eslint/js": "9.26.0",
|
||||
"@malept/flatpak-bundler": "0.4.0",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@stylistic/eslint-plugin-js": "4.2.0",
|
||||
@ -320,31 +321,31 @@
|
||||
"builtin-modules": "5.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"del-cli": "6.0.0",
|
||||
"discord-api-types": "0.38.1",
|
||||
"electron": "34.5.3",
|
||||
"discord-api-types": "0.38.4",
|
||||
"electron": "36.2.0",
|
||||
"electron-builder": "26.0.12",
|
||||
"electron-builder-squirrel-windows": "26.0.12",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-vite": "3.1.0",
|
||||
"esbuild": "0.25.3",
|
||||
"eslint": "9.25.1",
|
||||
"eslint-config-prettier": "10.1.2",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint": "9.26.0",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-import-resolver-exports": "1.0.0-beta.5",
|
||||
"eslint-import-resolver-typescript": "4.3.4",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-prettier": "5.2.6",
|
||||
"eslint-plugin-prettier": "5.4.0",
|
||||
"glob": "11.0.2",
|
||||
"node-gyp": "11.2.0",
|
||||
"playwright": "1.52.0",
|
||||
"rollup": "4.40.0",
|
||||
"rollup": "4.40.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.31.0",
|
||||
"typescript-eslint": "8.32.0",
|
||||
"utf-8-validate": "6.0.5",
|
||||
"vite": "6.3.3",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-inspect": "11.0.1",
|
||||
"vite-plugin-resolve": "2.5.2",
|
||||
"vite-plugin-solid": "2.11.6",
|
||||
"ws": "8.18.1"
|
||||
"ws": "8.18.2"
|
||||
},
|
||||
"auto-changelog": {
|
||||
"hideCredit": true,
|
||||
|
||||
1446
pnpm-lock.yaml
generated
1446
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -333,6 +333,30 @@
|
||||
"description": "Συμπίεση ήχου (μειώνει την ένταση των πιο δυνατών τμημάτων του κύματος και αυξάνει την ένταση των πιο μαλακών τμημάτων)",
|
||||
"name": "Συμπιεστής ήχου"
|
||||
},
|
||||
"auth-proxy-adapter": {
|
||||
"description": "Υποστήριξη για τη χρήση υπηρεσιών μεσολάβησης αυθεντικοποίησης",
|
||||
"menu": {
|
||||
"disable": "Απενεργοποίηση προσαρμογέα μεσολάβησης",
|
||||
"enable": "Ενεργοποίηση προσαρμογέα μεσολάβησης",
|
||||
"hostname": {
|
||||
"label": "Όνομα οικοδεσπότη"
|
||||
},
|
||||
"port": {
|
||||
"label": "Θύρα"
|
||||
}
|
||||
},
|
||||
"name": "Προσαρμογέας μεσολάβησης Auth",
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"label": "Εισάγετε το όνομα κεντρικού υπολογιστή για τον τοπικό διακομιστή μεσολάβησης (απαιτείται επανεκκίνηση):",
|
||||
"title": "Όνομα κεντρικού υπολογιστή μεσολάβησης"
|
||||
},
|
||||
"port": {
|
||||
"label": "Εισάγετε τη θύρα για τον τοπικό διακομιστή μεσολάβησης (απαιτεί επανεκκίνηση):",
|
||||
"title": "Θύρα διακομιστή μεσολάβησης"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blur-nav-bar": {
|
||||
"description": "θέτει τη γραμμή πλοήγησης διαφανή και θολή",
|
||||
"name": "Θόλωμα γραμμής πλοήγησης"
|
||||
|
||||
@ -333,6 +333,30 @@
|
||||
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
|
||||
"name": "Audio Compressor"
|
||||
},
|
||||
"auth-proxy-adapter": {
|
||||
"name": "Auth Proxy Adapter",
|
||||
"description": "Support for the use of authentication proxy services",
|
||||
"menu": {
|
||||
"disable": "Disable Proxy Adapter",
|
||||
"enable": "Enable Proxy Adapter",
|
||||
"hostname": {
|
||||
"label": "Hostname"
|
||||
},
|
||||
"port": {
|
||||
"label": "Port"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"title": "Proxy Hostname",
|
||||
"label": "Enter hostname for local proxy server (requires restart):"
|
||||
},
|
||||
"port": {
|
||||
"title": "Proxy Port",
|
||||
"label": "Enter port for local proxy server (requires restart):"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blur-nav-bar": {
|
||||
"description": "Makes navigation bar transparent and blurry",
|
||||
"name": "Blur Navigation Bar"
|
||||
|
||||
@ -333,6 +333,30 @@
|
||||
"description": "Aplicar compressão ao áudio (reduz o volume das partes mais altas e aumenta o volume das partes mais baixas)",
|
||||
"name": "Compressor de áudio"
|
||||
},
|
||||
"auth-proxy-adapter": {
|
||||
"description": "Suporte para o uso de serviços de proxy de autenticação",
|
||||
"menu": {
|
||||
"disable": "Desativar adaptador proxy",
|
||||
"enable": "Ativar adaptador proxy",
|
||||
"hostname": {
|
||||
"label": "Nome do host"
|
||||
},
|
||||
"port": {
|
||||
"label": "Porta"
|
||||
}
|
||||
},
|
||||
"name": "Adaptador de proxy de autenticação",
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"label": "Entre o nome do host do servidor proxy local (necessário reiniciar):",
|
||||
"title": "Nome do host do proxy"
|
||||
},
|
||||
"port": {
|
||||
"label": "Entre a porta do servidor proxy local (necessário reiniciar):",
|
||||
"title": "Porta do proxy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blur-nav-bar": {
|
||||
"description": "Torna a barra de navegação transparente e desfocada",
|
||||
"name": "Desfocar barra de navegação"
|
||||
|
||||
@ -333,6 +333,30 @@
|
||||
"description": "Применяет компрессию к аудио (уменьшает громкость самых громких частей сигнала и повышает громкость самых тихих частей)",
|
||||
"name": "Нормализация аудио"
|
||||
},
|
||||
"auth-proxy-adapter": {
|
||||
"description": "Поддержка использования сервисов аутентификационного прокси",
|
||||
"menu": {
|
||||
"disable": "Отключить адаптер прокси",
|
||||
"enable": "Включить адаптер прокси",
|
||||
"hostname": {
|
||||
"label": "Имя хоста"
|
||||
},
|
||||
"port": {
|
||||
"label": "Порт"
|
||||
}
|
||||
},
|
||||
"name": "Адаптер аутентификационного прокси",
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"label": "Введите имя хоста для локального прокси-сервера (требуется перезапуск):",
|
||||
"title": "Имя хоста прокси"
|
||||
},
|
||||
"port": {
|
||||
"label": "Введите порт для локального прокси-сервера (требуется перезапуск):",
|
||||
"title": "Порт прокси"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blur-nav-bar": {
|
||||
"description": "Делает панель навигации прозрачной и размытой",
|
||||
"name": "Размытие панели навигации"
|
||||
|
||||
@ -333,6 +333,30 @@
|
||||
"description": "对音频应用压缩(压低响亮部分,提升柔和部分)",
|
||||
"name": "音频压缩器"
|
||||
},
|
||||
"auth-proxy-adapter": {
|
||||
"name": "认证代理适配",
|
||||
"description": "支持使用需要身份验证的代理",
|
||||
"menu": {
|
||||
"disable": "禁用代理适配",
|
||||
"enable": "启用代理适配",
|
||||
"hostname": {
|
||||
"label": "主机名"
|
||||
},
|
||||
"port": {
|
||||
"label": "端口"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"title": "代理主机名",
|
||||
"label": "请输入本地代理服务器的主机名(需要重启):"
|
||||
},
|
||||
"port": {
|
||||
"title": "代理端口",
|
||||
"label": "请输入本地代理服务器的端口号(需要重启):"
|
||||
}
|
||||
}
|
||||
},
|
||||
"blur-nav-bar": {
|
||||
"description": "让导航栏透明及模糊",
|
||||
"name": "模糊导航栏"
|
||||
|
||||
21
src/index.ts
21
src/index.ts
@ -57,6 +57,8 @@ import { loadI18n, setLanguage, t } from '@/i18n';
|
||||
|
||||
import ErrorHtmlAsset from '@assets/error.html?asset';
|
||||
|
||||
import { defaultAuthProxyConfig } from '@/plugins/auth-proxy-adapter/config';
|
||||
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
if (!is.macOS()) {
|
||||
@ -141,7 +143,24 @@ if (is.linux()) {
|
||||
}
|
||||
|
||||
if (config.get('options.proxy')) {
|
||||
app.commandLine.appendSwitch('proxy-server', config.get('options.proxy'));
|
||||
const authProxyEnabled = config.plugins.isEnabled('auth-proxy-adapter');
|
||||
|
||||
let proxyToUse = '';
|
||||
if (authProxyEnabled) {
|
||||
// Use proxy from Auth-Proxy-Adapter plugin
|
||||
const authProxyConfig = deepmerge(
|
||||
defaultAuthProxyConfig,
|
||||
config.get('plugins.auth-proxy-adapter') ?? {},
|
||||
) as typeof defaultAuthProxyConfig;
|
||||
|
||||
const { hostname, port } = authProxyConfig;
|
||||
proxyToUse = `socks5://${hostname}:${port}`;
|
||||
} else if (config.get('options.proxy')) {
|
||||
// Use global proxy settings
|
||||
proxyToUse = config.get('options.proxy');
|
||||
}
|
||||
console.log(LoggerPrefix, `Using proxy: ${proxyToUse}`);
|
||||
app.commandLine.appendSwitch('proxy-server', proxyToUse);
|
||||
}
|
||||
|
||||
// Adds debug features like hotkeys for triggering dev tools and reload
|
||||
|
||||
@ -61,8 +61,8 @@ export default createPlugin<
|
||||
];
|
||||
//Finds the playlist
|
||||
const playlist =
|
||||
document.querySelector('ytmusic-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer')!;
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-shelf-renderer')!;
|
||||
// Adds an observer for every button, so it gets updated when one is clicked
|
||||
this.changeObserver?.disconnect();
|
||||
this.changeObserver = new MutationObserver(() => {
|
||||
@ -157,9 +157,9 @@ export default createPlugin<
|
||||
if (loader.children.length != 0) return;
|
||||
this.loadObserver?.disconnect();
|
||||
let playlistButtons: NodeListOf<HTMLElement> | undefined;
|
||||
const playlist = document.querySelector('ytmusic-shelf-renderer')
|
||||
? document.querySelector('ytmusic-shelf-renderer')
|
||||
: document.querySelector('ytmusic-playlist-shelf-renderer');
|
||||
const playlist =
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-shelf-renderer');
|
||||
switch (id) {
|
||||
case 'allundislike':
|
||||
playlistButtons = playlist?.querySelectorAll(
|
||||
|
||||
246
src/plugins/auth-proxy-adapter/backend/index.ts
Normal file
246
src/plugins/auth-proxy-adapter/backend/index.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import net from 'net';
|
||||
|
||||
import { SocksClient, SocksClientOptions } from 'socks';
|
||||
|
||||
import is from 'electron-is';
|
||||
|
||||
import { createBackend, LoggerPrefix } from '@/utils';
|
||||
|
||||
import { BackendType } from './types';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import { AuthProxyConfig, defaultAuthProxyConfig } from '../config';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
// Parsing the upstream authentication SOCK proxy URL
|
||||
const parseSocksUrl = (socksUrl: string) => {
|
||||
// Format: socks5://username:password@your_server_ip:port
|
||||
|
||||
const url = new URL(socksUrl);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10),
|
||||
type: url.protocol === 'socks5:' ? 5 : 4,
|
||||
username: url.username,
|
||||
password: url.password,
|
||||
};
|
||||
};
|
||||
|
||||
export const backend = createBackend<BackendType, AuthProxyConfig>({
|
||||
async start(ctx: BackendContext<AuthProxyConfig>) {
|
||||
const pluginConfig = await ctx.getConfig();
|
||||
this.startServer(pluginConfig);
|
||||
},
|
||||
stop() {
|
||||
this.stopServer();
|
||||
},
|
||||
onConfigChange(config: AuthProxyConfig) {
|
||||
if (
|
||||
this.oldConfig?.hostname === config.hostname &&
|
||||
this.oldConfig?.port === config.port
|
||||
) {
|
||||
this.oldConfig = config;
|
||||
return;
|
||||
}
|
||||
this.stopServer();
|
||||
this.startServer(config);
|
||||
|
||||
this.oldConfig = config;
|
||||
},
|
||||
|
||||
// Custom
|
||||
// Start proxy server - SOCKS5
|
||||
startServer(serverConfig: AuthProxyConfig) {
|
||||
if (this.server) {
|
||||
this.stopServer();
|
||||
}
|
||||
|
||||
const { port, hostname } = serverConfig;
|
||||
// Upstream proxy from system settings
|
||||
const upstreamProxyUrl = config.get('options.proxy');
|
||||
// Create SOCKS proxy server
|
||||
const socksServer = net.createServer((socket) => {
|
||||
socket.once('data', (chunk) => {
|
||||
if (chunk[0] === 0x05) {
|
||||
// SOCKS5
|
||||
this.handleSocks5(socket, chunk, upstreamProxyUrl);
|
||||
} else {
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error(LoggerPrefix, '[SOCKS] Socket error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
socksServer.on('error', (err) => {
|
||||
console.error(LoggerPrefix, '[SOCKS Server Error]', err.message);
|
||||
});
|
||||
|
||||
// Start server
|
||||
socksServer.listen(port, hostname, () => {
|
||||
console.log(LoggerPrefix, '===========================================');
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
`[Auth-Proxy-Adapter] Enable SOCKS proxy at socks5://${hostname}:${port}`,
|
||||
);
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
`[Auth-Proxy-Adapter] Using upstream proxy: ${upstreamProxyUrl}`,
|
||||
);
|
||||
console.log(LoggerPrefix, '===========================================');
|
||||
});
|
||||
|
||||
this.server = socksServer;
|
||||
},
|
||||
|
||||
// Handle SOCKS5 request
|
||||
handleSocks5(
|
||||
clientSocket: net.Socket,
|
||||
chunk: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) {
|
||||
// Handshake phase
|
||||
const numMethods = chunk[1];
|
||||
const methods = chunk.subarray(2, 2 + numMethods);
|
||||
|
||||
// Check if client supports no authentication method (0x00)
|
||||
if (methods.includes(0x00)) {
|
||||
// Reply to client, choose no authentication method
|
||||
clientSocket.write(Buffer.from([0x05, 0x00]));
|
||||
|
||||
// Wait for client's connection request
|
||||
clientSocket.once('data', (data) => {
|
||||
this.processSocks5Request(clientSocket, data, upstreamProxyUrl);
|
||||
});
|
||||
} else {
|
||||
// Authentication methods not supported by the client
|
||||
clientSocket.write(Buffer.from([0x05, 0xff]));
|
||||
clientSocket.end();
|
||||
}
|
||||
},
|
||||
|
||||
// Handle SOCKS5 connection request
|
||||
processSocks5Request(
|
||||
clientSocket: net.Socket,
|
||||
data: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) {
|
||||
// Parse target address and port
|
||||
let targetHost, targetPort;
|
||||
const cmd = data[1]; // Command: 0x01=CONNECT, 0x02=BIND, 0x03=UDP
|
||||
const atyp = data[3]; // Address type: 0x01=IPv4, 0x03=Domain, 0x04=IPv6
|
||||
|
||||
if (cmd !== 0x01) {
|
||||
// Currently only support CONNECT command
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (atyp === 0x01) {
|
||||
// IPv4
|
||||
targetHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
|
||||
targetPort = data.readUInt16BE(8);
|
||||
} else if (atyp === 0x03) {
|
||||
// Domain
|
||||
const hostLen = data[4];
|
||||
targetHost = data.subarray(5, 5 + hostLen).toString();
|
||||
targetPort = data.readUInt16BE(5 + hostLen);
|
||||
} else if (atyp === 0x04) {
|
||||
// IPv6
|
||||
const ipv6Buffer = data.subarray(4, 20);
|
||||
targetHost = Array.from(new Array(8), (_, i) =>
|
||||
ipv6Buffer.readUInt16BE(i * 2).toString(16),
|
||||
).join(':');
|
||||
targetPort = data.readUInt16BE(20);
|
||||
}
|
||||
if (is.dev()) {
|
||||
console.debug(
|
||||
LoggerPrefix,
|
||||
`[SOCKS5] Request to connect to ${targetHost}:${targetPort}`,
|
||||
);
|
||||
}
|
||||
|
||||
const socksProxy = parseSocksUrl(upstreamProxyUrl);
|
||||
|
||||
if (!socksProxy) {
|
||||
// Failed to parse proxy URL
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const options: SocksClientOptions = {
|
||||
proxy: {
|
||||
host: socksProxy.host,
|
||||
port: socksProxy.port,
|
||||
type: socksProxy.type as 4 | 5,
|
||||
userId: socksProxy.username,
|
||||
password: socksProxy.password,
|
||||
},
|
||||
command: 'connect',
|
||||
destination: {
|
||||
host: targetHost || defaultAuthProxyConfig.hostname,
|
||||
port: targetPort || defaultAuthProxyConfig.port,
|
||||
},
|
||||
};
|
||||
SocksClient.createConnection(options)
|
||||
.then((info) => {
|
||||
const { socket: proxySocket } = info;
|
||||
|
||||
// Connection successful, send success response to client
|
||||
const responseBuffer = Buffer.from([
|
||||
0x05, // VER: SOCKS5
|
||||
0x00, // REP: Success
|
||||
0x00, // RSV: Reserved field
|
||||
0x01, // ATYP: IPv4
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // BND.ADDR: 0.0.0.0 (Bound address, usually not important)
|
||||
0,
|
||||
0, // BND.PORT: 0 (Bound port, usually not important)
|
||||
]);
|
||||
clientSocket.write(responseBuffer);
|
||||
|
||||
// Establish bidirectional data stream
|
||||
proxySocket.pipe(clientSocket);
|
||||
clientSocket.pipe(proxySocket);
|
||||
|
||||
proxySocket.on('error', (error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Proxy socket error:', error);
|
||||
if (clientSocket.writable) clientSocket.end();
|
||||
});
|
||||
|
||||
clientSocket.on('error', (error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Client socket error:', error);
|
||||
if (proxySocket.writable) proxySocket.end();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Connection error:', error);
|
||||
// Send failure response to client
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
});
|
||||
},
|
||||
|
||||
// Stop proxy server
|
||||
stopServer() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
21
src/plugins/auth-proxy-adapter/backend/types.ts
Normal file
21
src/plugins/auth-proxy-adapter/backend/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import net from 'net';
|
||||
|
||||
import type { AuthProxyConfig } from '../config';
|
||||
import type { Server } from 'http';
|
||||
|
||||
export type BackendType = {
|
||||
server?: Server | net.Server;
|
||||
oldConfig?: AuthProxyConfig;
|
||||
startServer: (serverConfig: AuthProxyConfig) => void;
|
||||
stopServer: () => void;
|
||||
handleSocks5: (
|
||||
clientSocket: net.Socket,
|
||||
chunk: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) => void;
|
||||
processSocks5Request: (
|
||||
clientSocket: net.Socket,
|
||||
data: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) => void;
|
||||
};
|
||||
11
src/plugins/auth-proxy-adapter/config.ts
Normal file
11
src/plugins/auth-proxy-adapter/config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface AuthProxyConfig {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export const defaultAuthProxyConfig: AuthProxyConfig = {
|
||||
enabled: false,
|
||||
hostname: '127.0.0.1',
|
||||
port: 4545,
|
||||
};
|
||||
16
src/plugins/auth-proxy-adapter/index.ts
Normal file
16
src/plugins/auth-proxy-adapter/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createPlugin } from '@/utils';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { defaultAuthProxyConfig } from './config';
|
||||
import { onMenu } from './menu';
|
||||
import { backend } from './backend';
|
||||
|
||||
export default createPlugin({
|
||||
name: () => t('plugins.auth-proxy-adapter.name'),
|
||||
description: () => t('plugins.auth-proxy-adapter.description'),
|
||||
restartNeeded: true,
|
||||
config: defaultAuthProxyConfig,
|
||||
addedVersion: '3.10.X',
|
||||
menu: onMenu,
|
||||
backend,
|
||||
});
|
||||
68
src/plugins/auth-proxy-adapter/menu.ts
Normal file
68
src/plugins/auth-proxy-adapter/menu.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import { type AuthProxyConfig, defaultAuthProxyConfig } from './config';
|
||||
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export const onMenu = async ({
|
||||
getConfig,
|
||||
setConfig,
|
||||
window,
|
||||
}: MenuContext<AuthProxyConfig>): Promise<MenuTemplate> => {
|
||||
await getConfig();
|
||||
return [
|
||||
{
|
||||
label: t('plugins.auth-proxy-adapter.menu.hostname.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newHostname =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.auth-proxy-adapter.prompt.hostname.title'),
|
||||
label: t('plugins.auth-proxy-adapter.prompt.hostname.label'),
|
||||
value: config.hostname,
|
||||
type: 'input',
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.hostname ??
|
||||
defaultAuthProxyConfig.hostname;
|
||||
|
||||
setConfig({ ...config, hostname: newHostname });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.auth-proxy-adapter.menu.port.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newPort =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.auth-proxy-adapter.prompt.port.title'),
|
||||
label: t('plugins.auth-proxy-adapter.prompt.port.label'),
|
||||
value: config.port,
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, maximum: 65535 },
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.port ??
|
||||
defaultAuthProxyConfig.port;
|
||||
|
||||
setConfig({ ...config, port: newPort });
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -80,3 +80,8 @@ html {
|
||||
ytmusic-browse-response .ytmusic-responsive-list-item-renderer {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* fix fullscreen style */
|
||||
ytmusic-player[player-ui-state='FULLSCREEN'] {
|
||||
margin-top: calc(var(--menu-bar-height, 32px) * -1) !important;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DataConnection, Peer, PeerErrorType } from 'peerjs';
|
||||
import { DataConnection, Peer, PeerError, PeerErrorType } from 'peerjs';
|
||||
import delay from 'delay';
|
||||
|
||||
import type { Permission, Profile, VideoData } from './types';
|
||||
@ -14,6 +14,7 @@ export type ConnectionEventMap = {
|
||||
| { progress?: number; state?: number; index?: number }
|
||||
| undefined;
|
||||
PERMISSION: Permission | undefined;
|
||||
CONNECTION_CLOSED: null;
|
||||
};
|
||||
export type ConnectionEventUnion = {
|
||||
[Event in keyof ConnectionEventMap]: {
|
||||
@ -31,7 +32,7 @@ type PromiseUtil<T> = {
|
||||
|
||||
export type ConnectionListener = (
|
||||
event: ConnectionEventUnion,
|
||||
conn: DataConnection,
|
||||
conn: DataConnection | null,
|
||||
) => void;
|
||||
export type ConnectionMode = 'host' | 'guest' | 'disconnected';
|
||||
export class Connection {
|
||||
@ -44,7 +45,31 @@ export class Connection {
|
||||
private connectionListeners: ((connection?: DataConnection) => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this.peer = new Peer({ debug: 0 });
|
||||
this.peer = new Peer({
|
||||
debug: 0,
|
||||
config: {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{
|
||||
urls: [
|
||||
'turn:eu-0.turn.peerjs.com:3478',
|
||||
'turn:us-0.turn.peerjs.com:3478',
|
||||
],
|
||||
username: 'peerjs',
|
||||
credential: 'peerjsp',
|
||||
},
|
||||
{
|
||||
urls: 'stun:freestun.net:3478',
|
||||
},
|
||||
{
|
||||
urls: 'turn:freestun.net:3478',
|
||||
username: 'free',
|
||||
credential: 'free',
|
||||
},
|
||||
],
|
||||
sdpSemantics: 'unified-plan',
|
||||
},
|
||||
});
|
||||
|
||||
this.waitOpen.promise = new Promise<string>((resolve, reject) => {
|
||||
this.waitOpen.resolve = resolve;
|
||||
@ -59,6 +84,19 @@ export class Connection {
|
||||
this._mode = 'host';
|
||||
await this.registerConnection(conn);
|
||||
});
|
||||
this.peer.on('close', () => {
|
||||
for (const listener of this.listeners) {
|
||||
listener({ type: 'CONNECTION_CLOSED', payload: null }, null);
|
||||
}
|
||||
this.listeners = [];
|
||||
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
this.connectionListeners = [];
|
||||
this.connections = {};
|
||||
|
||||
this.peer.disconnect();
|
||||
this.peer.destroy();
|
||||
});
|
||||
this.peer.on('error', async (err) => {
|
||||
if (err.type === PeerErrorType.Network) {
|
||||
// retrying after 10 seconds
|
||||
@ -70,11 +108,12 @@ export class Connection {
|
||||
//ignored
|
||||
}
|
||||
}
|
||||
this._mode = 'disconnected';
|
||||
|
||||
this.waitOpen.reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
console.error(err);
|
||||
this.disconnect();
|
||||
|
||||
console.trace(err);
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,7 +135,18 @@ export class Connection {
|
||||
if (this._mode === 'disconnected') throw new Error('Already disconnected');
|
||||
|
||||
this._mode = 'disconnected';
|
||||
this.getConnections().forEach((conn) =>
|
||||
conn.close({
|
||||
flush: true,
|
||||
}),
|
||||
);
|
||||
this.connections = {};
|
||||
this.connectionListeners = [];
|
||||
for (const listener of this.listeners) {
|
||||
listener({ type: 'CONNECTION_CLOSED', payload: null }, null);
|
||||
}
|
||||
this.listeners = [];
|
||||
this.peer.disconnect();
|
||||
this.peer.destroy();
|
||||
}
|
||||
|
||||
@ -123,7 +173,9 @@ export class Connection {
|
||||
}
|
||||
|
||||
public on(listener: ConnectionListener) {
|
||||
this.listeners.push(listener);
|
||||
if (!this.listeners.includes(listener)) {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public onConnections(listener: (connections?: DataConnection) => void) {
|
||||
@ -134,10 +186,10 @@ export class Connection {
|
||||
private async registerConnection(conn: DataConnection) {
|
||||
return new Promise<DataConnection>((resolve, reject) => {
|
||||
this.peer.once('error', (err) => {
|
||||
this._mode = 'disconnected';
|
||||
|
||||
reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
|
||||
this.disconnect();
|
||||
});
|
||||
|
||||
conn.on('open', () => {
|
||||
@ -163,11 +215,28 @@ export class Connection {
|
||||
});
|
||||
});
|
||||
|
||||
const onClose = (err?: Error) => {
|
||||
if (err) reject(err);
|
||||
const onClose = (
|
||||
err?: PeerError<
|
||||
| 'not-open-yet'
|
||||
| 'message-too-big'
|
||||
| 'negotiation-failed'
|
||||
| 'connection-closed'
|
||||
>,
|
||||
) => {
|
||||
if (conn.open) {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
delete this.connections[conn.connectionId];
|
||||
this.connectionListeners.forEach((listener) => listener(conn));
|
||||
|
||||
if (err) {
|
||||
if (err.type === 'connection-closed') {
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
}
|
||||
reject(err);
|
||||
} else {
|
||||
this.connectionListeners.forEach((listener) => listener(conn));
|
||||
}
|
||||
};
|
||||
conn.on('error', onClose);
|
||||
conn.on('close', onClose);
|
||||
|
||||
@ -21,6 +21,8 @@ import { createSettingPopup } from './ui/setting';
|
||||
import settingHTML from './templates/setting.html?raw';
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { waitForElement } from '@/utils/wait-for-element';
|
||||
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||
@ -123,10 +125,12 @@ export default createPlugin<
|
||||
if (this.connection?.mode === 'host') {
|
||||
const videoList: VideoData[] =
|
||||
this.queue?.flatItems.map(
|
||||
(it) =>
|
||||
(it, index) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id,
|
||||
ownerId:
|
||||
this.queue?.videoList[index]?.ownerId ??
|
||||
this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
|
||||
@ -163,6 +167,17 @@ export default createPlugin<
|
||||
if (!wait) return false;
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
|
||||
this.profiles = {};
|
||||
this.putProfile(this.connection.id, {
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
const rawItems =
|
||||
this.queue?.flatItems?.map(
|
||||
(it) =>
|
||||
@ -171,16 +186,11 @@ export default createPlugin<
|
||||
ownerId: this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
this.queue?.setVideoList(rawItems, false);
|
||||
this.queue?.syncQueueOwner();
|
||||
this.queue?.initQueue();
|
||||
this.queue?.injection();
|
||||
|
||||
this.profiles = {};
|
||||
this.connection.onConnections((connection) => {
|
||||
if (!connection) {
|
||||
this.api?.toastService?.show(
|
||||
@ -199,30 +209,43 @@ export default createPlugin<
|
||||
this.putProfile(connection.peer, undefined);
|
||||
}
|
||||
});
|
||||
this.putProfile(this.connection.id, {
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
const listener = async (
|
||||
event: ConnectionEventUnion,
|
||||
conn?: DataConnection,
|
||||
conn?: DataConnection | null,
|
||||
) => {
|
||||
this.ignoreChange = true;
|
||||
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
if (conn && this.permission === 'host-only') return;
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.queue?.addVideos(
|
||||
event.payload.videoList,
|
||||
event.payload.index,
|
||||
const videoList: VideoData[] = event.payload.videoList.map(
|
||||
(it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? conn?.peer ?? this.connection!.id,
|
||||
}),
|
||||
);
|
||||
await this.connection?.broadcast('ADD_SONGS', event.payload);
|
||||
|
||||
await this.queue?.addVideos(videoList, event.payload.index);
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
videoList,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
if (conn && this.permission === 'host-only') return;
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue?.removeVideo(event.payload.index);
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
@ -309,6 +332,10 @@ export default createPlugin<
|
||||
|
||||
break;
|
||||
}
|
||||
case 'CONNECTION_CLOSED': {
|
||||
this.queue?.off(listener);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Host]: Unknown Event', event);
|
||||
break;
|
||||
@ -357,14 +384,53 @@ export default createPlugin<
|
||||
});
|
||||
|
||||
let resolveIgnore: number | null = null;
|
||||
const queueListener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
videoList: event.payload.videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.connection!.id,
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
await this.connection?.broadcast('MOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
if (this.permission === 'host-only')
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
else
|
||||
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
|
||||
resolveIgnore = window.setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
};
|
||||
const listener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.queue?.addVideos(
|
||||
event.payload.videoList,
|
||||
event.payload.index,
|
||||
const videoList: VideoData[] = event.payload.videoList.map(
|
||||
(it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.connection!.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.queue?.addVideos(videoList, event.payload.index);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
@ -446,6 +512,10 @@ export default createPlugin<
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'CONNECTION_CLOSED': {
|
||||
this.queue?.off(queueListener);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Guest]: Unknown Event', event);
|
||||
break;
|
||||
@ -459,37 +529,7 @@ export default createPlugin<
|
||||
};
|
||||
|
||||
this.connection.on(listener);
|
||||
this.queue?.on(async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.connection?.broadcast('ADD_SONGS', event.payload);
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
await this.connection?.broadcast('MOVE_SONG', event.payload);
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
if (this.permission === 'host-only')
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
else
|
||||
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
|
||||
resolveIgnore = window.setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
});
|
||||
this.queue?.on(queueListener);
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
this.queue?.injection();
|
||||
@ -595,19 +635,22 @@ export default createPlugin<
|
||||
this.elements.spinner.setAttribute('hidden', '');
|
||||
},
|
||||
|
||||
initMyProfile() {
|
||||
const accountButton = document.querySelector<
|
||||
HTMLElement & {
|
||||
onButtonTap: () => void;
|
||||
}
|
||||
>('ytmusic-settings-button');
|
||||
async initMyProfile() {
|
||||
const accountButton = await waitForElement<HTMLElement>(
|
||||
'#right-content > ytmusic-settings-button *:where(tp-yt-paper-icon-button,yt-icon-button,.ytmusic-settings-button)',
|
||||
{
|
||||
maxRetry: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
accountButton?.onButtonTap();
|
||||
setTimeout(() => {
|
||||
accountButton?.onButtonTap();
|
||||
const renderer = document.querySelector<
|
||||
HTMLElement & { data: unknown }
|
||||
>('ytd-active-account-header-renderer');
|
||||
accountButton?.click();
|
||||
setTimeout(async () => {
|
||||
const renderer = await waitForElement<HTMLElement & { data: unknown }>(
|
||||
'ytd-active-account-header-renderer',
|
||||
{
|
||||
maxRetry: 10000,
|
||||
},
|
||||
);
|
||||
if (!accountButton || !renderer) {
|
||||
console.warn('Music Together: Cannot find account');
|
||||
this.me = getDefaultProfile(this.connection?.id ?? '');
|
||||
@ -628,13 +671,14 @@ export default createPlugin<
|
||||
this.popups.guest.setProfile(this.me.thumbnail);
|
||||
this.popups.setting.setProfile(this.me.thumbnail);
|
||||
}
|
||||
accountButton?.click(); // close menu
|
||||
}, 0);
|
||||
},
|
||||
/* hooks */
|
||||
|
||||
start({ ipc }) {
|
||||
this.ipc = ipc;
|
||||
this.showPrompt = async (title: string, label: string) =>
|
||||
this.showPrompt = (title: string, label: string) =>
|
||||
ipc.invoke('music-together:prompt', title, label) as Promise<string>;
|
||||
this.api = document.querySelector<AppElement>('ytmusic-app');
|
||||
|
||||
|
||||
@ -3,8 +3,9 @@ import { mapQueueItem } from './utils';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { getDefaultProfile, type Profile, type VideoData } from '../types';
|
||||
|
||||
import type { ConnectionEventUnion } from '@/plugins/music-together/connection';
|
||||
import type { Profile, VideoData } from '../types';
|
||||
import type { QueueItem } from '@/types/datahost-get-state';
|
||||
import type { QueueElement, Store } from '@/types/queue';
|
||||
|
||||
@ -177,21 +178,46 @@ export class Queue {
|
||||
if (!items) return false;
|
||||
|
||||
this.internalDispatch = true;
|
||||
this._videoList.push(...videos);
|
||||
this._videoList = this._videoList.map(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it.videoId,
|
||||
ownerId: it.ownerId ?? this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
);
|
||||
|
||||
const state = this.queue.queue.store.store.getState();
|
||||
|
||||
this.queue?.dispatch({
|
||||
type: 'ADD_ITEMS',
|
||||
payload: {
|
||||
nextQueueItemId:
|
||||
this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
nextQueueItemId: state.queue.nextQueueItemId,
|
||||
index:
|
||||
index ??
|
||||
this.queue.queue.store.store.getState().queue.items.length ??
|
||||
(state.queue.items.length ? state.queue.items.length - 1 : null) ??
|
||||
0,
|
||||
items,
|
||||
shuffleEnabled: false,
|
||||
shouldAssignIds: true,
|
||||
},
|
||||
});
|
||||
|
||||
const insertedItem = this._videoList[index ?? this._videoList.length];
|
||||
if (
|
||||
!insertedItem ||
|
||||
(insertedItem.videoId !== videos[0].videoId &&
|
||||
insertedItem.ownerId !== videos[0].ownerId)
|
||||
) {
|
||||
this._videoList.splice(
|
||||
index ?? this._videoList.length,
|
||||
0,
|
||||
...videos.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
@ -252,7 +278,9 @@ export class Queue {
|
||||
}
|
||||
|
||||
on(listener: QueueEventListener) {
|
||||
this.listeners.push(listener);
|
||||
if (!this.listeners.includes(listener)) {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
off(listener: QueueEventListener) {
|
||||
@ -302,9 +330,15 @@ export class Queue {
|
||||
}
|
||||
).items,
|
||||
);
|
||||
const index = this._videoList.length + videoList.length - 1;
|
||||
const index = this._videoList.length;
|
||||
|
||||
if (videoList.length > 0) {
|
||||
this._videoList = [
|
||||
...videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
];
|
||||
this.broadcast({
|
||||
// play
|
||||
type: 'ADD_SONGS',
|
||||
@ -328,23 +362,45 @@ export class Queue {
|
||||
}
|
||||
).items.length === 1
|
||||
) {
|
||||
const videoList = mapQueueItem(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
(
|
||||
event.payload! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
);
|
||||
this._videoList.splice(
|
||||
event.payload && Object.hasOwn(event.payload, 'index')
|
||||
? (
|
||||
event.payload as {
|
||||
index: number;
|
||||
}
|
||||
).index
|
||||
: this._videoList.length,
|
||||
0,
|
||||
...videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
);
|
||||
this.broadcast({
|
||||
// add playlist
|
||||
type: 'ADD_SONGS',
|
||||
payload: {
|
||||
// index: (event.payload as any).index,
|
||||
videoList: mapQueueItem(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
(
|
||||
event.payload! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
),
|
||||
index:
|
||||
event.payload && Object.hasOwn(event.payload, 'index')
|
||||
? (
|
||||
event.payload as {
|
||||
index: number;
|
||||
}
|
||||
).index
|
||||
: undefined,
|
||||
videoList,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -478,14 +534,16 @@ export class Queue {
|
||||
|
||||
allQueue.forEach((queue) => {
|
||||
const list = Array.from(
|
||||
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
|
||||
queue?.querySelectorAll<HTMLElement>(
|
||||
'#contents > ytmusic-player-queue-item,#contents > ytmusic-playlist-panel-video-wrapper-renderer > #primary-renderer > ytmusic-player-queue-item',
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
list.forEach((item, index: number | undefined) => {
|
||||
if (typeof index !== 'number') return;
|
||||
|
||||
const id = this._videoList[index]?.ownerId;
|
||||
const data = this.getProfile(id);
|
||||
let data = this.getProfile(id);
|
||||
|
||||
const profile =
|
||||
item.querySelector<HTMLImageElement>('.music-together-owner') ??
|
||||
@ -501,6 +559,10 @@ export class Queue {
|
||||
name.textContent =
|
||||
data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||
|
||||
if (!data?.name && !data?.handleId) {
|
||||
data = getDefaultProfile(data?.id ?? '');
|
||||
}
|
||||
|
||||
if (data) {
|
||||
profile.dataset.thumbnail = data.thumbnail ?? '';
|
||||
profile.dataset.name = data.name ?? '';
|
||||
|
||||
@ -44,6 +44,19 @@ async function listenForApiLoad() {
|
||||
}
|
||||
|
||||
async function onApiLoaded() {
|
||||
// Workaround for macOS traffic lights
|
||||
{
|
||||
let osType = 'Unknown';
|
||||
if (window.electronIs.osx()) {
|
||||
osType = 'Macintosh';
|
||||
} else if (window.electronIs.windows()) {
|
||||
osType = 'Windows';
|
||||
} else if (window.electronIs.linux()) {
|
||||
osType = 'Linux';
|
||||
}
|
||||
document.documentElement.setAttribute('data-os', osType);
|
||||
}
|
||||
|
||||
// Workaround for #2459
|
||||
document
|
||||
.querySelector('button.video-button.ytmusic-av-toggle')
|
||||
|
||||
@ -1,13 +1,30 @@
|
||||
export const waitForElement = <T extends Element>(
|
||||
selector: string,
|
||||
options: {
|
||||
maxRetry?: number;
|
||||
retryInterval?: number;
|
||||
} = {
|
||||
maxRetry: -1,
|
||||
retryInterval: 100,
|
||||
},
|
||||
): Promise<T> => {
|
||||
return new Promise<T>((resolve) => {
|
||||
let retryCount = 0;
|
||||
const maxRetry = options.maxRetry ?? -1;
|
||||
const retryInterval = options.retryInterval ?? 100;
|
||||
const interval = setInterval(() => {
|
||||
if (maxRetry > 0 && retryCount >= maxRetry) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
const elem = document.querySelector<T>(selector);
|
||||
if (!elem) return;
|
||||
if (!elem) {
|
||||
retryCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(interval);
|
||||
resolve(elem);
|
||||
}, 100 /* ms */);
|
||||
}, retryInterval /* ms */);
|
||||
});
|
||||
};
|
||||
|
||||
@ -79,10 +79,6 @@ tp-yt-paper-item.ytmusic-guide-entry-renderer::before {
|
||||
max-width: calc(100% - var(--ytmusic-player-page-vertical-padding) * 2);
|
||||
}
|
||||
|
||||
ytmusic-player[player-ui-state='FULLSCREEN'] {
|
||||
margin-top: calc(var(--menu-bar-height, 32px) * -1) !important;
|
||||
}
|
||||
|
||||
/* macos traffic lights fix */
|
||||
:where([data-os*='Macintosh']) ytmusic-app-layout#layout ytmusic-nav-bar {
|
||||
padding-top: var(--ytmusic-nav-bar-offset, 0);
|
||||
|
||||
Reference in New Issue
Block a user