From 80346b302d556860bd01b8f4396205d666464644 Mon Sep 17 00:00:00 2001 From: VampireChicken12 Date: Sun, 12 Nov 2023 09:36:43 -0500 Subject: [PATCH 1/5] refactor: break out jsx to component files --- src/components/Inputs/CheckBox/CheckBox.tsx | 6 +- src/components/Inputs/Number/Number.tsx | 6 +- src/components/Inputs/Select/Select.tsx | 7 +- src/components/Inputs/Slider/Slider.tsx | 2 +- .../Settings/components/Setting.tsx | 66 +++++++++++++++++++ .../components/SettingNotifications.tsx | 49 ++++++++++++++ .../Settings/components/SettingSection.tsx | 6 ++ .../Settings/components/SettingTitle.tsx | 6 ++ 8 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 src/components/Settings/components/Setting.tsx create mode 100644 src/components/Settings/components/SettingNotifications.tsx create mode 100644 src/components/Settings/components/SettingSection.tsx create mode 100644 src/components/Settings/components/SettingTitle.tsx diff --git a/src/components/Inputs/CheckBox/CheckBox.tsx b/src/components/Inputs/CheckBox/CheckBox.tsx index a4aa648a..2cc54351 100644 --- a/src/components/Inputs/CheckBox/CheckBox.tsx +++ b/src/components/Inputs/CheckBox/CheckBox.tsx @@ -1,18 +1,18 @@ import { cn } from "@/src/utils/utilities"; import React, { type ChangeEvent } from "react"; -interface CheckboxProps { +export type CheckboxProps = { id?: string; className?: string; label: string; checked: boolean; title: string; onChange: (event: ChangeEvent) => void; -} +}; const Checkbox: React.FC = ({ label, checked, onChange, className, id, title }) => { return ( -
+
) => void; disabled: boolean; -} +}; const NumberInput: React.FC = ({ value, min = 0, max = undefined, step = 1, onChange, className, id, label, disabled }) => { const inputElement: MutableRefObject = useRef(null); @@ -50,7 +50,7 @@ const NumberInput: React.FC = ({ value, min = 0, max = undefin "dark:text-[#4b5563]": disabled } satisfies ClassValue; return ( -
+
diff --git a/src/components/Inputs/Select/Select.tsx b/src/components/Inputs/Select/Select.tsx index 3b83103b..3840ffb5 100644 --- a/src/components/Inputs/Select/Select.tsx +++ b/src/components/Inputs/Select/Select.tsx @@ -18,16 +18,17 @@ export type SelectOption = { element?: React.ReactElement; }; -interface SelectProps { +export type SelectProps = { id?: string; className?: string; options: SelectOption[]; onChange: (value: ChangeEvent) => void; + title: string; label: string; selectedOption: string | undefined; setSelectedOption: Dispatch>; disabled: boolean; -} +}; const Select: React.FC = ({ onChange, options, className, id, selectedOption, label, setSelectedOption, disabled }) => { const { @@ -48,7 +49,7 @@ const Select: React.FC = ({ onChange, options, className, id, selec const disabledButtonClasses = { "text-[#4b5563]": disabled, "dark:text-[#4b5563]": disabled } satisfies ClassValue; return ( -
+
- -
- Miscellaneous settings -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- Scroll wheel volume control settings - + + + + + + + + + + + + -
- -
-
- -
-
-
- Playback speed settings - + + + + -
- -
-
- {notifications.filter((n) => n.action === "reset_settings").length > 0 ? ( @@ -552,8 +626,8 @@ export default function Settings({ id="confirm_button" className="p-2 danger dark:hover:bg-[rgba(24,26,27,0.5)] text-sm sm:text-base md:text-lg" style={{ marginLeft: "auto" }} - value="Confirm" - title="Confirm setting reset" + title={t("settings.sections.bottomButtons.confirm.title")} + value={t("settings.sections.bottomButtons.confirm.value")} onClick={() => { const notificationToRemove = notifications.find((n) => n.action === "reset_settings"); if (notificationToRemove) { @@ -562,7 +636,7 @@ export default function Settings({ Object.assign(localStorage, Object.assign(defaultSettings, { remembered_volumes: settings.remembered_volumes })); chrome.storage.local.set(Object.assign(defaultSettings, { remembered_volumes: settings.remembered_volumes })); - addNotification("success", "Options saved"); + addNotification("success", t("pages.options.notifications.success.saved")); }} /> ) : ( @@ -571,51 +645,13 @@ export default function Settings({ id="reset_button" className="p-2 warning dark:hover:bg-[rgba(24,26,27,0.5)] text-sm sm:text-base md:text-lg" style={{ marginLeft: "auto" }} - value="Reset" - title="Resets all settings to their defaults, Click the confirm button to save the changes" + title={t("settings.sections.bottomButtons.reset.title")} + value={t("settings.sections.bottomButtons.reset.value")} onClick={resetOptions} /> )}
-
- {notifications.map((notification, index) => ( -
- {notification.action ? ( - notification.action === "reset_settings" ? ( - <> - {notification.message.split("\n").map((line, index) => ( -

{line}

- ))} - - - ) : null - ) : ( - <> - {notification.message} - - - )} -
-
- ))} -
+
) ); diff --git a/src/components/SettingsWrapper/index.tsx b/src/components/SettingsWrapper/index.tsx index ffdb4a59..0277b7e3 100644 --- a/src/components/SettingsWrapper/index.tsx +++ b/src/components/SettingsWrapper/index.tsx @@ -1,9 +1,11 @@ import Loader from "@/src/components/Loader"; import Settings from "@/src/components/Settings/Settings"; -import type { configuration } from "@/src/types"; +import { NotificationsProvider } from "@/src/hooks/useNotifications/provider"; +import type { configuration } from "@/src/@types"; import { defaultConfiguration } from "@/src/utils/constants"; import { parseStoredValue } from "@/src/utils/utilities"; import React, { useEffect, useState } from "react"; +import { i18nService, type i18nInstanceType, type AvailableLocales } from "@/src/i18n"; export default function SettingsWrapper(): JSX.Element { const [settings, setSettings] = useState(undefined); @@ -14,6 +16,8 @@ export default function SettingsWrapper(): JSX.Element { const [selectedPlayerSpeed, setSelectedPlayerSpeed] = useState(); const [selectedScreenshotSaveAs, setSelectedScreenshotSaveAs] = useState(); const [selectedScreenshotFormat, setSelectedScreenshotFormat] = useState(); + const [selectedLanguage, setSelectedLanguage] = useState(); + const [i18nInstance, setI18nInstance] = useState(null); useEffect(() => { const fetchSettings = () => { chrome.storage.local.get((settings) => { @@ -28,6 +32,7 @@ export default function SettingsWrapper(): JSX.Element { setSelectedPlayerSpeed(settings.player_speed); setSelectedScreenshotSaveAs(settings.screenshot_save_as); setSelectedScreenshotFormat(settings.screenshot_format); + setSelectedLanguage(settings.language); }); }; @@ -70,6 +75,9 @@ export default function SettingsWrapper(): JSX.Element { case "screenshot_format": setSelectedScreenshotFormat(newValue); break; + case "language": + setSelectedLanguage(newValue); + break; } setSettings((prevSettings) => { if (prevSettings) { @@ -88,30 +96,40 @@ export default function SettingsWrapper(): JSX.Element { chrome.storage.onChanged.removeListener(handleStorageChange); }; }, []); - + useEffect(() => { + (async () => { + const instance = await i18nService((selectedLanguage as AvailableLocales) ?? "en-US"); + setI18nInstance(instance); + })(); + }, [selectedLanguage]); const defaultOptions = defaultConfiguration; - if (!settings) { + if (!settings || !i18nInstance || (i18nInstance && i18nInstance.isInitialized === false)) { return ; } return ( - + + + ); } diff --git a/src/features/featureMenu/index.ts b/src/features/featureMenu/index.ts index f6a73f47..4ff62e51 100644 --- a/src/features/featureMenu/index.ts +++ b/src/features/featureMenu/index.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/src/utils/EventManager"; import { isWatchPage, isShortsPage, createTooltip } from "@/src/utils/utilities"; @@ -36,7 +36,7 @@ function createFeatureMenuButton() { const featureMenuButton = document.createElement("button"); featureMenuButton.classList.add("ytp-button"); featureMenuButton.id = "yte-feature-menu-button"; - featureMenuButton.dataset.title = "Feature menu"; + featureMenuButton.dataset.title = window.i18nextInstance.t("pages.content.features.featureMenu.label"); featureMenuButton.style.display = "none"; // Create the SVG icon for the button const featureButtonSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); diff --git a/src/features/featureMenu/utils.ts b/src/features/featureMenu/utils.ts index 0b829a50..e1cfdfed 100644 --- a/src/features/featureMenu/utils.ts +++ b/src/features/featureMenu/utils.ts @@ -1,4 +1,4 @@ -import type { FeatureMenuItemIconId, FeatureMenuItemId, FeatureMenuItemLabelId, WithId } from "@/src/types"; +import type { FeatureMenuItemIconId, FeatureMenuItemId, FeatureMenuItemLabelId, WithId } from "@/src/@types"; import eventManager, { type FeatureName } from "@/src/utils/EventManager"; import { waitForAllElements } from "@/src/utils/utilities"; /** @@ -146,7 +146,32 @@ export async function removeFeatureItemFromMenu(featureName: FeatureName) { // Adjust the height and width of the feature menu panel featureMenu.style.height = `${40 * featureMenuPanel.childElementCount + 16}px`; } - +/** + * Updates the label for a feature item. + * @param featureName the name of the feature + * @param label the label to set + * @returns + */ +export async function updateFeatureMenuItemLabel(featureName: FeatureName, label: string) { + const featureMenuItemLabel = getFeatureMenuItemLabel(featureName); + if (!featureMenuItemLabel) return; + featureMenuItemLabel.textContent = label; +} +/** + * Updates the title for the feature menu button. + * @param title the title to set + * @returns + */ +export async function updateFeatureMenuTitle(title: string) { + const featureMenuButton = document.querySelector("#yte-feature-menu-button") as HTMLButtonElement | null; + if (!featureMenuButton) return; + featureMenuButton.dataset.title = title; +} +/** + * Gets the IDs for a feature item. + * @param featureName the name of the feature + * @returns { featureMenuItemIconId, featureMenuItemId, featureMenuItemLabelId} + */ export function getFeatureIds(featureName: FeatureName): { featureMenuItemIconId: FeatureMenuItemIconId; featureMenuItemId: FeatureMenuItemId; diff --git a/src/features/maximizePlayerButton/index.ts b/src/features/maximizePlayerButton/index.ts index efabcada..7c7f9e70 100644 --- a/src/features/maximizePlayerButton/index.ts +++ b/src/features/maximizePlayerButton/index.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/src/utils/EventManager"; import { waitForSpecificMessage } from "@/src/utils/utilities"; import { makeMaximizeSVG, updateProgressBarPositions, setupVideoPlayerTimeUpdate, maximizePlayer } from "./utils"; diff --git a/src/features/maximizePlayerButton/utils.ts b/src/features/maximizePlayerButton/utils.ts index 3c9b8af9..55a9c914 100644 --- a/src/features/maximizePlayerButton/utils.ts +++ b/src/features/maximizePlayerButton/utils.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/src/utils/EventManager"; let wasInTheatreMode = false; let setToTheatreMode = false; diff --git a/src/features/playerQuality/index.ts b/src/features/playerQuality/index.ts index 9cde4620..a1ba6632 100644 --- a/src/features/playerQuality/index.ts +++ b/src/features/playerQuality/index.ts @@ -1,5 +1,5 @@ -import { youtubePlayerQualityLevel, youtubePlayerQualityLabel } from "@/src/types"; -import type { YoutubePlayerQualityLabel, YoutubePlayerQualityLevel, YouTubePlayerDiv } from "@/src/types"; +import { youtubePlayerQualityLevel, youtubePlayerQualityLabel } from "@/src/@types"; +import type { YoutubePlayerQualityLabel, YoutubePlayerQualityLevel, YouTubePlayerDiv } from "@/src/@types"; import { waitForSpecificMessage, isWatchPage, isShortsPage, chooseClosetQuality, browserColorLog } from "@/src/utils/utilities"; /** diff --git a/src/features/playerSpeed/index.ts b/src/features/playerSpeed/index.ts index 6a7effa8..d78b48ee 100644 --- a/src/features/playerSpeed/index.ts +++ b/src/features/playerSpeed/index.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/src/utils/EventManager"; import { isWatchPage, isShortsPage, browserColorLog, waitForSpecificMessage } from "@/src/utils/utilities"; @@ -102,6 +102,7 @@ export function setupPlaybackSpeedChangeListener() { for (const mutation of mutationsList) { if (mutation.type === "childList") { const titleElement: HTMLSpanElement | null = document.querySelector("div.ytp-panel > div.ytp-panel-header > span.ytp-panel-title"); + // TODO: fix this it relies on the language being English if (titleElement && titleElement.textContent && titleElement.textContent.includes("Playback speed")) { const menuItems: NodeListOf = document.querySelectorAll("div.ytp-panel-menu div.ytp-menuitem"); menuItems.forEach((node: HTMLDivElement) => { diff --git a/src/features/remainingTime/index.ts b/src/features/remainingTime/index.ts index 7e8b7de8..14fecc22 100644 --- a/src/features/remainingTime/index.ts +++ b/src/features/remainingTime/index.ts @@ -1,5 +1,5 @@ import { isShortsPage, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import { calculateRemainingTime } from "./utils"; import eventManager from "@/src/utils/EventManager"; async function playerTimeUpdateListener() { @@ -49,6 +49,9 @@ export async function setupRemainingTime() { const videoElement = playerContainer.querySelector("video") as HTMLVideoElement | null; // If video element is not available, return if (!videoElement) return; + const playerVideoData = await playerContainer.getVideoData(); + // If the video is live return + if (playerVideoData.isLive) return; const remainingTime = await calculateRemainingTime({ videoElement, playerContainer }); const remainingTimeElementExists = document.querySelector("span#ytp-time-remaining") !== null; const remainingTimeElement = document.querySelector("span#ytp-time-remaining") ?? document.createElement("span"); diff --git a/src/features/remainingTime/utils.ts b/src/features/remainingTime/utils.ts index f7053b78..607db7c5 100644 --- a/src/features/remainingTime/utils.ts +++ b/src/features/remainingTime/utils.ts @@ -1,5 +1,5 @@ -import type { YouTubePlayerDiv } from "@/src/types"; -function formatTime(timeInSeconds: number) { +import type { YouTubePlayerDiv } from "@/src/@types"; +export function formatTime(timeInSeconds: number) { timeInSeconds = Math.round(timeInSeconds); const units: number[] = [ Math.floor(timeInSeconds / (3600 * 24)), @@ -19,7 +19,7 @@ function formatTime(timeInSeconds: number) { return acc; }, []); - return ` (-${formattedUnits.length > 0 ? formattedUnits.join(":") : "0"})`; + return `${formattedUnits.length > 0 ? formattedUnits.join(":") : "0"}`; } export async function calculateRemainingTime({ videoElement, @@ -39,5 +39,5 @@ export async function calculateRemainingTime({ const remainingTimeInSeconds = (duration - currentTime) / playbackRate; // Format the remaining time - return formatTime(remainingTimeInSeconds); + return ` (-${formatTime(remainingTimeInSeconds)})`; } diff --git a/src/features/rememberVolume/index.ts b/src/features/rememberVolume/index.ts index 30b21222..2799d573 100644 --- a/src/features/rememberVolume/index.ts +++ b/src/features/rememberVolume/index.ts @@ -2,7 +2,7 @@ import { isShortsPage, isWatchPage, waitForSpecificMessage } from "@/src/utils/u import { setRememberedVolume, setupVolumeChangeListener } from "./utils"; -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; /** * Sets the remembered volume based on the options received from a specific message. * It restores the last volume if the option is enabled. diff --git a/src/features/rememberVolume/utils.ts b/src/features/rememberVolume/utils.ts index dc63b4a5..27f1b91d 100644 --- a/src/features/rememberVolume/utils.ts +++ b/src/features/rememberVolume/utils.ts @@ -1,7 +1,7 @@ import eventManager from "@/src/utils/EventManager"; import { browserColorLog, isShortsPage, isWatchPage, sendContentOnlyMessage, waitForSpecificMessage } from "@/src/utils/utilities"; -import type { YouTubePlayerDiv, configuration } from "@/src/types"; +import type { YouTubePlayerDiv, configuration } from "@/src/@types"; export async function setupVolumeChangeListener() { // Wait for the "options" message from the content script const optionsData = await waitForSpecificMessage("options", "request_data", "content"); diff --git a/src/features/screenshotButton/index.ts b/src/features/screenshotButton/index.ts index c4219c34..b61566a4 100644 --- a/src/features/screenshotButton/index.ts +++ b/src/features/screenshotButton/index.ts @@ -39,7 +39,7 @@ async function takeScreenshot(videoElement: HTMLVideoElement) { const clipboardImage = new ClipboardItem({ "image/png": blob }); navigator.clipboard.write([clipboardImage]); navigator.clipboard.writeText(dataUrl); - screenshotTooltip.textContent = "Screenshot copied to clipboard"; + screenshotTooltip.textContent = window.i18nextInstance.t("pages.content.features.screenshotButton.copiedToClipboard"); } break; } @@ -66,7 +66,6 @@ export async function addScreenshotButton(): Promise { const { enable_screenshot_button: enableScreenshotButton } = options; // If the screenshot button option is disabled, return if (!enableScreenshotButton) return; - // Add a click event listener to the screenshot button async function screenshotButtonClickListener() { // Get the video element @@ -83,7 +82,7 @@ export async function addScreenshotButton(): Promise { addFeatureItemToMenu({ featureName: "screenshotButton", icon: makeScreenshotIcon(), - label: "Screenshot", + label: window.i18nextInstance.t("pages.content.features.screenshotButton.label"), listener: screenshotButtonClickListener }); } diff --git a/src/features/scrollWheelVolumeControl/index.ts b/src/features/scrollWheelVolumeControl/index.ts index 0621f78e..e6c60b59 100644 --- a/src/features/scrollWheelVolumeControl/index.ts +++ b/src/features/scrollWheelVolumeControl/index.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import { waitForAllElements, isWatchPage, isShortsPage, waitForSpecificMessage } from "@/src/utils/utilities"; import { adjustVolume, getScrollDirection, setupScrollListeners, drawVolumeDisplay } from "./utils"; diff --git a/src/features/scrollWheelVolumeControl/utils.ts b/src/features/scrollWheelVolumeControl/utils.ts index dabf8da6..1738e9a4 100644 --- a/src/features/scrollWheelVolumeControl/utils.ts +++ b/src/features/scrollWheelVolumeControl/utils.ts @@ -1,4 +1,4 @@ -import type { OnScreenDisplayColor, OnScreenDisplayPosition, OnScreenDisplayType, Selector, YouTubePlayerDiv } from "@/src/types"; +import type { OnScreenDisplayColor, OnScreenDisplayPosition, OnScreenDisplayType, Selector, YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/src/utils/EventManager"; import { isWatchPage, isShortsPage, clamp, toDivisible, browserColorLog, round } from "@/src/utils/utilities"; diff --git a/src/features/videoHistory/index.ts b/src/features/videoHistory/index.ts index a32e1427..da75c7d7 100644 --- a/src/features/videoHistory/index.ts +++ b/src/features/videoHistory/index.ts @@ -1,6 +1,7 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import eventManager from "@/utils/EventManager"; import { browserColorLog, createTooltip, isShortsPage, isWatchPage, sendContentMessage, waitForSpecificMessage } from "@/utils/utilities"; +import { formatTime } from "../remainingTime/utils"; export async function setupVideoHistory() { // Wait for the "options" message from the content script @@ -34,6 +35,15 @@ export async function setupVideoHistory() { eventManager.addEventListener(videoElement, "timeupdate", videoPlayerTimeUpdateListener, "videoHistory"); } export async function promptUserToResumeVideo() { + // Wait for the "options" message from the content script + const optionsData = await waitForSpecificMessage("options", "request_data", "content"); + if (!optionsData) return; + const { + data: { options } + } = optionsData; + const { enable_video_history: enableVideoHistory } = options; + if (!enableVideoHistory) return; + // Get the player container element const playerContainer = isWatchPage() ? (document.querySelector("div#movie_player") as YouTubePlayerDiv | null) : isShortsPage() ? null : null; @@ -85,7 +95,7 @@ export async function promptUserToResumeVideo() { clearInterval(countdownInterval); prompt.style.display = "none"; overlay.style.display = "none"; - browserColorLog(`Resuming video`, "FgGreen"); + browserColorLog(window.i18nextInstance.t("messages.resumingVideo", { VIDEO_TIME: formatTime(video_history_entry.timestamp) }), "FgGreen"); playerContainer.seekTo(video_history_entry.timestamp, true); }; const overlay = document.getElementById("resume-prompt-overlay") ?? document.createElement("div"); @@ -118,7 +128,7 @@ export async function promptUserToResumeVideo() { closeButton.style.padding = "5px"; closeButton.style.cursor = "pointer"; closeButton.style.lineHeight = "1px"; - closeButton.dataset.title = "Close"; + closeButton.dataset.title = window.i18nextInstance.t("pages.content.features.videoHistory.resumePrompt.close"); const { listener: resumePromptCloseButtonMouseOverListener } = createTooltip({ element: closeButton, id: "yte-resume-prompt-close-button-tooltip", @@ -141,7 +151,7 @@ export async function promptUserToResumeVideo() { prompt.style.boxShadow = "0px 0px 10px rgba(0, 0, 0, 0.2)"; prompt.style.zIndex = "25000"; resumeButton.id = "resume-prompt-button"; - resumeButton.textContent = "Resume"; + resumeButton.textContent = window.i18nextInstance.t("pages.content.features.videoHistory.resumeButton"); resumeButton.style.backgroundColor = "hsl(213, 80%, 50%)"; resumeButton.style.border = "transparent"; resumeButton.style.color = "white"; diff --git a/src/features/videoHistory/utils.ts b/src/features/videoHistory/utils.ts index 8a1844a8..50a113ce 100644 --- a/src/features/videoHistory/utils.ts +++ b/src/features/videoHistory/utils.ts @@ -1,4 +1,4 @@ -import type { VideoHistoryStatus, VideoHistoryStorage } from "@/src/types"; +import type { VideoHistoryStatus, VideoHistoryStorage } from "@/src/@types"; export function getVideoHistory() { return JSON.parse(window.localStorage.getItem("videoHistory") ?? "{}") as VideoHistoryStorage; } diff --git a/src/features/volumeBoost/index.ts b/src/features/volumeBoost/index.ts index c11b8d70..861237df 100644 --- a/src/features/volumeBoost/index.ts +++ b/src/features/volumeBoost/index.ts @@ -1,4 +1,4 @@ -import type { YouTubePlayerDiv } from "@/src/types"; +import type { YouTubePlayerDiv } from "@/src/@types"; import { waitForSpecificMessage, browserColorLog, formatError } from "@/src/utils/utilities"; export default async function volumeBoost() { diff --git a/src/global.d.ts b/src/global.d.ts index 81398779..daf04128 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,3 +1,5 @@ +import type { i18nInstanceType } from "./i18n"; + declare module "*.svg" { import React = require("react"); export const ReactComponent: React.SFC>; @@ -50,6 +52,7 @@ declare global { audioCtx: AudioContext; webkitAudioContext: AudioContext; gainNode: GainNode; + i18nextInstance: i18nInstanceType; } } export {}; diff --git a/src/i18n/i18n.d.ts b/src/i18n/i18n.d.ts new file mode 100644 index 00000000..3e5bb1d8 --- /dev/null +++ b/src/i18n/i18n.d.ts @@ -0,0 +1,9 @@ +import "i18next"; +declare module "i18next" { + interface CustomTypeOptions { + defaultNS: "en-US"; + resources: { + "en-US": typeof import("../../public/locales/en-US.json"); + }; + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..f58902eb --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,49 @@ +import i18next, { createInstance, type Resource } from "i18next"; +import { waitForSpecificMessage } from "../utils/utilities"; +export const availableLocales = ["en-US", "es-ES", "de-DE"] as const; +export type AvailableLocales = (typeof availableLocales)[number]; +export type i18nInstanceType = ReturnType; + +export async function i18nService(locale: AvailableLocales) { + let extensionURL; + const isYouTube = window.location.hostname === "www.youtube.com"; + if (isYouTube) { + const extensionURLResponse = await waitForSpecificMessage("extensionURL", "request_data", "content"); + if (!extensionURLResponse) throw new Error("Failed to get extension URL"); + ({ + data: { extensionURL } + } = extensionURLResponse); + } else { + extensionURL = chrome.runtime.getURL(""); + } + if (!availableLocales.includes(locale)) throw new Error(`The locale '${locale}' is not available`); + const response = await fetch(`${extensionURL}locales/${locale}.json`).catch((err) => console.error(err)); + const translations = (await response?.json()) as typeof import("../../public/locales/en-US.json"); + const i18nextInstance = await new Promise((resolve, reject) => { + const resources: { + [k in AvailableLocales]?: { + translation: typeof import("../../public/locales/en-US.json"); + }; + } = { + [locale]: { translation: translations } + }; + const instance = i18next.createInstance(); + instance.init( + { + fallbackLng: "en-US", + interpolation: { + escapeValue: false + }, + returnObjects: true, + lng: locale, + debug: true, + resources: resources as unknown as { [key: string]: Resource } + }, + (err) => { + if (err) reject(err); + else resolve(instance); + } + ); + }); + return i18nextInstance; +} diff --git a/src/manifest.ts b/src/manifest.ts index c8260292..e24d5acd 100755 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,5 +1,6 @@ import type { Manifest } from "webextension-polyfill"; import pkg from "../package.json"; +import { availableLocales } from "./i18n"; const manifestV3: Manifest.WebExtensionManifest = { manifest_version: 3, @@ -43,7 +44,8 @@ const manifestV3: Manifest.WebExtensionManifest = { "/icons/icon_48.png", "/icons/icon_16.png", "src/pages/content/index.js", - "src/pages/inject/index.js" + "src/pages/inject/index.js", + ...availableLocales.map((locale) => `/locales/${locale}.json`) ], matches: ["https://www.youtube.com/*"] } @@ -87,7 +89,8 @@ const manifestV2: Manifest.WebExtensionManifest = { "/icons/icon_48.png", "/icons/icon_16.png", "src/pages/content/index.js", - "src/pages/inject/index.js" + "src/pages/inject/index.js", + ...availableLocales.map((locale) => `/locales/${locale}.json`) ] }; diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index b6683d93..da643e2b 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -11,11 +11,13 @@ import adjustVolumeOnScrollWheel from "@/src/features/scrollWheelVolumeControl"; import { promptUserToResumeVideo, setupVideoHistory } from "@/src/features/videoHistory"; import volumeBoost from "@/src/features/volumeBoost"; import eventManager from "@/utils/EventManager"; -import { browserColorLog, formatError } from "@/utils/utilities"; +import { browserColorLog, formatError, waitForSpecificMessage } from "@/utils/utilities"; -import type { ExtensionSendOnlyMessageMappings, Messages, YouTubePlayerDiv } from "@/src/types"; +import type { ExtensionSendOnlyMessageMappings, Messages, YouTubePlayerDiv } from "@/src/@types"; import { enableHideScrollBar } from "@/src/features/hideScrollBar"; import { hideScrollBar, showScrollBar } from "@/src/features/hideScrollBar/utils"; +import { i18nService } from "@/src/i18n"; +import { updateFeatureMenuItemLabel, updateFeatureMenuTitle } from "@/src/features/featureMenu/utils"; // TODO: Add always show progressbar feature // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -66,9 +68,10 @@ element.style.display = "none"; element.id = "yte-message-from-youtube"; document.documentElement.appendChild(element); -window.onload = function () { +window.onload = async function () { enableRememberVolume(); enableHideScrollBar(); + const enableFeatures = () => { eventManager.removeAllEventListeners(["featureMenu"]); enableFeatureMenu(); @@ -85,6 +88,13 @@ window.onload = function () { promptUserToResumeVideo(); setupRemainingTime(); }; + const response = await waitForSpecificMessage("language", "request_data", "content"); + if (!response) return; + const { + data: { language } + } = response; + const i18nextInstance = await i18nService(language); + window.i18nextInstance = i18nextInstance; document.addEventListener("yt-player-updated", enableFeatures); /** * Listens for the "yte-message-from-youtube" event and handles incoming messages from the YouTube page. @@ -111,14 +121,24 @@ window.onload = function () { } = message; if (volumeBoostEnabled) { if (window.audioCtx && window.gainNode) { - browserColorLog(`Setting volume boost to ${Math.pow(10, Number(volumeBoostAmount) / 20)}`, "FgMagenta"); + browserColorLog( + i18nextInstance.t("messages.settingVolume", { + VOLUME_BOOST_AMOUNT: Math.pow(10, Number(volumeBoostAmount) / 20) + }), + "FgMagenta" + ); window.gainNode.gain.value = Math.pow(10, Number(volumeBoostAmount) / 20); } else { volumeBoost(); } } else { if (window.audioCtx && window.gainNode) { - browserColorLog(`Setting volume boost to 1x`, "FgMagenta"); + browserColorLog( + i18nextInstance.t("messages.settingVolume", { + VOLUME_BOOST_AMOUNT: "1x" + }), + "FgMagenta" + ); window.gainNode.gain.value = 1; } } @@ -239,6 +259,17 @@ window.onload = function () { } break; } + case "languageChange": { + const { + data: { language } + } = message; + window.i18nextInstance = await i18nService(language); + updateFeatureMenuTitle(window.i18nextInstance.t("pages.content.features.featureMenu.label")); + updateFeatureMenuItemLabel("screenshotButton", window.i18nextInstance.t("pages.content.features.screenshotButton.label")); + updateFeatureMenuItemLabel("maximizePlayerButton", window.i18nextInstance.t("pages.content.features.maximizePlayerButton.label")); + updateFeatureMenuItemLabel("loopButton", window.i18nextInstance.t("pages.content.features.loopButton.label")); + break; + } default: { return; } diff --git a/src/pages/inject/index.tsx b/src/pages/inject/index.tsx index db87201d..0c05baa1 100644 --- a/src/pages/inject/index.tsx +++ b/src/pages/inject/index.tsx @@ -1,6 +1,7 @@ import { getVideoHistory, setVideoHistory } from "@/src/features/videoHistory/utils"; -import type { ContentSendOnlyMessageMappings, Messages, StorageChanges, configuration } from "@/src/types"; +import type { ContentSendOnlyMessageMappings, Messages, StorageChanges, configuration } from "@/src/@types"; import { parseReviver, sendExtensionOnlyMessage, sendExtensionMessage, parseStoredValue } from "@/src/utils/utilities"; +import type { AvailableLocales } from "@/src/i18n"; /** * Adds a script element to the document's root element, which loads a JavaScript file from the extension's runtime URL. @@ -108,6 +109,23 @@ document.addEventListener("yte-message-from-youtube", async () => { chrome.storage.local.set({ remembered_volumes: { ...message.data } }); break; } + case "extensionURL": { + sendExtensionMessage("extensionURL", "data_response", { + extensionURL: chrome.runtime.getURL("") + }); + break; + } + case "language": { + const language = await new Promise((resolve) => { + chrome.storage.local.get("language", (o) => { + resolve(o.language); + }); + }); + sendExtensionMessage("language", "data_response", { + language + }); + break; + } } }); const storageListeners = async (changes: StorageChanges, areaName: string) => { @@ -202,6 +220,11 @@ const storageChangeHandler = async (changes: StorageChanges, areaName: string) = sendExtensionOnlyMessage("hideScrollBarChange", { hideScrollBarEnabled: castedChanges.enable_hide_scrollbar.newValue }); + }, + language: () => { + sendExtensionOnlyMessage("languageChange", { + language: castedChanges.language.newValue + }); } }; Object.entries( diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 14ba4f02..f1f8e8d5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,5 +1,6 @@ import z from "zod"; -import type { PartialConfigurationToZodSchema, configuration } from "../types"; +import type { PartialConfigurationToZodSchema, configuration } from "../@types"; +import { availableLocales } from "../i18n/index"; import { screenshotFormat, screenshotType, @@ -7,7 +8,7 @@ import { onScreenDisplayType, onScreenDisplayPosition, youtubePlayerQualityLevel -} from "../types"; +} from "../@types"; export const outputFolderName = "dist"; export const defaultConfiguration = { // Options @@ -35,7 +36,8 @@ export const defaultConfiguration = { volume_adjustment_steps: 5, volume_boost_amount: 1, player_quality: "auto", - player_speed: 1 + player_speed: 1, + language: "en-US" } satisfies configuration; export const configurationImportSchema: PartialConfigurationToZodSchema = z.object({ @@ -62,5 +64,6 @@ export const configurationImportSchema: PartialConfigurationToZodSchema (value2: unknown) => value1 === value2; diff --git a/tsconfig.json b/tsconfig.json index 6b7e534b..cf630528 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,5 +29,5 @@ "@/hooks/*": ["src/hooks/*"] } }, - "include": ["src", "src/utils", "vite.config.ts"] + "include": ["src", "src/utils", "vite.config.ts", "public/locales"] }