TikTok Enhancer Plus

Safely enhance TikTok web: download videos (with blob fallback via recorder), audio-only (WIP), dark mode, auto loop/mute/scroll, UI tweaks, draggable settings gear/menu w/ backup & import/export — by Eliminater74

// ==UserScript==
// @name         TikTok Enhancer Plus
// @namespace    https://greasyfork.org/en/users/123456-eliminater74
// @version      2.4
// @description  Safely enhance TikTok web: download videos (with blob fallback via recorder), audio-only (WIP), dark mode, auto loop/mute/scroll, UI tweaks, draggable settings gear/menu w/ backup & import/export — by Eliminater74
// @author       Eliminater74
// @license      MIT
// @match        https://www.tiktok.com/*
// @icon         https://www.tiktok.com/favicon.ico
// @grant        GM_download
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const SETTINGS_KEY = 'tiktokEnhancerSettings';
  const UI_GEAR_KEY  = 'tiktokEnhancer_ui_gear';
  const UI_MENU_KEY  = 'tiktokEnhancer_ui_menu';

  const defaultSettings = {
    darkMode:    false,
    autoMute:    false,
    autoLoop:    false,
    autoScroll:  false,
    removeAds:   true,
    wideMode:    false,
    blobRecorder:false   // <-- new: enable Record/Stop button + "r" hotkey
  };

  const config = { ...defaultSettings, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') };
  const saveSettings = () => localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));

  // ----------------- utils -----------------
  const $  = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const once = (id, nodeMaker) => {
    let el = document.getElementById(id);
    if (!el) {
      el = nodeMaker();
      el.id = id;
      (el.tagName === 'STYLE' ? document.head : document.body).appendChild(el);
    }
    return el;
  };
  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
  const safeTitle = (s) => (s || 'TikTok').replace(/[\\/:*?"<>|]+/g, '').slice(0, 80) || 'TikTok';

  // checkbox id map (fixes earlier toggle bugs)
  const settingToCheckboxId = {
    darkMode:     'toggle-dark',
    autoMute:     'toggle-mute',
    autoLoop:     'toggle-loop',
    autoScroll:   'toggle-scroll',
    removeAds:    'toggle-ads',
    wideMode:     'toggle-wide',
    blobRecorder: 'toggle-blobrec'
  };

  // -------------- draggable helper --------------
  function makeDraggable(el, storageKey, fallbackPos) {
    el.style.position = 'fixed';
    el.style.touchAction = 'none';

    // restore position
    try {
      const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
      if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
        el.style.left = saved.x + 'px';
        el.style.top  = saved.y + 'px';
      } else if (fallbackPos) {
        const { x, y } = fallbackPos();
        el.style.left = x + 'px';
        el.style.top  = y + 'px';
      }
    } catch {}

    let startX, startY, startLeft, startTop, moved = false;

    const onDown = (e) => {
      moved = false;
      const p = e.touches ? e.touches[0] : e;
      startX = p.clientX; startY = p.clientY;
      const rect = el.getBoundingClientRect();
      startLeft = rect.left; startTop = rect.top;
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
      document.addEventListener('touchmove', onMove, { passive: false });
      document.addEventListener('touchend', onUp);
    };

    const onMove = (e) => {
      const p = e.touches ? e.touches[0] : e;
      if (e.cancelable) e.preventDefault();
      moved = true;
      const dx = p.clientX - startX;
      const dy = p.clientY - startY;
      const newX = clamp(startLeft + dx, 0, window.innerWidth  - el.offsetWidth);
      const newY = clamp(startTop  + dy, 0, window.innerHeight - el.offsetHeight);
      el.style.left = newX + 'px';
      el.style.top  = newY + 'px';
    };

    const onUp = () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      document.removeEventListener('touchmove', onMove);
      document.removeEventListener('touchend', onUp);
      const rect = el.getBoundingClientRect();
      localStorage.setItem(storageKey, JSON.stringify({ x: rect.left, y: rect.top }));
      if (moved) {
        el.dataset.justDragged = '1';
        setTimeout(() => delete el.dataset.justDragged, 150);
      }
    };

    el.addEventListener('mousedown', onDown);
    el.addEventListener('touchstart', onDown, { passive: false });
  }

  // ---------------- UI: menu + gear ----------------
  function createMenu() {
    const menu = document.createElement('div');
    menu.id = 'tiktok-enhancer-menu';
    menu.style.cssText = `
      position: fixed;
      background: #222;
      color: white;
      padding: 10px;
      border-radius: 10px;
      z-index: 999999;
      font-family: sans-serif;
      box-shadow: 0 0 10px #000;
      display: none;
      line-height: 1.8;
      user-select: none;
      width: 230px;
    `;
    menu.innerHTML = `
      <div style="cursor:move; font-weight:600; margin-bottom:6px;">TikTok Enhancer • Menu</div>
      <label><input type="checkbox" id="toggle-dark"> Dark Mode</label><br>
      <label><input type="checkbox" id="toggle-mute"> Auto Mute</label><br>
      <label><input type="checkbox" id="toggle-loop"> Auto Loop</label><br>
      <label><input type="checkbox" id="toggle-scroll"> Auto Scroll</label><br>
      <label><input type="checkbox" id="toggle-ads"> Remove Ads</label><br>
      <label><input type="checkbox" id="toggle-wide"> Wide Mode</label><br>
      <label><input type="checkbox" id="toggle-blobrec"> Blob Recorder (WebM)</label><br>
      <hr style="border-color:#444">
      <div style="display:flex; gap:6px; flex-wrap:wrap;">
        <button id="save-settings">💾 Backup</button>
        <button id="load-settings">📂 Restore</button>
        <button id="export-settings">⬇ Export JSON</button>
        <button id="import-settings">⬆ Import JSON</button>
        <input id="import-file" type="file" accept="application/json" style="display:none">
      </div>
    `;
    document.body.appendChild(menu);

    makeDraggable(menu, UI_MENU_KEY, () => ({
      x: window.innerWidth - 260,
      y: window.innerHeight - 260
    }));

    const gear = document.createElement('div');
    gear.textContent = '⚙️';
    gear.style.cssText = `
      position: fixed;
      font-size: 24px;
      z-index: 999998;
      cursor: pointer;
      background: #333;
      color: white;
      padding: 5px 10px;
      border-radius: 50%;
      box-shadow: 0 0 8px #000;
    `;
    document.body.appendChild(gear);
    makeDraggable(gear, UI_GEAR_KEY, () => ({
      x: window.innerWidth - 70,
      y: window.innerHeight - 70
    }));

    gear.addEventListener('click', () => {
      if (gear.dataset.justDragged) return;
      menu.style.display = (menu.style.display === 'none' || !menu.style.display) ? 'block' : 'none';
    });

    // bind checkboxes
    Object.entries(settingToCheckboxId).forEach(([key, id]) => {
      const el = document.getElementById(id);
      if (el) {
        el.checked = !!config[key];
        el.addEventListener('change', () => {
          config[key] = el.checked;
          saveSettings();
          applySettings();
        });
      }
    });

    // backup / restore
    $('#save-settings').onclick = () => {
      localStorage.setItem(SETTINGS_KEY + '_backup', JSON.stringify(config));
      alert('Settings backed up locally.');
    };
    $('#load-settings').onclick = () => {
      const backup = JSON.parse(localStorage.getItem(SETTINGS_KEY + '_backup') || '{}');
      Object.assign(config, backup);
      saveSettings();
      Object.entries(settingToCheckboxId).forEach(([k, id]) => {
        const el = document.getElementById(id);
        if (el) el.checked = !!config[k];
      });
      applySettings();
    };

    // export / import JSON
    $('#export-settings').onclick = () => {
      const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      const ts = new Date().toISOString().replace(/[:.]/g, '-');
      a.href = url;
      a.download = `tiktok-enhancer-settings-${ts}.json`;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    };
    const importFile = $('#import-file');
    $('#import-settings').onclick = () => importFile.click();
    importFile.addEventListener('change', async (e) => {
      const file = e.target.files?.[0];
      if (!file) return;
      try {
        const text = await file.text();
        const incoming = JSON.parse(text);
        Object.keys(defaultSettings).forEach(k => {
          if (typeof incoming[k] === 'boolean') config[k] = incoming[k];
        });
        saveSettings();
        Object.entries(settingToCheckboxId).forEach(([k, id]) => {
          const el = document.getElementById(id);
          if (el) el.checked = !!config[k];
        });
        applySettings();
        alert('Settings imported.');
      } catch (err) {
        alert('Failed to import settings: ' + err);
      } finally {
        importFile.value = '';
      }
    });
  }

  // -------------- applySettings (styles + timers) --------------
  let adSweepTimer = null;

  function applySettings() {
    // Dark mode via invert
    document.documentElement.style.filter = config.darkMode ? 'invert(1) hue-rotate(180deg)' : '';
    $$('img, video, canvas').forEach(el => {
      el.style.filter = config.darkMode ? 'invert(1) hue-rotate(180deg)' : '';
    });

    // Remove Ads (single managed timer)
    if (config.removeAds) {
      if (!adSweepTimer) {
        adSweepTimer = setInterval(() => {
          const adSelectors = [
            '[data-e2e*="sponsored"]',
            '[data-e2e="search-hot"]',
            '[data-e2e="trending"]',
            '[data-testid*="trending"]',
            'a[href*="/sponsor"]',
            'a[href*="utm_source="]',
            '.tiktok-1soki6-DivAdWrapper', '.ad-wrapper', '[class*="ad-"]'
          ];
          adSelectors.forEach(sel => $$(sel).forEach(n => n.remove()));
        }, 1200);
      }
    } else if (adSweepTimer) {
      clearInterval(adSweepTimer);
      adSweepTimer = null;
    }

    // Wide mode – single style node
    const style = once('tiktok-enhancer-wide-style', () => document.createElement('style'));
    style.textContent = config.wideMode ? `
      #app, main, body, html { max-width: 100% !important; width: 100% !important; }
      main > div, #app > div { max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
      [data-e2e="feed-list"], [data-e2e="search-video"], [data-e2e="recommend-list"], [class*="feed"]
      { max-width: 100vw !important; width: 100vw !important; }
    ` : '';
  }

  // ---------------- Blob Recorder (WebM) ----------------
  let activeRecorder = null;
  let recordedChunks = [];

  function startRecordingFromVideo(video, filenameBase = 'TikTok_Record') {
    if (!window.MediaRecorder) { alert('MediaRecorder not supported in this browser.'); return; }
    if (!video) { alert('No video element found.'); return; }
    if (activeRecorder) { alert('Already recording.'); return; }

    try {
      const stream = video.captureStream ? video.captureStream() : video.mozCaptureStream?.();
      if (!stream) throw new Error('Unable to capture stream from the video element.');

      // ensure playback (muting the element also mutes the capture, so unmute if needed)
      if (video.muted) video.muted = false;

      recordedChunks = [];
      const mr = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9,opus' });
      mr.ondataavailable = (e) => { if (e.data && e.data.size) recordedChunks.push(e.data); };
      mr.onstop = () => {
        const blob = new Blob(recordedChunks, { type: 'video/webm' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        const ts = new Date().toISOString().replace(/[:.]/g, '-');
        a.href = url;
        a.download = `${filenameBase}_${ts}.webm`;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
        activeRecorder = null;
        recordedChunks = [];
      };
      mr.start(1000);
      activeRecorder = mr;
      video.play().catch(()=>{});
    } catch (err) {
      console.error(err);
      alert('Failed to start recording: ' + err.message);
    }
  }

  function stopRecording() {
    if (activeRecorder) activeRecorder.stop();
    else alert('Not recording.');
  }

  // ---------------- video wiring ----------------
  function injectDownload(video) {
    if (!video || video.dataset.enhanced) return;
    video.dataset.enhanced = 'true';

    if (config.autoMute) video.muted = true;
    if (config.autoScroll) {
      video.addEventListener('ended', () => window.scrollBy({ top: window.innerHeight, behavior: 'smooth' }), { once: true });
    }
    if (config.autoLoop) {
      video.addEventListener('ended', () => { video.currentTime = 0; video.play().catch(()=>{}); });
    }

    const btnId = 'tiktok-download-btn';
    const placeButtons = () => {
      const src = video.currentSrc || video.src;
      if (!src) return;

      const existing = document.getElementById(btnId);
      if (existing) existing.remove();

      const wrap = document.createElement('div');
      wrap.id = btnId;
      wrap.style.cssText = `
        position: fixed;
        bottom: 80px;
        left: 20px;
        z-index: 999999;
      `;

      // Download button (direct URLs only)
      const dl = document.createElement('button');
      dl.textContent = '⬇ Download';
      dl.onclick = () => {
        const url = video.currentSrc || video.src;
        if (!url) return;
        if (url.startsWith('blob:')) {
          alert('Blob stream detected. Use Record to capture as WebM.');
        } else {
          GM_download(url, `TikTok_${Date.now()}.mp4`);
        }
      };
      dl.style.cssText = `background:#e11;color:white;padding:6px 12px;border:none;border-radius:6px;cursor:pointer;margin-right:5px;`;

      // Audio-only (placeholder)
      const audioBtn = document.createElement('button');
      audioBtn.textContent = '🎵 Audio';
      audioBtn.onclick = () => alert('Audio-only download not yet implemented. Coming soon!');
      audioBtn.style.cssText = `background:#333;color:white;padding:6px 12px;border:none;border-radius:6px;cursor:pointer;`;

      wrap.append(dl, audioBtn);

      // Record/Stop (if enabled)
      if (config.blobRecorder) {
        const recBtn = document.createElement('button');
        recBtn.textContent = '⏺ Record';
        recBtn.style.cssText = `background:#0a0;color:white;padding:6px 12px;border:none;border-radius:6px;cursor:pointer;margin-left:5px;`;
        let recording = false;
        recBtn.onclick = () => {
          if (!recording) {
            recording = true;
            recBtn.textContent = '⏹ Stop';
            startRecordingFromVideo(video, safeTitle(document.title));
          } else {
            recording = false;
            recBtn.textContent = '⏺ Record';
            stopRecording();
          }
        };
        wrap.appendChild(recBtn);
      }

      document.body.appendChild(wrap);
    };

    if (video.readyState >= 2) placeButtons();
    else {
      const onCanPlay = () => { placeButtons(); video.removeEventListener('canplay', onCanPlay); };
      video.addEventListener('canplay', onCanPlay);
    }
  }

  function monitorVideos() {
    $$('video').forEach(injectDownload);
    const observer = new MutationObserver(() => {
      $$('video').forEach(injectDownload);
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // ---------------- hotkeys ----------------
  function keyShortcuts() {
    document.addEventListener('keydown', e => {
      const vid = $('video');
      if (!vid) return;
      if (e.key === 'd') {
        const url = vid.currentSrc || vid.src;
        if (url && !url.startsWith('blob:')) GM_download(url, `TikTok_${Date.now()}.mp4`);
        else alert('Blob stream — use Record (WebM).');
      }
      if (e.key === 'm') vid.muted = !vid.muted;
      if (e.key === 't') {
        config.darkMode = !config.darkMode; saveSettings(); applySettings();
      }
      if (e.key === 'r' && config.blobRecorder) {
        if (!activeRecorder) startRecordingFromVideo(vid, safeTitle(document.title));
        else stopRecording();
      }
    });
  }

  // ---------------- init ----------------
  createMenu();
  applySettings();
  monitorVideos();
  keyShortcuts();
})();