ThisVid Downloader

Download or share videos & bulk-save favourites on ThisVid. iOS/macOS (Userscripts app), Android, desktop (Tampermonkey/Violentmonkey).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ThisVid Downloader
// @namespace    https://thisvid.com/
// @version      1.1
// @description  Download or share videos & bulk-save favourites on ThisVid. iOS/macOS (Userscripts app), Android, desktop (Tampermonkey/Violentmonkey).
// @author       MaleVoreLover
// @license      MIT
// @match        https://thisvid.com/videos/*
// @match        https://thisvid.com/members/*/favourites*
// @match        https://thisvid.com/members/favourites*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setClipboard
// @connect      thisvid.com
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const IS_IOS    = /iPhone|iPad|iPod/.test(navigator.userAgent);
  const IS_SAFARI = IS_IOS || (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent));
  const CAN_SHARE = typeof navigator.share === 'function';

  const isVideoPage = /^https:\/\/thisvid\.com\/videos\/[^/?#]+\/?$/.test(location.href);
  const isFavPage   = /\/favourites/.test(location.pathname);

  function waitForVideoSrc(timeout = 12000) {
    return new Promise((resolve, reject) => {
      const deadline = Date.now() + timeout;
      const check = () => {
        const el = document.querySelector('video');
        const src = el?.src || el?.currentSrc || '';
        if (src.includes('/get_file/')) { el.pause(); return resolve(src); }
        if (Date.now() > deadline) return reject(new Error('Could not find video source. Try pressing Play manually first.'));
        setTimeout(check, 300);
      };
      setTimeout(check, 600);
    });
  }

  function createHiddenVideoElement() {
    let video = document.getElementById('tv-native-video');
    if (!video) {
      video = document.createElement('video');
      video.id = 'tv-native-video';
      Object.assign(video.style, {
        position: 'fixed',
        top: '0',
        left: '0',
        width: '1px',
        height: '1px',
        opacity: '0',
        pointerEvents: 'none',
      });
      video.playsInline = true;
      video.muted = true;
      video.preload = 'auto';
      video.setAttribute('playsinline', '');
      video.setAttribute('webkit-playsinline', '');
      video.crossOrigin = 'anonymous';
      document.body.appendChild(video);
    }
    return video;
  }

  async function loadHiddenVideoSrc(src) {
    if (!src) return null;
    const video = createHiddenVideoElement();
    if (video.src !== src) {
      video.src = src;
    }
    video.muted = true;
    video.playsInline = true;
    video.preload = 'auto';
    try { video.load(); } catch {}

    return new Promise(resolve => {
      const cleanup = () => {
        video.removeEventListener('loadedmetadata', onReady);
        video.removeEventListener('loadeddata', onReady);
        video.removeEventListener('canplay', onReady);
        video.removeEventListener('canplaythrough', onReady);
        video.removeEventListener('error', onError);
        clearTimeout(timer);
      };
      const onReady = () => { cleanup(); resolve(video.currentSrc || video.src); };
      const onError = () => { cleanup(); resolve(video.currentSrc || video.src); };
      const timer = setTimeout(() => { cleanup(); resolve(video.currentSrc || video.src); }, 10000);

      video.addEventListener('loadedmetadata', onReady);
      video.addEventListener('loadeddata', onReady);
      video.addEventListener('canplay', onReady);
      video.addEventListener('canplaythrough', onReady);
      video.addEventListener('error', onError);
      video.play().catch(() => {});
    });
  }

  async function loadNativeVideoFallback() {
    let src = extractSrcFromHtml(document.documentElement.innerHTML);
    if (!src) src = await fetchVideoPageSrc(location.href);
    if (!src) return null;

    const hiddenSrc = await loadHiddenVideoSrc(src);
    return hiddenSrc || src;
  }

  async function getVideoSrc() {
    const v = document.querySelector('video');
    if (v?.src?.includes('/get_file/')) return v.src;
    if (v?.currentSrc?.includes('/get_file/')) return v.currentSrc;

    const extractedSrc = extractSrcFromHtml(document.documentElement.innerHTML);
    if (extractedSrc) {
      const validated = await loadHiddenVideoSrc(extractedSrc);
      if (validated?.includes('/get_file/')) return validated;
      return extractedSrc;
    }

    const btn = document.querySelector('#kt_player .fp-play, .fp-play, .jw-icon-playback, [aria-label="Play"]')
             ?? document.querySelector('#kt_player, #player, .fp-player, .jwplayer');
    btn?.click();

    try {
      return await waitForVideoSrc();
    } catch (err) {
      const fallbackSrc = await loadNativeVideoFallback();
      if (fallbackSrc) return fallbackSrc;
      throw err;
    }
  }

  function triggerDownload(url, filename) {
    try {
      if (typeof GM_download !== 'undefined') {
        GM_download({ url, name: filename });
        return;
      }
    } catch {}
    window.open(url, '_blank');
  }

  function getTitle() {
    return (document.querySelector('h1.title, h1, .video-title')?.textContent || document.title)
      .trim().replace(/\s*[-|].*$/, '').replace(/[/\\:*?"<>|]/g, '_').substring(0, 100) || 'thisvid-video';
  }

  async function doCopy(text) {
    try { if (typeof GM_setClipboard !== 'undefined') { GM_setClipboard(text); return; } } catch {}
    try { await navigator.clipboard.writeText(text); return; } catch {}
    const ta = Object.assign(document.createElement('textarea'), { value: text });
    Object.assign(ta.style, { position: 'fixed', opacity: '0' });
    document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
  }

  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

  function toast(msg) {
    const t = document.createElement('div');
    t.className = 'tv-toast'; t.textContent = msg;
    document.body.appendChild(t);
    setTimeout(() => t.remove(), 2200);
  }

  function injectStyles() {
    const s = document.createElement('style');
    s.textContent = `
      #tv-fab {
        position: fixed; bottom: 24px; right: 20px; z-index: 2147483647;
        display: flex; align-items: center; gap: 8px;
        padding: 0 18px 0 14px; height: 44px;
        background: rgba(28,28,30,.95);
        -webkit-backdrop-filter: blur(20px) saturate(180%);
        backdrop-filter: blur(20px) saturate(180%);
        border: 1px solid rgba(255,255,255,.1); border-radius: 22px;
        box-shadow: 0 4px 24px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.05) inset;
        cursor: pointer; font-family: -apple-system,'Helvetica Neue',system-ui,sans-serif;
        font-size: 14px; font-weight: 600; color: #fff; letter-spacing: -.01em;
        touch-action: manipulation; -webkit-tap-highlight-color: transparent;
        transition: transform .12s, opacity .12s; user-select: none;
      }
      #tv-fab:active { transform: scale(.93); opacity: .85; }
      #tv-fab-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; box-shadow: 0 0 6px rgba(255,59,48,.7); }

      #tv-backdrop {
        position: fixed; inset: 0; z-index: 2147483645;
        background: rgba(0,0,0,.45);
        -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
        display: none; animation: tvFadeIn .2s ease;
      }
      #tv-backdrop.open { display: block; }
      @keyframes tvFadeIn { from { opacity: 0 } to { opacity: 1 } }

      #tv-card {
        position: fixed; z-index: 2147483646;
        bottom: 80px; right: 20px; width: 300px;
        max-height: calc(100svh - 110px);
        background: #1c1c1e; border-radius: 20px;
        box-shadow: 0 20px 60px rgba(0,0,0,.7), 0 1px 0 rgba(255,255,255,.06) inset;
        overflow: hidden; display: none; flex-direction: column;
        font-family: -apple-system,'Helvetica Neue',system-ui,sans-serif;
        animation: tvSlideUp .22s cubic-bezier(.32,1,.23,1);
        transform-origin: bottom right;
      }
      #tv-card.open { display: flex; }
      @keyframes tvSlideUp {
        from { opacity: 0; transform: scale(.88) translateY(20px); }
        to   { opacity: 1; transform: scale(1) translateY(0); }
      }

      #tv-card-head {
        padding: 18px 44px 14px 20px;
        border-bottom: 0.5px solid rgba(255,255,255,.08); flex-shrink: 0;
      }
      #tv-card-title { font-size: 20px; font-weight: 600; color: #fff; margin: 0 0 3px; }
      #tv-card-sub   { font-size: 13px; color: rgba(255,255,255,.4); margin: 0; line-height: 1.4; }

      #tv-card-close {
        position: absolute; top: 16px; right: 14px;
        width: 28px; height: 28px; border-radius: 50%;
        background: rgba(255,255,255,.1); border: none; cursor: pointer;
        color: rgba(255,255,255,.6); display: flex; align-items: center; justify-content: center;
        touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background .1s;
      }
      #tv-card-close:active { background: rgba(255,255,255,.2); }

      #tv-card-body { overflow-y: auto; -webkit-overflow-scrolling: touch; flex: 1; }

      .tv-list-row {
        display: flex; align-items: center; gap: 14px;
        padding: 13px 20px;
        border-bottom: 0.5px solid rgba(255,255,255,.06);
        background: none; border-left: none; border-right: none; border-top: none;
        width: 100%; cursor: pointer; text-align: left;
        touch-action: manipulation; -webkit-tap-highlight-color: transparent;
        transition: background .1s;
      }
      .tv-list-row:last-child { border-bottom: none; }
      .tv-list-row:active { background: rgba(255,255,255,.06); }
      .tv-list-row.disabled { opacity: .45; cursor: default; }
      .tv-list-row.hidden { display: none; }

      .tv-icon {
        width: 36px; height: 36px; border-radius: 9px;
        display: flex; align-items: center; justify-content: center; flex-shrink: 0;
      }
      .ic-red    { background: #c0392b; }
      .ic-blue   { background: #0a7aff; }
      .ic-purple { background: #5856d6; }
      .ic-gray   { background: #3a3a3c; }

      .tv-row-label { font-size: 16px; font-weight: 400; color: #fff; flex: 1; }
      .tv-row-chevron { color: rgba(255,255,255,.2); flex-shrink: 0; }

      #tv-status {
        padding: 10px 20px 14px; font-size: 13px; color: rgba(255,255,255,.35);
        line-height: 1.4; display: none; border-top: 0.5px solid rgba(255,255,255,.06);
      }
      #tv-status.visible { display: block; }
      #tv-status.ok   { color: #30d158; }
      #tv-status.err  { color: #ff453a; }
      #tv-status.info { color: #0a84ff; }

      /* ── Favourites overlay ── */
      #tv-fav-overlay {
        position: fixed; inset: 0; z-index: 2147483647;
        display: flex; flex-direction: column; background: #000;
        font-family: -apple-system,'Helvetica Neue',system-ui,sans-serif;
        animation: tvFadeIn .18s ease;
      }
      #tv-fav-head {
        display: flex; align-items: center; gap: 10px;
        padding: 14px 16px 10px; border-bottom: 0.5px solid rgba(255,255,255,.1); flex-shrink: 0;
      }
      #tv-fav-head-text { flex: 1; min-width: 0; }
      #tv-fav-head h2  { margin: 0; font-size: 17px; font-weight: 700; color: #fff; }
      #tv-fav-sub      { font-size: 13px; color: rgba(255,255,255,.35); margin-top: 1px; }
      .tv-fav-hbtn {
        padding: 7px 14px; border: none; border-radius: 10px;
        font-size: 13px; font-weight: 600; cursor: pointer;
        touch-action: manipulation; -webkit-tap-highlight-color: transparent;
        white-space: nowrap; flex-shrink: 0; transition: filter .1s;
      }
      .tv-fav-hbtn:active   { filter: brightness(.75); }
      .tv-fav-hbtn:disabled { opacity: .25; cursor: default; }
      .tv-fav-hbtn.copy  { background: #2c2c2e; color: #0a84ff; }
      .tv-fav-hbtn.close { background: #2c2c2e; color: #fff; }
      #tv-fav-prog { height: 2px; flex-shrink: 0; background: rgba(255,255,255,.06); }
      #tv-fav-bar  { height: 100%; width: 0%; background: #c0392b; transition: width .2s; }
      #tv-fav-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 12px 16px 40px; }
      .tv-fav-group { background: #1c1c1e; border-radius: 12px; overflow: hidden; margin-bottom: 10px; }
      .tv-fav-row {
        display: flex; align-items: center; padding: 12px 14px; gap: 12px;
        border-bottom: 0.5px solid rgba(255,255,255,.06);
      }
      .tv-fav-row:last-child { border-bottom: none; }
      .tv-fav-info { flex: 1; min-width: 0; }
      .tv-fav-title {
        font-size: 14px; font-weight: 500; color: #fff;
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      }
      .tv-fav-status { font-size: 12px; margin-top: 2px; color: rgba(255,255,255,.28); }
      .tv-fav-status.ok  { color: #30d158; }
      .tv-fav-status.err { color: #ff453a; }
      .tv-fav-btn {
        flex-shrink: 0; padding: 7px 14px; border: none; border-radius: 8px;
        font-size: 13px; font-weight: 600; cursor: pointer;
        touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: filter .1s;
      }
      .tv-fav-btn:active   { filter: brightness(.75); }
      .tv-fav-btn:disabled { opacity: .25; cursor: default; }
      .tv-fav-btn.share { background: #0a7aff; color: #fff; }
      .tv-fav-btn.dl    { background: #c0392b; color: #fff; }
      .tv-fav-btn.muted { background: #2c2c2e; color: rgba(255,255,255,.3); }
      #tv-fav-scan {
        display: flex; flex-direction: column; align-items: center;
        justify-content: center; min-height: 220px; gap: 14px;
        color: rgba(255,255,255,.28); font-size: 14px;
      }
      .tv-spinner {
        width: 24px; height: 24px;
        border: 2.5px solid rgba(255,255,255,.08); border-top-color: #c0392b;
        border-radius: 50%; animation: tvSpin .65s linear infinite;
      }
      @keyframes tvSpin { to { transform: rotate(360deg) } }

      .tv-toast {
        position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%);
        background: rgba(255,255,255,.93); color: #000;
        padding: 9px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
        z-index: 2147483647; pointer-events: none; white-space: nowrap;
        box-shadow: 0 2px 14px rgba(0,0,0,.35); animation: tvToast 2.2s ease forwards;
      }
      @keyframes tvToast {
        0%   { opacity: 0; transform: translateX(-50%) translateY(6px); }
        10%  { opacity: 1; transform: translateX(-50%) translateY(0); }
        75%  { opacity: 1; }
        100% { opacity: 0; }
      }
    `;
    document.head.appendChild(s);
  }

  const ICON = {
    dl:    `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
    share: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>`,
    copy:  `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
    open:  `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
    list:  `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
    chev:  `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`,
    x:     `<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>`,
  };

  function mkIcon(name, bg) {
    const d = document.createElement('div');
    d.className = `tv-icon ${bg}`; d.innerHTML = ICON[name]; return d;
  }

  function setStatus(msg, type = '') {
    const el = document.getElementById('tv-status');
    if (!el) return;
    el.textContent = msg; el.className = msg ? `visible ${type}` : '';
  }

  function showError(msg) {
    if (!msg) return;
    alert(msg);
    setStatus(msg, 'err');
  }

  function openCard()  {
    document.getElementById('tv-card')?.classList.add('open');
    document.getElementById('tv-backdrop')?.classList.add('open');
    if (isVideoPage) prefetchDownloadSrc();
  }
  function closeCard() {
    document.getElementById('tv-card')?.classList.remove('open');
    document.getElementById('tv-backdrop')?.classList.remove('open');
    setStatus('');
  }

  function buildCard() {
    const backdrop = document.createElement('div');
    backdrop.id = 'tv-backdrop';
    backdrop.onclick = closeCard;

    const card = document.createElement('div');
    card.id = 'tv-card';

    const head = document.createElement('div');
    head.id = 'tv-card-head';

    const titleEl = document.createElement('p');
    titleEl.id = 'tv-card-title';
    titleEl.textContent = isVideoPage ? '1 Video Found' : 'Favourites';

    const subEl = document.createElement('p');
    subEl.id = 'tv-card-sub';
    subEl.textContent = IS_IOS
      ? 'Hold \u201cDownload\u201d then tap \u201cDownload Linked File\u201d'
      : location.hostname;

    const closeBtn = document.createElement('button');
    closeBtn.id = 'tv-card-close';
    closeBtn.innerHTML = ICON.x;
    closeBtn.onclick = closeCard;

    head.appendChild(titleEl);
    head.appendChild(subEl);
    head.appendChild(closeBtn);

    const body = document.createElement('div');
    body.id = 'tv-card-body';

    const mkRow = (label, iconName, iconBg, handler) => {
      const btn = document.createElement('button');
      btn.className = 'tv-list-row';
      btn.appendChild(mkIcon(iconName, iconBg));
      const lbl = document.createElement('span');
      lbl.className = 'tv-row-label'; lbl.textContent = label;
      const chev = document.createElement('span');
      chev.className = 'tv-row-chevron'; chev.innerHTML = ICON.chev;
      btn.appendChild(lbl); btn.appendChild(chev);
      btn.addEventListener('click', handler);
      return btn;
    };

    const mkLinkRow = (label, iconName, iconBg, handler) => {
      const link = document.createElement('a');
      link.className = 'tv-list-row';
      link.href = '#';
      link.target = '_blank';
      link.dataset.ready = 'false';
      if (IS_IOS) {
        link.title = 'Hold to download, or play first if the link is not ready';
      }
      link.appendChild(mkIcon(iconName, iconBg));
      const lbl = document.createElement('span');
      lbl.className = 'tv-row-label'; lbl.textContent = label;
      const chev = document.createElement('span');
      chev.className = 'tv-row-chevron'; chev.innerHTML = ICON.chev;
      link.appendChild(lbl); link.appendChild(chev);
      link.addEventListener('click', event => {
        if (link.dataset.ready === 'true') {
          return;
        }
        event.preventDefault();
        handler();
      });
      return link;
    };

    if (isVideoPage) {
      if (CAN_SHARE) body.appendChild(mkRow('Share', 'share', 'ic-blue', onShareSingle));
      const downloadLabel = IS_IOS ? 'Hold to download' : 'Download';
      const downloadPlaceholderLabel = 'Loading...';
      const downloadPlaceholder = mkRow(downloadPlaceholderLabel, 'dl', 'ic-red', async () => {
        try { await getSrc(); }
        catch (e) { showError(e.message); }
      });
      downloadPlaceholder.id = 'tv-download-placeholder';
      downloadPlaceholder.classList.add('disabled');
      body.appendChild(downloadPlaceholder);

      const downloadRow = mkLinkRow(downloadLabel, 'dl', 'ic-red', onDownloadSingle);
      downloadRow.id = 'tv-download-row';
      downloadRow.classList.add('hidden');
      downloadLinkAnchor = downloadRow;
      body.appendChild(downloadRow);

      body.appendChild(mkRow('Copy Link', 'copy', 'ic-purple', onCopyLink));
      body.appendChild(mkRow('Open in Tab', 'open', 'ic-gray', onOpenTab));
    }

    if (isFavPage) {
      body.appendChild(mkRow('Load & Save Favourites', 'list', 'ic-red', onBulkStart));
    }

    const statusEl = document.createElement('div');
    statusEl.id = 'tv-status';
    body.appendChild(statusEl);

    card.appendChild(head);
    card.appendChild(body);
    document.body.appendChild(backdrop);
    document.body.appendChild(card);
  }

  let downloadLinkAnchor = null;

  function buildFab() {
    const fab = document.createElement('button');
    fab.id = 'tv-fab';
    fab.innerHTML = `<div id="tv-fab-dot"></div><span>Save Video</span>`;
    fab.addEventListener('click', () => {
      document.getElementById('tv-card')?.classList.contains('open') ? closeCard() : openCard();
    });
    document.body.appendChild(fab);
  }

  function setDownloadLinkHref(src) {
    if (!downloadLinkAnchor) return;
    downloadLinkAnchor.href = src;
    downloadLinkAnchor.dataset.ready = 'true';
    downloadLinkAnchor.setAttribute('download', `${getTitle()}.mp4`);
    downloadLinkAnchor.classList.remove('hidden');
    const placeholder = document.getElementById('tv-download-placeholder');
    if (placeholder) placeholder.classList.add('hidden');
  }

  function prefetchDownloadSrc() {
    if (_cachedSrc) return;
    getSrc().then(src => setDownloadLinkHref(src)).catch(() => {});
  }

  let _cachedSrc = null;

  async function getSrc() {
    if (_cachedSrc) return _cachedSrc;
    setStatus('Starting player…', 'info');
    _cachedSrc = await getVideoSrc();
    setDownloadLinkHref(_cachedSrc);
    setStatus('');
    return _cachedSrc;
  }

  async function onShareSingle() {
    try {
      const src = await getSrc();
      await navigator.share({ title: getTitle(), url: src });
      setStatus('Shared!', 'ok');
    } catch(e) {
      if (e.name !== 'AbortError') showError(e.message);
      else setStatus('');
    }
  }

  async function onDownloadSingle() {
    try {
      const src = await getSrc();
      triggerDownload(src, `${getTitle()}.mp4`);
      setStatus(IS_IOS ? 'Hold the link \u2014 tap \u201cDownload Linked File\u201d' : 'Download started', 'ok');
    } catch(e) { showError(e.message); }
  }

  async function onCopyLink() {
    try {
      const src = await getSrc();
      await doCopy(src);
      toast('Link copied'); setStatus('Direct link copied', 'ok');
    } catch(e) { showError(e.message); }
  }

  async function onOpenTab() {
    try {
      const src = await getSrc();
      window.open(src, '_blank');
      setStatus('Opened in new tab', 'ok');
    } catch(e) { showError(e.message); }
  }

  function parseVideoLinks(doc) {
    return [...new Set(
      Array.from(doc.querySelectorAll('a[href*="/videos/"]'))
        .map(a => a.href)
        .filter(h => /\/videos\/[^/?#]+\/?$/.test(h))
    )];
  }

  async function collectAllFavLinks() {
    let links = parseVideoLinks(document);
    const pageNums = Array.from(document.querySelectorAll('a[href*="/favourites/"]'))
      .map(a => { const m = a.href.match(/\/(\d+)\/?$/); return m ? +m[1] : 0; })
      .filter(n => n > 1);
    const maxPage = pageNums.length ? Math.max(...pageNums) : 1;
    const basePath = location.pathname.replace(/\/\d+\/?$/, '').replace(/\/$/, '');
    for (let p = 2; p <= maxPage; p++) {
      const url = `${location.origin}${basePath}/${p}/`;
      await new Promise(res => {
        GM_xmlhttpRequest({
          method: 'GET', url, headers: { Referer: location.href },
          onload(r) {
            links = links.concat(parseVideoLinks(new DOMParser().parseFromString(r.responseText, 'text/html')));
            res();
          },
          onerror: res,
        });
      });
      await sleep(250);
    }
    return [...new Set(links)];
  }

  function isPreviewSrc(src) {
    const normalized = src.toLowerCase();
    return /(?:\.gif|preview|thumbnail|thumb|poster|sprite)/.test(normalized)
      || /\/get_file\/(?:1|2)\//.test(normalized)
      || /\.mp4\.gif/i.test(src)
      || /\.gif\.mp4/i.test(src)
      || /(?:preview|thumb|poster)=/.test(normalized);
  }

  function scoreVideoSrc(src) {
    let score = 0;
    if (/([?&])rnd=/.test(src)) score += 100;
    const match = src.match(/\/get_file\/(\d+)\//);
    if (match) {
      if (match[1] === '4') score += 20;
      if (match[1] === '1') score -= 20;
      if (match[1] === '2') score -= 10;
    }
    if (/\.mp4\/.+\?/.test(src)) score += 10;
    if (/\.mp4$/i.test(src)) score += 5;
    return score;
  }

  function extractSrcCandidatesFromHtml(html) {
    const candidates = new Set();
    const patterns = [
      /['"](https:\/\/(?:[\w-]+\.)?thisvid\.com\/get_file\/[^"']+?\.mp4(?:\/?(?:\?[^"']*)?))['"]/ig,
      /src:\s*['"](https:\/\/[^"']+\/get_file\/[^"']+?\.mp4(?:\/?(?:\?[^"']*)?))['"]/ig,
      /file:\s*['"](https:\/\/[^"']+\/get_file\/[^"']+?\.mp4(?:\/?(?:\?[^"']*)?))['"]/ig,
    ];

    for (const re of patterns) {
      let m;
      while ((m = re.exec(html)) !== null) {
        const candidate = m[1].replace(/\\u0026/g, '&').replace(/&amp;/g, '&');
        let detector = candidate;
        try { detector = decodeURIComponent(candidate); } catch {}
        if (!isPreviewSrc(detector)) {
          candidates.add(candidate);
        }
      }
    }

    return [...candidates].map(src => ({ src, score: scoreVideoSrc(src) }))
      .sort((a, b) => b.score - a.score)
      .map(item => item.src);
  }

  function extractSrcFromHtml(html) {
    return extractSrcCandidatesFromHtml(html)[0] || null;
  }

  function fetchVideoPageSrc(videoUrl) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        method: 'GET', url: videoUrl, headers: { Referer: 'https://thisvid.com/' },
        onload(r)  {
          const candidates = extractSrcCandidatesFromHtml(r.responseText);
          resolve(candidates[0] || null);
        },
        onerror: () => resolve(null),
      });
    });
  }

  async function onBulkStart() {
    closeCard();

    const ov = document.createElement('div');
    ov.id = 'tv-fav-overlay';
    ov.innerHTML = `
      <div id="tv-fav-head">
        <div id="tv-fav-head-text"><h2>Favourites</h2><div id="tv-fav-sub">Scanning…</div></div>
        <button class="tv-fav-hbtn copy" id="tv-fav-copyall" disabled>Copy All Links</button>
        <button class="tv-fav-hbtn close" id="tv-fav-close">Close</button>
      </div>
      <div id="tv-fav-prog"><div id="tv-fav-bar"></div></div>
      <div id="tv-fav-body">
        <div id="tv-fav-scan"><div class="tv-spinner"></div><div>Scanning pages…</div></div>
      </div>`;
    ov.querySelector('#tv-fav-close').onclick = () => ov.remove();
    document.body.appendChild(ov);

    const body    = ov.querySelector('#tv-fav-body');
    const bar     = ov.querySelector('#tv-fav-bar');
    const sub     = ov.querySelector('#tv-fav-sub');
    const copyAll = ov.querySelector('#tv-fav-copyall');

    let links;
    try { links = await collectAllFavLinks(); }
    catch(e) { sub.textContent = `Error: ${e.message}`; return; }

    if (!links.length) {
      body.innerHTML = '<div id="tv-fav-scan"><div>No videos found.</div></div>';
      return;
    }

    sub.textContent = `0 / ${links.length} resolved`;
    body.innerHTML = '';
    const group = document.createElement('div');
    group.className = 'tv-fav-group';
    body.appendChild(group);

    const rows = links.map(url => {
      const slug = url.split('/videos/')[1]?.replace(/\//g, '') || 'video';
      const el = document.createElement('div');
      el.className = 'tv-fav-row';
      el.innerHTML = `
        <div class="tv-fav-info">
          <div class="tv-fav-title">${slug.replace(/-/g, ' ')}</div>
          <div class="tv-fav-status">Pending</div>
        </div>`;
      const btn = document.createElement('button');
      btn.className = 'tv-fav-btn muted'; btn.textContent = '…'; btn.disabled = true;
      el.appendChild(btn);
      group.appendChild(el);
      return { el, slug, title: slug.replace(/-/g, ' ') };
    });

    const allSrcs = [];

    for (let i = 0; i < links.length; i++) {
      const src = await fetchVideoPageSrc(links[i]);
      const { el, slug, title } = rows[i];
      const statusEl = el.querySelector('.tv-fav-status');
      const btn      = el.querySelector('.tv-fav-btn');

      if (!src) {
        statusEl.className = 'tv-fav-status err'; statusEl.textContent = 'Not found';
        btn.className = 'tv-fav-btn muted'; btn.textContent = 'Open'; btn.disabled = false;
        btn.onclick = () => window.open(links[i], '_blank');
      } else {
        allSrcs.push(src);
        statusEl.className = 'tv-fav-status ok'; statusEl.textContent = 'Ready';

        if (CAN_SHARE) {
          btn.className = 'tv-fav-btn share'; btn.textContent = 'Share'; btn.disabled = false;
          btn.onclick = async () => {
            try { await navigator.share({ title, url: src }); }
            catch(e) { if (e.name !== 'AbortError') { await doCopy(src); toast('Link copied'); } }
          };
        } else {
          btn.className = 'tv-fav-btn dl'; btn.textContent = 'Save'; btn.disabled = false;
          btn.onclick = () => triggerDownload(src, `${slug}.mp4`);
        }
      }

      bar.style.width = `${Math.round(((i + 1) / links.length) * 100)}%`;
      sub.textContent = `${allSrcs.length} / ${links.length} resolved`;
      await sleep(250);
    }

    if (allSrcs.length) {
      copyAll.disabled = false;
      copyAll.onclick = async () => { await doCopy(allSrcs.join('\n')); toast(`${allSrcs.length} links copied`); };
    }
  }

  injectStyles();
  if (isVideoPage || isFavPage) { buildCard(); buildFab(); }

})();