diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..63662bfd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/package-lock.json b/package-lock.json index 8ba7e49e..9831286f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "youtube-enhancer", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "youtube-enhancer", - "version": "1.1.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@formkit/auto-animate": "^0.7.0", - "get-installed-browsers": "^0.1.7", "react": "^18.2.0", "react-dom": "^18.2.0", "vite-plugin-css-injected-by-js": "^3.1.1", @@ -42,14 +41,17 @@ "eslint-plugin-react": "^7.32.1", "eslint-plugin-react-hooks": "^4.3.0", "fs-extra": "^11.1.0", + "get-installed-browsers": "^0.1.7", "nodemon": "^2.0.20", "postcss": "^8.4.21", "prettier": "^2.8.8", "semantic-release": "^21.1.1", "tailwindcss": "^3.2.4", "ts-node": "^10.9.1", - "typescript": "^4.9.4", - "vite": "^4.0.4" + "typescript": "^5.2.2", + "vite": "^4.0.4", + "zod": "^3.22.3", + "zod-error": "^1.5.0" } }, "node_modules/@alloc/quick-lru": { @@ -4345,7 +4347,8 @@ "node_modules/get-installed-browsers": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/get-installed-browsers/-/get-installed-browsers-0.1.7.tgz", - "integrity": "sha512-gxGDcxaOpA9QNk/REyILnXJVmYS9Se33HTfFN7u03Pxpkn9R/ogsYIFRwyzvc5fCoZ548RAzGk4YSF0xLM4BUw==" + "integrity": "sha512-gxGDcxaOpA9QNk/REyILnXJVmYS9Se33HTfFN7u03Pxpkn9R/ogsYIFRwyzvc5fCoZ548RAzGk4YSF0xLM4BUw==", + "dev": true }, "node_modules/get-intrinsic": { "version": "1.2.1", @@ -11840,16 +11843,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/uglify-js": { @@ -12309,6 +12312,24 @@ "engines": { "node": ">= 6" } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-error": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/zod-error/-/zod-error-1.5.0.tgz", + "integrity": "sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ==", + "dev": true, + "dependencies": { + "zod": "^3.20.2" + } } } } diff --git a/package.json b/package.json index b4e5449b..fb6cc31d 100755 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "type": "module", "dependencies": { "@formkit/auto-animate": "^0.7.0", - "get-installed-browsers": "^0.1.7", "react": "^18.2.0", "react-dom": "^18.2.0", "vite-plugin-css-injected-by-js": "^3.1.1", @@ -52,13 +51,16 @@ "eslint-plugin-react": "^7.32.1", "eslint-plugin-react-hooks": "^4.3.0", "fs-extra": "^11.1.0", + "get-installed-browsers": "^0.1.7", "nodemon": "^2.0.20", "postcss": "^8.4.21", "prettier": "^2.8.8", "semantic-release": "^21.1.1", "tailwindcss": "^3.2.4", "ts-node": "^10.9.1", - "typescript": "^4.9.4", - "vite": "^4.0.4" + "typescript": "^5.2.2", + "vite": "^4.0.4", + "zod": "^3.22.3", + "zod-error": "^1.5.0" } -} \ No newline at end of file +} diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index 0ef478b9..b0701cb6 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -2,12 +2,14 @@ import "@/assets/styles/tailwind.css"; import "@/components/Settings/Settings.css"; import { useNotifications } from "@/hooks"; -import { configuration, configurationKeys } from "@/src/types"; +import { configuration, configurationKeys, youtubePlayerSpeedRate } from "@/src/types"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import React, { ChangeEvent, Dispatch, SetStateAction, useEffect, useState } from "react"; import { Checkbox, NumberInput, Select, SelectOption } from "../Inputs"; import { settingsAreDefault } from "@/src/utils/utilities"; -import { YoutubePlayerSpeedRates } from "@/src/utils/constants"; +import { configurationSchema } from "@/src/utils/constants"; +import { generateErrorMessage } from "zod-error"; +import { formatDateForFileName } from "../../utils/utilities"; export default function Settings({ settings, @@ -192,7 +194,7 @@ export default function Settings({ { label: "4320p", value: "highres" }, { label: "auto", value: "auto" } ].reverse(); - const YouTubePlayerSpeedOptions: SelectOption[] = YoutubePlayerSpeedRates.map((rate) => ({ label: rate.toString(), value: rate.toString() })); + const YouTubePlayerSpeedOptions: SelectOption[] = youtubePlayerSpeedRate.map((rate) => ({ label: rate.toString(), value: rate.toString() })); const ScreenshotFormatOptions: SelectOption[] = [ { label: "PNG", value: "png" }, { label: "JPEG", value: "jpeg" }, @@ -202,6 +204,66 @@ export default function Settings({ { label: "File", value: "file" }, { label: "Clipboard", value: "clipboard" } ]; + // Import settings from a JSON file. + const importSettings = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.addEventListener("change", async (event) => { + const { target } = event; + if (!target) return; + const { files } = target as HTMLInputElement; + const file = files?.[0]; + if (file) { + try { + const fileContents = await file.text(); + const importedSettings = JSON.parse(fileContents); + // Validate the imported settings. + const result = configurationSchema.safeParse(importedSettings); + if (!result.success) { + const { error } = result; + const errorMessage = generateErrorMessage(error.errors); + window.alert(`Error importing settings. Please check the file format.\n${errorMessage}`); + } else { + const castSettings = importedSettings as configuration; + // Set the imported settings in your state. + setSettings({ ...castSettings }); + Object.assign(localStorage, castSettings); + chrome.storage.local.set(castSettings); + // Show a success notification. + addNotification("success", "Settings imported successfully"); + } + } catch (error) { + // Handle any import errors. + window.alert("Error importing settings. Please check the file format."); + } + } + }); + + // Trigger the file input dialog. + input.click(); + }; + + // Export settings to a JSON file. + const exportSettings = () => { + if (settings) { + const timestamp = formatDateForFileName(new Date()); + const filename = `youtube_enhancer_settings_${timestamp}.json`; + const settingsJSON = JSON.stringify(settings); + + const blob = new Blob([settingsJSON], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + + // Show a success notification. + addNotification("success", "Settings exported successfully"); + } + }; return ( settings && (
@@ -210,7 +272,28 @@ export default function Settings({ YouTube Enhancer v{chrome.runtime.getManifest().version} - +
+ Import/Export Settings +
+ + +
+
Miscellaneous settings
@@ -437,7 +520,7 @@ export default function Settings({ { const fetchSettings = () => { chrome.storage.local.get((settings) => { + for (const [key, value] of Object.entries(settings)) { + settings[key] = parseStoredValue(value); + } setSettings({ ...settings } as configuration); setSelectedColor(settings.osd_display_color); setSelectedDisplayType(settings.osd_display_type); diff --git a/src/features/playerQuality/index.ts b/src/features/playerQuality/index.ts index 0e25f4a6..f0b62b7d 100644 --- a/src/features/playerQuality/index.ts +++ b/src/features/playerQuality/index.ts @@ -1,5 +1,10 @@ -import { YoutubePlayerQualityLabel, YoutubePlayerQualityLevel, YouTubePlayerDiv } from "@/src/types"; -import { YoutubePlayerQualityLabels, YoutubePlayerQualityLevels } from "@/src/utils/constants"; +import { + YoutubePlayerQualityLabel, + YoutubePlayerQualityLevel, + YouTubePlayerDiv, + youtubePlayerQualityLevel, + youtubePlayerQualityLabel +} from "@/src/types"; import { waitForSpecificMessage, isWatchPage, isShortsPage, chooseClosetQuality, browserColorLog } from "@/src/utils/utilities"; /** @@ -48,20 +53,20 @@ export default async function setPlayerQuality(): Promise { if (!availableQualityLevels.includes(playerQuality)) { // Convert the available quality levels to their corresponding labels const availableResolutions = availableQualityLevels.reduce(function (array, elem) { - if (YoutubePlayerQualityLabels[YoutubePlayerQualityLevels.indexOf(elem)]) { - array.push(YoutubePlayerQualityLabels[YoutubePlayerQualityLevels.indexOf(elem)]); + if (youtubePlayerQualityLabel[youtubePlayerQualityLevel.indexOf(elem)]) { + array.push(youtubePlayerQualityLabel[youtubePlayerQualityLevel.indexOf(elem)]); } return array; }, [] as YoutubePlayerQualityLabel[]); // Choose the closest quality level based on the available resolutions - playerQuality = chooseClosetQuality(YoutubePlayerQualityLabels[YoutubePlayerQualityLevels.indexOf(playerQuality)], availableResolutions); + playerQuality = chooseClosetQuality(youtubePlayerQualityLabel[youtubePlayerQualityLevel.indexOf(playerQuality)], availableResolutions); // If the chosen quality level is not available, return - if (!YoutubePlayerQualityLevels.at(YoutubePlayerQualityLabels.indexOf(playerQuality))) return; + if (!youtubePlayerQualityLevel.at(youtubePlayerQualityLabel.indexOf(playerQuality))) return; // Update the playerQuality variable - playerQuality = YoutubePlayerQualityLevels.at(YoutubePlayerQualityLabels.indexOf(playerQuality)) as YoutubePlayerQualityLevel; + playerQuality = youtubePlayerQualityLevel.at(youtubePlayerQualityLabel.indexOf(playerQuality)) as YoutubePlayerQualityLevel; } // Log the message indicating the player quality being set diff --git a/src/types.ts b/src/types.ts index 2408eacd..6ac69c10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,29 +1,38 @@ +import z from "zod"; import type { YouTubePlayer } from "node_modules/@types/youtube-player/dist/types"; /* eslint-disable no-mixed-spaces-and-tabs */ export type Writeable = { -readonly [P in keyof T]: T[P] }; export type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; -export type OnScreenDisplayColor = "red" | "green" | "blue" | "yellow" | "orange" | "purple" | "pink" | "white"; -export type OnScreenDisplayType = "no_display" | "text" | "line" | "round"; -export type OnScreenDisplayPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"; +export const onScreenDisplayColor = ["red", "green", "blue", "yellow", "orange", "purple", "pink", "white"] as const; +export type OnScreenDisplayColor = (typeof onScreenDisplayColor)[number]; +export const onScreenDisplayType = ["no_display", "text", "line", "round"] as const; +export type OnScreenDisplayType = (typeof onScreenDisplayType)[number]; +export const onScreenDisplayPosition = ["top_left", "top_right", "bottom_left", "bottom_right", "center"] as const; +export type OnScreenDisplayPosition = (typeof onScreenDisplayPosition)[number]; +export const youtubePlayerQualityLabel = ["144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p", "2880p", "4320p", "auto"] as const; +export type YoutubePlayerQualityLabel = (typeof youtubePlayerQualityLabel)[number]; +export const youtubePlayerQualityLevel = [ + "tiny", + "small", + "medium", + "large", + "hd720", + "hd1080", + "hd1440", + "hd2160", + "hd2880", + "highres", + "auto" +] as const; +export type YoutubePlayerQualityLevel = (typeof youtubePlayerQualityLevel)[number]; +export const youtubePlayerSpeedRateExtended = [2.25, 2.5, 2.75, 3, 3.25, 3.75, 4] as const; +export const youtubePlayerSpeedRate = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, ...youtubePlayerSpeedRateExtended] as const; +export type YouTubePlayerSpeedRate = (typeof youtubePlayerSpeedRate)[number] | (typeof youtubePlayerSpeedRateExtended)[number]; +export const screenshotType = ["file", "clipboard"] as const; +export type ScreenshotType = (typeof screenshotType)[number]; +export const screenshotFormat = ["png", "jpg", "webp"] as const; -export type YoutubePlayerQualityLabel = "144p" | "240p" | "360p" | "480p" | "720p" | "1080p" | "1440p" | "2160p" | "2880p" | "4320p" | "auto"; -export type YoutubePlayerQualityLevel = - | "tiny" - | "small" - | "medium" - | "large" - | "hd720" - | "hd1080" - | "hd1440" - | "hd2160" - | "hd2880" - | "highres" - | "auto"; -export type YouTubePlayerSpeedRateExpanded = 2.25 | 2.5 | 2.75 | 3 | 3.25 | 3.75 | 4; -export type YouTubePlayerSpeedRate = 0.25 | 0.5 | 0.75 | 1 | 1.25 | 1.5 | 1.75 | 2 | YouTubePlayerSpeedRateExpanded; -export type ScreenshotType = "file" | "clipboard"; - -export type ScreenshotFormat = "png" | "jpeg" | "webp"; +export type ScreenshotFormat = (typeof screenshotFormat)[number]; export type configuration = { enable_scroll_wheel_volume_control: boolean; @@ -109,3 +118,11 @@ export type Messages = MessageMappings[keyof MessageMappings]; export type YouTubePlayerDiv = YouTubePlayer & HTMLDivElement; export type Selector = string; export type StorageChanges = { [key: string]: chrome.storage.StorageChange }; +// Taken from https://github.com/colinhacks/zod/issues/53#issuecomment-1681090113 +export type TypeToZod = { + [K in keyof T]: T[K] extends string | number | boolean | null | undefined + ? undefined extends T[K] + ? z.ZodOptional>> + : z.ZodType + : z.ZodObject>; +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 09468b15..f55578a4 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,5 +1,13 @@ -import type { configuration } from "../types"; - +import z from "zod"; +import type { TypeToZod, configuration } from "../types"; +import { + screenshotFormat, + screenshotType, + onScreenDisplayColor, + onScreenDisplayType, + onScreenDisplayPosition, + youtubePlayerQualityLevel +} from "../types"; export const outputFolderName = "dist"; export const defaultConfiguration = { // Options @@ -26,18 +34,29 @@ export const defaultConfiguration = { player_quality: "auto", player_speed: 1 } satisfies configuration; -export const YoutubePlayerQualityLabels = ["144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p", "2880p", "4320p", "auto"] as const; -export const YoutubePlayerQualityLevels = [ - "tiny", - "small", - "medium", - "large", - "hd720", - "hd1080", - "hd1440", - "hd2160", - "hd2880", - "highres", - "auto" -] as const; -export const YoutubePlayerSpeedRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3, 3.25, 3.5, 3.75, 4] as const; + +export const configurationSchemaProperties = { + enable_scroll_wheel_volume_control: z.boolean(), + enable_remember_last_volume: z.boolean(), + enable_automatically_set_quality: z.boolean(), + enable_forced_playback_speed: z.boolean(), + enable_volume_boost: z.boolean(), + enable_screenshot_button: z.boolean(), + enable_maximize_player_button: z.boolean(), + enable_video_history: z.boolean(), + screenshot_save_as: z.enum(screenshotType), + screenshot_format: z.enum(screenshotFormat), + osd_display_color: z.enum(onScreenDisplayColor), + osd_display_type: z.enum(onScreenDisplayType), + osd_display_position: z.enum(onScreenDisplayPosition), + osd_display_hide_time: z.number(), + osd_display_padding: z.number(), + osd_display_opacity: z.number().min(1).max(100), + volume_adjustment_steps: z.number().min(1).max(100), + volume_boost_amount: z.number(), + player_quality: z.enum(youtubePlayerQualityLevel), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Figure out this error + player_speed: z.number().min(0.25).max(4.0).step(0.25) +} satisfies TypeToZod; +export const configurationSchema = z.object(configurationSchemaProperties); diff --git a/src/utils/utilities.ts b/src/utils/utilities.ts index 85042881..b8af6297 100644 --- a/src/utils/utilities.ts +++ b/src/utils/utilities.ts @@ -387,3 +387,45 @@ export function settingsAreDefault(defaultSettings: Partial, curr // Check if the number of keys that match is the same as the total number of keys return isStrictEqual(settingsTheSame.length)(commonKeys.length); } +export function formatDateForFileName(date: Date): string { + const dateFormatOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "2-digit", + day: "2-digit" + }; + + const timeFormatOptions: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false // Ensure 24-hour time format + }; + + // Get the user's locale + const userLocale = navigator.language || "en-GB"; + + const formattedDate = date.toLocaleDateString(userLocale, dateFormatOptions); + const formattedTime = date.toLocaleTimeString(userLocale, timeFormatOptions); + + // Replace characters that can't be used in a filename + const sanitizedDate = formattedDate.replace(/[\/]/g, "-"); + const sanitizedTime = formattedTime.replace(/[:]/g, "-"); + + return `${sanitizedDate}_${sanitizedTime}`; +} +export function parseStoredValue(value: string) { + try { + // Attempt to parse the value as JSON + const parsedValue = JSON.parse(value); + + // Check if the parsed value is a boolean or a number + if (typeof parsedValue === "boolean" || typeof parsedValue === "number") { + return parsedValue; // Return the parsed value + } + } catch (error) { + // If parsing or type checking fails, return the original value as a string + } + + // If parsing or type checking fails, return the original value as a string + return value; +} diff --git a/yarn.lock b/yarn.lock index 350c26e5..0670a465 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5722,10 +5722,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@^4.9.4, typescript@>=2.7, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta": - version "4.9.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.2.2, typescript@>=2.7, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta": + version "5.2.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== uglify-js@^3.1.4: version "3.17.4" @@ -6031,3 +6031,15 @@ zip-stream@^4.1.0: archiver-utils "^2.1.0" compress-commons "^4.1.0" readable-stream "^3.6.0" + +zod-error@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/zod-error/-/zod-error-1.5.0.tgz" + integrity sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ== + dependencies: + zod "^3.20.2" + +zod@^3.20.2, zod@^3.22.3: + version "3.22.3" + resolved "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz" + integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==