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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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