您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a discrete icon to the YouTube header to automatically copy video links for VR. History toggles on click.
// ==UserScript== // @name Cottonee's VR Clipboard // @namespace http://tampermonkey.net/ // @version 2.1 // @description Adds a discrete icon to the YouTube header to automatically copy video links for VR. History toggles on click. // @author Cottonee // @match *://www.youtube.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @icon data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAABJpO3/TKfr/xgZGQMAAAAAAQECFqKxyP6vw+X/sMTl/6/E5P+htN7/scTj/36OpP9WZLv/H1R0/xhTcf9ZsOr/XLTq/xdqvf8YGBkBNz1GyrDE5f+vxOX/s8fm/zRLsv9FYN3/l6nV/7LE5P+xxOX/fIeU/yVSdP8gVHL/Xrbr/1Ow6f8HT47/ICooQ7HD5f+xxOP/r8Tj/5Giw/8bHYX/Fhp//yItmv9GYeX/ssXk/7DE5P8xPUz/IU1s/2C65/9et+b/CzRZ/6q65v+ktuf/q77l/7DD5f+xxOT/sMTl/7DE5f+yxOX/XWqg/7DE5f+vxOP/scTl/xArQP9lv+X/Zr7k/z1Ga/+Upuz/j6Pt/5mt6v+sveX/r8Tk/7DE5P+vxOT/r8Tk/6/E4/+vxOX/sMTl/7DE5f9jc7v/acHk/2Syz/+svef/lKXs/46g6/+Yqur/qLnn/6/E5P+outb/r8Tk/7DD5P+tveb/qbvm/6q95f+uweP/YHG2/2zE4/9EY43/scPl/66+5f+ouOD/p7nm/7DB5f+wxOX/mKrc/7DE5f+tv+X/obPp/5Wo7P+RpO3/mKrs/1Bpqf9rw+L/HCRV/0lCWP/Nztb/FgkB/xQNAv+nrsH/sMTl/7HE5f9Wh7z/sMPj/6a55/+Xqev/j6Ds/5Oj6/9Ha5//bcXj/0NQkv+Lmr7/AgEF/wEBAf8BAQL/T2Ke/7DE5f95stP/aout/7DF5f/KzNv/WWeM/6/C6f+qu+j/S3yo/27H4/8xTo//ssTl/zhojP+aq87/iZrO/z9Rkf9AWn7/ba/c/42nvf9ebaX/Wk9t/0I9P/8aEQP/Pjk4/02Jsf9uyuT/NmqR/7LE5f9BmNH/lqTC/3mIu/8bMlr/YL3e/3bI5P+ru9r/NESM/7fL6P8BAAD/AQEB/wAAAf9Jf5X/bs3k/2fD1f+tvuT/QKTY/6G04v+SsM3/R5Os/2zO5f960OX/mafd/7LC5f9WdJT/sMTl/7DE5f+zxej/bcDb/3DO5f9y0+f/TV6w/0qy3f9EUo//UZa6/2/R5v9u0eb/b9Dm/3CCy/+2x+b/Sn+R/6a2zP+hsd7/VKrX/1KVpv9v0Ob/cdHm/x07eP9dxOP/CVuZ/2XE1v9sz+X/bdHn/23R5v9HV6X/asHU/1GQov85XYf/d8nh/zug0/9jssT/b9Ln/3DR5v8KXaD/cNHn/xOG0v9x1Of/bdHo/4TT5v9u0+j/CBUk/3HR5v8ha5T/BVmM/3HO5P8kdaf/cdHm/wADdf+O1uf/NqLc/3bT5/9Ntt3/bdLn/3HR5/9w0ef/ac7n/wxNfv9v0eb/bM7g/3HS5/9v0eb/aMXV/wUJkP8BBZL/OAAAACAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== // ==/UserScript== (function () { "use strict"; // --- CONFIGURATION --- const MAX_HISTORY_SIZE = 10; const UI_TARGET_SELECTOR = "ytd-masthead #end"; // A stable target for UI injection // --- STATE MANAGEMENT --- let isEnabled = GM_getValue("isEnabled", true); // History now stores objects: { url: string, timestamp: number } let linkHistory = GM_getValue("linkHistory", []); let lastCopiedUrl = ""; let isDropdownOpen = false; // --- UI ELEMENTS --- let clipboardContainer, toggleSwitch, historyList, statusMessage, iconWrapper, historyDropdown; /** * Helper function to format a timestamp into a human-readable string. */ function formatTimestamp(timestamp) { const date = new Date(timestamp); const options = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: true, }; return date.toLocaleDateString(undefined, options); } /** * Transforms old history (array of strings) to new format (array of objects). * This handles existing users who update the script. */ function migrateHistoryIfNecessary(history) { if (history.length === 0) return []; if (typeof history[0] === "string") { // Old format detected (array of URLs) console.log("Cottonee's VR Clipboard: Migrating old history format..."); return history.map((url) => ({ url: url, timestamp: Date.now(), // Use current time as a reasonable default })); } return history; // Already in new format } /** * Injects the CSS for the GUI. */ function addStyles() { GM_addStyle(` #cottonees-clipboard { position: relative; display: flex; align-items: center; margin-right: 8px; /* Standard YouTube button spacing */ font-family: 'Roboto', Arial, sans-serif; user-select: none; } .cc-icon-wrapper { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background-color: transparent; border-radius: 50%; cursor: pointer; transition: background-color 0.2s ease; } .cc-icon-wrapper:hover { background-color: var(--yt-spec-badge-chip-background, #3f3f3f); } /* Ensure the image fits within its wrapper */ .cc-icon-wrapper img { width: 24px; height: 24px; /* Optional: Add some padding if the icon appears too large */ /* padding: 2px; */ } .cc-history-dropdown { display: none; /* Hidden by default, toggled by JS */ position: absolute; top: 50px; /* Position below the icon */ right: 0; width: 300px; background-color: #282828; border: 1px solid #4a4a4a; /* Slightly lighter border */ border-radius: 12px; padding: 12px 15px; /* More generous padding */ z-index: 9999; box-shadow: 0 6px 16px rgba(0,0,0,0.6); /* Enhanced shadow */ } .cc-history-dropdown.open { display: block; } .cc-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; /* More space */ padding-bottom: 8px; /* Separator for header */ border-bottom: 1px solid #4a4a4a; /* Subtle line */ } .cc-title { font-weight: 500; font-size: 15px; /* Slightly larger title */ color: #e0e0e0; /* Slightly brighter text */ } .cc-status { font-size: 13px; /* Slightly larger status message */ color: #d1b4ff; /* Purple for copied status! */ height: 18px; /* Reserve space to prevent layout shifts */ text-align: right; min-width: 60px; /* Ensure space for longer messages */ font-weight: 500; } .cc-history-list { max-height: 220px; overflow-y: auto; padding-top: 5px; /* Space from separator */ } .cc-history-list::-webkit-scrollbar { width: 8px; } .cc-history-list::-webkit-scrollbar-track { background: #383838; border-radius: 4px; } .cc-history-list::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; } .cc-history-list::-webkit-scrollbar-thumb:hover { background: #666; } .cc-history-item { padding: 6px 6px; /* Adjusted padding for timestamp */ font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 6px; /* Smoother corners */ cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; color: #eeeeee; /* Brighter default text */ display: flex; /* Use flexbox for timestamp and link */ flex-direction: column; line-height: 1.3; } .cc-history-item:hover { background-color: #4a3070; /* Purple hover background */ color: #ffffff; /* White text on hover for contrast */ } .cc-history-list > div:not(:last-child).cc-history-item { margin-bottom: 4px; /* Spacing between items */ } .cc-history-timestamp { font-size: 10px; /* Smaller timestamp */ color: #999; /* Grey timestamp */ margin-bottom: 2px; /* Space between timestamp and link */ } .cc-history-url-text { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; } /* Toggle Switch CSS (with purple accent) */ .cc-switch { position: relative; display: inline-block; width: 38px; /* Slightly wider */ height: 22px; /* Slightly taller */ } .cc-switch input { display: none; } .cc-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #666; /* Slightly darker grey when off */ transition: .4s; border-radius: 22px; /* Matches height */ } .cc-slider:before { position: absolute; content: ""; height: 16px; width: 16px; /* Larger handle */ left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .cc-slider { background-color: #8a2be2; } /* Vibrant purple when on */ input:checked + .cc-slider:before { transform: translateX(16px); } /* Adjust translation for new width */ `); } /** * Creates and injects the GUI into the page. */ function createGUI(parentElement) { if (document.getElementById("cottonees-clipboard")) return; // Prevent duplicates clipboardContainer = document.createElement("div"); clipboardContainer.id = "cottonees-clipboard"; // Use an <img> tag for the ICO icon with the correct MIME type const iconBase64 = "data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAABJpO3/TKfr/xgZGQMAAAAAAQECFqKxyP6vw+X/sMTl/6/E5P+htN7/scTj/36OpP9WZLv/H1R0/xhTcf9ZsOr/XLTq/xdqvf8YGBkBNz1GyrDE5f+vxOX/s8fm/zRLsv9FYN3/l6nV/7LE5P+xxOX/fIeU/yVSdP8gVHL/Xrbr/1Ow6f8HT47/ICooQ7HD5f+xxOP/r8Tj/5Giw/8bHYX/Fhp//yItmv9GYeX/ssXk/7DE5P8xPUz/IU1s/2C65/9et+b/CzRZ/6q65v+ktuf/q77l/7DD5f+xxOT/sMTl/7DE5f+yxOX/XWqg/7DE5f+vxOP/scTl/xArQP9lv+X/Zr7k/z1Ga/+Upuz/j6Pt/5mt6v+sveX/r8Tk/7DE5P+vxOT/r8Tk/6/E4/+vxOX/sMTl/7DE5f9jc7v/acHk/2Syz/+svef/lKXs/46g6/+Yqur/qLnn/6/E5P+outb/r8Tk/7DD5P+tveb/qbvm/6q95f+uweP/YHG2/2zE4/9EY43/scPl/66+5f+ouOD/p7nm/7DB5f+wxOX/mKrc/7DE5f+tv+X/obPp/5Wo7P+RpO3/mKrs/1Bpqf9rw+L/HCRV/0lCWP/Nztb/FgkB/xQNAv+nrsH/sMTl/7HE5f9Wh7z/sMPj/6a55/+Xqev/j6Ds/5Oj6/9Ha5//bcXj/0NQkv+Lmr7/AgEF/wEBAf8BAQL/T2Ke/7DE5f95stP/aout/7DF5f/KzNv/WWeM/6/C6f+qu+j/S3yo/27H4/8xTo//ssTl/zhojP+aq87/iZrO/z9Rkf9AWn7/ba/c/42nvf9ebaX/Wk9t/0I9P/8aEQP/Pjk4/02Jsf9uyuT/NmqR/7LE5f9BmNH/lqTC/3mIu/8bMlr/YL3e/3bI5P+ru9r/NESM/7fL6P8BAAD/AQEB/wAAAf9Jf5X/bs3k/2fD1f+tvuT/QKTY/6G04v+SsM3/R5Os/2zO5f960OX/mafd/7LC5f9WdJT/sMTl/7DE5f+zxej/bcDb/3DO5f9y0+f/TV6w/0qy3f9EUo//UZa6/2/R5v9u0eb/b9Dm/3CCy/+2x+b/Sn+R/6a2zP+hsd7/VKrX/1KVpv9v0Ob/cdHm/x07eP9dxOP/CVuZ/2XE1v9sz+X/bdHn/23R5v9HV6X/asHU/1GQov85XYf/d8nh/zug0/9jssT/b9Ln/3DR5v8KXaD/cNHn/xOG0v9x1Of/bdHo/4TT5v9u0+j/CBUk/3HR5v8ha5T/BVmM/3HO5P8kdaf/cdHm/wADdf+O1uf/NqLc/3bT5/9Ntt3/bdLn/3HR5/9w0ef/ac7n/wxNfv9v0eb/bM7g/3HS5/9v0eb/aMXV/wUJkP8BBZL/OAAAACAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" clipboardContainer.innerHTML = ` <div class="cc-icon-wrapper" title="Cottonee's VR Clipboard - Click to Toggle History"> <img src="${iconBase64}" alt="Cottonee's VR Clipboard Icon"/> </div> <div class="cc-history-dropdown"> <div class="cc-header"> <span class="cc-title">Cottonee's VR Clipboard</span> <label class="cc-switch"> <input type="checkbox" id="cc-toggle"> <span class="cc-slider"></span> </label> </div> <div class="cc-status" id="cc-status-message"></div> <div class="cc-history-list" id="cc-history-list"></div> </div> `; parentElement.insertBefore(clipboardContainer, parentElement.firstChild); // Assign element references iconWrapper = clipboardContainer.querySelector(".cc-icon-wrapper"); historyDropdown = clipboardContainer.querySelector(".cc-history-dropdown"); toggleSwitch = document.getElementById("cc-toggle"); historyList = document.getElementById("cc-history-list"); statusMessage = document.getElementById("cc-status-message"); // Set initial state toggleSwitch.checked = isEnabled; updateHistoryUI(); // Event Listeners iconWrapper.addEventListener("click", (e) => { e.stopPropagation(); // Prevent document click from closing immediately toggleDropdown(); }); toggleSwitch.addEventListener("change", () => { isEnabled = toggleSwitch.checked; GM_setValue("isEnabled", isEnabled); showStatusMessage(isEnabled ? "Enabled" : "Disabled"); }); historyList.addEventListener("click", (e) => { // Find the closest history item to the clicked element const historyItem = e.target.closest(".cc-history-item"); if (historyItem && historyItem.dataset.url) { // Ensure dataset.url exists // Ensure we copy the URL from the dataset navigator.clipboard.writeText(historyItem.dataset.url).then(() => { showStatusMessage("Copied!"); }); } }); // Close dropdown if clicking outside document.addEventListener("click", (e) => { if (isDropdownOpen && !clipboardContainer.contains(e.target)) { closeDropdown(); } }); } /** * Toggles the visibility of the history dropdown. */ function toggleDropdown() { isDropdownOpen = !isDropdownOpen; historyDropdown.classList.toggle("open", isDropdownOpen); } /** * Closes the history dropdown. */ function closeDropdown() { isDropdownOpen = false; historyDropdown.classList.remove("open"); } /** * Updates the history list in the UI. */ function updateHistoryUI() { if (!historyList) return; // Ensure element exists historyList.innerHTML = ""; if (linkHistory.length === 0) { historyList.innerHTML = '<div style="color:#888; font-size:12px; padding: 4px;">History is empty.</div>'; return; } // Display newest links first [...linkHistory].reverse().forEach((item) => { // item is now { url: string, timestamp: number } const div = document.createElement("div"); div.className = "cc-history-item"; div.title = `Click to copy: ${item.url}`; // Full URL on hover tooltip div.dataset.url = item.url; // Store full URL for copying const timestampSpan = document.createElement("span"); timestampSpan.className = "cc-history-timestamp"; timestampSpan.textContent = formatTimestamp(item.timestamp); div.appendChild(timestampSpan); const urlSpan = document.createElement("span"); urlSpan.className = "cc-history-url-text"; urlSpan.textContent = item.url.replace("https://www.", ""); // Display a cleaner version div.appendChild(urlSpan); historyList.appendChild(div); }); } /** * Shows a temporary message in the status area. */ function showStatusMessage(message) { if (!statusMessage) return; // Ensure element exists statusMessage.textContent = message; setTimeout(() => { // Only clear if the message hasn't been replaced by a new one if (statusMessage.textContent === message) { statusMessage.textContent = ""; } }, 1500); } /** * The main logic to check, clean, and copy the URL. */ function processUrl() { if (!isEnabled) return; const currentUrl = window.location.href; // Only process video pages (watch?v=) if (!currentUrl.includes("watch?v=")) return; const urlObj = new URL(currentUrl); const videoId = urlObj.searchParams.get("v"); if (!videoId) return; // Construct the clean, canonical YouTube video URL const cleanUrl = `https://www.youtube.com/watch?v=${videoId}`; // Only copy and update history if the URL is different from the last one if (cleanUrl !== lastCopiedUrl) { lastCopiedUrl = cleanUrl; navigator.clipboard.writeText(cleanUrl).then(() => { showStatusMessage("Copied!"); const newHistoryItem = { url: cleanUrl, timestamp: Date.now(), // Store current timestamp }; // Update history: remove existing entry if its URL is present, then add to end // Need to check by URL property now const existingIndex = linkHistory.findIndex((item) => item.url === cleanUrl); if (existingIndex > -1) { linkHistory.splice(existingIndex, 1); } linkHistory.push(newHistoryItem); // Enforce maximum history size if (linkHistory.length > MAX_HISTORY_SIZE) { linkHistory.shift(); // Remove the oldest item } GM_setValue("linkHistory", linkHistory); // Persist history updateHistoryUI(); // Refresh the UI display }); } } /** * Waits for a specific element to appear in the DOM and then executes a callback. * Uses MutationObserver for efficiency. */ function waitForElement(selector, callback) { // Check immediately in case the element is already there let targetElement = document.querySelector(selector); if (targetElement) { callback(targetElement); return; } const observer = new MutationObserver((mutations, obs) => { targetElement = document.querySelector(selector); if (targetElement) { obs.disconnect(); // Stop observing once found callback(targetElement); } }); // Start observing the body for childList changes and subtree for deep changes observer.observe(document.body, { childList: true, subtree: true }); } // --- INITIALIZATION --- // Migrate history format immediately after retrieving it linkHistory = migrateHistoryIfNecessary(linkHistory); GM_setValue("linkHistory", linkHistory); // Save migrated history addStyles(); waitForElement(UI_TARGET_SELECTOR, (targetElement) => { createGUI(targetElement); // Initial check for the URL on page load, with a slight delay // to ensure YouTube's JS has processed the initial video. setTimeout(processUrl, 1500); }); // Listen for YouTube's custom navigation event (for SPA updates) window.addEventListener("yt-navigate-finish", processUrl); })();