diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json new file mode 100644 index 0000000..dd5a35a --- /dev/null +++ b/extension/_locales/en/messages.json @@ -0,0 +1,158 @@ +{ + "extensionName": { + "message": "YouTube Customizer" + }, + "extensionDescription": { + "message": "Changes layout of YouTube and adds QoL features." + }, + "mainSection": { + "message": "Main" + }, + "homeOption": { + "message": "Home" + }, + "shortsOption": { + "message": "Shorts" + }, + "subscriptionsOption": { + "message": "Subscriptions" + }, + "libraryOption": { + "message": "Library (You)" + }, + "channelOption": { + "message": "Your channel" + }, + "historyOption": { + "message": "History" + }, + "videosOption": { + "message": "Your videos" + }, + "watchLaterOption": { + "message": "Watch Later" + }, + "downloadsOption": { + "message": "Downloads" + }, + "likedOption": { + "message": "Liked videos" + }, + "playlistsOption": { + "message": "Playlists" + }, + "channelsOption": { + "message": "Browse channels" + }, + "exploreSection": { + "message": "Explore" + }, + "trendingOption": { + "message": "Trending" + }, + "musicOption": { + "message": "Music" + }, + "filmsOption": { + "message": "Films" + }, + "liveOption": { + "message": "Live" + }, + "gamingOption": { + "message": "Gaming" + }, + "newsOption": { + "message": "News" + }, + "sportsOption": { + "message": "Sports" + }, + "learningOption": { + "message": "Learning" + }, + "fashionOption": { + "message": "Fashion & Beauty" + }, + "ytMoreSection": { + "message": "More from YouTube" + }, + "ytPremiumOption": { + "message": "YouTube Premium" + }, + "ytStudioOption": { + "message": "YouTube Studio" + }, + "ytMusicOption": { + "message": "YouTube Music" + }, + "ytKidsOption": { + "message": "YouTube Kids" + }, + "miscSection": { + "message": "Miscellaneous" + }, + "settingsOption": { + "message": "Settings" + }, + "reportOption": { + "message": "Report history" + }, + "helpOption": { + "message": "Help" + }, + "feedbackOption": { + "message": "Send feedback" + }, + "topbarSection": { + "message": "Topbar" + }, + "topbarLogoOption": { + "message": "Logo" + }, + "topbarSearchOption": { + "message": "Search box" + }, + "topbarVoiceSearchOption": { + "message": "Voice search" + }, + "topbarCreateButtonOption": { + "message": "Create button" + }, + "topbarNotificationsButtonOption": { + "message": "Notifications button" + }, + "topbarAccountButtonOption": { + "message": "Account button" + }, + "topbarSignInButtonOption": { + "message": "Sign-in button" + }, + "otherSection": { + "message": "Other" + }, + "disableSignInPromoOption": { + "message": "Disable sign-in promo" + }, + "disableSubscriptionsOption": { + "message": "Disable sidebar subscriptions" + }, + "disableFooterOption": { + "message": "Disable sidebar footer" + }, + "startGuideClosedOption": { + "message": "Start sidebar closed" + }, + "redirectHomeOption": { + "message": "Redirect Home to Subscriptions" + }, + "ff2mpvEnabledOption": { + "message": "Launch ff2mpv on video click" + }, + "enableAllButton": { + "message": "Enable all" + }, + "disableAllButton": { + "message": "Disable all" + } +} diff --git a/extension/_locales/es/messages.json b/extension/_locales/es/messages.json new file mode 100644 index 0000000..68d4ec6 --- /dev/null +++ b/extension/_locales/es/messages.json @@ -0,0 +1,158 @@ +{ + "extensionName": { + "message": "Personalizador de YouTube - YouTube Customizer" + }, + "extensionDescription": { + "message": "Modifica el Layout de YouTube e implementa características de calidad de vida." + }, + "mainSection": { + "message": "Principal" + }, + "homeOption": { + "message": "Inicio" + }, + "shortsOption": { + "message": "Shorts" + }, + "subscriptionsOption": { + "message": "Suscripciones" + }, + "libraryOption": { + "message": "Mi biblioteca (Tú)" + }, + "channelOption": { + "message": "Tu canal" + }, + "historyOption": { + "message": "Historial" + }, + "videosOption": { + "message": "Mis vídeos" + }, + "watchLaterOption": { + "message": "Ver más tarde" + }, + "downloadsOption": { + "message": "Descargas" + }, + "likedOption": { + "message": "Vídeos que me gustan" + }, + "playlistsOption": { + "message": "Listas de reproducción" + }, + "channelsOption": { + "message": "Explorar canales" + }, + "exploreSection": { + "message": "Explorar" + }, + "trendingOption": { + "message": "Tendencias" + }, + "musicOption": { + "message": "Música" + }, + "filmsOption": { + "message": "Películas" + }, + "liveOption": { + "message": "En directo" + }, + "gamingOption": { + "message": "Videojuegos" + }, + "newsOption": { + "message": "Noticias" + }, + "sportsOption": { + "message": "Deportes" + }, + "learningOption": { + "message": "Aprendizaje" + }, + "fashionOption": { + "message": "Moda y belleza" + }, + "ytMoreSection": { + "message": "Más de YouTube" + }, + "ytPremiumOption": { + "message": "YouTube Premium" + }, + "ytStudioOption": { + "message": "YouTube Studio" + }, + "ytMusicOption": { + "message": "YouTube Music" + }, + "ytKidsOption": { + "message": "YouTube Kids" + }, + "miscSection": { + "message": "Misceláneo" + }, + "settingsOption": { + "message": "Configuración" + }, + "reportOption": { + "message": "Historial de denuncias" + }, + "helpOption": { + "message": "Ayuda" + }, + "feedbackOption": { + "message": "Enviar sugerencias" + }, + "topbarSection": { + "message": "Barra Superior" + }, + "topbarLogoOption": { + "message": "Logotipo" + }, + "topbarSearchOption": { + "message": "Buscador" + }, + "topbarVoiceSearchOption": { + "message": "Búsqueda por voz" + }, + "topbarCreateButtonOption": { + "message": "Crear Botón" + }, + "topbarNotificationsButtonOption": { + "message": "Botón de Notificaciones" + }, + "topbarAccountButtonOption": { + "message": "Botón de Perfíl" + }, + "topbarSignInButtonOption": { + "message": "Botón de Inicio de Sesión" + }, + "otherSection": { + "message": "Otro" + }, + "disableSignInPromoOption": { + "message": "Deshabilitar promo de inicio de sesión" + }, + "disableSubscriptionsOption": { + "message": "Deshabilitar panel lateral de suscripciones" + }, + "disableFooterOption": { + "message": "Deshabilitar pie de página del panel lateral" + }, + "startGuideClosedOption": { + "message": "Panel lateral de Comienzo Cerrado" + }, + "redirectHomeOption": { + "message": "Redireccionar menú a suscripciones" + }, + "ff2mpvEnabledOption": { + "message": "Reproducir vídeos usando ff2mpv" + }, + "enableAllButton": { + "message": "Habilitar todo" + }, + "disableAllButton": { + "message": "Deshabilitar todo" + } +} diff --git a/extension/_locales/fr/messages.json b/extension/_locales/fr/messages.json new file mode 100644 index 0000000..ad70275 --- /dev/null +++ b/extension/_locales/fr/messages.json @@ -0,0 +1,158 @@ +{ + "extensionName": { + "message": "Personnalisateur YouTube - YouTube Customizer" + }, + "extensionDescription": { + "message": "Change l'apparence de YouTube et ajoute des fonctionnalités de qualité de vie." + }, + "mainSection": { + "message": "Principal" + }, + "homeOption": { + "message": "Accueil" + }, + "shortsOption": { + "message": "Shorts" + }, + "subscriptionsOption": { + "message": "Abonnements" + }, + "libraryOption": { + "message": "Bibliothèque (Vous)" + }, + "channelOption": { + "message": "Votre chaîne" + }, + "historyOption": { + "message": "Historique" + }, + "videosOption": { + "message": "Vos vidéos" + }, + "watchLaterOption": { + "message": "À regarder plus tard" + }, + "downloadsOption": { + "message": "Téléchargements" + }, + "likedOption": { + "message": "Vidéos \"J'aime\"" + }, + "playlistsOption": { + "message": "Playlists" + }, + "channelsOption": { + "message": "Chaînes" + }, + "exploreSection": { + "message": "Explorer" + }, + "trendingOption": { + "message": "Tendancies" + }, + "musicOption": { + "message": "Musique" + }, + "filmsOption": { + "message": "Films" + }, + "liveOption": { + "message": "Direct" + }, + "gamingOption": { + "message": "Jeux vidéo" + }, + "newsOption": { + "message": "Actualités" + }, + "sportsOption": { + "message": "Sports" + }, + "learningOption": { + "message": "Savoirs & Cultures" + }, + "fashionOption": { + "message": "Mode et beauté" + }, + "ytMoreSection": { + "message": "Autres contenus YouTube" + }, + "ytPremiumOption": { + "message": "YouTube Premium" + }, + "ytStudioOption": { + "message": "YouTube Studio" + }, + "ytMusicOption": { + "message": "YouTube Music" + }, + "ytKidsOption": { + "message": "YouTube Kids" + }, + "miscSection": { + "message": "Divers" + }, + "settingsOption": { + "message": "Paramètres" + }, + "reportOption": { + "message": "Historique des signalements" + }, + "helpOption": { + "message": "Aide" + }, + "feedbackOption": { + "message": "Envoyer des commentaires" + }, + "otherSection": { + "message": "Autres" + }, + "topbarSection": { + "message": "En-tête de navigation" + }, + "topbarLogoOption": { + "message": "Logo" + }, + "topbarSearchOption": { + "message": "Boîte de recherche" + }, + "topbarVoiceSearchOption": { + "message": "Recherche vocale" + }, + "topbarCreateButtonOption": { + "message": "Bouton Créer" + }, + "topbarNotificationsButtonOption": { + "message": "Bouton Notifications" + }, + "topbarAccountButtonOption": { + "message": "Bouton Compte" + }, + "topbarSignInButtonOption": { + "message": "Bouton Connexion" + }, + "disableSignInPromoOption": { + "message": "Désactiver l'invite de connexion" + }, + "disableSubscriptionsOption": { + "message": "Désactiver les abonnements dans la barre de navigation" + }, + "disableFooterOption": { + "message": "Désactiver le pied de page de la barre de navigation" + }, + "startGuideClosedOption": { + "message": "Démarrer avec la bare de navigation fermée" + }, + "redirectHomeOption": { + "message": "Rediriger Accueil vers Abonnements" + }, + "ff2mpvEnabledOption": { + "message": "Ouvrir les vidéos avec ff2mpv" + }, + "enableAllButton": { + "message": "Tout activer" + }, + "disableAllButton": { + "message": "Tout désactiver" + } +} diff --git a/extension/_locales/hu/messages.json b/extension/_locales/hu/messages.json new file mode 100644 index 0000000..9df884a --- /dev/null +++ b/extension/_locales/hu/messages.json @@ -0,0 +1,158 @@ +{ + "extensionName": { + "message": "YouTube Customizer - YouTube személyreszabó" + }, + "extensionDescription": { + "message": "Eltávolítja a fölösleges gombokat a YouTube oldalsávjáról" + }, + "mainSection": { + "message": "Fő" + }, + "homeOption": { + "message": "Kezdőlap" + }, + "shortsOption": { + "message": "Shorts" + }, + "subscriptionsOption": { + "message": "Feliratkozások" + }, + "libraryOption": { + "message": "Könyvtár (Te)" + }, + "channelOption": { + "message": "Saját csatorna" + }, + "historyOption": { + "message": "Előzmények" + }, + "videosOption": { + "message": "Videóid" + }, + "watchLaterOption": { + "message": "Megnézendő videók" + }, + "downloadsOption": { + "message": "Letöltések" + }, + "likedOption": { + "message": "Kedvelt videók" + }, + "playlistsOption": { + "message": "Lejátszási listák" + }, + "channelsOption": { + "message": "Böngészés" + }, + "exploreSection": { + "message": "Felfedezés" + }, + "trendingOption": { + "message": "Felkapott" + }, + "musicOption": { + "message": "Zene" + }, + "filmsOption": { + "message": "Filmek" + }, + "liveOption": { + "message": "Élő" + }, + "gamingOption": { + "message": "Játék" + }, + "newsOption": { + "message": "Hírek" + }, + "sportsOption": { + "message": "Sports" + }, + "learningOption": { + "message": "Tanulás" + }, + "fashionOption": { + "message": "Divat és szépségápolás" + }, + "ytMoreSection": { + "message": "Továbbiak a YouTube-ról" + }, + "ytPremiumOption": { + "message": "YouTube Premium" + }, + "ytStudioOption": { + "message": "YouTube Studio" + }, + "ytMusicOption": { + "message": "YouTube Music" + }, + "ytKidsOption": { + "message": "YouTube Kids" + }, + "miscSection": { + "message": "Vegyes" + }, + "settingsOption": { + "message": "Beállítások" + }, + "reportOption": { + "message": "Bejelentési előzmények" + }, + "helpOption": { + "message": "Súgó" + }, + "feedbackOption": { + "message": "Visszajelzés küldése" + }, + "topbarSection": { + "message": "Fejléc" + }, + "topbarLogoOption": { + "message": "Logó" + }, + "topbarSearchOption": { + "message": "Keresőmező" + }, + "topbarVoiceSearchOption": { + "message": "Keresés hanggal" + }, + "topbarCreateButtonOption": { + "message": "Létrehozás gomb" + }, + "topbarNotificationsButtonOption": { + "message": "Értesítések gomb" + }, + "topbarAccountButtonOption": { + "message": "Fiók gomb" + }, + "topbarSignInButtonOption": { + "message": "Bejelentkezés gomb" + }, + "otherSection": { + "message": "Egyéb" + }, + "disableSignInPromoOption": { + "message": "Bejelentkeztető promó eltávolítása" + }, + "disableSubscriptionsOption": { + "message": "Feliratkozások eltávolítása az oldalsávról" + }, + "disableFooterOption": { + "message": "Lábjegyzet eltávolítása az oldalsávról" + }, + "startGuideClosedOption": { + "message": "Indítás zárt oldalsávval" + }, + "redirectHomeOption": { + "message": "A kezdőlap átirányítása a feliratkozásokhoz" + }, + "ff2mpvEnabledOption": { + "message": "Videók megnyitása ff2mpv-vel" + }, + "enableAllButton": { + "message": "Minden engedélyezése" + }, + "disableAllButton": { + "message": "Minden tiltása" + } +} diff --git a/extension/_locales/ru/messages.json b/extension/_locales/ru/messages.json new file mode 100644 index 0000000..3f0caf7 --- /dev/null +++ b/extension/_locales/ru/messages.json @@ -0,0 +1,158 @@ +{ + "extensionName": { + "message": "YouTube Customizer - Редактор YouTube" + }, + "extensionDescription": { + "message": "Изменяет дизайн YouTube и добавляет полезные функции." + }, + "mainSection": { + "message": "Главное" + }, + "homeOption": { + "message": "Главная" + }, + "shortsOption": { + "message": "Shorts" + }, + "subscriptionsOption": { + "message": "Подписки" + }, + "libraryOption": { + "message": "Библиотека (Вы)" + }, + "channelOption": { + "message": "Мой канал" + }, + "historyOption": { + "message": "История" + }, + "videosOption": { + "message": "Ваш канал" + }, + "watchLaterOption": { + "message": "Смотреть позже" + }, + "downloadsOption": { + "message": "Скачанные" + }, + "likedOption": { + "message": "Понравившиеся" + }, + "playlistsOption": { + "message": "Плейлисты" + }, + "channelsOption": { + "message": "Каталог каналов" + }, + "exploreSection": { + "message": "Навигатор" + }, + "trendingOption": { + "message": "В тренде" + }, + "musicOption": { + "message": "Музыка" + }, + "filmsOption": { + "message": "Фильмы" + }, + "liveOption": { + "message": "Трансляции" + }, + "gamingOption": { + "message": "Видеоигры" + }, + "newsOption": { + "message": "Новости" + }, + "sportsOption": { + "message": "Спорт" + }, + "learningOption": { + "message": "Обучение" + }, + "fashionOption": { + "message": "Мода и красота" + }, + "ytMoreSection": { + "message": "Другие возможности" + }, + "ytPremiumOption": { + "message": "YouTube Premium" + }, + "ytStudioOption": { + "message": "Творческая студия YouTube" + }, + "ytMusicOption": { + "message": "YouTube Music" + }, + "ytKidsOption": { + "message": "YouTube Детям" + }, + "miscSection": { + "message": "Дополнительное" + }, + "settingsOption": { + "message": "Настройки" + }, + "reportOption": { + "message": "Жалобы" + }, + "helpOption": { + "message": "Справка" + }, + "feedbackOption": { + "message": "Отправить отзыв" + }, + "topbarSection": { + "message": "Верхняя панель" + }, + "topbarLogoOption": { + "message": "Логотип" + }, + "topbarSearchOption": { + "message": "Строка поиска" + }, + "topbarVoiceSearchOption": { + "message": "Голосовой поиск" + }, + "topbarCreateButtonOption": { + "message": "Кнопка \"Cоздать\"" + }, + "topbarNotificationsButtonOption": { + "message": "Кнопка уведомлений" + }, + "topbarAccountButtonOption": { + "message": "Кнопка аккаунта" + }, + "topbarSignInButtonOption": { + "message": "Кнопка входа" + }, + "otherSection": { + "message": "Другое" + }, + "disableSignInPromoOption": { + "message": "Убрать промо-панель входа" + }, + "disableSubscriptionsOption": { + "message": "Убрать боковую панель подписок" + }, + "disableFooterOption": { + "message": "Убрать футер боковой панели" + }, + "startGuideClosedOption": { + "message": "Закрыть боковую панель" + }, + "redirectHomeOption": { + "message": "Открывать подписки вместо главной страницы" + }, + "ff2mpvEnabledOption": { + "message": "Открытывать видео в ff2mpv" + }, + "enableAllButton": { + "message": "Включить всё" + }, + "disableAllButton": { + "message": "Выключить всё" + } +} diff --git a/extension/icon.svg b/extension/icon.svg new file mode 100644 index 0000000..44d56e5 --- /dev/null +++ b/extension/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..0e3c025 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,53 @@ +{ + "manifest_version": 2, + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "default_locale": "en", + "version": "1.0.0", + "developer": { + "name": "Ryze", + "url": "https://github.com/ryze312" + }, + + "icons": { + "16": "icon.svg", + "32": "icon.svg", + "64": "icon.svg", + "128": "icon.svg", + "256": "icon.svg", + "512": "icon.svg", + "1024": "icon.svg" + }, + + "options_ui": { + "page": "settings/settings.html", + "open_in_tab": true + }, + + "browser_specific_settings": { + "gecko": { + "id": "{841bfda8-83e6-11ee-82a9-4bf1ae962c91}" + } + }, + + "background": { + "scripts": ["scripts/background/config.js"], + "type": "module", + "persistent": false + }, + + "content_scripts": [ + { + "matches": ["*://*.youtube.com/*"], + "js": ["scripts/content/ytCustomizer.js"], + "run_at": "document_end", + "all_frames": false + } + ], + + "permissions": [ + "storage", + "nativeMessaging" + ] + } + diff --git a/extension/settings/settings.html b/extension/settings/settings.html new file mode 100644 index 0000000..865ec78 --- /dev/null +++ b/extension/settings/settings.html @@ -0,0 +1,68 @@ + + + + + YouTube Customizer + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/extension/settings/style.css b/extension/settings/style.css new file mode 100644 index 0000000..6a0bf1a --- /dev/null +++ b/extension/settings/style.css @@ -0,0 +1,97 @@ +:root { + --background-color: #160F1D; + --config-section-background-color: #6241BD; + --config-button-disabled: #27194E; + --config-button-enabled: #3F1D9E; + --shadow: 4px 4px 10px #2C0633; + --glow: 10px 10px 10px #2C0633; +} + +html, body { + margin: 0; +} + +body { + background-color: var(--background-color); + color: #EEEEEE; + + font-size: 1.2vw; + font-family: "Open Sans", "Roboto", "Arial", "sans-serif"; +} + +#options { + display: grid; + grid-template-columns: repeat(auto-fit, 20em); + gap: 2vh; + justify-content: center; + + width: 80vw; + margin: auto; + padding: 30px; + + border-radius: 2vh; +} + +config-section { + display: flex; + flex-direction: column; + text-align: center; + + padding: 20px; + border-radius: 3vh; + + background: var(--config-section-background-color); + box-shadow: var(--glow); +} + +config-option { + margin: 2px; + padding: 10px; + border-radius: 1vh; + + background-color: var(--config-button-disabled); + cursor: pointer; + transition: ease-out 0.2s; +} + +config-option:hover { + transform: translate(-4px, -4px); + box-shadow: var(--shadow); +} + +.config-option-enabled { + background-color: var(--config-button-enabled); +} + +.config-heading { + margin: 0; + margin-bottom: 10px; +} + +.config-buttons { + display: flex; + margin-top: auto; + padding-top: 10px; +} + +.config-buttons button { + margin-left: auto; + margin-right: auto; + padding: 10px; + + border: none; + border-radius: 1vh; + + background-color: var(--config-button-enabled); + color: inherit; + font-size: inherit; + + cursor: inherit; + transition: ease-out 0.1s; +} + +.config-buttons button:hover { + transform: translate(-4px, -4px); + box-shadow: var(--shadow); +} + diff --git a/src/scripts/background/config.ts b/src/scripts/background/config.ts new file mode 100644 index 0000000..b6eab60 --- /dev/null +++ b/src/scripts/background/config.ts @@ -0,0 +1,111 @@ +declare global { + interface Window { + ytCustomizerConfig: Config + } +} + +export type Config = { + [key: string]: boolean, + WHAT_TO_WATCH: boolean, + TAB_SHORTS: boolean, + SUBSCRIPTIONS: boolean, + VIDEO_LIBRARY_WHITE: boolean, + ACCOUNT_BOX: boolean, + WATCH_HISTORY: boolean, + MY_VIDEOS: boolean, + WATCH_LATER: boolean, + OFFLINE_DOWNLOAD: boolean, + LIKES_PLAYLIST: boolean, + PLAYLISTS: boolean, + ADD_CIRCLE: boolean, + TRENDING: boolean, + MUSIC: boolean, + CLAPPERBOARD: boolean, + LIVE: boolean, + GAMING_LOGO: boolean, + NEWS: boolean, + TROPHY: boolean, + COURSE: boolean, + FASHION_LOGO: boolean, + YOUTUBE_RED_LOGO: boolean, + CREATOR_STUDIO_RED_LOGO: boolean, + YOUTUBE_MUSIC: boolean, + YOUTUBE_KIDS_ROUND: boolean, + SETTINGS: boolean, + FLAG: boolean, + HELP: boolean, + FEEDBACK: boolean, + topbarLogo: boolean, + topbarSearch: boolean, + topbarVoiceSearchButton: boolean, + topbarCreateButton: boolean, + topbarNotificationsButton: boolean, + topbarAccountButton: boolean, + topbarSignInButton: boolean, + disableSigninPromo: boolean, + disableSubscriptions: boolean, + disableFooter: boolean, + startGuideClosed: boolean, + redirectHome: boolean + ff2mpvEnabled: boolean +} + +const defaultConfig: Config = { + WHAT_TO_WATCH: true, + TAB_SHORTS: true, + SUBSCRIPTIONS: true, + VIDEO_LIBRARY_WHITE: true, + ACCOUNT_BOX: true, + WATCH_HISTORY: true, + MY_VIDEOS: true, + WATCH_LATER: true, + OFFLINE_DOWNLOAD: true, + LIKES_PLAYLIST: true, + PLAYLISTS: true, + ADD_CIRCLE: true, + TRENDING: true, + MUSIC: true, + CLAPPERBOARD: true, + LIVE: true, + GAMING_LOGO: true, + NEWS: true, + TROPHY: true, + COURSE: true, + FASHION_LOGO: true, + YOUTUBE_RED_LOGO: true, + CREATOR_STUDIO_RED_LOGO: true, + YOUTUBE_MUSIC: true, + YOUTUBE_KIDS_ROUND: true, + SETTINGS: true, + FLAG: true, + HELP: true, + FEEDBACK: true, + topbarLogo: true, + topbarSearch: true, + topbarVoiceSearchButton: true, + topbarCreateButton: true, + topbarNotificationsButton: true, + topbarAccountButton: true, + topbarSignInButton: true, + disableSigninPromo: false, + disableSubscriptions: false, + disableFooter: false, + startGuideClosed: false, + redirectHome: false, + ff2mpvEnabled: false +} + +export let config: Config; + +browser.runtime.onInstalled.addListener((event) => { + if (event.reason === "install") { + browser.storage.sync.set(defaultConfig); + } +}); + +browser.runtime.onMessage.addListener((videoId) => { + browser.runtime.sendNativeMessage("ff2mpv", {url: `https://youtu.be/${videoId}`}); +}); + +browser.storage.sync.get() + .then((installedConfig) => config = installedConfig as Config); diff --git a/src/scripts/content/ytCustomizer.ts b/src/scripts/content/ytCustomizer.ts new file mode 100644 index 0000000..b49b741 --- /dev/null +++ b/src/scripts/content/ytCustomizer.ts @@ -0,0 +1,26 @@ +function ff2mpv(videoId: string) { + browser.runtime.sendMessage(videoId); +} + +// Firefox doesn't support ExecutionWorld.MAIN yet +// So just inject manually +// See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld +async function injectScript() { + const config = await browser.storage.sync.get(); + + // SAFETY: Cannot import Config from config.ts, because content scripts aren't modules + // SAFETY: WrappedJObject and exportFunction aren't defined in @types module, so use ts-ignore + // @ts-ignore + window.wrappedJSObject.ytCustomizerConfig = cloneInto(config, window); + // @ts-ignore + exportFunction(ff2mpv, window, {defineAs: "ff2mpv"}) + + const script = document.createElement("script"); + script.type = "module"; + script.src = browser.runtime.getURL("scripts/page/ytCustomizer.js"); + + document.head.appendChild(script); +} + +injectScript(); + diff --git a/src/scripts/page/detour.ts b/src/scripts/page/detour.ts new file mode 100644 index 0000000..4d65da5 --- /dev/null +++ b/src/scripts/page/detour.ts @@ -0,0 +1,6 @@ +export function detourFunc(func: Function, origFunc: Function): Function { + return function (this: any, ...args: any[]) { + args.push(origFunc); + return func.apply(this, args); + } +} diff --git a/src/scripts/page/ff2mpv.ts b/src/scripts/page/ff2mpv.ts new file mode 100644 index 0000000..2da90e7 --- /dev/null +++ b/src/scripts/page/ff2mpv.ts @@ -0,0 +1,30 @@ +import { detourFunc } from "./detour.js"; +import * as popup from "./popup.js"; + +function openMpvPopup(app: YTApp) { + const confirmButton: YTPopupButton = popup.createButton("Close", YTButtonStyle.BlueText, YTButtonSize.Default); + const confirmDialog: YTConfirmDialog = popup.createConfirmDialog("YouTube Layout Customizer", "Video has opened in mpv", confirmButton); + const confirmPopup = popup.createConfirmPopup(confirmDialog); + + popup.openPopup(app, YTPopupType.Dialog, confirmPopup); +} + +function detourNavigate(this: YTApp, event: any, eventData: YTNavigateEventData, origFunc: Function): any { + const videoId = eventData.endpoint.watchEndpoint?.videoId; + + if (videoId) { + window.ff2mpv(videoId); + openMpvPopup(this); + + return; + } + + return origFunc.call(this, event, eventData); +} + + +export default function setupDetour(app: YTApp) { + if (window.ytCustomizerConfig.ff2mpvEnabled) { + app.onYtNavigate = detourFunc(detourNavigate, app.onYtNavigate); + } +} diff --git a/src/scripts/page/guide.ts b/src/scripts/page/guide.ts new file mode 100644 index 0000000..33b9682 --- /dev/null +++ b/src/scripts/page/guide.ts @@ -0,0 +1,36 @@ +import { detourFunc } from "./detour.js"; +import * as guideData from "./guide/guideData.js"; + +// Detour guide fetch to modify guide data +async function detourGuideFetch(this: YTGuideService, origFunc: Function): Promise { + let guide = await origFunc.call(this); + guide = guideData.getNewGuide(guide); + + return guide; +} + +// Detour guide after it's init once to disable footer +// Guide renderer gets created only after calling setGuideDataAfterInit +function detourAfterInit(this: YTGuideService, data: any, origFunc: Function): any { + let ret = origFunc.call(this, data); + + if (window.ytCustomizerConfig.disableFooter) { + const guideRenderer = document.getElementById("guide-renderer") as YTGuideRenderer; + guideRenderer.showFooter = false; + } + + if (window.ytCustomizerConfig.startGuideClosed) { + // NOTE: closeGuide doesn't update guideUserStateOpened, do it manually + this.closeGuide(); + this.guideUserStateOpened = false; + } + + // Restore original function, we don't need do this more than once + this.setGuideDataAfterInit = origFunc; + return ret; +} + +export default function setupDetour(guideService: YTGuideService) { + guideService.fetchGuideData = detourFunc(detourGuideFetch, guideService.fetchGuideData); + guideService.setGuideDataAfterInit = detourFunc(detourAfterInit, guideService.setGuideDataAfterInit); +} diff --git a/src/scripts/page/guide/guideData.ts b/src/scripts/page/guide/guideData.ts new file mode 100644 index 0000000..df7bb05 --- /dev/null +++ b/src/scripts/page/guide/guideData.ts @@ -0,0 +1,108 @@ +import * as utils from "../utils.js"; + +function shouldRemoveRenderer(renderer: YTGuideDataItem): boolean { + // Removing guide entries based on their icon + const inner = utils.getInner(renderer) as YTGuideDataRenderer; + const iconType = inner.icon.iconType; + return !window.ytCustomizerConfig[iconType]; +} + +function getNewCollapsableSection(section: YTGuideDataItem): any { + const inner = utils.getInner(section); + inner.sectionItems = getNewRenderers(inner.sectionItems); + + if (shouldRemoveRenderer(inner.headerEntry)) { + // Don't leave the header empty + // Replace with the first collapsed item + inner.headerEntry = inner.sectionItems.shift(); + } + + if (inner.sectionItems.length > 0) { + return section; + } + + // If only the header left, unpack and just use it + return inner.headerEntry ? inner.headerEntry : null; +} + +function getNewCollapsableRenderer(renderer: YTGuideDataItem): any { + const inner = utils.getInner(renderer) as YTGuideDataCollapsibleRenderer; + inner.expandableItems = getNewRenderers(inner.expandableItems); + + return inner.expandableItems.length > 0 ? renderer : null; +} + +function getNewDownloadsRenderer(renderer: YTGuideDataItem): any { + const inner = utils.getInner(renderer) as YTGuideDataDownloads; + return shouldRemoveRenderer(inner.entryRenderer) ? null : renderer; +} + +function getNewRenderer(renderer: YTGuideDataItem): any { + switch (utils.getInnerName(renderer)) { + case "guideCollapsibleSectionEntryRenderer": { + return getNewCollapsableSection(renderer); + } + case "guideCollapsibleEntryRenderer": { + return getNewCollapsableRenderer(renderer); + } + case "guideDownloadsEntryRenderer": { + return getNewDownloadsRenderer(renderer); + } + default: { + return shouldRemoveRenderer(renderer) ? null : renderer; + } + } +} + +function getNewRenderers(renderers: YTGuideDataItem[]): any { + const newRenderers = []; + + for (const renderer of renderers) { + const newRenderer = getNewRenderer(renderer); + if (newRenderer) { + newRenderers.push(newRenderer); + } + } + + return newRenderers; +} + +function modifySection(section: YTGuideDataItem): any { + const inner = utils.getInner(section) as YTGuideSection; + inner.items = getNewRenderers(inner.items); + + return inner.items.length > 0 ? section : null; +} + + +function getNewSection(section: YTGuideDataItem): any { + switch (utils.getInnerName(section)) { + case "guideSigninPromoRenderer": { + return window.ytCustomizerConfig.disableSigninPromo ? null : section; + } + case "guideSubscriptionsSectionRenderer": { + return window.ytCustomizerConfig.disableSubscriptions ? null : section; + } + default: { + return modifySection(section); + } + } +} + +function getNewSections(sections: YTGuideDataItem[]): any { + const newSections = []; + + for (const section of sections) { + const newSection = getNewSection(section); + if (newSection) { + newSections.push(newSection); + } + } + + return newSections; +} + +export function getNewGuide(guide: YTGuideData): YTGuideData { + guide.items = getNewSections(guide.items); + return guide; +} diff --git a/src/scripts/page/page.ts b/src/scripts/page/page.ts new file mode 100644 index 0000000..a226a9f --- /dev/null +++ b/src/scripts/page/page.ts @@ -0,0 +1,55 @@ +import { detourFunc } from "./detour.js"; +import * as pageData from "./page/pageData.js"; + +function getBrowseId(endpoint: YTNavigationEndpoint): string | null { + const browseEndpoint = endpoint.browseEndpoint; + if (browseEndpoint) { + return browseEndpoint.browseId; + } + + return null; +} + +// Detour setter and getter of pagePool +// To reuse the same ytd-browse element for home and subscriptions tab +function detourPageSet(this: Map, name: string, page: YTPage, origFunc: Function): any { + if (name === "home") { + name = "subscriptions" + page.pageSubtype = "subscriptions"; + } + + return origFunc.call(this, name, page); +} + +function detourPageGet(this: Map, name: string, origFunc: Function): any { + if (name === "home") { + name = "subscriptions" + } + + return origFunc.call(this, name); +} + +// Detour page fetch to modify page data +async function detourPageFetch(this: YTApp, event: any, eventData: YTPageFetchEventData, origFunc: Function): Promise { + const page = eventData.pageData.response; + const browseId = getBrowseId(eventData.pageData.endpoint); + + eventData.pageData.response = await pageData.getNewPageData(this, page, browseId); + return origFunc.call(this, event, eventData); +} + + +export default function setupDetour(app: YTApp, pageManager: YTPageManager) { + app.onYtPageDataFetched = detourFunc(detourPageFetch, app.onYtPageDataFetched); + + if (window.ytCustomizerConfig.redirectHome) { + const pageMap = pageManager.pagePool.pageNameToElement; + + // SAFETY: Replacing setter and getter of Map + // SAFETY: TypeScript tries to set them as Map keys instead + // @ts-ignore + pageMap.set = detourFunc(detourPageSet, pageMap.set); + // @ts-ignore + pageMap.get = detourFunc(detourPageGet, pageMap.get); + } +} diff --git a/src/scripts/page/page/pageData.ts b/src/scripts/page/page/pageData.ts new file mode 100644 index 0000000..bd0e20a --- /dev/null +++ b/src/scripts/page/page/pageData.ts @@ -0,0 +1,174 @@ +import * as store from "./store.js"; +import { getInner } from "../utils.js"; + +async function hashStr(str: string): Promise { + const bytes = new TextEncoder().encode(str); + const hash = await crypto.subtle.digest("SHA-1", bytes); + const hashBuffer = new DataView(hash); + + let hashStr = ""; + for (let offset = 0; offset < hashBuffer.byteLength; offset += 4) { + const num = hashBuffer.getUint32(offset, false); // NOTE: Big Endian here is intentional, otherwise hex is reversed + hashStr += num.toString(16).padStart(8, '0'); + } + + return hashStr; +} + +function getSAPISID(): string { + const cookie = document.cookie; + const start = cookie.indexOf("SAPISID=") + 8; + const end = cookie.indexOf(';', start); + + return cookie.substring(start, end); +} + +// Auth token format is +// SAPISIDHASH {unix_seconds}_{hash} +// Where {hash} is SHA1 hash computed from the following string +// {unix_seconds} {SAPISID} {origin_url} +// See: https://stackoverflow.com/questions/16907352/reverse-engineering-javascript-behind-google-button#32065323 +async function getAuthToken(): Promise { + const unixSeconds = Math.trunc(Date.now() / 1000); + const SAPISID = getSAPISID(); + const hash = await hashStr(`${unixSeconds} ${SAPISID} https://www.youtube.com`); + const token = `SAPISIDHASH ${unixSeconds}_${hash}`; + + return token; +} + +// YouTube uses signals to indicate the action to execute on button press +// Unfortunately not all signals are clearly defined, +// Thus we have to resort to determening and defining them ourselves +function getSignal(button: YTButtonRenderer): string { + if (button.menuRequest) { + return button.menuRequest.signalServiceEndpoint.signal; + } + + if (button.navigationEndpoint?.signInEndpoint) { + return "SIGNIN"; + } + + return "CREATE" +} + +async function updateCache(app: YTApp, pageData: YTPageData, browseId: string | null) { + if (browseId !== "FEwhat_to_watch" && browseId !== "FEsubscriptions") { + return; + } + + // If we're on the home page, change page data to subscriptions page data + // Then duplicate it to subscriptions key, so we won't have to fetch it again. + // Otherwise, we're on subscriptions page, just copy it to home key. + + let responseStore = app.ephemeralResponseStore; + if (browseId === "FEwhat_to_watch") { + await store.changePageData("service:browse/browseId:FEwhat_to_watch", pageData, responseStore); + await store.duplicate("service:browse/browseId:FEwhat_to_watch", "service:browse/browseId:FEsubscriptions", responseStore); + } else { + await store.duplicate("service:browse/browseId:FEsubscriptions", "service:browse/browseId:FEwhat_to_watch", responseStore); + } +} + +async function getSubscriptionsPageData(): Promise { + const ytConfig = window.yt.config_; + + const context = ytConfig["INNERTUBE_CONTEXT"]; + const apiKey = ytConfig["INNERTUBE_API_KEY"]; + const loggedIn = ytConfig["LOGGED_IN"]; + const url = `https://www.youtube.com/youtubei/v1/browse?key=${apiKey}&prettyPrint=false` + + const response = await fetch(url, { + method: "POST", + mode: "cors", + headers: { + Authorization: loggedIn ? await getAuthToken() : "", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + browseId: "FEsubscriptions", + context: context + }) + }); + + const pageData = await response.json(); + return pageData; +} + +function getNewButtons(buttons: any[]): any[] { + const config = window.ytCustomizerConfig; + const newButtons = []; + + for (const button of buttons) { + const innerButton = getInner(button) as YTButtonRenderer; + const signal = getSignal(innerButton); + + let push = true; + switch(signal) { + case "CREATE": { + push = config.topbarCreateButton; + break; + } + case "GET_NOTIFICATIONS_MENU": { + push = config.topbarNotificationsButton; + break; + } + case "GET_ACCOUNT_MENU": { + push = config.topbarAccountButton; + break; + } + case "SIGNIN": { + push = config.topbarSignInButton; + break; + } + } + + if (push) { + newButtons.push(button); + } + } + + return newButtons; +} + +function modifyPageData(pageData: YTPageData): YTPageData { + const config = window.ytCustomizerConfig; + const topbar = pageData.topbar.desktopTopbarRenderer; + + if (!config.topbarLogo) { + delete topbar.logo; + } + + if (!config.topbarSearch) { + delete topbar.searchbox; + } + + if (!config.topbarVoiceSearchButton) { + delete topbar.voiceSearchButton; + } + + topbar.topbarButtons = getNewButtons(topbar.topbarButtons); + pageData.customized = true; + + return pageData; +} + +export async function getNewPageData(app: YTApp, pageData: YTPageData, browseId: string | null): Promise { + if (pageData.customized) { + // Page was cached, no need to modify + return pageData; + } + + if (window.ytCustomizerConfig.redirectHome && browseId === "FEwhat_to_watch") { + pageData = await getSubscriptionsPageData(); + } + + pageData = modifyPageData(pageData); + + if (window.ytCustomizerConfig.redirectHome) { + // Update cache since pageData reference possibly has changed + updateCache(app, pageData, browseId); + } + + return pageData; +} diff --git a/src/scripts/page/page/store.ts b/src/scripts/page/page/store.ts new file mode 100644 index 0000000..9495ee1 --- /dev/null +++ b/src/scripts/page/page/store.ts @@ -0,0 +1,15 @@ +export async function changePageData(key: string, pageData: YTPageData, responseStore: YTResponseStore) { + const response = await responseStore.get(key); + const copy = {...response.data}; + + copy.innertubeResponse = pageData; + responseStore.putInternal(key, copy); +} + +export async function duplicate(copyFromKey: string, copyToKey: string, responseStore: YTResponseStore) { + const response = await responseStore.get(copyFromKey); + const copy = {...response.data}; + + copy.key = copyToKey + responseStore.putInternal(copyToKey, copy); +} diff --git a/src/scripts/page/popup.ts b/src/scripts/page/popup.ts new file mode 100644 index 0000000..64bbab3 --- /dev/null +++ b/src/scripts/page/popup.ts @@ -0,0 +1,51 @@ +export function createConfirmDialog(title: string, dialogMessage: string, confirmButton?: YTPopupButton , cancelButton?: YTPopupButton): YTConfirmDialog { + const confirmDialog: YTConfirmDialog = { + title: createMessage(title), + dialogMessages: [ createMessage(dialogMessage) ], + }; + + if (confirmButton) { + confirmDialog.confirmButton = confirmButton; + } + + if (cancelButton) { + confirmDialog.cancelButton = cancelButton; + } + + return confirmDialog; +} + +export function createConfirmPopup(confirmDialog: YTConfirmDialog): YTPopup { + return { + confirmDialogRenderer: confirmDialog + } +} + +export function createMessage(message: string): YTMessage { + return { + runs: [{ + text: message + }] + } +} + +export function createButton(text: string, style: YTButtonStyle, size: YTButtonSize): YTPopupButton { + return { + buttonRenderer: { + text: createMessage(text), + style: style, + size: size + } + } +} + +export function openPopup(app: YTApp, popupType: YTPopupType, popup: YTPopup) { + const openPopupAction = { + openPopupAction: { + popupType: popupType, + popup: popup + } + } + + app.openPopup(openPopupAction); +} diff --git a/src/scripts/page/utils.ts b/src/scripts/page/utils.ts new file mode 100644 index 0000000..7786e65 --- /dev/null +++ b/src/scripts/page/utils.ts @@ -0,0 +1,7 @@ +export function getInner(obj: Object): any { + return Object.values(obj)[0]; +} + +export function getInnerName(obj: Object): string | undefined { + return Object.keys(obj)[0]; +} diff --git a/src/scripts/page/ytCustomizer.ts b/src/scripts/page/ytCustomizer.ts new file mode 100644 index 0000000..f746a87 --- /dev/null +++ b/src/scripts/page/ytCustomizer.ts @@ -0,0 +1,15 @@ +import setupPageDetour from "./page.js"; +import setupGuideDetour from "./guide.js"; +import setupFF2MpvDetour from "./ff2mpv.js"; + +// TODO: Do the general review and clean up before release + +{ + const app = document.getElementsByTagName("ytd-app")[0] as YTApp; + const pageManager = document.getElementById("page-manager") as YTPageManager; + const guideService = document.getElementById("guide-service") as YTGuideService; + + setupPageDetour(app, pageManager); + setupGuideDetour(guideService); + setupFF2MpvDetour(app); +} diff --git a/src/scripts/page/ytTypes.d.ts b/src/scripts/page/ytTypes.d.ts new file mode 100644 index 0000000..4b77cc9 --- /dev/null +++ b/src/scripts/page/ytTypes.d.ts @@ -0,0 +1,12 @@ +// These types directly correspond to the objects defined by YouTube itself +// Unfortunately a lot of them are defined badly +// We use plain "any" or "Function" when we don't have enough type information +// Or a way to distinguish it from the other types, in an array for example + +interface Window { + yt: { + config_: { + [key: string]: any + } + } +} diff --git a/src/scripts/page/ytTypes/ytApp.d.ts b/src/scripts/page/ytTypes/ytApp.d.ts new file mode 100644 index 0000000..e527cc9 --- /dev/null +++ b/src/scripts/page/ytTypes/ytApp.d.ts @@ -0,0 +1,18 @@ +interface YTApp extends HTMLElement { + ephemeralResponseStore: YTResponseStore + onYtPageDataFetched: Function + onYtNavigate: Function + openPopup: (action: YTAction) => void +} + +interface YTResponseStore { + get: (key: string) => Promise<{ + data: YTResponse + }>, + putInternal: (key: string, response: YTResponse) => void +} + +interface YTResponse { + key: string + innertubeResponse: YTPageData +} diff --git a/src/scripts/page/ytTypes/ytGuide.d.ts b/src/scripts/page/ytTypes/ytGuide.d.ts new file mode 100644 index 0000000..a408bdc --- /dev/null +++ b/src/scripts/page/ytTypes/ytGuide.d.ts @@ -0,0 +1,44 @@ +interface YTGuideService extends HTMLElement { + guideUserStateOpened: boolean, + setGuideDataAfterInit: Function, + fetchGuideData: Function, + closeGuide: () => void +} + +interface YTGuideRenderer extends HTMLElement { + showFooter: boolean +} + +interface YTGuideData { + items: YTGuideDataItem[] +} + +interface YTGuideSection { + items: YTGuideDataItem[] +} + +// This is wrapper for the guide item itself +// with only one key as the name of the type, holding the item object. +// We use utils.getInner and utils.getInnerName to find out what item it holds +interface YTGuideDataItem { + [key: string]: any +} + +interface YTGuideDataRenderer { + icon: { + iconType: string + } +} + +interface YTGuideDataDownloads { + entryRenderer: YTGuideDataItem +} + +interface YTGuideDataCollapsibleSection { + headerEntry: YTGuideDataItem + sectionItems: YTGuideDataItem[] +} + +interface YTGuideDataCollapsibleRenderer { + expandableItems: YTGuideDataItem[] +} diff --git a/src/scripts/page/ytTypes/ytPage.d.ts b/src/scripts/page/ytTypes/ytPage.d.ts new file mode 100644 index 0000000..41d5f42 --- /dev/null +++ b/src/scripts/page/ytTypes/ytPage.d.ts @@ -0,0 +1,62 @@ +interface YTPageManager extends HTMLElement { + pagePool: { + pageNameToElement: Map + } +} + +interface YTPage { + pageSubtype: string +} + +interface YTPageData { + contents: any[] + header: any[], + topbar: { + desktopTopbarRenderer: YTTopbar, + } + customized?: boolean +} + +interface YTTopbar { + logo: any + searchbox: any, + voiceSearchButton: any + topbarButtons: any[], +} + +interface YTButtonRenderer { + menuRequest?: YTMenuRequest, + menuRenderer?: any + navigationEndpoint?: YTNavigationEndpoint +} + +interface YTMenuRequest { + signalServiceEndpoint: { + signal: string + } +} + +interface YTNavigationEndpoint { + browseEndpoint?: YTBrowseEndpoint + signInEndpoint?: any + watchEndpoint?: YTWatchEndpoint +} + +interface YTBrowseEndpoint { + browseId: string +} + +interface YTWatchEndpoint { + videoId: string +} + +interface YTPageFetchEventData { + pageData: { + response: YTPageData + endpoint: YTNavigationEndpoint + } +} + +interface YTNavigateEventData { + endpoint: YTNavigationEndpoint +} diff --git a/src/scripts/page/ytTypes/ytPopup.d.ts b/src/scripts/page/ytTypes/ytPopup.d.ts new file mode 100644 index 0000000..4c6d135 --- /dev/null +++ b/src/scripts/page/ytTypes/ytPopup.d.ts @@ -0,0 +1,47 @@ +interface YTAction { + openPopupAction?: YTOpenPopupAction +} + +interface YTOpenPopupAction { + popupType: YTPopupType, + popup: YTPopup +} + +interface YTPopup { + confirmDialogRenderer?: YTConfirmDialog +} + +interface YTConfirmDialog { + title: YTMessage + dialogMessages: YTMessage[] + confirmButton?: YTPopupButton + cancelButton?: YTPopupButton +} + +interface YTPopupButton { + buttonRenderer: { + style: YTButtonStyle, + size: YTButtonSize, + text: YTMessage + } +} + +interface YTMessage { + runs: YTText[] +} + +interface YTText { + text: String +} + +declare const enum YTPopupType { + Dialog = "DIALOG" +} + +declare const enum YTButtonStyle { + BlueText = "STYLE_BLUE_TEXT" +} + +declare const enum YTButtonSize { + Default = "SIZE_DEFAULT" +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts new file mode 100644 index 0000000..6bc41a2 --- /dev/null +++ b/src/settings/settings.ts @@ -0,0 +1,96 @@ +class ConfigOption extends HTMLElement { + constructor() { + super(); + + const localizedText = browser.i18n.getMessage(this.i18nMessage); + const text = document.createTextNode(localizedText); + this.append(text); + + this.addEventListener("change", () => setConfigOption(this.key, this.enabled)); + this.addEventListener("click", () => this.enabled = !this.enabled); + } + + get i18nMessage(): string { + return this.getAttribute("msg") as string; + } + + get key(): string { + return this.getAttribute("key") as string; + } + + set enabled(value: boolean) { + if (value) { + this.setAttribute("enabled", ""); + this.className = "config-option-enabled"; + } else { + this.removeAttribute("enabled"); + this.className = ""; + } + + this.dispatchEvent(new Event("change")); + } + + get enabled(): boolean { + return this.hasAttribute("enabled"); + } +} + +class ConfigSection extends HTMLElement { + constructor() { + super(); + + const heading = document.createElement("h4"); + const localizedHeading = browser.i18n.getMessage(this.i18nMessage); + const text = document.createTextNode(localizedHeading); + heading.className = "config-heading"; + heading.appendChild(text); + + const enableButton = document.createElement("button"); + const localizedEnable = browser.i18n.getMessage("enableAllButton"); + const enableText = document.createTextNode(localizedEnable); + enableButton.addEventListener("click", () => this.enabled = true); + enableButton.appendChild(enableText); + + const disableButton = document.createElement("button"); + const localizedDisable = browser.i18n.getMessage("disableAllButton"); + const disableText = document.createTextNode(localizedDisable); + disableButton.addEventListener("click", () => this.enabled = false); + disableButton.appendChild(disableText); + + const buttons = document.createElement("div"); + buttons.className = "config-buttons"; + buttons.append(enableButton, disableButton); + + this.prepend(heading); + this.append(buttons); + } + + set enabled(value: boolean) { + const options = this.getElementsByTagName("config-option") as HTMLCollectionOf; + for (const option of options) { + option.enabled = value; + } + } + + get i18nMessage(): string { + return this.getAttribute("msg") as string; + } +} + +async function setConfigOption(key: string, value: any) { + browser.storage.sync.set({[key]: value}) +} + +async function populateOptions() { + const config = await browser.storage.sync.get(); + const options = document.getElementsByTagName("config-option") as HTMLCollectionOf; + + for (const option of options) { + option.enabled = config[option.key]; + } +} + +window.customElements.define("config-option", ConfigOption); +window.customElements.define("config-section", ConfigSection); +populateOptions(); +