ThisVid Downloader

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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

})();