├── background ├── index.ts └── messages │ └── download.ts ├── assets ├── icon.png └── download.svg ├── tsconfig.json ├── utils └── sanitizeFilename.ts ├── .gitignore ├── .prettierrc.mjs ├── README.md ├── .github └── workflows │ └── submit.yml ├── package.json ├── contents ├── style.css ├── details.tsx └── home.tsx └── components └── downloadButton.tsx /background/index.ts: -------------------------------------------------------------------------------- 1 | import "@plasmohq/messaging/background" 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asif-jalil/yt-thumbnail-downloader-extension/HEAD/assets/icon.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": ["node_modules"], 4 | "include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"], 5 | "compilerOptions": { 6 | "paths": { 7 | "~*": ["./*"] 8 | }, 9 | "baseUrl": "." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /utils/sanitizeFilename.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeFilename(filename) { 2 | // Replace reserved characters with underscores 3 | filename = filename.replace(/[^0-9A-Za-z]/g, "_") 4 | 5 | // Replace whitespace with underscores 6 | filename = filename.replace(/\s/g, "_") 7 | 8 | // Remove trailing dots (Windows doesn't allow them) 9 | filename = filename.replace(/\.+$/, "") 10 | 11 | // Truncate filename if it's too long (max 255 characters for most filesystems) 12 | if (filename.length > 255) { 13 | filename = filename.substring(0, 255) 14 | } 15 | 16 | return filename 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | #cache 13 | .turbo 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .pnpm-debug.log* 24 | 25 | # local env files 26 | .env* 27 | 28 | out/ 29 | build/ 30 | dist/ 31 | 32 | # plasmo - https://www.plasmo.com 33 | .plasmo 34 | 35 | # bpp - http://bpp.browser.market/ 36 | keys.json 37 | 38 | # typescript 39 | .tsbuildinfo 40 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | export default { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: false, 10 | trailingComma: "none", 11 | bracketSpacing: true, 12 | bracketSameLine: true, 13 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 14 | importOrder: [ 15 | "", // Node.js built-in modules 16 | "", // Imports not matched by other special words or groups. 17 | "", // Empty line 18 | "^@plasmo/(.*)$", 19 | "", 20 | "^@plasmohq/(.*)$", 21 | "", 22 | "^~(.*)$", 23 | "", 24 | "^[./]" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # youtube-thumbnail-downloader 2 | 3 | ## Technologies 4 | 5 | - ReactJs 6 | - Typescript 7 | - Plasmo 8 | 9 | ## Run Locally 10 | 11 | Clone the project 12 | 13 | ```bash 14 | git clone git@github.com:asif-jalil/yt-thumbnail-downloader-extension.git 15 | ``` 16 | 17 | Go to the project directory 18 | 19 | ```bash 20 | cd yt-thumbnail-downloader-extension 21 | ``` 22 | 23 | Install dependencies 24 | 25 | ```bash 26 | pnpm install 27 | ``` 28 | 29 | Start the dev server 30 | 31 | ```bash 32 | pnpm run dev 33 | ``` 34 | 35 | ## How to 36 | 37 | - Run on local machine : `pnpm dev` 38 | - Build for production : `pnpm build` 39 | - Package ready: `pnpm package` 40 | -------------------------------------------------------------------------------- /background/messages/download.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | 3 | import { sanitizeFilename } from "~utils/sanitizeFilename" 4 | 5 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 6 | const imageUrl = `https://img.youtube.com/vi/${req.body.videoId}/${req.body.videoType}.jpg` 7 | const title = sanitizeFilename(req.body.videoTitle) 8 | 9 | chrome.downloads.download( 10 | { 11 | url: imageUrl, 12 | filename: `${title}_${req.body.videoType}.jpg` 13 | }, 14 | (downloadId) => { 15 | // Handle download success or failure 16 | if (downloadId !== undefined) { 17 | console.log("Download started successfully") 18 | } else { 19 | console.error("Download failed") 20 | } 21 | } 22 | ) 23 | } 24 | 25 | export default handler 26 | -------------------------------------------------------------------------------- /.github/workflows/submit.yml: -------------------------------------------------------------------------------- 1 | name: "Submit to Web Store" 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Cache pnpm modules 11 | uses: actions/cache@v3 12 | with: 13 | path: ~/.pnpm-store 14 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 15 | restore-keys: | 16 | ${{ runner.os }}- 17 | - uses: pnpm/action-setup@v2.2.4 18 | with: 19 | version: latest 20 | run_install: true 21 | - name: Use Node.js 16.x 22 | uses: actions/setup-node@v3.4.1 23 | with: 24 | node-version: 16.x 25 | cache: "pnpm" 26 | - name: Build the extension 27 | run: pnpm build 28 | - name: Package the extension into a zip artifact 29 | run: pnpm package 30 | - name: Browser Platform Publish 31 | uses: PlasmoHQ/bpp@v3 32 | with: 33 | keys: ${{ secrets.SUBMIT_KEYS }} 34 | artifact: build/chrome-mv3-prod.zip 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-thumbnail-downloader", 3 | "displayName": "Youtube Thumbnail Downloader", 4 | "version": "1.1.0", 5 | "description": "Download thumbnail from youtube videos easily", 6 | "author": "Asif Jalil ", 7 | "scripts": { 8 | "dev": "plasmo dev", 9 | "build": "plasmo build", 10 | "package": "plasmo package" 11 | }, 12 | "dependencies": { 13 | "@plasmohq/messaging": "^0.6.2", 14 | "plasmo": "0.84.2", 15 | "react": "18.2.0", 16 | "react-dom": "18.2.0", 17 | "wildcard-match": "^5.1.2" 18 | }, 19 | "devDependencies": { 20 | "@ianvs/prettier-plugin-sort-imports": "4.1.1", 21 | "@types/chrome": "0.0.258", 22 | "@types/node": "20.11.5", 23 | "@types/react": "18.2.48", 24 | "@types/react-dom": "18.2.18", 25 | "prettier": "3.2.4", 26 | "typescript": "5.3.3" 27 | }, 28 | "manifest": { 29 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmRc1zoCStfOAsJXAKDJxXJOJgdh/MWL8IzNA064YgdM17BCTKGysyklpLyR+Fb5pZ/p+ub/GjhWScXLKOSozcndu7yLcXMUFF8MR/mM7P1XNrnvqdztrTKzAYUbj+4t28Nx/To+2styv6arQGaoLDem3bl03tImZFAdnn/WnY6CgcUkxgXRvjtHh02n0lbIOVr+UKHPGCoHtkIiVH53ujEFwsSgbd/4IoBE57xmlFjsIhVijJI8p+w0NMmj83Cf+itsmujFNxuVi9WVFFHXROwkJvjxZzuA705v5zH+deOaXA1+eMDH8mJgi0Dz93VVgSRwYgIrF1G/jH3m6noPlbwIDAQAB", 30 | "host_permissions": [ 31 | "https://www.youtube.com/*" 32 | ], 33 | "permissions": [ 34 | "downloads" 35 | ] 36 | }, 37 | "keywords": [ 38 | "youtube", 39 | "best", 40 | "thumbnail downloader", 41 | "thumbnail", 42 | "youtube thumbnail downloader" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /contents/style.css: -------------------------------------------------------------------------------- 1 | #plasmo-shadow-container { 2 | position: absolute !important; 3 | top: 4px; 4 | left: 4px; 5 | z-index: 999 !important; 6 | } 7 | 8 | .wrapper { 9 | display: flex; 10 | } 11 | 12 | button { 13 | outline: none; 14 | border: none; 15 | cursor: pointer; 16 | } 17 | 18 | .download-btn { 19 | background-color: rgba(0, 0, 0, 0.7); 20 | width: 32px; 21 | height: 32px; 22 | padding: 0 4px; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | border-radius: 3px; 27 | opacity: 0.7; 28 | transition: opacity 0.2s ease; 29 | } 30 | 31 | .wrapper:hover .download-btn { 32 | opacity: 1; 33 | } 34 | 35 | .size-wrapper { 36 | display: flex; 37 | width: 0; 38 | overflow: hidden; 39 | background-color: rgba(0, 0, 0, 0.8); 40 | padding: 6px 0px; 41 | transition: width 0.3s ease; 42 | } 43 | 44 | .size-wrapper.open { 45 | width: 226; 46 | } 47 | 48 | .size-btn { 49 | padding: 1px 6px; 50 | background-color: transparent; 51 | font-size: 12px; 52 | font-weight: 600; 53 | color: #fff; 54 | border-right: 1px solid rgba(255, 255, 255, 0.7); 55 | opacity: 0.5; 56 | transition: opacity 0.2s ease; 57 | } 58 | 59 | .wrapper:hover .size-btn { 60 | opacity: 1; 61 | } 62 | 63 | .html5-video-player .home-button-position { 64 | margin-top: 8px; 65 | } 66 | 67 | .wrapper, 68 | .html5-video-player.paused-mode .wrapper { 69 | opacity: 1; 70 | } 71 | 72 | .html5-video-player.ytp-autohide .wrapper { 73 | opacity: 0; 74 | } 75 | 76 | .html5-video-player .download-btn, 77 | .html5-video-player .size-btn { 78 | opacity: 1; 79 | } 80 | 81 | .size-btn:last-child { 82 | border-right: none; 83 | } 84 | -------------------------------------------------------------------------------- /components/downloadButton.tsx: -------------------------------------------------------------------------------- 1 | import downloadIcon from "data-base64:~assets/download.svg" 2 | import { useState } from "react" 3 | 4 | import { sendToBackground } from "@plasmohq/messaging" 5 | 6 | export const DownloadButton = ({ videoId, videoTitle }) => { 7 | const [isOpen, setIsOpen] = useState(false) 8 | 9 | const download = async ( 10 | e: React.MouseEvent, 11 | videoType: string 12 | ) => { 13 | e.stopPropagation() 14 | 15 | await sendToBackground({ 16 | name: "download", 17 | body: { 18 | videoId, 19 | videoTitle, 20 | videoType 21 | } 22 | }) 23 | } 24 | 25 | const handleOption = (e: React.MouseEvent) => { 26 | e.stopPropagation() 27 | 28 | setIsOpen((prev) => !prev) 29 | } 30 | 31 | window.addEventListener("click", () => { 32 | setIsOpen(false) 33 | }) 34 | 35 | return ( 36 |
37 | 40 |
41 | 46 | 49 | 52 | 55 | 58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /assets/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /contents/details.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | PlasmoCSConfig, 3 | PlasmoCSUIJSXContainer, 4 | PlasmoRender 5 | } from "plasmo" 6 | import { useEffect, useState } from "react" 7 | import { createRoot } from "react-dom/client" 8 | 9 | import { DownloadButton } from "~components/downloadButton" 10 | 11 | import "./style.css" 12 | 13 | import wcmatch from "wildcard-match" 14 | 15 | export const config: PlasmoCSConfig = { 16 | matches: ["https://*.youtube.com/*"] 17 | } 18 | 19 | export const getRootContainer = () => 20 | new Promise((resolve) => { 21 | const checkInterval = setInterval(() => { 22 | const rootContainerParent = document.querySelector(`.ytp-chrome-top`) 23 | if (rootContainerParent) { 24 | clearInterval(checkInterval) 25 | const rootContainer = document.createElement("div") 26 | rootContainer.style.width = "100%" 27 | rootContainerParent.prepend(rootContainer) 28 | resolve(rootContainer) 29 | } 30 | }, 137) 31 | }) 32 | 33 | const DetailsPage = ({ anchor }) => { 34 | const [videoId, setVideoId] = useState("") 35 | const [videoTitle, setVideoTitle] = useState("") 36 | 37 | useEffect(() => { 38 | try { 39 | const urlParams = new URLSearchParams( 40 | new URL(window.location.href).search 41 | ) 42 | const title = anchor.element.querySelector(".ytp-title-link").innerText 43 | 44 | const videoId = urlParams.get("v") 45 | setVideoId(videoId) 46 | setVideoTitle(title) 47 | } catch { 48 | console.log("video is not supported") 49 | } 50 | }, [anchor.element, window.location.href]) 51 | 52 | return ( 53 |
54 | 55 |
56 | ) 57 | } 58 | 59 | export const render: PlasmoRender = async ({ 60 | anchor, 61 | createRootContainer 62 | }) => { 63 | const rootContainer = await createRootContainer() 64 | 65 | const config = { attributes: true, childList: true, subtree: true } 66 | 67 | let oldUrl: string 68 | let root 69 | 70 | const callback = (mutationList, observer) => { 71 | if (oldUrl !== window.location.href) { 72 | oldUrl = window.location.href 73 | 74 | const targetNode = document.querySelector(".ytp-chrome-top") 75 | 76 | if (!root) { 77 | root = createRoot(rootContainer) 78 | } 79 | 80 | const isMatch = wcmatch("https://www.youtube.com/watch*", { 81 | separator: "." 82 | }) 83 | const isMatchingUrl = isMatch(window.location.href) 84 | 85 | if (isMatchingUrl && targetNode.children.length) { 86 | root.render() 87 | } else { 88 | if (root) { 89 | root.unmount() 90 | root = null 91 | } 92 | } 93 | } 94 | } 95 | 96 | const observer = new MutationObserver(callback) 97 | 98 | observer.observe(document.querySelector("body"), config) 99 | } 100 | 101 | export default DetailsPage 102 | -------------------------------------------------------------------------------- /contents/home.tsx: -------------------------------------------------------------------------------- 1 | import cssText from "data-text:~contents/style.css" 2 | import type { 3 | PlasmoCSConfig, 4 | PlasmoGetInlineAnchorList, 5 | PlasmoRender 6 | } from "plasmo" 7 | import { useEffect, useState } from "react" 8 | import { createRoot } from "react-dom/client" 9 | import wcmatch from "wildcard-match" 10 | 11 | import { DownloadButton } from "~components/downloadButton" 12 | 13 | export const config: PlasmoCSConfig = { 14 | matches: ["https://*.youtube.com/*"] 15 | } 16 | 17 | export const getStyle = () => { 18 | const style = document.createElement("style") 19 | style.textContent = cssText 20 | return style 21 | } 22 | 23 | export const getInlineAnchorList: PlasmoGetInlineAnchorList = async () => 24 | document.querySelectorAll("a#thumbnail.ytd-thumbnail") 25 | 26 | const HomePage = ({ anchor }) => { 27 | const [videoId, setVideoId] = useState("") 28 | const [videoTitle, setVideoTitle] = useState("") 29 | 30 | useEffect(() => { 31 | try { 32 | const urlParams = new URLSearchParams(new URL(anchor.element.href).search) 33 | const title = 34 | anchor.element.parentNode.parentNode.parentNode.querySelector( 35 | "#video-title-link" 36 | )?.title || 37 | anchor.element.parentNode.parentNode.parentNode.querySelector( 38 | "#video-title" 39 | )?.title || 40 | "Unnamed" 41 | 42 | const videoId = urlParams.get("v") 43 | setVideoId(videoId) 44 | setVideoTitle(title) 45 | } catch { 46 | console.log("video is not supported") 47 | } 48 | }, [anchor.element.href]) 49 | 50 | return ( 51 |
52 | 53 |
54 | ) 55 | } 56 | 57 | export const render: PlasmoRender = async ({ 58 | anchor, 59 | createRootContainer 60 | }) => { 61 | const rootContainer = await createRootContainer({ 62 | type: "inline", 63 | element: anchor.element 64 | }) 65 | 66 | const config = { attributes: true, childList: true, subtree: true } 67 | 68 | let root 69 | 70 | const callback = async (mutationList, observer) => { 71 | const homeTargetNode = document.querySelector( 72 | "#contents.ytd-rich-grid-renderer" 73 | ) 74 | const channelTargetNode = document.querySelector( 75 | "#contents.style-scope ytd-shelf-renderer" 76 | ) 77 | 78 | if (!root) { 79 | root = createRoot(rootContainer) 80 | } 81 | 82 | const isMatch = wcmatch( 83 | ["https://www.youtube.com/", "https://www.youtube.com/@*"], 84 | { 85 | separator: "." 86 | } 87 | ) 88 | const isMatchingUrl = isMatch(window.location.href) 89 | 90 | if ( 91 | isMatchingUrl && 92 | (homeTargetNode?.children.length || channelTargetNode?.children.length) 93 | ) { 94 | root.render() 95 | } else { 96 | if (root) { 97 | root.unmount() 98 | root = null 99 | } 100 | } 101 | } 102 | 103 | const observer = new MutationObserver(callback) 104 | 105 | observer.observe(document.querySelector("body"), config) 106 | } 107 | 108 | export default HomePage 109 | --------------------------------------------------------------------------------