- // ==UserScript==
- // @name YouTube Enhancer (Loop & Screenshot Buttons)
- // @description Add Loop, Save and Copy Screenshot Buttons.
- // @icon https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
- // @version 1.5
- // @author exyezed
- // @namespace https://github.com/exyezed/youtube-enhancer/
- // @supportURL https://github.com/exyezed/youtube-enhancer/issues
- // @license MIT
- // @match https://www.youtube.com/*
- // @grant none
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- const buttonConfig = {
- screenshotFormat: "png",
- extension: 'png',
- clickDuration: 500
- };
-
- const buttonCSS = `
- a.buttonLoopAndScreenshot-loop-button,
- a.buttonLoopAndScreenshot-save-screenshot-button,
- a.buttonLoopAndScreenshot-copy-screenshot-button {
- text-align: center;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 48px;
- height: 48px;
- }
-
- a.buttonLoopAndScreenshot-loop-button svg,
- a.buttonLoopAndScreenshot-save-screenshot-button svg,
- a.buttonLoopAndScreenshot-copy-screenshot-button svg {
- width: 24px;
- height: 24px;
- vertical-align: middle;
- transition: fill 0.2s ease;
- }
-
- a.buttonLoopAndScreenshot-loop-button:hover svg,
- a.buttonLoopAndScreenshot-save-screenshot-button:hover svg,
- a.buttonLoopAndScreenshot-copy-screenshot-button:hover svg {
- fill: url(#buttonGradient);
- }
-
- a.buttonLoopAndScreenshot-loop-button.active svg,
- a.buttonLoopAndScreenshot-save-screenshot-button.clicked svg,
- a.buttonLoopAndScreenshot-copy-screenshot-button.clicked svg {
- fill: url(#successGradient);
- }
-
- .buttonLoopAndScreenshot-shorts-save-button,
- .buttonLoopAndScreenshot-shorts-copy-button {
- display: flex;
- align-items: center;
- justify-content: center;
- margin-top: 16px;
- width: 48px;
- height: 48px;
- border-radius: 50%;
- cursor: pointer;
- transition: background-color 0.3s;
- }
-
- .buttonLoopAndScreenshot-shorts-save-button svg,
- .buttonLoopAndScreenshot-shorts-copy-button svg {
- width: 24px;
- height: 24px;
- transition: fill 0.1s ease;
- }
-
- .buttonLoopAndScreenshot-shorts-save-button svg path,
- .buttonLoopAndScreenshot-shorts-copy-button svg path {
- transition: fill 0.1s ease;
- }
-
- .buttonLoopAndScreenshot-shorts-save-button:hover svg path,
- .buttonLoopAndScreenshot-shorts-copy-button:hover svg path {
- fill: url(#shortsButtonGradient) !important;
- }
-
- .buttonLoopAndScreenshot-shorts-save-button.clicked svg path,
- .buttonLoopAndScreenshot-shorts-copy-button.clicked svg path {
- fill: url(#shortsSuccessGradient) !important;
- }
-
- html[dark] .buttonLoopAndScreenshot-shorts-save-button,
- html[dark] .buttonLoopAndScreenshot-shorts-copy-button {
- background-color: rgba(255, 255, 255, 0.1);
- }
-
- html[dark] .buttonLoopAndScreenshot-shorts-save-button:hover,
- html[dark] .buttonLoopAndScreenshot-shorts-copy-button:hover {
- background-color: rgba(255, 255, 255, 0.2);
- }
-
- html[dark] .buttonLoopAndScreenshot-shorts-save-button svg path,
- html[dark] .buttonLoopAndScreenshot-shorts-copy-button svg path {
- fill: white;
- }
-
- html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button,
- html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button {
- background-color: rgba(0, 0, 0, 0.05);
- }
-
- html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button:hover,
- html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button:hover {
- background-color: rgba(0, 0, 0, 0.1);
- }
-
- html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button svg path,
- html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button svg path {
- fill: #030303;
- }
- `;
-
- const iconUtils = {
- createGradientDefs(isShortsButton = false) {
- const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
-
- const hoverGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
- hoverGradient.setAttribute('id', isShortsButton ? 'shortsButtonGradient' : 'buttonGradient');
- hoverGradient.setAttribute('x1', '0%');
- hoverGradient.setAttribute('y1', '0%');
- hoverGradient.setAttribute('x2', '100%');
- hoverGradient.setAttribute('y2', '100%');
-
- const hoverStop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
- hoverStop1.setAttribute('offset', '0%');
- hoverStop1.setAttribute('style', 'stop-color:#f03');
-
- const hoverStop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
- hoverStop2.setAttribute('offset', '100%');
- hoverStop2.setAttribute('style', 'stop-color:#ff2791');
-
- hoverGradient.appendChild(hoverStop1);
- hoverGradient.appendChild(hoverStop2);
- defs.appendChild(hoverGradient);
-
- const successGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
- successGradient.setAttribute('id', isShortsButton ? 'shortsSuccessGradient' : 'successGradient');
- successGradient.setAttribute('x1', '0%');
- successGradient.setAttribute('y1', '0%');
- successGradient.setAttribute('x2', '100%');
- successGradient.setAttribute('y2', '100%');
-
- const successStop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
- successStop1.setAttribute('offset', '0%');
- successStop1.setAttribute('style', 'stop-color:#0f9d58');
-
- const successStop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
- successStop2.setAttribute('offset', '100%');
- successStop2.setAttribute('style', 'stop-color:#00c853');
-
- successGradient.appendChild(successStop1);
- successGradient.appendChild(successStop2);
- defs.appendChild(successGradient);
-
- return defs;
- },
-
- createBaseSVG(viewBox, fill = '#e8eaed', isShortsButton = false) {
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
- svg.setAttribute('height', '24px');
- svg.setAttribute('viewBox', viewBox);
- svg.setAttribute('width', '24px');
- svg.setAttribute('fill', fill);
- svg.appendChild(this.createGradientDefs(isShortsButton));
- return svg;
- },
-
- paths: {
- loopPath: 'M220-260q-92 0-156-64T0-480q0-92 64-156t156-64q37 0 71 13t61 37l68 62-60 54-62-56q-16-14-36-22t-42-8q-58 0-99 41t-41 99q0 58 41 99t99 41q22 0 42-8t36-22l310-280q27-24 61-37t71-13q92 0 156 64t64 156q0 92-64 156t-156 64q-37 0-71-13t-61-37l-68-62 60-54 62 56q16 14 36 22t42 8q58 0 99-41t41-99q0-58-41-99t-99-41q-22 0-42 8t-36 22L352-310q-27 24-61 37t-71 13Z',
- screenshotPath: 'M20 5h-3.17l-1.24-1.35A2 2 0 0 0 14.12 3H9.88c-.56 0-1.1.24-1.47.65L7.17 5H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m-3 12H7a.5.5 0 0 1-.4-.8l2-2.67c.2-.27.6-.27.8 0L11.25 16l2.6-3.47c.2-.27.6-.27.8 0l2.75 3.67a.5.5 0 0 1-.4.8',
- copyScreenshotPath: 'M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm0-2h12V4H8zm-4 6q-.825 0-1.412-.587T2 20V6h2v14h14v2zM8 4h12v12H8z'
- },
-
- createLoopIcon() {
- const svg = this.createBaseSVG('0 -960 960 960');
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', this.paths.loopPath);
-
- svg.appendChild(path);
- return svg;
- },
-
- createSaveScreenshotIcon(isShortsButton = false) {
- const svg = this.createBaseSVG('0 0 24 24', '#e8eaed', isShortsButton);
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', this.paths.screenshotPath);
-
- svg.appendChild(path);
- return svg;
- },
-
- createCopyScreenshotIcon(isShortsButton = false) {
- const svg = this.createBaseSVG('0 0 24 24', '#e8eaed', isShortsButton);
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', this.paths.copyScreenshotPath);
-
- svg.appendChild(path);
- return svg;
- }
- };
-
- const buttonUtils = {
- addStyle(styleString) {
- const style = document.createElement('style');
- style.textContent = styleString;
- document.head.append(style);
- },
-
- getVideoId() {
- const urlParams = new URLSearchParams(window.location.search);
- return urlParams.get('v') || window.location.pathname.split('/').pop();
- },
-
- getApiKey() {
- const scripts = document.getElementsByTagName('script');
- for (const script of scripts) {
- const match = script.textContent.match(/"INNERTUBE_API_KEY":\s*"([^"]+)"/);
- if (match && match[1]) return match[1];
- }
- return null;
- },
-
- getClientInfo() {
- const scripts = document.getElementsByTagName('script');
- let clientName = null;
- let clientVersion = null;
-
- for (const script of scripts) {
- const nameMatch = script.textContent.match(/"INNERTUBE_CLIENT_NAME":\s*"([^"]+)"/);
- const versionMatch = script.textContent.match(/"INNERTUBE_CLIENT_VERSION":\s*"([^"]+)"/);
-
- if (nameMatch && nameMatch[1]) clientName = nameMatch[1];
- if (versionMatch && versionMatch[1]) clientVersion = versionMatch[1];
- }
-
- return { clientName, clientVersion };
- },
-
- async fetchVideoDetails(videoId) {
- try {
- const apiKey = this.getApiKey();
- if (!apiKey) return null;
-
- const { clientName, clientVersion } = this.getClientInfo();
- if (!clientName || !clientVersion) return null;
-
- const response = await fetch(`https://www.youtube.com/youtubei/v1/player?key=${apiKey}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- videoId: videoId,
- context: {
- client: {
- clientName: clientName,
- clientVersion: clientVersion,
- }
- }
- })
- });
-
- if (!response.ok) return null;
- const data = await response.json();
- if (data && data.videoDetails && data.videoDetails.title) {
- return data.videoDetails.title;
- }
- return 'YouTube Video';
- } catch (error) {
- return 'YouTube Video';
- }
- },
-
- async getVideoTitle(callback) {
- const videoId = this.getVideoId();
- const title = await this.fetchVideoDetails(videoId);
- callback(title || 'YouTube Video');
- },
-
- formatTime(time) {
- const date = new Date();
- const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
- const timeString = [
- Math.floor(time / 3600),
- Math.floor((time % 3600) / 60),
- Math.floor(time % 60)
- ].map(v => v.toString().padStart(2, '0')).join('-');
- return `${dateString} ${timeString}`;
- },
-
- async copyToClipboard(blob) {
- const clipboardItem = new ClipboardItem({ "image/png": blob });
- await navigator.clipboard.write([clipboardItem]);
- },
-
- downloadScreenshot(blob, filename) {
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.style.display = 'none';
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- },
-
- captureScreenshot(player, action = 'download') {
- if (!player) return;
-
- const canvas = document.createElement("canvas");
- canvas.width = player.videoWidth;
- canvas.height = player.videoHeight;
- canvas.getContext('2d').drawImage(player, 0, 0, canvas.width, canvas.height);
-
- this.getVideoTitle((title) => {
- const time = player.currentTime;
- const filename = `${title} ${this.formatTime(time)}.${buttonConfig.extension}`;
-
- canvas.toBlob(async (blob) => {
- if (action === 'copy') {
- await this.copyToClipboard(blob);
- } else {
- this.downloadScreenshot(blob, filename);
- }
- }, `image/${buttonConfig.screenshotFormat}`);
- });
- }
- };
-
- const regularVideo = {
- init() {
- this.waitForControls().then(() => {
- this.insertLoopElement();
- this.insertSaveScreenshotElement();
- this.insertCopyScreenshotElement();
- this.addObserver();
- this.addContextMenuListener();
- });
- },
-
- waitForControls() {
- return new Promise((resolve, reject) => {
- let attempts = 0;
- const maxAttempts = 50;
-
- const checkControls = () => {
- const controls = document.querySelector('div.ytp-left-controls');
- if (controls) {
- resolve(controls);
- } else if (attempts >= maxAttempts) {
- reject(new Error('Controls not found after maximum attempts'));
- } else {
- attempts++;
- setTimeout(checkControls, 100);
- }
- };
-
- checkControls();
- });
- },
-
- insertLoopElement() {
- const controls = document.querySelector('div.ytp-left-controls');
- if (!controls) return;
-
- if (document.querySelector('.buttonLoopAndScreenshot-loop-button')) return;
-
- const newButton = document.createElement('a');
- newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-loop-button');
- newButton.title = 'Loop Video';
- newButton.appendChild(iconUtils.createLoopIcon());
- newButton.addEventListener('click', this.toggleLoopState);
-
- controls.appendChild(newButton);
- },
-
- insertSaveScreenshotElement() {
- const controls = document.querySelector('div.ytp-left-controls');
- if (!controls) return;
-
- if (document.querySelector('.buttonLoopAndScreenshot-save-screenshot-button')) return;
-
- const newButton = document.createElement('a');
- newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-save-screenshot-button');
- newButton.title = 'Save Screenshot';
- newButton.appendChild(iconUtils.createSaveScreenshotIcon());
- newButton.addEventListener('click', this.handleSaveScreenshotClick);
-
- const loopButton = document.querySelector('.buttonLoopAndScreenshot-loop-button');
- if (loopButton) {
- loopButton.parentNode.insertBefore(newButton, loopButton.nextSibling);
- } else {
- controls.appendChild(newButton);
- }
- },
-
- insertCopyScreenshotElement() {
- const controls = document.querySelector('div.ytp-left-controls');
- if (!controls) return;
-
- if (document.querySelector('.buttonLoopAndScreenshot-copy-screenshot-button')) return;
-
- const newButton = document.createElement('a');
- newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-copy-screenshot-button');
- newButton.title = 'Copy Screenshot to Clipboard';
- newButton.appendChild(iconUtils.createCopyScreenshotIcon());
- newButton.addEventListener('click', this.handleCopyScreenshotClick);
-
- const saveButton = document.querySelector('.buttonLoopAndScreenshot-save-screenshot-button');
- if (saveButton) {
- saveButton.parentNode.insertBefore(newButton, saveButton.nextSibling);
- } else {
- controls.appendChild(newButton);
- }
- },
-
- toggleLoopState() {
- const video = document.querySelector('video');
- video.loop = !video.loop;
- if (video.loop) video.play();
-
- regularVideo.updateToggleControls();
- },
-
- updateToggleControls() {
- const youtubeVideoLoop = document.querySelector('.buttonLoopAndScreenshot-loop-button');
- youtubeVideoLoop.classList.toggle('active');
- youtubeVideoLoop.setAttribute('title', this.isActive() ? 'Stop Looping' : 'Loop Video');
- },
-
- isActive() {
- const youtubeVideoLoop = document.querySelector('.buttonLoopAndScreenshot-loop-button');
- return youtubeVideoLoop.classList.contains('active');
- },
-
- addObserver() {
- const video = document.querySelector('video');
- new MutationObserver((mutations) => {
- mutations.forEach(() => {
- if ((video.getAttribute('loop') === null && this.isActive()) ||
- (video.getAttribute('loop') !== null && !this.isActive())) this.updateToggleControls();
- });
- }).observe(video, { attributes: true, attributeFilter: ['loop'] });
- },
-
- addContextMenuListener() {
- const video = document.querySelector('video');
- video.addEventListener('contextmenu', () => {
- setTimeout(() => {
- const checkbox = document.querySelector('[role=menuitemcheckbox]');
- checkbox.setAttribute('aria-checked', this.isActive());
- checkbox.addEventListener('click', this.toggleLoopState);
- }, 50);
- });
- },
-
- handleSaveScreenshotClick(event) {
- const button = event.currentTarget;
- button.classList.add('clicked');
- setTimeout(() => {
- button.classList.remove('clicked');
- }, buttonConfig.clickDuration);
-
- const player = document.querySelector('video');
- buttonUtils.captureScreenshot(player, 'download');
- },
-
- handleCopyScreenshotClick(event) {
- const button = event.currentTarget;
- button.classList.add('clicked');
- setTimeout(() => {
- button.classList.remove('clicked');
- }, buttonConfig.clickDuration);
-
- const player = document.querySelector('video');
- buttonUtils.captureScreenshot(player, 'copy');
- }
- };
-
- const shortsVideo = {
- init() {
- this.insertSaveScreenshotElement();
- this.insertCopyScreenshotElement();
- },
-
- insertSaveScreenshotElement() {
- const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
- if (shortsContainer && !shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-save-button')) {
- const iconDiv = document.createElement('div');
- iconDiv.className = 'buttonLoopAndScreenshot-shorts-save-button';
- iconDiv.title = 'Save Screenshot';
- iconDiv.appendChild(iconUtils.createSaveScreenshotIcon(true));
-
- const customShortsIcon = shortsContainer.querySelector('#custom-shorts-icon');
- if (customShortsIcon) {
- customShortsIcon.parentNode.insertBefore(iconDiv, customShortsIcon);
- } else {
- shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
- }
-
- iconDiv.addEventListener('click', (event) => {
- const button = event.currentTarget;
- button.classList.add('clicked');
-
- setTimeout(() => {
- button.classList.remove('clicked');
- }, buttonConfig.clickDuration);
-
- this.captureScreenshot('download');
- });
- }
- },
-
- insertCopyScreenshotElement() {
- const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
- if (shortsContainer && !shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-copy-button')) {
- const iconDiv = document.createElement('div');
- iconDiv.className = 'buttonLoopAndScreenshot-shorts-copy-button';
- iconDiv.title = 'Copy Screenshot to Clipboard';
- iconDiv.appendChild(iconUtils.createCopyScreenshotIcon(true));
-
- const saveButton = shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-save-button');
- if (saveButton) {
- saveButton.parentNode.insertBefore(iconDiv, saveButton.nextSibling);
- } else {
- const customShortsIcon = shortsContainer.querySelector('#custom-shorts-icon');
- if (customShortsIcon) {
- customShortsIcon.parentNode.insertBefore(iconDiv, customShortsIcon);
- } else {
- shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
- }
- }
-
- iconDiv.addEventListener('click', (event) => {
- const button = event.currentTarget;
- button.classList.add('clicked');
-
- setTimeout(() => {
- button.classList.remove('clicked');
- }, buttonConfig.clickDuration);
-
- this.captureScreenshot('copy');
- });
- }
- },
-
- captureScreenshot(action) {
- const player = document.querySelector('ytd-reel-video-renderer[is-active] video');
- buttonUtils.captureScreenshot(player, action);
- }
- };
-
- const themeHandler = {
- init() {
- this.updateStyles();
- this.addObserver();
- },
-
- updateStyles() {
- const isDarkTheme = document.documentElement.hasAttribute('dark');
- document.documentElement.classList.toggle('dark-theme', isDarkTheme);
- },
-
- addObserver() {
- const observer = new MutationObserver(() => this.updateStyles());
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['dark']
- });
- }
- };
-
- function initialize() {
- buttonUtils.addStyle(buttonCSS);
- waitForVideo().then(initializeWhenReady);
- }
-
- function waitForVideo() {
- return new Promise((resolve) => {
- const checkVideo = () => {
- if (document.querySelector('video')) {
- resolve();
- } else {
- setTimeout(checkVideo, 100);
- }
- };
- checkVideo();
- });
- }
-
- function initializeWhenReady() {
- initializeFeatures();
- }
-
- function initializeFeatures() {
- regularVideo.init();
- themeHandler.init();
- initializeShortsFeatures();
- }
-
- function initializeShortsFeatures() {
- if (window.location.pathname.includes('/shorts/')) {
- setTimeout(shortsVideo.init.bind(shortsVideo), 500);
- }
- }
-
- const shortsObserver = new MutationObserver((mutations) => {
- for (let mutation of mutations) {
- if (mutation.type === 'childList') {
- initializeShortsFeatures();
- }
- }
- });
-
- shortsObserver.observe(document.body, { childList: true, subtree: true });
-
- window.addEventListener('yt-navigate-finish', initializeShortsFeatures);
-
- document.addEventListener('yt-action', function(event) {
- if (event.detail && event.detail.actionName === 'yt-reload-continuation-items-command') {
- initializeShortsFeatures();
- }
- });
-
- initialize();
- })();