YouTube Transcript Floating Bar

Transcript-only YouTubeToTranscript helper with a floating player-bar popup for Safari userscript managers.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         YouTube Transcript Floating Bar
// @namespace    https://youtubetotranscript.com/
// @version      0.1.6
// @description  Transcript-only YouTubeToTranscript helper with a floating player-bar popup for Safari userscript managers.
// @author       YouTube To Transcript userscript variant
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @match        https://youtubetotranscript.com/*
// @connect      youtubetotranscript.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const SCRIPT_ID = "ytt-transcript-bar";
  const SERVICE_BASE = "https://youtubetotranscript.com";
  const TOKEN_LIFETIME_MS = 31536000000;
  const CACHE_TTL_MS = 15 * 60 * 1000;

  const state = {
    videoID: "",
    transcript: null,
    loading: false,
    error: "",
    toolbar: null,
    panel: null,
    statusNode: null,
    mutationTimer: 0
  };

  GM_addStyle(`
    .${SCRIPT_ID}-toolbar {
      display: inline-flex;
      height: 100%;
      min-width: 40px;
      overflow: visible;
      position: relative;
      vertical-align: top;
    }

    .${SCRIPT_ID}-button {
      align-items: center;
      background: transparent;
      border: 0;
      border-radius: 0;
      color: #fff;
      cursor: pointer;
      display: inline-flex;
      font: 700 13px/1 Arial, sans-serif;
      height: 100%;
      justify-content: center;
      margin: 0;
      min-width: 40px;
      opacity: 0.92;
      padding: 0;
      text-shadow: 0 0 2px rgba(0, 0, 0, 0.85);
    }

    .${SCRIPT_ID}-button:hover {
      opacity: 1;
    }

    .${SCRIPT_ID}-panel {
      background: #0f0f0f;
      border: 1px solid #3f3f3f;
      border-radius: 12px;
      box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
      color: #fff;
      display: none;
      font: 13px/1.4 Arial, sans-serif;
      max-height: min(620px, calc(100vh - 130px));
      overflow: hidden;
      padding: 0;
      position: absolute;
      right: 0;
      bottom: 52px;
      width: min(420px, calc(100vw - 28px));
      z-index: 2147483646;
    }

    .${SCRIPT_ID}-panel.${SCRIPT_ID}-open {
      display: grid;
      grid-template-rows: auto auto minmax(160px, 1fr) auto;
    }

    .${SCRIPT_ID}-header,
    .${SCRIPT_ID}-actions,
    .${SCRIPT_ID}-auth {
      padding: 10px;
    }

    .${SCRIPT_ID}-header {
      align-items: center;
      border-bottom: 1px solid #272727;
      display: flex;
      gap: 8px;
      justify-content: space-between;
    }

    .${SCRIPT_ID}-title {
      font-size: 15px;
      font-weight: 700;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .${SCRIPT_ID}-status {
      color: #aaa;
      font-size: 12px;
      min-height: 17px;
      padding: 8px 10px 0;
    }

    .${SCRIPT_ID}-body {
      margin: 8px 10px;
      max-height: min(420px, calc(100vh - 265px));
      overflow: auto;
      overscroll-behavior: contain;
      padding-right: 4px;
      white-space: pre-wrap;
      word-break: break-word;
    }

    .${SCRIPT_ID}-cue {
      border-radius: 5px;
      display: grid;
      gap: 8px;
      grid-template-columns: 54px 1fr;
      padding: 5px 3px;
    }

    .${SCRIPT_ID}-cue:hover {
      background: #272727;
    }

    .${SCRIPT_ID}-time {
      color: #3ea6ff;
      cursor: pointer;
      font-variant-numeric: tabular-nums;
      text-decoration: none;
    }

    .${SCRIPT_ID}-text {
      color: #f1f1f1;
    }

    .${SCRIPT_ID}-empty {
      color: #aaa;
      padding: 18px 2px;
      text-align: center;
    }

    .${SCRIPT_ID}-actions {
      align-items: center;
      border-top: 1px solid #272727;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      justify-content: space-between;
    }

    .${SCRIPT_ID}-left-actions,
    .${SCRIPT_ID}-right-actions {
      align-items: center;
      display: inline-flex;
      flex-wrap: wrap;
      gap: 8px;
    }

    .${SCRIPT_ID}-small,
    .${SCRIPT_ID}-primary {
      align-items: center;
      border: 1px solid #3f3f3f;
      border-radius: 18px;
      color: #fff;
      cursor: pointer;
      display: inline-flex;
      font: 600 12px/1 Arial, sans-serif;
      height: 30px;
      justify-content: center;
      padding: 0 10px;
    }

    .${SCRIPT_ID}-small {
      background: #272727;
    }

    .${SCRIPT_ID}-primary {
      background: #fff;
      border-color: #fff;
      color: #0f0f0f;
    }

    .${SCRIPT_ID}-small:hover,
    .${SCRIPT_ID}-primary:hover {
      filter: brightness(1.1);
    }

    .${SCRIPT_ID}-toggle {
      align-items: center;
      color: #f1f1f1;
      display: inline-flex;
      gap: 6px;
      user-select: none;
      white-space: nowrap;
    }

    .${SCRIPT_ID}-toggle input {
      height: 15px;
      margin: 0;
      width: 15px;
    }

    .${SCRIPT_ID}-auth {
      border-top: 1px solid #272727;
      display: none;
      gap: 8px;
      grid-template-columns: 1fr auto;
    }

    .${SCRIPT_ID}-auth.${SCRIPT_ID}-show {
      display: grid;
    }

    .${SCRIPT_ID}-auth-text {
      align-items: center;
      color: #aaa;
      display: flex;
      font-size: 12px;
      min-height: 30px;
    }

    @media (max-width: 520px) {
      .${SCRIPT_ID}-panel {
        right: -72px;
        width: min(360px, calc(100vw - 18px));
      }

      .${SCRIPT_ID}-auth {
        grid-template-columns: 1fr;
      }
    }
  `);

  function gmGet(key, fallback) {
    try {
      const value = GM_getValue(key);
      return value === undefined ? fallback : value;
    } catch (_) {
      return fallback;
    }
  }

  function gmSet(key, value) {
    try {
      GM_setValue(key, value);
    } catch (_) {
      // Storage is optional; the script can still fetch without caching.
    }
  }

  function getVideoID() {
    try {
      const url = new URL(location.href);
      if (url.pathname.startsWith("/shorts/")) return url.pathname.split("/")[2] || "";
      if (url.pathname.startsWith("/embed/")) return url.pathname.split("/")[2] || "";
      return url.searchParams.get("v") || "";
    } catch (_) {
      return "";
    }
  }

  function getVideoElement() {
    const videos = Array.from(document.querySelectorAll("video"));
    return videos.find((video) => Number.isFinite(video.duration) && video.duration > 0) || videos[0] || null;
  }

  function getControlsContainer() {
    const settingsButton = document.querySelector(".html5-video-player .ytp-settings-button")
      || document.querySelector(".ytp-settings-button");

    return settingsButton && settingsButton.parentElement
      || document.querySelector(".html5-video-player .ytp-right-controls-left")
      || document.querySelector(".ytp-right-controls-left")
      || document.querySelector(".html5-video-player .ytp-right-controls")
      || document.querySelector(".ytp-right-controls");
  }

  function mountToolbar() {
    if (!state.toolbar) return false;

    const controls = getControlsContainer();
    if (!controls) return false;

    if (state.toolbar.parentElement !== controls) {
      const anchor = controls.querySelector(":scope > .ytp-settings-button")
        || controls.querySelector(":scope > .ytp-miniplayer-button")
        || controls.firstElementChild;
      controls.insertBefore(state.toolbar, anchor);
    }

    return true;
  }

  function buildToolbar() {
    if (state.toolbar) {
      mountToolbar();
      return;
    }

    const toolbar = document.createElement("div");
    toolbar.className = `${SCRIPT_ID}-toolbar`;

    const button = document.createElement("button");
    button.className = `${SCRIPT_ID}-button`;
    button.type = "button";
    button.textContent = "TR";
    button.title = "YouTube transcript";

    const panel = document.createElement("div");
    panel.className = `${SCRIPT_ID}-panel`;

    panel.append(
      createHeader(),
      createStatus(),
      createBody(),
      createActions(),
      createAuthForm()
    );

    button.addEventListener("click", () => {
      const open = panel.classList.toggle(`${SCRIPT_ID}-open`);
      if (open) void refreshTranscript(false);
    });

    document.addEventListener("click", (event) => {
      if (!panel.classList.contains(`${SCRIPT_ID}-open`)) return;
      if (toolbar.contains(event.target)) return;
      panel.classList.remove(`${SCRIPT_ID}-open`);
    }, true);

    toolbar.append(button, panel);
    state.toolbar = toolbar;
    state.panel = panel;
    mountToolbar();
  }

  function createHeader() {
    const header = document.createElement("div");
    header.className = `${SCRIPT_ID}-header`;

    const title = document.createElement("div");
    title.className = `${SCRIPT_ID}-title`;
    title.textContent = "Transcript";

    const signOut = document.createElement("button");
    signOut.className = `${SCRIPT_ID}-small ${SCRIPT_ID}-signout`;
    signOut.type = "button";
    signOut.textContent = "Sign out";
    signOut.style.display = isLoggedIn() ? "inline-flex" : "none";
    signOut.addEventListener("click", () => {
      clearAuth();
      setStatus("Signed out.");
      updatePanel();
    });

    header.append(title, signOut);
    return header;
  }

  function createStatus() {
    const status = document.createElement("div");
    status.className = `${SCRIPT_ID}-status`;
    state.statusNode = status;
    return status;
  }

  function createBody() {
    const body = document.createElement("div");
    body.className = `${SCRIPT_ID}-body`;
    return body;
  }

  function createActions() {
    const actions = document.createElement("div");
    actions.className = `${SCRIPT_ID}-actions`;

    const left = document.createElement("div");
    left.className = `${SCRIPT_ID}-left-actions`;

    const toggle = document.createElement("label");
    toggle.className = `${SCRIPT_ID}-toggle`;
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = timestampsEnabled();
    checkbox.addEventListener("change", () => {
      gmSet("timestampsEnabled", checkbox.checked);
      renderTranscript();
    });
    const labelText = document.createElement("span");
    labelText.textContent = "Timestamps";
    toggle.append(checkbox, labelText);

    const reload = document.createElement("button");
    reload.className = `${SCRIPT_ID}-small`;
    reload.type = "button";
    reload.textContent = "Reload";
    reload.addEventListener("click", () => void refreshTranscript(true));

    left.append(toggle, reload);

    const right = document.createElement("div");
    right.className = `${SCRIPT_ID}-right-actions`;

    const copy = document.createElement("button");
    copy.className = `${SCRIPT_ID}-primary`;
    copy.type = "button";
    copy.textContent = "Copy";
    copy.addEventListener("click", () => void copyTranscript(copy));

    const chatgpt = document.createElement("button");
    chatgpt.className = `${SCRIPT_ID}-small`;
    chatgpt.type = "button";
    chatgpt.textContent = "ChatGPT";
    chatgpt.title = "Copy transcript and open ChatGPT";
    chatgpt.addEventListener("click", () => void openChatGPTWithTranscript(chatgpt));

    right.append(chatgpt, copy);
    actions.append(left, right);
    return actions;
  }

  function createAuthForm() {
    const form = document.createElement("form");
    form.className = `${SCRIPT_ID}-auth`;
    if (!isLoggedIn()) form.classList.add(`${SCRIPT_ID}-show`);

    const text = document.createElement("div");
    text.className = `${SCRIPT_ID}-auth-text`;
    text.textContent = "Sign in with the YouTubeToTranscript email flow.";

    const submit = document.createElement("button");
    submit.className = `${SCRIPT_ID}-primary`;
    submit.type = "button";
    submit.textContent = "Login";
    submit.addEventListener("click", () => {
      openServiceLogin();
      setStatus("Complete login in the YouTubeToTranscript window, then return here.");
    });

    form.append(text, submit);
    stopYouTubeShortcutsInside(form);
    return form;
  }

  function stopYouTubeShortcutsInside(element) {
    const stop = (event) => {
      event.stopPropagation();
      if (event.stopImmediatePropagation) event.stopImmediatePropagation();
    };

    for (const type of ["keydown", "keypress", "keyup"]) {
      element.addEventListener(type, stop, true);
    }
  }

  function updatePanel() {
    if (!state.panel) return;

    const signOut = state.panel.querySelector(`.${SCRIPT_ID}-signout`);
    if (signOut) signOut.style.display = isLoggedIn() ? "inline-flex" : "none";

    const form = state.panel.querySelector(`.${SCRIPT_ID}-auth`);
    if (form) form.classList.toggle(`${SCRIPT_ID}-show`, !isLoggedIn());

    renderTranscript();
  }

  function setStatus(message) {
    if (state.statusNode) state.statusNode.textContent = message || "";
  }

  function timestampsEnabled() {
    return gmGet("timestampsEnabled", true) !== false;
  }

  function isLoggedIn() {
    const auth = gmGet("auth", null);
    return !!(auth && auth.token && auth.refreshToken);
  }

  function storeAuth(payload) {
    const expiresAt = payload.expires_at
      ? new Date(payload.expires_at).getTime()
      : Date.now() + TOKEN_LIFETIME_MS;

    gmSet("auth", {
      token: payload.access_token || payload.token,
      refreshToken: payload.refresh_token || payload.refreshToken,
      expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + TOKEN_LIFETIME_MS,
      userEmail: payload.userEmail || ""
    });
  }

  function clearAuth() {
    gmSet("auth", null);
  }

  function openServiceLogin() {
    const popup = window.open(
      `${SERVICE_BASE}/auth/login?flow=extension`,
      "yttTranscriptLogin",
      "popup=yes,width=520,height=720"
    );

    if (popup && popup.focus) popup.focus();
  }

  async function authHeaders() {
    const auth = gmGet("auth", null);
    const headers = { "Content-Type": "application/json" };
    if (!auth || !auth.token || !auth.refreshToken) return headers;

    if (Number(auth.expiresAt) && Date.now() >= Number(auth.expiresAt) - 60000) {
      try {
        const refreshed = await requestJson(`${SERVICE_BASE}/auth/refresh`, {
          method: "POST",
          headers: { Authorization: `Bearer ${auth.refreshToken}` }
        });
        storeAuth({
          access_token: refreshed.access_token,
          refresh_token: refreshed.refresh_token || auth.refreshToken,
          expires_at: refreshed.expires_at,
          userEmail: auth.userEmail
        });
        headers.Authorization = `Bearer ${refreshed.access_token}`;
        return headers;
      } catch (error) {
        if (String(errorMessage(error)).match(/401|unauthori[sz]ed|expired|invalid/i)) clearAuth();
      }
    }

    headers.Authorization = `Bearer ${auth.token}`;
    return headers;
  }

  function requestJson(url, options = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: options.method || "GET",
        url,
        headers: options.headers || {},
        data: options.data,
        timeout: options.timeout || 20000,
        onload: (response) => {
          const text = response.responseText || "";
          let parsed = null;
          if (text) {
            try {
              parsed = JSON.parse(text);
            } catch (_) {
              parsed = text;
            }
          }

          if (response.status < 200 || response.status >= 300) {
            const detail = describeErrorPayload(parsed) || text;
            reject(new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`));
            return;
          }

          resolve(parsed);
        },
        onerror: reject,
        ontimeout: () => reject(new Error("Request timed out"))
      });
    });
  }

  function describeErrorPayload(payload) {
    if (!payload || typeof payload !== "object") return "";
    if (typeof payload.message === "string") return payload.message;
    if (typeof payload.error === "string") return payload.error;
    if (typeof payload.detail === "string") return payload.detail;

    const detail = payload.detail;
    if (Array.isArray(detail)) {
      return detail
        .map((item) => {
          if (!item || typeof item !== "object") return String(item);
          const loc = Array.isArray(item.loc) ? item.loc.join(".") : "";
          const msg = item.msg || item.message || JSON.stringify(item);
          return loc ? `${loc}: ${msg}` : msg;
        })
        .join("; ");
    }

    return JSON.stringify(payload);
  }

  async function refreshTranscript(force) {
    const videoID = getVideoID();
    if (!videoID) {
      state.videoID = "";
      state.transcript = null;
      state.error = "Open a YouTube video to load a transcript.";
      updatePanel();
      return;
    }

    if (!force && state.videoID === videoID && (state.transcript || state.loading)) {
      renderTranscript();
      return;
    }

    state.videoID = videoID;
    state.loading = true;
    state.error = "";
    state.transcript = null;
    renderTranscript();
    setStatus("Loading transcript...");

    try {
      const transcript = await getTranscript(videoID, force);
      if (state.videoID !== videoID) return;
      state.transcript = transcript;
      state.error = transcript.length ? "" : "No transcript cues were found.";
      setStatus(transcript.length ? `${transcript.length} transcript lines loaded.` : state.error);
    } catch (error) {
      state.error = errorMessage(error);
      setStatus(state.error);
    } finally {
      if (state.videoID === videoID) {
        state.loading = false;
        updatePanel();
      }
    }
  }

  async function getTranscript(videoID, force) {
    const cacheKey = `transcript:${videoID}`;
    const cached = gmGet(cacheKey, null);
    if (!force && cached && Date.now() - cached.time < CACHE_TTL_MS && Array.isArray(cached.transcript)) {
      return cached.transcript;
    }

    const serviceTranscript = await getServiceTranscript(videoID);
    gmSet(cacheKey, { time: Date.now(), transcript: serviceTranscript });
    return serviceTranscript;
  }

  async function getServiceTranscript(videoID) {
    const headers = await authHeaders();
    if (!headers.Authorization) {
      throw new Error("Login is required to fetch transcripts from YouTubeToTranscript.");
    }

    const data = await requestJson(`${SERVICE_BASE}/api/transcript?video_id=${encodeURIComponent(videoID)}`, {
      headers
    });

    return normalizeTranscript(data);
  }

  function normalizeTranscript(data) {
    const raw = Array.isArray(data)
      ? data
      : data && Array.isArray(data.transcript)
        ? data.transcript
        : data && Array.isArray(data.data)
          ? data.data
          : data && Array.isArray(data.items)
            ? data.items
            : [];

    if (typeof data === "string") {
      return splitPlainTranscript(data);
    }

    return raw
      .map((item) => {
        if (typeof item === "string") {
          const text = cleanCueText(item);
          return text ? { start: 0, duration: 0, text } : null;
        }

        const start = Number(item.start ?? item.startTime ?? item.offset ?? item.time ?? 0);
        const duration = Number(item.duration ?? item.dur ?? 0);
        const text = cleanCueText(String(item.text ?? item.content ?? item.caption ?? ""));
        if (!text) return null;
        return {
          start: Number.isFinite(start) ? start : 0,
          duration: Number.isFinite(duration) ? duration : 0,
          text
        };
      })
      .filter(Boolean);
  }

  function cleanCueText(text) {
    return decodeHtmlEntities(String(text || ""))
      .replace(/<br\s*\/?>/gi, " ")
      .replace(/<\/?[^>]+>/g, " ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function decodeHtmlEntities(text) {
    return text
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, "\"")
      .replace(/&#39;/g, "'")
      .replace(/&apos;/g, "'")
      .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
      .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)));
  }

  function splitPlainTranscript(text) {
    const lines = String(text || "")
      .split(/\r?\n+/)
      .map(cleanCueText)
      .filter(Boolean);

    if (lines.length) return lines.map((line) => ({ start: 0, duration: 0, text: line }));

    const cleaned = cleanCueText(text);
    return cleaned ? [{ start: 0, duration: 0, text: cleaned }] : [];
  }

  function renderTranscript() {
    if (!state.panel) return;

    const body = state.panel.querySelector(`.${SCRIPT_ID}-body`);
    if (!body) return;

    body.textContent = "";

    if (state.loading) {
      const loading = document.createElement("div");
      loading.className = `${SCRIPT_ID}-empty`;
      loading.textContent = "Loading transcript...";
      body.appendChild(loading);
      return;
    }

    if (state.error && !state.transcript) {
      const error = document.createElement("div");
      error.className = `${SCRIPT_ID}-empty`;
      error.textContent = state.error;
      body.appendChild(error);
      return;
    }

    if (!state.transcript || !state.transcript.length) {
      const empty = document.createElement("div");
      empty.className = `${SCRIPT_ID}-empty`;
      empty.textContent = "Open a YouTube video, then click Reload.";
      body.appendChild(empty);
      return;
    }

    const showTimestamps = timestampsEnabled();
    for (const cue of state.transcript) {
      const row = document.createElement("div");
      row.className = `${SCRIPT_ID}-cue`;

      const time = document.createElement("button");
      time.className = `${SCRIPT_ID}-time`;
      time.type = "button";
      time.textContent = formatTime(cue.start);
      time.title = "Seek to this timestamp";
      time.addEventListener("click", () => seekTo(cue.start));

      const text = document.createElement("div");
      text.className = `${SCRIPT_ID}-text`;
      text.textContent = cue.text;

      if (showTimestamps) {
        row.append(time, text);
      } else {
        row.style.gridTemplateColumns = "1fr";
        row.append(text);
      }

      body.appendChild(row);
    }
  }

  async function copyTranscript(button) {
    if (!state.transcript || !state.transcript.length) {
      setStatus("No transcript to copy.");
      return;
    }

    const text = transcriptToText(state.transcript, timestampsEnabled());
    const original = button.textContent;
    button.textContent = "Copying...";

    try {
      await navigator.clipboard.writeText(text);
      button.textContent = "Copied";
      setStatus("Copied to clipboard.");
    } catch (_) {
      fallbackCopy(text);
      button.textContent = "Copied";
      setStatus("Copied to clipboard.");
    } finally {
      setTimeout(() => {
        button.textContent = original;
      }, 1600);
    }
  }

  async function openChatGPTWithTranscript(button) {
    if (!state.transcript || !state.transcript.length) {
      setStatus("No transcript to send to ChatGPT.");
      return;
    }

    const original = button.textContent;
    button.textContent = "Copying...";
    const chatWindow = window.open("https://chatgpt.com/", "_blank");

    try {
      await copyTranscriptText(transcriptToText(state.transcript, timestampsEnabled()));
      if (chatWindow && chatWindow.focus) chatWindow.focus();
      setStatus("Transcript copied. ChatGPT opened.");
    } catch (_) {
      setStatus("Could not copy transcript for ChatGPT.");
    } finally {
      button.textContent = "Opened";
      setTimeout(() => {
        button.textContent = original;
      }, 1600);
    }
  }

  async function copyTranscriptText(text) {
    try {
      await navigator.clipboard.writeText(text);
    } catch (_) {
      fallbackCopy(text);
    }
  }

  function fallbackCopy(text) {
    const textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.style.position = "fixed";
    textarea.style.left = "-9999px";
    textarea.style.top = "0";
    document.documentElement.appendChild(textarea);
    textarea.focus();
    textarea.select();
    document.execCommand("copy");
    textarea.remove();
  }

  function transcriptToText(transcript, includeTimestamps) {
    if (includeTimestamps) {
      return transcript.map((cue) => `[${formatTime(cue.start)}] ${cue.text}`).join("\n");
    }

    return transcript.map((cue) => cue.text).join(" ").replace(/\s+/g, " ").trim();
  }

  function formatTime(seconds) {
    const safeSeconds = Math.max(0, Math.floor(Number(seconds) || 0));
    const hours = Math.floor(safeSeconds / 3600);
    const minutes = Math.floor((safeSeconds % 3600) / 60);
    const remainingSeconds = String(safeSeconds % 60).padStart(2, "0");

    if (hours) return `${hours}:${String(minutes).padStart(2, "0")}:${remainingSeconds}`;
    return `${minutes}:${remainingSeconds}`;
  }

  function seekTo(seconds) {
    const video = getVideoElement();
    if (!video) return;
    video.currentTime = Math.max(0, Number(seconds) || 0);
    video.play().catch(() => {});
  }

  function errorMessage(error) {
    return error && error.message ? error.message : String(error || "Unknown error");
  }

  function updateForCurrentVideo() {
    buildToolbar();

    const videoID = getVideoID();
    if (videoID && videoID !== state.videoID) {
      state.videoID = videoID;
      state.transcript = null;
      state.error = "";
      state.loading = false;
      if (state.panel && state.panel.classList.contains(`${SCRIPT_ID}-open`)) {
        void refreshTranscript(false);
      } else {
        updatePanel();
      }
    }
  }

  function scheduleUpdate() {
    if (state.mutationTimer) return;
    state.mutationTimer = window.setTimeout(() => {
      state.mutationTimer = 0;
      updateForCurrentVideo();
    }, 250);
  }

  function init() {
    if (location.hostname === "youtubetotranscript.com") {
      initServiceAuthBridge();
      return;
    }

    updateForCurrentVideo();

    window.addEventListener("yt-navigate-finish", scheduleUpdate);
    window.addEventListener("popstate", scheduleUpdate);
    window.addEventListener("resize", () => mountToolbar());

    const observer = new MutationObserver(scheduleUpdate);
    observer.observe(document.documentElement, { childList: true, subtree: true });

    window.setInterval(updateForCurrentVideo, 2000);
  }

  function initServiceAuthBridge() {
    injectServicePageBridge();

    window.addEventListener("message", (event) => {
      if (event.origin !== SERVICE_BASE) return;
      const data = event.data || {};
      if (data.type === "EXTENSION_REQUEST_TOKENS") {
        const auth = gmGet("auth", null) || {};
        window.postMessage({
          type: "EXTENSION_TOKENS_FROM_USERSCRIPT",
          token: auth.token || null,
          refresh_token: auth.refreshToken || null,
          isLoggedIn: !!(auth.token && auth.refreshToken),
          expiresAt: auth.expiresAt || null
        }, SERVICE_BASE);
        return;
      }

      if (data.type !== "EXTENSION_AUTH_SUCCESS") return;

      const token = data.token || data.access_token;
      const refreshToken = data.refresh_token || data.refreshToken;
      if (!token || !refreshToken) return;

      storeAuth({
        access_token: token,
        refresh_token: refreshToken,
        expires_at: data.expiresAt || Date.now() + TOKEN_LIFETIME_MS
      });

      showServiceAuthNotice("Login saved. You can return to YouTube now.");
    });

    window.__sendTokensToExtension = (token, refreshToken) => {
      if (!token || !refreshToken) return;
      storeAuth({
        access_token: token,
        refresh_token: refreshToken,
        expires_at: Date.now() + TOKEN_LIFETIME_MS
      });
      showServiceAuthNotice("Login saved. You can return to YouTube now.");
    };
  }

  function injectServicePageBridge() {
    const script = document.createElement("script");
    script.textContent = `
      (function () {
        if (window.__yttTranscriptUserscriptBridgeInstalled) return;
        window.__yttTranscriptUserscriptBridgeInstalled = true;

        window.__sendTokensToExtension = function (token, refreshToken) {
          window.postMessage({
            type: "EXTENSION_AUTH_SUCCESS",
            token: token,
            refresh_token: refreshToken
          }, window.location.origin);
        };

        window.addEventListener("message", function (event) {
          if (event.origin !== window.location.origin) return;
          if (!event.data || event.data.type !== "EXTENSION_TOKENS_FROM_USERSCRIPT") return;
          window.postMessage({
            type: "EXTENSION_TOKENS",
            token: event.data.token || null,
            refresh_token: event.data.refresh_token || null,
            isLoggedIn: !!event.data.isLoggedIn,
            expiresAt: event.data.expiresAt || null
          }, window.location.origin);
        });
      })();
    `;
    (document.head || document.documentElement).appendChild(script);
    script.remove();
  }

  function showServiceAuthNotice(message) {
    let notice = document.getElementById(`${SCRIPT_ID}-service-notice`);
    if (!notice) {
      notice = document.createElement("div");
      notice.id = `${SCRIPT_ID}-service-notice`;
      notice.style.cssText = [
        "position: fixed",
        "left: 50%",
        "bottom: 24px",
        "transform: translateX(-50%)",
        "z-index: 2147483647",
        "background: #0f0f0f",
        "color: #fff",
        "border: 1px solid #3f3f3f",
        "border-radius: 12px",
        "box-shadow: 0 12px 36px rgba(0,0,0,.45)",
        "font: 14px Arial, sans-serif",
        "padding: 12px 16px"
      ].join(";");
      document.documentElement.appendChild(notice);
    }
    notice.textContent = message;
  }

  init();
})();