Cottonee's VR Clipboard

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