Cottonee's VR Clipboard

Adds a discrete icon to the YouTube header to automatically copy video links for VR. History toggles on click.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);
})();